From 835620c36bc1c81f424ddc61c9df5fcd1086f393 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Tue, 6 Oct 2026 00:01:44 +0200 Subject: [PATCH 1/2] feat(agenstra): plan mode agenstra now has a plan mode that allows to run a plan on top of a provided input promt it can be refined respectively with consecutive messages that are all encapsulated within the hidden plan chat a plan bubbles back up to the original chat, similar to the way automation runs do --- .../backend-agent-controller/project.json | 1 + .../1775600000000_CreateChatPlan.ts | 113 +++ .../src/typeorm.config.ts | 2 + .../src/i18n/messages.xlf | 60 ++ .../applications/frontend-agent-console.md | 3 +- docs/agenstra/features/README.md | 13 + docs/agenstra/features/chat-interface.md | 4 +- docs/agenstra/features/chat-plan-mode.md | 58 ++ .../features/websocket-communication.md | 2 +- graph/graph.json | 734 +++++++++++++++--- .../feature-agent-controller/README.md | 1 + .../docs/sequence-chat-plan-mode.mmd | 23 + .../spec/asyncapi.yaml | 182 +++++ .../spec/openapi.yaml | 238 ++++++ .../feature-agent-controller/src/index.ts | 9 + .../lib/controllers/chat-plan.controller.ts | 59 ++ .../dto/chat-plan/chat-plan-chat-event.dto.ts | 30 + .../dto/chat-plan/chat-plan-response.dto.ts | 25 + .../src/lib/dto/chat-plan/index.ts | 2 + .../src/lib/entities/chat-plan.entity.ts | 91 +++ .../src/lib/entities/chat-plan.enums.ts | 37 + .../statistics-chat-io.entity.spec.ts | 10 + .../lib/entities/statistics-chat-io.entity.ts | 4 + .../src/lib/gateways/clients.gateway.spec.ts | 28 + .../src/lib/gateways/clients.gateway.ts | 204 ++++- .../src/lib/modules/clients.module.spec.ts | 3 + .../src/lib/modules/clients.module.ts | 12 + .../chat-plan-chat-sync.service.spec.ts | 87 +++ .../services/chat-plan-chat-sync.service.ts | 102 +++ .../chat-plan-orchestrator.service.ts | 358 +++++++++ .../services/chat-plan-realtime.service.ts | 47 ++ .../lib/services/chat-plan.service.spec.ts | 124 +++ .../src/lib/services/chat-plan.service.ts | 250 ++++++ .../services/client-chat-plan.constants.ts | 8 + .../services/remote-agents-session.service.ts | 284 +++++++ .../src/lib/utils/chat-plan-mappers.ts | 27 + .../lib/utils/chat-plan-prompt.utils.spec.ts | 35 + .../src/lib/utils/chat-plan-prompt.utils.ts | 68 ++ .../lib/utils/chat-plan-turn-status.spec.ts | 102 +++ .../src/lib/utils/chat-plan-turn-status.ts | 255 ++++++ .../feature-agent-manager/spec/asyncapi.yaml | 15 +- .../constants/chat-session.constants.spec.ts | 28 + .../lib/constants/chat-session.constants.ts | 25 +- .../src/lib/gateways/agents.gateway.spec.ts | 71 ++ .../src/lib/gateways/agents.gateway.ts | 42 +- .../opencode/opencode-config-sync.service.ts | 73 ++ .../opencode/opencode-runtime.service.ts | 50 ++ .../opencode/opencode-session.service.spec.ts | 36 + .../opencode/opencode-session.service.ts | 17 +- .../data-access-agent-console/src/index.ts | 1 + .../chat-timeline/chat-timeline.actions.ts | 6 + .../chat-timeline/chat-timeline.facade.ts | 5 + .../chat-timeline.reducer.spec.ts | 90 ++- .../chat-timeline/chat-timeline.reducer.ts | 36 + .../chat-timeline.selectors.spec.ts | 139 +++- .../chat-timeline/chat-timeline.selectors.ts | 94 ++- .../chat-timeline/chat-timeline.types.ts | 8 + .../client-chat-plan.constants.ts | 11 + .../container-socket.effects.ts | 7 + .../container-socket.facade.ts | 77 ++ .../container-socket.types.ts | 65 ++ .../src/lib/chat/chat-plan-labels.ts | 41 + .../src/lib/chat/chat-thread-display.spec.ts | 63 ++ .../src/lib/chat/chat-thread-display.ts | 41 +- .../src/lib/chat/chat.component.html | 132 ++++ .../src/lib/chat/chat.component.ts | 142 +++- ...enstra-shared-util-opencode-config.spec.ts | 24 +- .../agenstra-shared-util-opencode-config.ts | 1 + .../src/lib/materialize.ts | 11 +- .../src/lib/plan-platform.spec.ts | 65 ++ .../src/lib/plan-platform.ts | 253 ++++++ 71 files changed, 5205 insertions(+), 159 deletions(-) create mode 100644 apps/agenstra/backend-agent-controller/src/migrations/1775600000000_CreateChatPlan.ts create mode 100644 docs/agenstra/features/chat-plan-mode.md create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/index.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.enums.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-chat-plan.constants.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-mappers.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/client-chat-plan.constants.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.spec.ts create mode 100644 libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.ts diff --git a/apps/agenstra/backend-agent-controller/project.json b/apps/agenstra/backend-agent-controller/project.json index 69e3beeef..3cc9169f8 100644 --- a/apps/agenstra/backend-agent-controller/project.json +++ b/apps/agenstra/backend-agent-controller/project.json @@ -77,6 +77,7 @@ "executor": "nx:run-commands", "options": { "commands": [ + "rm -rf dist/apps/agenstra/backend-agent-controller/src/migrations", "mkdir -p dist/apps/agenstra/backend-agent-controller/src/migrations", "(npx tsc $(find apps/agenstra/backend-agent-controller/src/migrations -name '*.ts' 2>/dev/null || echo '') --outDir dist/apps/agenstra/backend-agent-controller/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir apps/agenstra/backend-agent-controller/src/migrations 2>/dev/null || true)", "npx tsc $(find libs/domains/identity/backend/util-auth/src/lib/migrations -name '*.ts') --outDir dist/apps/agenstra/backend-agent-controller/src/migrations --module commonjs --target es2021 --moduleResolution node --esModuleInterop --skipLibCheck --resolveJsonModule --declaration false --rootDir libs/domains/identity/backend/util-auth/src/lib/migrations", diff --git a/apps/agenstra/backend-agent-controller/src/migrations/1775600000000_CreateChatPlan.ts b/apps/agenstra/backend-agent-controller/src/migrations/1775600000000_CreateChatPlan.ts new file mode 100644 index 000000000..03de39939 --- /dev/null +++ b/apps/agenstra/backend-agent-controller/src/migrations/1775600000000_CreateChatPlan.ts @@ -0,0 +1,113 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +/** + * Durable chat-scoped plan aggregates for Agenstra chat plan mode. + */ +export class CreateChatPlan1775500000000 implements MigrationInterface { + name = 'CreateChatPlan1775500000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'chat_plan_turn' + `); + await queryRunner.query(` + ALTER TYPE "statistics_interaction_kind_enum" ADD VALUE IF NOT EXISTS 'chat_plan_execute' + `); + + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "chat_plan_status_enum" AS ENUM ( + 'pending', 'exploring', 'ready', 'refining', 'executing', 'executed', 'failed', 'cancelled' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + await queryRunner.query(` + DO $$ BEGIN + CREATE TYPE "chat_plan_phase_enum" AS ENUM ( + 'explore', 'draft', 'refine', 'ready' + ); + EXCEPTION WHEN duplicate_object THEN null; END $$; + `); + + await queryRunner.createTable( + new Table({ + name: 'chat_plan', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'client_id', type: 'uuid', isNullable: false }, + { name: 'agent_id', type: 'uuid', isNullable: false }, + { name: 'chat_id', type: 'uuid', isNullable: false }, + { + name: 'status', + type: 'enum', + enum: ['pending', 'exploring', 'ready', 'refining', 'executing', 'executed', 'failed', 'cancelled'], + enumName: 'chat_plan_status_enum', + isNullable: false, + }, + { + name: 'phase', + type: 'enum', + enum: ['explore', 'draft', 'refine', 'ready'], + enumName: 'chat_plan_phase_enum', + isNullable: false, + }, + { name: 'source_prompt', type: 'text', isNullable: false }, + { name: 'plan_markdown', type: 'text', isNullable: true }, + { name: 'summary', type: 'varchar', length: '512', isNullable: true }, + { name: 'context_injection', type: 'jsonb', isNullable: true }, + { name: 'model', type: 'varchar', length: '256', isNullable: true }, + { name: 'resume_session_suffix', type: 'varchar', length: '128', isNullable: false }, + { name: 'completion_signal_seen', type: 'boolean', default: false, isNullable: false }, + { name: 'failure_code', type: 'varchar', length: '64', isNullable: true }, + { name: 'failure_message', type: 'varchar', length: '512', isNullable: true }, + { name: 'created_by_user_id', type: 'uuid', isNullable: true }, + { name: 'started_at', type: 'timestamptz', isNullable: false }, + { name: 'finished_at', type: 'timestamptz', isNullable: true }, + { + name: 'created_at', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamptz', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'chat_plan', + new TableIndex({ + name: 'IDX_chat_plan_client_agent_chat', + columnNames: ['client_id', 'agent_id', 'chat_id'], + }), + ); + await queryRunner.createIndex( + 'chat_plan', + new TableIndex({ + name: 'IDX_chat_plan_agent_chat_status', + columnNames: ['agent_id', 'chat_id', 'status'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('chat_plan', 'IDX_chat_plan_agent_chat_status'); + await queryRunner.dropIndex('chat_plan', 'IDX_chat_plan_client_agent_chat'); + await queryRunner.dropTable('chat_plan', true); + await queryRunner.query(`DROP TYPE IF EXISTS "chat_plan_phase_enum"`); + await queryRunner.query(`DROP TYPE IF EXISTS "chat_plan_status_enum"`); + // Postgres cannot remove enum values from statistics_interaction_kind_enum safely. + } +} diff --git a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts index aeaece0de..4cd0bf748 100644 --- a/apps/agenstra/backend-agent-controller/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-controller/src/typeorm.config.ts @@ -25,6 +25,7 @@ import { TicketAutomationRunEntity, TicketAutomationRunStepEntity, TicketAutomationEntity, + ChatPlanEntity, TicketBodyGenerationSessionEntity, TicketCommentEntity, TicketEntity, @@ -103,6 +104,7 @@ export const typeormConfig: DataSourceOptions = { TicketAutomationRunEntity, TicketAutomationLeaseEntity, TicketAutomationRunStepEntity, + ChatPlanEntity, ClientAgentAutonomyEntity, AtlassianSiteConnectionEntity, ExternalImportConfigEntity, diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 4dc3ca1ba..c0cffbfd7 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -827,6 +827,66 @@ Enhance prompt with AI + + Create plan with AI + + + Create plan with AI + + + Plan + + + Describe changes to the plan… + + + Refine + + + Execute + + + Cancel plan + + + Plan content will appear here as the agent explores… + + + Pending + + + Exploring + + + Ready + + + Refining + + + Executing + + + Executed + + + Failed + + + Cancelled + + + Explore + + + Draft + + + Refine + + + Ready + Model diff --git a/docs/agenstra/applications/frontend-agent-console.md b/docs/agenstra/applications/frontend-agent-console.md index 14c368ebe..6a4c8a068 100644 --- a/docs/agenstra/applications/frontend-agent-console.md +++ b/docs/agenstra/applications/frontend-agent-console.md @@ -205,7 +205,7 @@ On reconnection: 2. Restores client context (`setClient`) 3. Restores agent login (if previously logged in) 4. Clears stale local buffers where required to avoid duplicates -5. Receives chat history for the active session (`chatId` / primary) and ticket automation cards on the **primary** session only (as implemented in NgRx selectors/effects); session switches use `restoreChat` +5. Receives chat history for the active session (`chatId` / primary) and ticket automation cards on the **primary** session only (as implemented in NgRx selectors/effects); **chat plan cards** hydrate for the matching `plan.chatId` (primary or user session). Session switches use `restoreChat` ## Authentication @@ -308,6 +308,7 @@ Before deploying to production: ## Related documentation - **[Chat Interface Feature](../features/chat-interface.md)** Chat functionality guide +- **[Chat plan mode](../features/chat-plan-mode.md)** Explore-only plan cards, refine, and execute - **[Web IDE Feature](../features/web-ide.md)** Code editor guide - **[File Management Feature](../features/file-management.md)** File operations guide - **[Version Control Feature](../features/version-control.md)** Git operations guide diff --git a/docs/agenstra/features/README.md b/docs/agenstra/features/README.md index 7c1fa83e2..d87f22932 100644 --- a/docs/agenstra/features/README.md +++ b/docs/agenstra/features/README.md @@ -15,6 +15,7 @@ Agenstra provides a complete set of features for managing distributed AI agent i - **Version Control** Git operations directly from the web interface - **Web IDE** Monaco Editor integration for code editing - **Chat Interface** AI chat functionality with real-time responses +- **[Chat plan mode](./chat-plan-mode.md)** Explore-only OpenCode planning with refine and execute-into-chat - **Deployment** CI/CD pipeline management and deployment functionality - **Authentication** Multiple authentication methods with configurable user registration - **Tickets and Workspaces** Ticket boards, migration, and automation on the controller @@ -131,6 +132,18 @@ AI chat functionality with real-time responses. Send messages to agents and rece - Markdown rendering - Automatic history restoration +### [Chat plan mode](./chat-plan-mode.md) + +Explore-only OpenCode planning from the composer: live chat-scoped cards, refine in a modal, execute into the same visible chat. + +**Key Capabilities**: + +- Plan button beside prompt enhance +- Full composer context injection snapshot +- Explore-only hidden `-plan-*` sessions +- Chat-scoped hydrate across hard reload +- Execute plan into the current chat + ### [Deployment](./deployment.md) CI/CD pipeline management and deployment functionality. Configure CI/CD providers (GitHub Actions), trigger pipeline runs, monitor their status, and view logs directly from the Agenstra console. diff --git a/docs/agenstra/features/chat-interface.md b/docs/agenstra/features/chat-interface.md index ecdfab037..e8c87ca6a 100644 --- a/docs/agenstra/features/chat-interface.md +++ b/docs/agenstra/features/chat-interface.md @@ -25,11 +25,13 @@ Background and helper flows use reserved ACP `resumeSessionSuffix` values. Those - `-prompt-enhance` - `-ticket-body` - `-ticket-auto-*` (for example `-ticket-auto-pre`, `-ticket-auto-loop`, `-ticket-auto-commit-msg`) +- `-plan-{planId}` (chat plan mode explore/refine; explore-only permissions) -See [Agent Client Protocol](../ai-agents/agent-client-protocol.md) for suffix rules. +See [Agent Client Protocol](../ai-agents/agent-client-protocol.md) for suffix rules and [Chat plan mode](./chat-plan-mode.md) for the plan-mode product flow. Ticket automation **run cards** in the chat timeline are environment-scoped ACP work, but the console shows those embeddings on the **primary chat session only**. Side (`user`) sessions show that session’s messages without automation cards. +**Chat plan cards** are scoped to the **visible chat** that created them (`plan.chatId`): they appear on primary or user sessions accordingly, and survive hard reload via hydrate. Unread badges in the chat session dropdown follow the same rule: each visible session has its own unread flag (shown even when that session is selected); automation activity only marks the primary session unread. Selecting a session marks that session read. The dropdown toggle shows a badge when any visible session for the environment has unread. ### REST API diff --git a/docs/agenstra/features/chat-plan-mode.md b/docs/agenstra/features/chat-plan-mode.md new file mode 100644 index 000000000..86f6a5f44 --- /dev/null +++ b/docs/agenstra/features/chat-plan-mode.md @@ -0,0 +1,58 @@ +# Chat plan mode + +Operators can turn the current chat composer prompt (plus selected context) into an **explore-then-plan** workflow. A hidden OpenCode session investigates the repository with **explore-only** permissions, a chat-scoped timeline card shows live status and plan markdown, a detail modal supports refine, and **Execute plan** injects the plan into the same visible chat. + +This is an Agenstra productivity feature built on OpenCode (not a native OpenCode “plan mode” product API). It mirrors ticket-automation durability and hydrate patterns, but is user-triggered and chat-session scoped. + +## Prerequisites + +- Authenticated console user with access to the client and `agents:chats` scope +- Selected agent environment with a working OpenCode worker and up-to-date config sync (platform plan agent/skill injected) +- Non-empty composer prompt and a selected visible chat session (`primary` or `user`) + +## Context injection + +Plan creation uses the **same composer context** as Send (`ContextInjectionPayload`): workspace, related environments, ticket SHAs, knowledge SHAs, and auto-enrichment. The controller stores a snapshot on `chat_plan.context_injection` and reuses it for explore, refine, and execute turns. Refine may optionally send a new snapshot to replace the stored one. + +## Phases (high level) + +1. **Create** Console emits `createChatPlan` with `chatId`, prompt, model, and context. Controller inserts a `chat_plan` row (`exploring` / `explore`), emits `chatPlanUpsert`, and starts the orchestrator. +2. **Explore** Hidden OpenCode session `-plan-{planId}` with platform agent `agenstra-plan` and explore-only session permission ruleset (deny edit/write/patch/bash). Live markdown/status updates via throttled `chatPlanUpsert`. +3. **Ready** Structured turn status reports `ready` with `planMarkdown` / `summary` (or best-effort text extraction). Card becomes executable. +4. **Refine** (optional) `refineChatPlan` continues the same hidden session; status moves through `refining` then back to `ready`. +5. **Execute** `executeChatPlan` prompts the **visible** chat with the plan body and stored context (normal interactive permissions). Plan status becomes `executed`. +6. **Cancel** `cancelChatPlan` or REST cancel stops active explore/refine. + +At most one **active** plan (`exploring` / `refining`) is allowed per `(agentId, chatId)`. + +## Unattended OpenCode sessions (explore-only) + +Reserved resume suffix `-plan-{planId}`: + +- Hidden / ephemeral — no `agent_messages` rows; not listed in session switcher +- Session-scoped permission override: allow read/glob/grep/(web explore); **deny** write/mutation tools (including bash) +- Runtime auto-replies residual permission asks: allow explore, **reject** write/unknown; not automation allow-all and not `unattendedAutomation` +- Platform-injected agent `agenstra-plan` and skill `agenstra-chat-plan` (config sync bump via `AGENSTRA_OPENCODE_PLATFORM_WIRE_VERSION`) + +Interactive chat and execute paths keep normal worker permissions. + +## Persistence and restore + +Source of truth is the controller `chat_plan` table (not chat message history). After agent login, the controller unicasts recent plans via `chatPlanUpsert` with `hydrate: true` (capped similarly to automation hydrate). Live updates broadcast to room `client:{clientId}`. Frontend merges cards into the timeline only when `plan.chatId` matches the selected chat (unlike automation cards, which are primary-only). + +## HTTP and realtime + +- **REST** `GET /clients/{id}/agents/{agentId}/chats/{chatId}/plans`, `GET .../plans/{planId}`, `POST .../plans/{planId}/cancel` (OpenAPI operationIds `listChatPlans`, `getChatPlan`, `cancelChatPlan`) +- **WS (clients namespace, controller-handled)** `createChatPlan`, `refineChatPlan`, `executeChatPlan`, `cancelChatPlan`, `chatPlanUpsert` +- Statistics kinds: `chat_plan_turn`, `chat_plan_execute` + +See [WebSocket communication](./websocket-communication.md), [Chat Interface](./chat-interface.md), and the agent-controller AsyncAPI / OpenAPI. + +## Related documentation + +- [Chat Interface](./chat-interface.md) Hidden `-plan-*` suffixes and chat-scoped cards +- [Ticket automation](./ticket-automation.md) Parallel durability / hydrate pattern (primary-only cards; allow-all sessions) +- [Agent configuration](./agent-configuration.md) Platform wire / OpenCode config sync +- [Usage statistics](./usage-statistics.md) Interaction kinds +- [Backend Agent Controller](../applications/backend-agent-controller.md) +- [Frontend Agent Console](../applications/frontend-agent-console.md) diff --git a/docs/agenstra/features/websocket-communication.md b/docs/agenstra/features/websocket-communication.md index 8b1db9131..69942ec69 100644 --- a/docs/agenstra/features/websocket-communication.md +++ b/docs/agenstra/features/websocket-communication.md @@ -134,7 +134,7 @@ Authenticated environments receive an immediate `containerStats` snapshot and pe #### Controller-originated ticket events (still on `clients`) -To refresh ticket metadata in chat without subscribing to `tickets`, the controller may emit `ticketChatTicketUpsert` and automation timeline payloads such as `ticketAutomationRunChatUpsert` to room `client:{clientId}`. See the agent-controller AsyncAPI for fields. +To refresh ticket metadata in chat without subscribing to `tickets`, the controller may emit `ticketChatTicketUpsert` and automation timeline payloads such as `ticketAutomationRunChatUpsert` to room `client:{clientId}`. Chat plan mode uses controller-handled `createChatPlan` / `refineChatPlan` / `executeChatPlan` / `cancelChatPlan` and emits `chatPlanUpsert` (hydrate on login + live room broadcast). See the agent-controller AsyncAPI for fields and [Chat plan mode](./chat-plan-mode.md). ### Manager → Controller diff --git a/graph/graph.json b/graph/graph.json index e751400d1..d67eefa39 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-10T11:35:52.515Z", + "generatedAt": "2026-10-10T15:18:53.973Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -4115,6 +4115,7 @@ "sliceName": "container-socket", "memberFiles": [ "client-chat-automation.constants.ts", + "client-chat-plan.constants.ts", "container-socket.actions.ts", "container-socket.effects.ts", "container-socket.facade.ts", @@ -4580,6 +4581,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd", + "type": "diagram", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd", + "languageOrKind": "mmd", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-deployment-proxy.mmd", "type": "diagram", @@ -4742,6 +4752,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts", "type": "controller", @@ -4931,6 +4950,24 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts", "type": "dto", @@ -5381,6 +5418,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.ts", "type": "entity", @@ -6002,6 +6048,42 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", "type": "service", @@ -13938,6 +14020,14 @@ "languageOrKind": "md" } }, + { + "id": "file:docs/agenstra/features/chat-plan-mode.md", + "type": "doc", + "attrs": { + "path": "docs/agenstra/features/chat-plan-mode.md", + "languageOrKind": "md" + } + }, { "id": "file:docs/agenstra/features/client-management.md", "type": "doc", @@ -14865,7 +14955,7 @@ "type": "channel", "attrs": { "pathOrChannel": "clients/forward", - "summary": "Forward an arbitrary event to the selected client's agents namespace. When `agentId` is provided,\nthe gateway automatically logs in the agent using stored credentials before forwarding the event.\nTo restore chat history for an agent, forward a \"login\" event with `agentId` (the payload is\nautomatically overridden with credentials from the database, triggering login and subsequent\nchat history restoration). Supported events include \"chat\", \"enhanceChat\" (prompt improvement;\npayload `{ message, correlationId, model? }`; response `chatEnhanceResult` unicast from\nagent-manager, proxied to this socket only), \"generateTicketBody\" (ticket body from title;\npayload `{ title, correlationId, model? }`; response `ticketBodyResult` unicast, same envelope\nshape as `chatEnhanceResult`), \"fileUpdate\", \"logout\", \"createTerminal\", \"terminalInput\", \"terminalResize\",\n\"closeTerminal\", etc. The remote agent-manager gateway will process the event and may emit\nresponse events (e.g., \"chatMessage\", \"chatMessageBatch\", \"chatEnhanceResult\", \"ticketBodyResult\",\n\"fileUpdateNotification\", \"workspaceIndexChanged\", \"workspaceIndexRebuildRequired\",\n\"terminalCreated\", \"terminalOutput\", \"terminalClosed\", \"containerStats\", \"environmentProgress\", \"opencodeConfigSynced\")\nwhich are automatically forwarded back to the client. For each new terminal, \"terminalCreated\"\nprecedes \"terminalOutput\", including buffered startup output such as the initial shell prompt.\nThe \"containerStats\" event payload\n(defined in the agent-manager AsyncAPI) includes container status (running/stopped) and, when\nrunning, container statistics. \"environmentProgress\" (agent-manager AsyncAPI `agents/environmentProgress`)\nis broadcast by the agent-manager to every connected socket as soon as a client is selected (no agent login\nrequired) and carries the live step/percentage of environment creates and container-recreating updates;\nthe initial state is available via `GET /clients/{id}/agents/progress`.\n\"opencodeConfigSynced\" (agent-manager AsyncAPI `agents/opencodeConfigSynced`) is forwarded after\neffective config is applied, including updates without container recreation. Its success envelope\ncontains `data: { agentId }`; clients refresh cached models and slash commands for that environment.\nFor autonomous ticket runs, proxied `chat` may include\n`responseMode: \"single\" | \"stream\" | \"sync\"`, `ephemeral: true`, `continue`, and `resumeSessionSuffix`;\nthe controller also uses isolated suffixes (e.g. `-ticket-auto-loop`, `-ticket-auto-commit-msg`) for\nimplementation turns vs. post-verify conventional-commit subject generation; statistics on the controller\nside record the latter under interaction kind `autonomous_ticket_commit_message`.\nAfter a successful run, the controller also completes the workflow over HTTP by proxying agent-manager VCS\n`commit` and `push` (see controller OpenAPI under `/clients/{clientId}/agents/{agentId}/vcs/...`).\nagent-manager passes these to `sendMessage` and skips `agent_messages` persistence when `ephemeral` is true.\nWhen `ephemeral` is true, chatMessage / chatEvent traffic for that turn is emitted only to the requesting\nsocket (not broadcast to other viewers on the same agent).\nAfter a successful **login** (explicit `forward` with `event: \"login\"` or first-time auto-login before another event),\nthe controller may emit **ticketAutomationRunChatUpsert** (unicast) to hydrate ticket automation runs for that\n`clientId` + `agentId` into the chat timeline (see `clients/ticketAutomationRunChatUpsert`). Live run updates use\nthe same event broadcast to room `client:{clientId}` on the **clients** namespace.\nLogin auto-restore from agent-manager emits a single **chatMessageBatch** (latest page) rather than individual\nchatMessage / messageFilterResult / chatEvent frames; clients should forward that event to the FE. Explicit\n`restoreChat` forwards also yield chatMessageBatch then restoreChatSuccess (with hasMoreOlder / oldestMessageId).\nWhen a ticket row is created or updated (REST or internal), the controller also emits **ticketChatTicketUpsert**\nto room `client:{clientId}` on **clients** (same body as `tickets/ticketUpsert`).\nWorkspace index events (`workspaceIndexChanged` / `workspaceIndexRebuildRequired`) are also consumed locally\nby the controller to hydrate OpenSearch (`agenstra-workspace-files`) before being forwarded to the FE.\n", + "summary": "Forward an arbitrary event to the selected client's agents namespace. When `agentId` is provided,\nthe gateway automatically logs in the agent using stored credentials before forwarding the event.\nTo restore chat history for an agent, forward a \"login\" event with `agentId` (the payload is\nautomatically overridden with credentials from the database, triggering login and subsequent\nchat history restoration). Supported events include \"chat\", \"enhanceChat\" (prompt improvement;\npayload `{ message, correlationId, model? }`; response `chatEnhanceResult` unicast from\nagent-manager, proxied to this socket only), \"generateTicketBody\" (ticket body from title;\npayload `{ title, correlationId, model? }`; response `ticketBodyResult` unicast, same envelope\nshape as `chatEnhanceResult`), \"fileUpdate\", \"logout\", \"createTerminal\", \"terminalInput\", \"terminalResize\",\n\"closeTerminal\", etc. The remote agent-manager gateway will process the event and may emit\nresponse events (e.g., \"chatMessage\", \"chatMessageBatch\", \"chatEnhanceResult\", \"ticketBodyResult\",\n\"fileUpdateNotification\", \"workspaceIndexChanged\", \"workspaceIndexRebuildRequired\",\n\"terminalCreated\", \"terminalOutput\", \"terminalClosed\", \"containerStats\", \"environmentProgress\", \"opencodeConfigSynced\")\nwhich are automatically forwarded back to the client. For each new terminal, \"terminalCreated\"\nprecedes \"terminalOutput\", including buffered startup output such as the initial shell prompt.\nThe \"containerStats\" event payload\n(defined in the agent-manager AsyncAPI) includes container status (running/stopped) and, when\nrunning, container statistics. \"environmentProgress\" (agent-manager AsyncAPI `agents/environmentProgress`)\nis broadcast by the agent-manager to every connected socket as soon as a client is selected (no agent login\nrequired) and carries the live step/percentage of environment creates and container-recreating updates;\nthe initial state is available via `GET /clients/{id}/agents/progress`.\n\"opencodeConfigSynced\" (agent-manager AsyncAPI `agents/opencodeConfigSynced`) is forwarded after\neffective config is applied, including updates without container recreation. Its success envelope\ncontains `data: { agentId }`; clients refresh cached models and slash commands for that environment.\nFor autonomous ticket runs, proxied `chat` may include\n`responseMode: \"single\" | \"stream\" | \"sync\"`, `ephemeral: true`, `continue`, and `resumeSessionSuffix`;\nthe controller also uses isolated suffixes (e.g. `-ticket-auto-loop`, `-ticket-auto-commit-msg`) for\nimplementation turns vs. post-verify conventional-commit subject generation; statistics on the controller\nside record the latter under interaction kind `autonomous_ticket_commit_message`.\nAfter a successful run, the controller also completes the workflow over HTTP by proxying agent-manager VCS\n`commit` and `push` (see controller OpenAPI under `/clients/{clientId}/agents/{agentId}/vcs/...`).\nagent-manager passes these to `sendMessage` and skips `agent_messages` persistence when `ephemeral` is true.\nWhen `ephemeral` is true, chatMessage / chatEvent traffic for that turn is emitted only to the requesting\nsocket (not broadcast to other viewers on the same agent).\nAfter a successful **login** (explicit `forward` with `event: \"login\"` or first-time auto-login before another event),\nthe controller may emit **ticketAutomationRunChatUpsert** (unicast) to hydrate ticket automation runs for that\n`clientId` + `agentId` into the chat timeline (see `clients/ticketAutomationRunChatUpsert`). Live run updates use\nthe same event broadcast to room `client:{clientId}` on the **clients** namespace.\nThe controller may also emit **chatPlanUpsert** (unicast) to hydrate recent chat plans for that agent into the\ntimeline (see `clients/chatPlanUpsert`); live plan updates broadcast to the same client room.\nLogin auto-restore from agent-manager emits a single **chatMessageBatch** (latest page) rather than individual\nchatMessage / messageFilterResult / chatEvent frames; clients should forward that event to the FE. Explicit\n`restoreChat` forwards also yield chatMessageBatch then restoreChatSuccess (with hasMoreOlder / oldestMessageId).\nWhen a ticket row is created or updated (REST or internal), the controller also emits **ticketChatTicketUpsert**\nto room `client:{clientId}` on **clients** (same body as `tickets/ticketUpsert`).\nWorkspace index events (`workspaceIndexChanged` / `workspaceIndexRebuildRequired`) are also consumed locally\nby the controller to hydrate OpenSearch (`agenstra-workspace-files`) before being forwarded to the FE.\n", "specKind": "asyncapi" } }, @@ -14878,6 +14968,51 @@ "specKind": "asyncapi" } }, + { + "id": "api:channel:clients/createChatPlan", + "type": "channel", + "attrs": { + "pathOrChannel": "clients/createChatPlan", + "summary": "Start a chat plan-mode explore turn for the selected client. Controller-handled (not forwarded to agent-manager).\nPayload must include agentId, chatId, message, correlationId; optional model and contextInjection (full composer context).\n", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:clients/refineChatPlan", + "type": "channel", + "attrs": { + "pathOrChannel": "clients/refineChatPlan", + "summary": "Continue an existing plan on the same hidden `-plan-{planId}` OpenCode session.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:clients/executeChatPlan", + "type": "channel", + "attrs": { + "pathOrChannel": "clients/executeChatPlan", + "summary": "Inject the ready plan into the visible chat session (normal interactive permissions).", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:clients/cancelChatPlan", + "type": "channel", + "attrs": { + "pathOrChannel": "clients/cancelChatPlan", + "summary": "Cancel an exploring/refining chat plan.", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:clients/chatPlanUpsert", + "type": "channel", + "attrs": { + "pathOrChannel": "clients/chatPlanUpsert", + "summary": "Controller-originated chat plan snapshot for the chat UI. Aligns with OpenAPI `ChatPlanChatEventDto`.\nHydration uses `plan.startedAt`; live updates use `plan.updatedAt`. Cards are chat-session scoped via `plan.chatId`.\n", + "specKind": "asyncapi" + } + }, { "id": "api:channel:clients/ticketChatTicketUpsert", "type": "channel", @@ -16523,6 +16658,39 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/plans", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/{chatId}/plans", + "operationId": "listChatPlans", + "summary": "List chat plans for a chat session", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/plans/{planId}", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/plans/{planId}", + "operationId": "getChatPlan", + "summary": "Get a chat plan by id", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "operationId": "cancelChatPlan", + "summary": "Cancel an active chat plan", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/clients/{id}/configuration-overrides", "type": "endpoint", @@ -21854,7 +22022,7 @@ "type": "channel", "attrs": { "pathOrChannel": "agents/chat", - "summary": "Client sends a chat message (requires prior login). Payload may include `responseMode` \"single\" | \"stream\" | \"sync\",\n`ephemeral` (skip `agent_messages` persistence for the turn), `continue`, `resumeSessionSuffix`, `chatId`, `correlationId`, `model`.\nWhen `chatId` is set (and `resumeSessionSuffix` is not a reserved hidden ACP suffix), messages and events persist and\nemit under that user-visible chat session; otherwise the primary session is used.\nWhen `ephemeral` is true, `chatMessage`, `chatEvent`, and `messageFilterResult` for that turn are emitted only to the\nrequesting socket (not broadcast to other clients on the same agent). Otherwise those events are broadcast to every\nsocket authenticated to the agent. The gateway forwards options to `sendMessage` as implemented in code.\n", + "summary": "Client sends a chat message (requires prior login). Payload may include `responseMode` \"single\" | \"stream\" | \"sync\",\n`ephemeral` (skip `agent_messages` persistence for the turn), `suppressUserMessage` (send to agent but hide the user\nbubble; used by chat-plan execute), `continue`, `resumeSessionSuffix`, `chatId`, `correlationId`, `model`.\nWhen `chatId` is set (and `resumeSessionSuffix` is not a reserved hidden ACP suffix), messages and events persist and\nemit under that user-visible chat session; otherwise the primary session is used.\nReserved hidden suffixes include `-prompt-enhance`, `-ticket-body`, `-ticket-auto-*`, and `-plan-{planId}` (explore-only\npermissions; not automation allow-all). Those sessions are ephemeral/hidden and do not persist into `agent_messages`.\nWhen `ephemeral` is true, `chatMessage`, `chatEvent`, and `messageFilterResult` for that turn are emitted only to the\nrequesting socket (not broadcast to other clients on the same agent). Otherwise those events are broadcast to every\nsocket authenticated to the agent. The gateway forwards options to `sendMessage` as implemented in code.\n", "specKind": "asyncapi" } }, @@ -25660,6 +25828,66 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-chat-plan-mode", + "type": "concept", + "attrs": { + "title": "Chat plan mode", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "chat-plan-mode", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-context-injection", + "type": "concept", + "attrs": { + "title": "Context injection", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "context-injection", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-phases-high-level", + "type": "concept", + "attrs": { + "title": "Phases (high level)", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "phases-high-level", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-unattended-opencode-sessions-explore-only", + "type": "concept", + "attrs": { + "title": "Unattended OpenCode sessions (explore-only)", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "unattended-opencode-sessions-explore-only", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-persistence-and-restore", + "type": "concept", + "attrs": { + "title": "Persistence and restore", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "persistence-and-restore", + "domain": "agenstra" + } + }, + { + "id": "concept:agenstra-http-and-realtime", + "type": "concept", + "attrs": { + "title": "HTTP and realtime", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "http-and-realtime", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-client-management", "type": "concept", @@ -26210,16 +26438,6 @@ "domain": "agenstra" } }, - { - "id": "concept:agenstra-http-and-realtime", - "type": "concept", - "attrs": { - "title": "HTTP and realtime", - "docPath": "docs/agenstra/features/ticket-automation.md", - "sectionAnchor": "http-and-realtime", - "domain": "agenstra" - } - }, { "id": "concept:agenstra-tickets-and-workspaces", "type": "concept", @@ -37097,6 +37315,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-enhancement.mmd", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-deployment-proxy.mmd", @@ -37187,6 +37410,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/admin-opencode-layer-files.controller.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts", @@ -37292,6 +37520,16 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/agent-console-status.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/client-response.dto.ts", @@ -37542,6 +37780,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/atlassian-site-connection.entity.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/client-agent-autonomy.entity.ts", @@ -37887,6 +38130,26 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/autonomous-run-orchestrator.service.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", @@ -42082,6 +42345,31 @@ "to": "api:channel:clients/ticketAutomationRunChatUpsert", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", + "to": "api:channel:clients/createChatPlan", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", + "to": "api:channel:clients/refineChatPlan", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", + "to": "api:channel:clients/executeChatPlan", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", + "to": "api:channel:clients/cancelChatPlan", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", + "to": "api:channel:clients/chatPlanUpsert", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml", "to": "api:channel:clients/ticketChatTicketUpsert", @@ -42857,6 +43145,21 @@ "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/messages", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/plans", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/plans/{planId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", "to": "api:HTTP:GET:/clients/{id}/configuration-overrides", @@ -48002,6 +48305,46 @@ "to": "concept:agenstra-related-documentation", "type": "contains" }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-chat-plan-mode", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-prerequisites", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-context-injection", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-phases-high-level", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-unattended-opencode-sessions-explore-only", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-persistence-and-restore", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-http-and-realtime", + "type": "contains" + }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-related-documentation", + "type": "contains" + }, { "from": "file:docs/agenstra/features/client-management.md", "to": "concept:agenstra-client-management", @@ -53627,6 +53970,36 @@ "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/messages", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/plans", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/plans", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/plans/{planId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/plans/{planId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", "to": "api:HTTP:GET:/clients/{id}/configuration-overrides", @@ -60522,6 +60895,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/opencode-layer-files.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/client-users.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy-directory.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", @@ -60837,6 +61225,26 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/tickets.service.ts", @@ -61122,6 +61530,46 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/knowledge-tree.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/agent-console-status.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "to": "file:libs/domains/identity/backend/feature-auth/src/lib/repositories/client-users.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/repositories/clients.repository.ts", @@ -66117,6 +66565,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/ticket-automation.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/client-agent-autonomy.controller.ts", @@ -66212,6 +66665,16 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.service.ts", @@ -66417,6 +66880,16 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/ticket-automation-chat-sync.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts", + "type": "provides" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/tickets-board.gateway.ts", @@ -72107,6 +72580,106 @@ "to": "api:HTTP:POST:/agents/{agentId}/chats", "type": "documents" }, + { + "from": "concept:agenstra-phases-high-level", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "documents" + }, + { + "from": "concept:agenstra-persistence-and-restore", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/tickets", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/tickets", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/tickets/{ticketId}/automation", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:PATCH:/tickets/{ticketId}/automation", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/autonomy", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/autonomy", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:DELETE:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, { "from": "concept:agenstra-creating-a-client", "to": "api:HTTP:GET:/agents", @@ -72217,6 +72790,11 @@ "to": "api:HTTP:POST:/auth/login", "type": "documents" }, + { + "from": "concept:agenstra-events", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/plans/{planId}/cancel", + "type": "documents" + }, { "from": "concept:agenstra-why-two-tiers", "to": "api:HTTP:POST:/filter-rules", @@ -72312,96 +72890,6 @@ "to": "api:HTTP:POST:/auth/register", "type": "documents" }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/tickets", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/tickets", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/tickets/{ticketId}/automation", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:PATCH:/tickets/{ticketId}/automation", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/autonomy", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/autonomy", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/clients", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/clients", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/clients/{id}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/clients/{id}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:DELETE:/clients/{id}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/clients/{id}/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/clients/{id}/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:GET:/agents", - "type": "documents" - }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "api:HTTP:POST:/agents", - "type": "documents" - }, { "from": "concept:agenstra-tickets-and-workspaces", "to": "api:HTTP:GET:/tickets", @@ -78492,6 +78980,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "file:docs/agenstra/features/client-management.md", "to": "domain:agenstra", @@ -80292,6 +80785,36 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-chat-plan-mode", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-context-injection", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-phases-high-level", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-unattended-opencode-sessions-explore-only", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-persistence-and-restore", + "to": "domain:agenstra", + "type": "belongs_to" + }, + { + "from": "concept:agenstra-http-and-realtime", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-client-management", "to": "domain:agenstra", @@ -80567,11 +81090,6 @@ "to": "domain:agenstra", "type": "belongs_to" }, - { - "from": "concept:agenstra-http-and-realtime", - "to": "domain:agenstra", - "type": "belongs_to" - }, { "from": "concept:agenstra-tickets-and-workspaces", "to": "domain:agenstra", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/README.md b/libs/domains/agenstra/backend/feature-agent-controller/README.md index 89b1ac2dd..9bc1ccdec 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/README.md +++ b/libs/domains/agenstra/backend/feature-agent-controller/README.md @@ -77,6 +77,7 @@ All diagrams are available in the [`docs/`](./docs/) directory: - **[HTTP VCS Sequence Diagram](./docs/sequence-http-vcs.mmd)** - Detailed sequence diagram for proxied VCS (Git) operations - **[WebSocket Forwarding Diagram](./docs/sequence-ws-forward.mmd)** - Sequence diagram for WebSocket connection, client context setup, event forwarding, and auto-login - **[Chat prompt enhancement](./docs/sequence-chat-enhancement.mmd)** - Sequence for `enhanceChat` / `chatEnhanceResult` (magic-wand flow; statistics only, no `agent_messages`) +- **[Chat plan mode](./docs/sequence-chat-plan-mode.mmd)** - Sequence for `createChatPlan` / `chatPlanUpsert` / refine / execute (explore-only hidden session) - **[Lifecycle Diagram](./docs/lifecycle.mmd)** - End-to-end sequence diagram showing the complete lifecycle from client creation through proxied agent operations to WebSocket event forwarding These diagrams provide comprehensive visual documentation of: diff --git a/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd b/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd new file mode 100644 index 000000000..25bd27f91 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/docs/sequence-chat-plan-mode.mmd @@ -0,0 +1,23 @@ +sequenceDiagram + participant UI as AgentConsole + participant CG as ClientsGateway + participant PS as ChatPlanOrchestrator + participant DB as ControllerDB + participant RA as RemoteAgentsSession + participant AM as AgentManager_OpenCode + + UI->>CG: createChatPlan(chatId,prompt,model,contextInjection) + CG->>DB: insert chat_plan exploring + CG-->>UI: chatPlanUpsert + CG->>PS: startExplore(planId) + PS->>RA: stream hidden -plan-uuid + contextInjection + RA->>AM: chat ephemeral reserved suffix + AM-->>RA: assistant deltas / turn status + RA-->>PS: onDelta / onComplete + PS->>DB: update planMarkdown status + PS-->>UI: chatPlanUpsert throttled + UI->>CG: refineChatPlan(planId,message) + PS->>RA: continue same suffix + UI->>CG: executeChatPlan(planId) + PS->>AM: visible chat + suppressUserMessage + stored contextInjection + PS->>DB: status executed diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml index ebc80cf3b..f0900b1fd 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/asyncapi.yaml @@ -12,6 +12,9 @@ info: (see `clients/ticketAutomationRunChatUpsert` and OpenAPI `TicketAutomationRunChatEventDto`), and `ticketChatTicketUpsert` with the same payload shape as tickets-board `ticketUpsert` so chat clients can refresh ticket fields on automation cards without joining namespace **socket/tickets**. + Chat **plan mode** uses controller-handled events `createChatPlan` / `refineChatPlan` / `executeChatPlan` / + `cancelChatPlan` (not proxied via `forward`) and emits `chatPlanUpsert` (see OpenAPI `ChatPlanChatEventDto`) + for chat-scoped timeline cards; after login the controller also hydrates recent plans via unicast `chatPlanUpsert`. For **socket/clients**, all connections require authentication via the `Authorization` header in the handshake (same as HTTP API: `Bearer ` or `Bearer ` / `ApiKey `). Unauthenticated connections are rejected with connect_error "Unauthorized". @@ -108,6 +111,8 @@ channels: the controller may emit **ticketAutomationRunChatUpsert** (unicast) to hydrate ticket automation runs for that `clientId` + `agentId` into the chat timeline (see `clients/ticketAutomationRunChatUpsert`). Live run updates use the same event broadcast to room `client:{clientId}` on the **clients** namespace. + The controller may also emit **chatPlanUpsert** (unicast) to hydrate recent chat plans for that agent into the + timeline (see `clients/chatPlanUpsert`); live plan updates broadcast to the same client room. Login auto-restore from agent-manager emits a single **chatMessageBatch** (latest page) rather than individual chatMessage / messageFilterResult / chatEvent frames; clients should forward that event to the FE. Explicit `restoreChat` forwards also yield chatMessageBatch then restoreChatSuccess (with hasMoreOlder / oldestMessageId). @@ -124,6 +129,40 @@ channels: Controller-originated ticket automation snapshot for the chat UI (namespace **clients** only; not forwarded from agent-manager). Aligns with OpenAPI schema `TicketAutomationRunChatEventDto`. Hydration uses `timelineAt` from `run.startedAt`; live updates use `run.updatedAt`. + clients/createChatPlan: + address: clients/createChatPlan + messages: + createChatPlanCommand: + $ref: '#/components/messages/CreateChatPlan' + description: | + Start a chat plan-mode explore turn for the selected client. Controller-handled (not forwarded to agent-manager). + Payload must include agentId, chatId, message, correlationId; optional model and contextInjection (full composer context). + clients/refineChatPlan: + address: clients/refineChatPlan + messages: + refineChatPlanCommand: + $ref: '#/components/messages/RefineChatPlan' + description: Continue an existing plan on the same hidden `-plan-{planId}` OpenCode session. + clients/executeChatPlan: + address: clients/executeChatPlan + messages: + executeChatPlanCommand: + $ref: '#/components/messages/ExecuteChatPlan' + description: Inject the ready plan into the visible chat session (normal interactive permissions). + clients/cancelChatPlan: + address: clients/cancelChatPlan + messages: + cancelChatPlanCommand: + $ref: '#/components/messages/CancelChatPlan' + description: Cancel an exploring/refining chat plan. + clients/chatPlanUpsert: + address: clients/chatPlanUpsert + messages: + chatPlanUpsertEvent: + $ref: '#/components/messages/ChatPlanChatEvent' + description: | + Controller-originated chat plan snapshot for the chat UI. Aligns with OpenAPI `ChatPlanChatEventDto`. + Hydration uses `plan.startedAt`; live updates use `plan.updatedAt`. Cards are chat-session scoped via `plan.chatId`. clients/ticketChatTicketUpsert: address: clients/ticketChatTicketUpsert messages: @@ -387,6 +426,36 @@ operations: $ref: '#/channels/clients~1ticketAutomationRunChatUpsert' messages: - $ref: '#/channels/clients~1ticketAutomationRunChatUpsert/messages/ticketAutomationRunChatUpsertEvent' + clientSendsCreateChatPlan: + action: send + channel: + $ref: '#/channels/clients~1createChatPlan' + messages: + - $ref: '#/channels/clients~1createChatPlan/messages/createChatPlanCommand' + clientSendsRefineChatPlan: + action: send + channel: + $ref: '#/channels/clients~1refineChatPlan' + messages: + - $ref: '#/channels/clients~1refineChatPlan/messages/refineChatPlanCommand' + clientSendsExecuteChatPlan: + action: send + channel: + $ref: '#/channels/clients~1executeChatPlan' + messages: + - $ref: '#/channels/clients~1executeChatPlan/messages/executeChatPlanCommand' + clientSendsCancelChatPlan: + action: send + channel: + $ref: '#/channels/clients~1cancelChatPlan' + messages: + - $ref: '#/channels/clients~1cancelChatPlan/messages/cancelChatPlanCommand' + serverEmitsChatPlanUpsert: + action: receive + channel: + $ref: '#/channels/clients~1chatPlanUpsert' + messages: + - $ref: '#/channels/clients~1chatPlanUpsert/messages/chatPlanUpsertEvent' serverEmitsTicketChatTicketUpsert: action: receive channel: @@ -836,3 +905,116 @@ components: label: type: string additionalProperties: false + CreateChatPlan: + name: CreateChatPlan + title: Create chat plan + contentType: application/json + payload: + type: object + required: [agentId, chatId, message, correlationId] + properties: + agentId: + type: string + format: uuid + chatId: + type: string + format: uuid + message: + type: string + correlationId: + type: string + model: + type: string + contextInjection: + type: object + additionalProperties: true + additionalProperties: false + RefineChatPlan: + name: RefineChatPlan + title: Refine chat plan + contentType: application/json + payload: + type: object + required: [agentId, planId, message, correlationId] + properties: + agentId: + type: string + format: uuid + planId: + type: string + format: uuid + message: + type: string + correlationId: + type: string + contextInjection: + type: object + additionalProperties: true + additionalProperties: false + ExecuteChatPlan: + name: ExecuteChatPlan + title: Execute chat plan + contentType: application/json + payload: + type: object + required: [agentId, planId, correlationId] + properties: + agentId: + type: string + format: uuid + planId: + type: string + format: uuid + correlationId: + type: string + additionalProperties: false + CancelChatPlan: + name: CancelChatPlan + title: Cancel chat plan + contentType: application/json + payload: + type: object + required: [agentId, planId] + properties: + agentId: + type: string + format: uuid + planId: + type: string + format: uuid + additionalProperties: false + ChatPlanChatEvent: + name: ChatPlanChatEvent + title: Chat plan in chat timeline + contentType: application/json + description: Same shape as OpenAPI `ChatPlanChatEventDto`. + payload: + type: object + required: [timelineAt, hydrate, plan, actions] + properties: + timelineAt: + type: string + format: date-time + hydrate: + type: boolean + plan: + type: object + additionalProperties: true + actions: + type: array + items: + type: object + required: [type, planId, chatId, label] + properties: + type: + type: string + enum: [openChatPlan, executeChatPlan] + planId: + type: string + format: uuid + chatId: + type: string + format: uuid + label: + type: string + additionalProperties: false diff --git a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml index bc6f7e90c..34932f6cf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -3401,6 +3401,116 @@ paths: description: User does not have access to this client '404': description: Client, agent, or chat session not found + /clients/{id}/agents/{agentId}/chats/{chatId}/plans: + get: + summary: List chat plans for a chat session + description: > + Returns durable chat plan-mode rows for the given visible chat session (explore/refine/execute lifecycle). + Requires `agents:chats` scope and client access. + operationId: listChatPlans + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + description: The UUID of the client + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + description: The UUID of the agent + - in: path + name: chatId + required: true + schema: + type: string + format: uuid + description: The UUID of the visible chat session + responses: + '200': + description: Array of chat plans for the session + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatPlanResponseDto' + '403': + description: User does not have access to this client + '404': + description: Client or agent not found + /clients/{id}/agents/{agentId}/plans/{planId}: + get: + summary: Get a chat plan by id + operationId: getChatPlan + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: planId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Chat plan detail + content: + application/json: + schema: + $ref: '#/components/schemas/ChatPlanResponseDto' + '403': + description: User does not have access to this client + '404': + description: Plan not found for this client/agent + /clients/{id}/agents/{agentId}/plans/{planId}/cancel: + post: + summary: Cancel an active chat plan + operationId: cancelChatPlan + parameters: + - in: path + name: id + required: true + schema: + type: string + format: uuid + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: planId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Cancelled chat plan + content: + application/json: + schema: + $ref: '#/components/schemas/ChatPlanResponseDto' + '403': + description: User does not have access to this client + '404': + description: Plan not found for this client/agent /clients/{id}/configuration-overrides: get: summary: List effective workspace configuration overrides (proxied) @@ -4707,6 +4817,8 @@ paths: autonomous_ticket_run, autonomous_ticket_run_turn, autonomous_ticket_commit_message, + chat_plan_turn, + chat_plan_execute, ] description: Filter by interaction type (normal chat vs prompt enhancement metrics) - in: query @@ -4980,6 +5092,8 @@ paths: autonomous_ticket_run, autonomous_ticket_run_turn, autonomous_ticket_commit_message, + chat_plan_turn, + chat_plan_execute, ] description: Filter by interaction type (normal chat vs prompt enhancement metrics) - in: query @@ -7444,6 +7558,8 @@ components: autonomous_ticket_run, autonomous_ticket_run_turn, autonomous_ticket_commit_message, + chat_plan_turn, + chat_plan_execute, ] description: Normal chat vs prompt enhancement (magic wand) metrics wordCount: @@ -8271,6 +8387,128 @@ components: $ref: '#/components/schemas/TicketAutomationRunChatOpenActionDto' contextInjection: $ref: '#/components/schemas/ContextInjectionDto' + ChatPlanResponseDto: + type: object + required: + [ + id, + clientId, + agentId, + chatId, + status, + phase, + sourcePrompt, + resumeSessionSuffix, + completionSignalSeen, + startedAt, + createdAt, + updatedAt, + ] + properties: + id: + type: string + format: uuid + clientId: + type: string + format: uuid + agentId: + type: string + format: uuid + chatId: + type: string + format: uuid + status: + type: string + enum: + [ + pending, + exploring, + ready, + refining, + executing, + executed, + failed, + cancelled, + ] + phase: + type: string + enum: [explore, draft, refine, ready] + sourcePrompt: + type: string + planMarkdown: + type: string + nullable: true + summary: + type: string + nullable: true + contextInjection: + allOf: + - $ref: '#/components/schemas/ContextInjectionDto' + nullable: true + model: + type: string + nullable: true + resumeSessionSuffix: + type: string + completionSignalSeen: + type: boolean + failureCode: + type: string + nullable: true + failureMessage: + type: string + nullable: true + createdByUserId: + type: string + format: uuid + nullable: true + startedAt: + type: string + format: date-time + finishedAt: + type: string + format: date-time + nullable: true + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + ChatPlanChatEventDto: + type: object + description: > + Payload for WebSocket event `chatPlanUpsert` on namespace **clients**. + `timelineAt` uses `plan.startedAt` when `hydrate` is true and `plan.updatedAt` when false. + Clients merge with chat messages by chatId + this timestamp. + required: [timelineAt, hydrate, plan, actions] + properties: + timelineAt: + type: string + format: date-time + hydrate: + type: boolean + plan: + $ref: '#/components/schemas/ChatPlanResponseDto' + actions: + type: array + items: + $ref: '#/components/schemas/ChatPlanChatActionDto' + ChatPlanChatActionDto: + type: object + required: [type, planId, chatId, label] + properties: + type: + type: string + enum: [openChatPlan, executeChatPlan] + planId: + type: string + format: uuid + chatId: + type: string + format: uuid + label: + type: string PrototypePromptResponseDto: type: object required: [prompt] diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts index 469d0e211..f9132ed95 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/index.ts @@ -89,6 +89,8 @@ export * from './lib/entities/ticket-automation-run-step.entity'; export * from './lib/entities/ticket-automation-run.entity'; export * from './lib/entities/ticket-automation.entity'; export * from './lib/entities/ticket-automation.enums'; +export * from './lib/entities/chat-plan.entity'; +export * from './lib/entities/chat-plan.enums'; export * from './lib/entities/ticket-activity.entity'; export * from './lib/entities/ticket-body-generation-session.entity'; export * from './lib/entities/ticket-comment.entity'; @@ -129,6 +131,7 @@ export * from './lib/controllers/filter-rules.controller'; export * from './lib/controllers/client-agent-autonomy.controller'; export * from './lib/controllers/clients-agent-automation-proxy.controller'; export * from './lib/controllers/ticket-automation.controller'; +export * from './lib/controllers/chat-plan.controller'; export * from './lib/controllers/tickets.controller'; export * from './lib/modules/filter-rules.module'; export * from './lib/modules/context-import.module'; @@ -147,6 +150,7 @@ export * from './lib/search/agenstra-search-document.mapper'; export * from './lib/repositories/clients.repository'; export * from './lib/repositories/statistics.repository'; export * from './lib/dto/ticket-automation'; +export * from './lib/dto/chat-plan'; export * from './lib/services/autonomous-run-orchestrator.service'; export * from './lib/services/agent-manager-filter-rules-client.service'; export * from './lib/services/filter-rules.service'; @@ -164,6 +168,11 @@ export * from './lib/services/embeddings/knowledge-embedding-index.service'; export * from './lib/services/embeddings/local-embedding.provider'; export * from './lib/services/remote-agents-session.service'; export * from './lib/services/ticket-automation.service'; +export * from './lib/services/chat-plan.service'; +export * from './lib/services/chat-plan-orchestrator.service'; +export * from './lib/services/chat-plan-chat-sync.service'; +export * from './lib/services/chat-plan-realtime.service'; +export * from './lib/services/client-chat-plan.constants'; export * from './lib/services/clients.service'; export * from './lib/services/tickets.service'; export * from './lib/services/statistics.service'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts new file mode 100644 index 000000000..6afadbec8 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/chat-plan.controller.ts @@ -0,0 +1,59 @@ +import { + ClientUsersRepository, + ensureClientAccess, + RequireScopes, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { Controller, Get, HttpCode, HttpStatus, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { ChatPlanService } from '../services/chat-plan.service'; + +@Controller('clients/:id/agents/:agentId') +export class ChatPlanController { + constructor( + private readonly chatPlanService: ChatPlanService, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + ) {} + + @Get('chats/:chatId/plans') + @RequireScopes('agents:chats') + async listByChat( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Req() req?: RequestWithUser, + ) { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.chatPlanService.listByChat(clientId, agentId, chatId, req); + } + + @Get('plans/:planId') + @RequireScopes('agents:chats') + async getPlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('planId', new ParseUUIDPipe({ version: '4' })) planId: string, + @Req() req?: RequestWithUser, + ) { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.chatPlanService.get(clientId, agentId, planId, req); + } + + @Post('plans/:planId/cancel') + @HttpCode(HttpStatus.OK) + @RequireScopes('agents:chats') + async cancelPlan( + @Param('id', new ParseUUIDPipe({ version: '4' })) clientId: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('planId', new ParseUUIDPipe({ version: '4' })) planId: string, + @Req() req?: RequestWithUser, + ) { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + + return await this.chatPlanService.cancel(clientId, agentId, planId, req); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts new file mode 100644 index 000000000..3b72471c9 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-chat-event.dto.ts @@ -0,0 +1,30 @@ +import type { ChatPlanResponseDto } from './chat-plan-response.dto'; + +export type ChatPlanChatActionType = 'openChatPlan' | 'executeChatPlan'; + +export interface ChatPlanChatOpenActionDto { + type: 'openChatPlan'; + planId: string; + chatId: string; + label: string; +} + +export interface ChatPlanChatExecuteActionDto { + type: 'executeChatPlan'; + planId: string; + chatId: string; + label: string; +} + +export type ChatPlanChatActionDto = ChatPlanChatOpenActionDto | ChatPlanChatExecuteActionDto; + +/** + * Server → client payload on namespace `clients`, event `chatPlanUpsert`. + * `timelineAt` is ISO-8601: hydrate rows use `plan.startedAt`; live updates use `plan.updatedAt`. + */ +export interface ChatPlanChatEventDto { + timelineAt: string; + hydrate: boolean; + plan: ChatPlanResponseDto; + actions: ChatPlanChatActionDto[]; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts new file mode 100644 index 000000000..c0c8a4a3b --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/chat-plan-response.dto.ts @@ -0,0 +1,25 @@ +import type { ChatPlanPhase, ChatPlanStatus } from '../../entities/chat-plan.enums'; +import type { ChatPlanContextInjectionJson } from '../../entities/chat-plan.entity'; + +export class ChatPlanResponseDto { + id!: string; + clientId!: string; + agentId!: string; + chatId!: string; + status!: ChatPlanStatus; + phase!: ChatPlanPhase; + sourcePrompt!: string; + planMarkdown!: string | null; + summary!: string | null; + contextInjection!: ChatPlanContextInjectionJson | null; + model!: string | null; + resumeSessionSuffix!: string; + completionSignalSeen!: boolean; + failureCode!: string | null; + failureMessage!: string | null; + createdByUserId!: string | null; + startedAt!: Date; + finishedAt!: Date | null; + createdAt!: Date; + updatedAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/index.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/index.ts new file mode 100644 index 000000000..c51973a15 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/dto/chat-plan/index.ts @@ -0,0 +1,2 @@ +export * from './chat-plan-response.dto'; +export * from './chat-plan-chat-event.dto'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts new file mode 100644 index 000000000..59f7a8265 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.entity.ts @@ -0,0 +1,91 @@ +import { Column, CreateDateColumn, Entity, Index, PrimaryGeneratedColumn, UpdateDateColumn } from 'typeorm'; + +import { ChatPlanPhase, ChatPlanStatus } from './chat-plan.enums'; + +/** Stored contextInjection snapshot used for explore/refine/execute turns. */ +export type ChatPlanContextInjectionJson = { + includeWorkspace?: boolean; + environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; + ticketShas?: string[]; + ticketContexts?: string[]; + knowledgeShas?: string[]; + knowledgeContexts?: string[]; +}; + +@Entity('chat_plan') +@Index('IDX_chat_plan_client_agent_chat', ['clientId', 'agentId', 'chatId']) +@Index('IDX_chat_plan_agent_chat_status', ['agentId', 'chatId', 'status']) +export class ChatPlanEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'client_id' }) + clientId!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + /** Visible chat session UUID this plan card belongs to. */ + @Column({ type: 'uuid', name: 'chat_id' }) + chatId!: string; + + @Column({ + type: 'enum', + enum: ChatPlanStatus, + enumName: 'chat_plan_status_enum', + name: 'status', + }) + status!: ChatPlanStatus; + + @Column({ + type: 'enum', + enum: ChatPlanPhase, + enumName: 'chat_plan_phase_enum', + name: 'phase', + }) + phase!: ChatPlanPhase; + + @Column({ type: 'text', name: 'source_prompt' }) + sourcePrompt!: string; + + @Column({ type: 'text', name: 'plan_markdown', nullable: true }) + planMarkdown?: string | null; + + @Column({ type: 'varchar', length: 512, name: 'summary', nullable: true }) + summary?: string | null; + + @Column({ type: 'jsonb', name: 'context_injection', nullable: true }) + contextInjection?: ChatPlanContextInjectionJson | null; + + @Column({ type: 'varchar', length: 256, name: 'model', nullable: true }) + model?: string | null; + + /** Hidden OpenCode resume suffix (`-plan-{id}`). */ + @Column({ type: 'varchar', length: 128, name: 'resume_session_suffix' }) + resumeSessionSuffix!: string; + + @Column({ type: 'boolean', name: 'completion_signal_seen', default: false }) + completionSignalSeen!: boolean; + + @Column({ type: 'varchar', length: 64, name: 'failure_code', nullable: true }) + failureCode?: string | null; + + @Column({ type: 'varchar', length: 512, name: 'failure_message', nullable: true }) + failureMessage?: string | null; + + @Column({ type: 'uuid', name: 'created_by_user_id', nullable: true }) + createdByUserId?: string | null; + + @Column({ type: 'timestamptz', name: 'started_at' }) + startedAt!: Date; + + @Column({ type: 'timestamptz', name: 'finished_at', nullable: true }) + finishedAt?: Date | null; + + @CreateDateColumn({ type: 'timestamptz', name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ type: 'timestamptz', name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.enums.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.enums.ts new file mode 100644 index 000000000..1928e6ec4 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/chat-plan.enums.ts @@ -0,0 +1,37 @@ +/** Lifecycle status for a chat-scoped plan aggregate. */ +export enum ChatPlanStatus { + PENDING = 'pending', + EXPLORING = 'exploring', + READY = 'ready', + REFINING = 'refining', + EXECUTING = 'executing', + EXECUTED = 'executed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +/** Coarse phase for UI badges / hydrate cards. */ +export enum ChatPlanPhase { + EXPLORE = 'explore', + DRAFT = 'draft', + REFINE = 'refine', + READY = 'ready', +} + +/** Machine-oriented failure labels stored on `chat_plan.failure_code`. */ +export enum ChatPlanFailureCode { + AGENT_PROVIDER_ERROR = 'agent_provider_error', + AGENT_NO_PLAN_STATUS = 'agent_no_plan_status', + ACTIVE_PLAN_EXISTS = 'active_plan_exists', + INVALID_STATUS = 'invalid_status', + CANCELLED = 'cancelled', + ORCHESTRATOR_ERROR = 'orchestrator_error', +} + +/** Statuses that block creating another plan for the same agent+chat. */ +export const CHAT_PLAN_ACTIVE_STATUSES: readonly ChatPlanStatus[] = [ + ChatPlanStatus.PENDING, + ChatPlanStatus.EXPLORING, + ChatPlanStatus.REFINING, + ChatPlanStatus.EXECUTING, +]; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts index 58ea048ef..27c71a300 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.spec.ts @@ -80,5 +80,15 @@ describe('StatisticsChatIoEntity', () => { commitMsg.interactionKind = StatisticsInteractionKind.AUTONOMOUS_TICKET_COMMIT_MESSAGE; expect(commitMsg.interactionKind).toBe('autonomous_ticket_commit_message'); + + const planTurn = new StatisticsChatIoEntity(); + + planTurn.interactionKind = StatisticsInteractionKind.CHAT_PLAN_TURN; + expect(planTurn.interactionKind).toBe('chat_plan_turn'); + + const planExecute = new StatisticsChatIoEntity(); + + planExecute.interactionKind = StatisticsInteractionKind.CHAT_PLAN_EXECUTE; + expect(planExecute.interactionKind).toBe('chat_plan_execute'); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts index 8e50298a8..0988046e2 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/entities/statistics-chat-io.entity.ts @@ -18,6 +18,10 @@ export enum StatisticsInteractionKind { AUTONOMOUS_TICKET_RUN_TURN = 'autonomous_ticket_run_turn', /** Ephemeral remote chat used only to propose a Conventional Commits subject before `git commit`. */ AUTONOMOUS_TICKET_COMMIT_MESSAGE = 'autonomous_ticket_commit_message', + /** Hidden explore/refine turn for chat plan mode (`-plan-{id}` sessions). */ + CHAT_PLAN_TURN = 'chat_plan_turn', + /** Visible-chat execute turn that injects an approved plan into the operator thread. */ + CHAT_PLAN_EXECUTE = 'chat_plan_execute', } /** diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts index 5c5a23f8f..4b11a154c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts @@ -14,6 +14,10 @@ import { AutoContextResolverService } from '../services/auto-context-resolver.se import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { ChatPlanChatSyncService } from '../services/chat-plan-chat-sync.service'; +import { ChatPlanOrchestratorService } from '../services/chat-plan-orchestrator.service'; +import { ChatPlanRealtimeService } from '../services/chat-plan-realtime.service'; +import { ChatPlanService } from '../services/chat-plan.service'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; import { StatisticsService } from '../services/statistics.service'; import { TicketAutomationChatSyncService } from '../services/ticket-automation-chat-sync.service'; @@ -208,6 +212,30 @@ describe('ClientsGateway', () => { { provide: StatisticsService, useValue: mockStatisticsService }, { provide: ClientAutomationChatRealtimeService, useValue: mockClientAutomationChatRealtime }, { provide: TicketAutomationChatSyncService, useValue: mockTicketAutomationChatSync }, + { + provide: ChatPlanRealtimeService, + useValue: { attachServer: jest.fn() }, + }, + { + provide: ChatPlanChatSyncService, + useValue: { hydrateForAgentClient: jest.fn().mockResolvedValue(undefined) }, + }, + { + provide: ChatPlanService, + useValue: { + create: jest.fn(), + cancel: jest.fn(), + getEntityOrThrow: jest.fn(), + }, + }, + { + provide: ChatPlanOrchestratorService, + useValue: { + startExplore: jest.fn(), + startRefine: jest.fn(), + startExecute: jest.fn(), + }, + }, { provide: TicketsService, useValue: mockTicketsService }, { provide: KnowledgeTreeService, useValue: mockKnowledgeTreeService }, { provide: AutoContextResolverService, useValue: mockAutoContextResolverService }, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 0df9a1669..1670a729c 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -25,6 +25,7 @@ import type { Socket as ClientSocket } from 'socket.io-client'; import { FilterDropDirection } from '../entities/statistics-chat-filter-drop.entity'; import { FilterFlagDirection } from '../entities/statistics-chat-filter-flag.entity'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; +import type { ChatPlanContextInjectionJson } from '../entities/chat-plan.entity'; import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; import { ClientsRepository } from '../repositories/clients.repository'; import { WorkspaceSearchIndexService } from '../search/workspace-search-index.service'; @@ -33,6 +34,11 @@ import { AutoContextResolverService } from '../services/auto-context-resolver.se import { ClientAutomationChatRealtimeService } from '../services/client-automation-chat-realtime.service'; import { ClientWorkspaceConfigurationOverridesProxyService } from '../services/client-workspace-configuration-overrides-proxy.service'; import { ClientsService } from '../services/clients.service'; +import { ChatPlanChatSyncService } from '../services/chat-plan-chat-sync.service'; +import { ChatPlanOrchestratorService } from '../services/chat-plan-orchestrator.service'; +import { ChatPlanRealtimeService } from '../services/chat-plan-realtime.service'; +import { ChatPlanService } from '../services/chat-plan.service'; +import { CLIENT_CHAT_PLAN_EVENTS } from '../services/client-chat-plan.constants'; import { KnowledgeTreeService } from '../services/knowledge-tree.service'; import { StatisticsService } from '../services/statistics.service'; import { TicketAutomationChatSyncService } from '../services/ticket-automation-chat-sync.service'; @@ -166,6 +172,10 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat private readonly statisticsService: StatisticsService, private readonly clientAutomationChatRealtime: ClientAutomationChatRealtimeService, private readonly ticketAutomationChatSync: TicketAutomationChatSyncService, + private readonly chatPlanRealtime: ChatPlanRealtimeService, + private readonly chatPlanChatSync: ChatPlanChatSyncService, + private readonly chatPlanService: ChatPlanService, + private readonly chatPlanOrchestrator: ChatPlanOrchestratorService, private readonly ticketsService: TicketsService, private readonly knowledgeTreeService: KnowledgeTreeService, private readonly autoContextResolverService: AutoContextResolverService, @@ -177,6 +187,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat afterInit(server: Server): void { this.clientAutomationChatRealtime.attachServer(server); + this.chatPlanRealtime.attachServer(server); // When using namespace: 'clients', NestJS passes the namespace (not root Server) to afterInit. // Namespaces don't have .of(); use server directly for middleware. server.use(async (socket, next) => { @@ -1045,6 +1056,188 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat } } + @SubscribeMessage(CLIENT_CHAT_PLAN_EVENTS.createChatPlan) + async handleCreateChatPlan( + @MessageBody() + data: { + agentId?: string; + chatId?: string; + message?: string; + correlationId?: string; + model?: string; + contextInjection?: { + includeWorkspace?: boolean; + environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; + ticketShas?: string[]; + knowledgeShas?: string[]; + }; + }, + @ConnectedSocket() socket: Socket, + ): Promise { + const clientId = this.selectedClientBySocket.get(socket.id); + + if (!clientId) { + socket.emit('error', { message: 'No client selected. Call setClient first.' }); + + return; + } + + const agentId = data?.agentId; + const chatId = data?.chatId; + const message = data?.message?.trim() ?? ''; + + if (!agentId || !chatId || !message) { + socket.emit('error', { message: 'agentId, chatId, and message are required' }); + + return; + } + + try { + const userInfo = (socket as Socket & { data?: { userInfo?: Parameters[0] } }) + .data?.userInfo; + const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, { + message, + contextInjection: data.contextInjection, + })) as { + contextInjection?: ChatPlanContextInjectionJson; + }; + const plan = await this.chatPlanService.create( + { + clientId, + agentId, + chatId, + message, + model: data.model, + contextInjection: enrichedPayload.contextInjection ?? data.contextInjection ?? null, + createdByUserId: userInfo?.userId ?? null, + }, + buildRequestFromSocketUser(userInfo), + ); + + this.chatPlanOrchestrator.startExplore(plan.id); + socket.emit('forwardAck', { received: true, event: CLIENT_CHAT_PLAN_EVENTS.createChatPlan, planId: plan.id }); + } catch (error: unknown) { + this.logger.warn(`createChatPlan failed: ${(error as Error).message}`); + socket.emit('error', { message: 'Failed to create chat plan' }); + } + } + + @SubscribeMessage(CLIENT_CHAT_PLAN_EVENTS.refineChatPlan) + async handleRefineChatPlan( + @MessageBody() data: { agentId?: string; planId?: string; message?: string; correlationId?: string }, + @ConnectedSocket() socket: Socket, + ): Promise { + const clientId = this.selectedClientBySocket.get(socket.id); + + if (!clientId) { + socket.emit('error', { message: 'No client selected. Call setClient first.' }); + + return; + } + + const planId = data?.planId; + const message = data?.message?.trim() ?? ''; + + if (!planId || !message) { + socket.emit('error', { message: 'planId and message are required' }); + + return; + } + + try { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if (plan.clientId !== clientId || (data.agentId && plan.agentId !== data.agentId)) { + socket.emit('error', { message: 'Plan not found' }); + + return; + } + + this.chatPlanOrchestrator.startRefine(planId, message); + socket.emit('forwardAck', { received: true, event: CLIENT_CHAT_PLAN_EVENTS.refineChatPlan, planId }); + } catch (error: unknown) { + socket.emit('error', { message: 'Plan not found' }); + } + } + + @SubscribeMessage(CLIENT_CHAT_PLAN_EVENTS.executeChatPlan) + async handleExecuteChatPlan( + @MessageBody() data: { agentId?: string; planId?: string; correlationId?: string }, + @ConnectedSocket() socket: Socket, + ): Promise { + const clientId = this.selectedClientBySocket.get(socket.id); + + if (!clientId) { + socket.emit('error', { message: 'No client selected. Call setClient first.' }); + + return; + } + + const planId = data?.planId; + + if (!planId) { + socket.emit('error', { message: 'planId is required' }); + + return; + } + + try { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if (plan.clientId !== clientId || (data.agentId && plan.agentId !== data.agentId)) { + socket.emit('error', { message: 'Plan not found' }); + + return; + } + + this.chatPlanOrchestrator.startExecute(planId, data.correlationId); + socket.emit('forwardAck', { received: true, event: CLIENT_CHAT_PLAN_EVENTS.executeChatPlan, planId }); + } catch (error: unknown) { + socket.emit('error', { message: 'Plan not found' }); + } + } + + @SubscribeMessage(CLIENT_CHAT_PLAN_EVENTS.cancelChatPlan) + async handleCancelChatPlan( + @MessageBody() data: { agentId?: string; planId?: string }, + @ConnectedSocket() socket: Socket, + ): Promise { + const clientId = this.selectedClientBySocket.get(socket.id); + + if (!clientId) { + socket.emit('error', { message: 'No client selected. Call setClient first.' }); + + return; + } + + const planId = data?.planId; + const agentId = data?.agentId; + + if (!planId || !agentId) { + socket.emit('error', { message: 'planId and agentId are required' }); + + return; + } + + try { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if (plan.clientId !== clientId || plan.agentId !== agentId) { + socket.emit('error', { message: 'Plan not found' }); + + return; + } + + const userInfo = (socket as Socket & { data?: { userInfo?: { userId?: string; isApiKeyAuth?: boolean } } }).data + ?.userInfo; + await this.chatPlanService.cancel(clientId, agentId, planId, undefined, userInfo?.userId ?? null); + socket.emit('forwardAck', { received: true, event: CLIENT_CHAT_PLAN_EVENTS.cancelChatPlan, planId }); + } catch (error: unknown) { + socket.emit('error', { message: 'Plan not found' }); + } + } + /** * Forward generic events to the selected client agent-manager WebSocket. * SECURITY: All responses (forwardAck, error) are sent only to the initiating socket. @@ -1292,7 +1485,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat const suffix = typeof next['resumeSessionSuffix'] === 'string' ? next['resumeSessionSuffix'] : undefined; const ticketAutomationSuffixes = new Set(['-ticket-auto-pre', '-ticket-auto-loop', '-ticket-auto-commit-msg']); - if (suffix && ticketAutomationSuffixes.has(suffix)) { + if (suffix && (ticketAutomationSuffixes.has(suffix) || suffix.startsWith('-plan-'))) { delete next['resumeSessionSuffix']; } @@ -1540,6 +1733,15 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat this.logger.warn(`Ticket automation chat hydrate failed for agent ${agentId}: ${message}`); }); + this.scheduleChatPlanHydrate(socket, clientId, agentId); + } + + private scheduleChatPlanHydrate(socket: Socket, clientId: string, agentId: string): void { + void this.chatPlanChatSync.hydrateForAgentClient(socket, clientId, agentId).catch((err) => { + const message = err instanceof Error ? err.message : String(err); + + this.logger.warn(`Chat plan hydrate failed for agent ${agentId}: ${message}`); + }); } private async getAuthHeader(clientId: string): Promise { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts index 3f7677ec2..53b891e6f 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.spec.ts @@ -48,6 +48,7 @@ import { TicketAutomationLeaseEntity } from '../entities/ticket-automation-lease import { TicketAutomationRunStepEntity } from '../entities/ticket-automation-run-step.entity'; import { TicketAutomationRunEntity } from '../entities/ticket-automation-run.entity'; import { TicketAutomationEntity } from '../entities/ticket-automation.entity'; +import { ChatPlanEntity } from '../entities/chat-plan.entity'; import { TicketBodyGenerationSessionEntity } from '../entities/ticket-body-generation-session.entity'; import { TicketCommentEntity } from '../entities/ticket-comment.entity'; import { TicketEntity } from '../entities/ticket.entity'; @@ -238,6 +239,8 @@ describe('ClientsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(TicketAutomationRunStepEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(ChatPlanEntity)) + .useValue(mockTicketRepository) .overrideProvider(getRepositoryToken(ClientAgentAutonomyEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(KnowledgeNodeEntity)) diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts index f1e492485..52fa4d1e4 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/modules/clients.module.ts @@ -45,11 +45,13 @@ import { OpencodeProvidersController } from '../controllers/opencode-providers.c import { KnowledgeTreeController } from '../controllers/knowledge-tree.controller'; import { StatisticsController } from '../controllers/statistics.controller'; import { TicketAutomationController } from '../controllers/ticket-automation.controller'; +import { ChatPlanController } from '../controllers/chat-plan.controller'; import { TicketsController } from '../controllers/tickets.controller'; import { ClientAgentAutonomyEntity } from '../entities/client-agent-autonomy.entity'; import { ClientOpencodeConfigEntity } from '../entities/client-opencode-config.entity'; import { GlobalOpencodeConfigEntity } from '../entities/global-opencode-config.entity'; import { OpencodeMcpServerEntity } from '../entities/opencode-mcp-server.entity'; +import { ChatPlanEntity } from '../entities/chat-plan.entity'; import { OpencodeProviderEntity } from '../entities/opencode-provider.entity'; import { OpencodeLayerFileEntity } from '../entities/opencode-layer-file.entity'; import { OpencodeLayerFileSyncTargetEntity } from '../entities/opencode-layer-file-sync-target.entity'; @@ -119,6 +121,10 @@ import { RemoteAgentsSessionService } from '../services/remote-agents-session.se import { StatisticsAgentSyncService } from '../services/statistics-agent-sync.service'; import { TicketAutomationChatSyncService } from '../services/ticket-automation-chat-sync.service'; import { TicketAutomationService } from '../services/ticket-automation.service'; +import { ChatPlanChatSyncService } from '../services/chat-plan-chat-sync.service'; +import { ChatPlanOrchestratorService } from '../services/chat-plan-orchestrator.service'; +import { ChatPlanRealtimeService } from '../services/chat-plan-realtime.service'; +import { ChatPlanService } from '../services/chat-plan.service'; import { TicketBoardRealtimeService } from '../services/ticket-board-realtime.service'; import { TicketsService } from '../services/tickets.service'; @@ -155,6 +161,7 @@ const authMethod = getAuthenticationMethod(); TicketAutomationRunEntity, TicketAutomationLeaseEntity, TicketAutomationRunStepEntity, + ChatPlanEntity, ClientAgentAutonomyEntity, KnowledgeNodeEntity, KnowledgeNodeEmbeddingEntity, @@ -191,6 +198,7 @@ const authMethod = getAuthenticationMethod(); TicketsController, KnowledgeTreeController, TicketAutomationController, + ChatPlanController, ClientAgentAutonomyController, ClientAgentAutonomyDirectoryController, ClientsAgentAutomationProxyController, @@ -212,6 +220,8 @@ const authMethod = getAuthenticationMethod(); KnowledgeEmbeddingIndexService, LocalEmbeddingProvider, TicketAutomationService, + ChatPlanService, + ChatPlanOrchestratorService, ClientAgentAutonomyService, RemoteAgentsSessionService, AutonomousRunOrchestratorService, @@ -255,6 +265,8 @@ const authMethod = getAuthenticationMethod(); KnowledgeBoardRealtimeService, ClientAutomationChatRealtimeService, TicketAutomationChatSyncService, + ChatPlanRealtimeService, + ChatPlanChatSyncService, TicketsBoardGateway, KnowledgeBoardGateway, StatusGateway, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts new file mode 100644 index 000000000..b0b74a420 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts @@ -0,0 +1,87 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ChatPlanEntity } from '../entities/chat-plan.entity'; +import { ChatPlanPhase, ChatPlanStatus } from '../entities/chat-plan.enums'; + +import { AgentConsoleStatusService } from './agent-console-status.service'; +import { ChatPlanRealtimeService } from './chat-plan-realtime.service'; +import { CHAT_PLAN_HYDRATE_LIMIT, ChatPlanChatSyncService } from './chat-plan-chat-sync.service'; + +describe('ChatPlanChatSyncService', () => { + let service: ChatPlanChatSyncService; + const planRepo = { + createQueryBuilder: jest.fn(), + findOne: jest.fn(), + }; + const chatRealtime = { emitToClient: jest.fn(), emitToSocket: jest.fn() }; + const agentConsoleStatusService = { + onAutomationChatActivity: jest.fn().mockResolvedValue(undefined), + }; + + beforeEach(async () => { + jest.clearAllMocks(); + const qb = { + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + take: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + }; + + planRepo.createQueryBuilder.mockReturnValue(qb); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatPlanChatSyncService, + { provide: getRepositoryToken(ChatPlanEntity), useValue: planRepo }, + { provide: ChatPlanRealtimeService, useValue: chatRealtime }, + { provide: AgentConsoleStatusService, useValue: agentConsoleStatusService }, + ], + }).compile(); + + service = module.get(ChatPlanChatSyncService); + }); + + it('hydrateForAgentClient queries bounded plans and emits per row', async () => { + const plan = { + id: 'p1', + clientId: 'c1', + agentId: 'a1', + chatId: 'chat-1', + status: ChatPlanStatus.READY, + phase: ChatPlanPhase.READY, + sourcePrompt: 'hi', + planMarkdown: '# Plan', + summary: null, + contextInjection: null, + model: null, + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: true, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: new Date('2020-01-01'), + finishedAt: null, + createdAt: new Date('2020-01-01'), + updatedAt: new Date('2020-01-02'), + } as ChatPlanEntity; + + const qb = planRepo.createQueryBuilder(); + + qb.getMany.mockResolvedValue([plan]); + const socket = { connected: true, emit: jest.fn() } as never; + + await service.hydrateForAgentClient(socket, 'c1', 'a1'); + expect(planRepo.createQueryBuilder).toHaveBeenCalled(); + expect(qb.take).toHaveBeenCalledWith(CHAT_PLAN_HYDRATE_LIMIT); + expect(chatRealtime.emitToSocket).toHaveBeenCalledTimes(1); + const payload = (chatRealtime.emitToSocket as jest.Mock).mock.calls[0][1] as { + hydrate: boolean; + plan: { id: string; chatId: string }; + }; + + expect(payload.hydrate).toBe(true); + expect(payload.plan.id).toBe('p1'); + expect(payload.plan.chatId).toBe('chat-1'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts new file mode 100644 index 000000000..1ce5b45e4 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts @@ -0,0 +1,102 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import type { Socket } from 'socket.io'; +import { Repository } from 'typeorm'; + +import type { ChatPlanChatEventDto } from '../dto/chat-plan/chat-plan-chat-event.dto'; +import { ChatPlanEntity } from '../entities/chat-plan.entity'; +import { ChatPlanStatus } from '../entities/chat-plan.enums'; +import { chatPlanEntityToDto } from '../utils/chat-plan-mappers'; + +import { AgentConsoleStatusService } from './agent-console-status.service'; +import { ChatPlanRealtimeService } from './chat-plan-realtime.service'; + +/** Cap for post-login hydration (per agent + client). */ +export const CHAT_PLAN_HYDRATE_LIMIT = 100; + +/** Only hydrate plans started within this window. */ +export const CHAT_PLAN_HYDRATE_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; + +const OPEN_PLAN_LABEL = 'View plan'; +const EXECUTE_PLAN_LABEL = 'Execute plan'; + +@Injectable() +export class ChatPlanChatSyncService { + private readonly logger = new Logger(ChatPlanChatSyncService.name); + + constructor( + @InjectRepository(ChatPlanEntity) + private readonly planRepo: Repository, + private readonly chatPlanRealtime: ChatPlanRealtimeService, + private readonly agentConsoleStatusService: AgentConsoleStatusService, + ) {} + + /** + * After agent login: replay plan snapshots for this client+agent (unicast), oldest first. + */ + async hydrateForAgentClient(socket: Socket, clientId: string, agentId: string): Promise { + const cutoff = new Date(Date.now() - CHAT_PLAN_HYDRATE_MAX_AGE_MS); + const plans = await this.planRepo + .createQueryBuilder('p') + .where('p.client_id = :clientId', { clientId }) + .andWhere('p.agent_id = :agentId', { agentId }) + .andWhere('p.started_at >= :cutoff', { cutoff }) + .orderBy('p.started_at', 'ASC') + .take(CHAT_PLAN_HYDRATE_LIMIT) + .getMany(); + + for (const plan of plans) { + this.chatPlanRealtime.emitToSocket(socket, this.buildPayload(plan, true)); + } + + this.logger.debug(`Hydrated ${plans.length} chat plan(s), client=${clientId} agent=${agentId}`); + } + + /** Live plan snapshot to all sockets in the client room. */ + async emitLiveUpdateByPlanId(planId: string): Promise { + const plan = await this.planRepo.findOne({ where: { id: planId } }); + + if (!plan) { + return; + } + + this.emitLiveUpdateFromEntity(plan); + } + + /** Live update when plan entity is already loaded and saved. */ + emitLiveUpdateFromEntity(plan: ChatPlanEntity): void { + this.chatPlanRealtime.emitToClient(plan.clientId, this.buildPayload(plan, false)); + void this.agentConsoleStatusService + .onAutomationChatActivity(plan.clientId, plan.agentId, plan.updatedAt) + .catch(() => undefined); + } + + private buildPayload(plan: ChatPlanEntity, hydrate: boolean): ChatPlanChatEventDto { + const planDto = chatPlanEntityToDto(plan); + const timelineAt = hydrate ? plan.startedAt.toISOString() : plan.updatedAt.toISOString(); + const actions: ChatPlanChatEventDto['actions'] = [ + { + type: 'openChatPlan', + planId: plan.id, + chatId: plan.chatId, + label: OPEN_PLAN_LABEL, + }, + ]; + + if (plan.status === ChatPlanStatus.READY) { + actions.push({ + type: 'executeChatPlan', + planId: plan.id, + chatId: plan.chatId, + label: EXECUTE_PLAN_LABEL, + }); + } + + return { + timelineAt, + hydrate, + plan: planDto, + actions, + }; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts new file mode 100644 index 000000000..1c2ca1315 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts @@ -0,0 +1,358 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { randomUUID } from 'crypto'; + +import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; +import { ChatPlanFailureCode, ChatPlanPhase, ChatPlanStatus } from '../entities/chat-plan.enums'; +import { buildExecutePrompt, buildExplorePrompt, buildRefinePrompt } from '../utils/chat-plan-prompt.utils'; +import { + resolvePlanMarkdownFromTurn, + parsePlanTurnStatusFromAssistantText, + type AgenstraPlanTurnStatusPayload, +} from '../utils/chat-plan-turn-status'; + +import { ChatPlanChatSyncService } from './chat-plan-chat-sync.service'; +import { ChatPlanService } from './chat-plan.service'; +import { RemoteAgentsSessionService } from './remote-agents-session.service'; + +const UPSERT_THROTTLE_MS = 350; + +const STREAMING_STATUSES: readonly ChatPlanStatus[] = [ + ChatPlanStatus.PENDING, + ChatPlanStatus.EXPLORING, + ChatPlanStatus.REFINING, +]; + +@Injectable() +export class ChatPlanOrchestratorService { + private readonly logger = new Logger(ChatPlanOrchestratorService.name); + private readonly lastUpsertAtByPlanId = new Map(); + + constructor( + private readonly chatPlanService: ChatPlanService, + private readonly chatPlanChatSync: ChatPlanChatSyncService, + private readonly remoteAgents: RemoteAgentsSessionService, + ) {} + + /** + * Kick off explore on the hidden `-plan-{id}` session (fire-and-forget from gateway). + */ + startExplore(planId: string): void { + void this.runExplore(planId).catch((err) => { + this.logger.warn(`startExplore failed for ${planId}: ${(err as Error).message}`); + }); + } + + startRefine(planId: string, refineMessage: string): void { + void this.runRefine(planId, refineMessage).catch((err) => { + this.logger.warn(`startRefine failed for ${planId}: ${(err as Error).message}`); + }); + } + + startExecute(planId: string, correlationId?: string): void { + void this.runExecute(planId, correlationId).catch((err) => { + this.logger.warn(`startExecute failed for ${planId}: ${(err as Error).message}`); + }); + } + + private async isCancelled(planId: string): Promise { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + return plan.status === ChatPlanStatus.CANCELLED; + } + + private async emitThrottled(planId: string, force = false): Promise { + const now = Date.now(); + const last = this.lastUpsertAtByPlanId.get(planId) ?? 0; + + if (!force && now - last < UPSERT_THROTTLE_MS) { + return; + } + + this.lastUpsertAtByPlanId.set(planId, now); + await this.chatPlanChatSync.emitLiveUpdateByPlanId(planId); + } + + private async failPlan(planId: string, code: ChatPlanFailureCode, message: string): Promise { + await this.chatPlanService.updateIfStatus( + planId, + [ + ChatPlanStatus.PENDING, + ChatPlanStatus.EXPLORING, + ChatPlanStatus.REFINING, + ChatPlanStatus.READY, + ChatPlanStatus.EXECUTING, + ], + { + status: ChatPlanStatus.FAILED, + finishedAt: new Date(), + failureCode: code, + failureMessage: message, + }, + ); + } + + private async writeStreamingMarkdown(planId: string, draft: string, phase: ChatPlanPhase): Promise { + const updated = await this.chatPlanService.updateIfStatus(planId, STREAMING_STATUSES, { + planMarkdown: draft, + phase, + }); + + if (updated) { + await this.emitThrottled(planId); + } + } + + private async runExplore(planId: string): Promise { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if (plan.status !== ChatPlanStatus.EXPLORING && plan.status !== ChatPlanStatus.PENDING) { + return; + } + + const started = await this.chatPlanService.updateIfStatus( + planId, + [ChatPlanStatus.PENDING, ChatPlanStatus.EXPLORING], + { + status: ChatPlanStatus.EXPLORING, + phase: ChatPlanPhase.EXPLORE, + }, + ); + + if (!started) { + return; + } + + let draft = ''; + + try { + const result = await this.remoteAgents.sendChatStreaming({ + clientId: plan.clientId, + agentId: plan.agentId, + message: buildExplorePrompt(plan.sourcePrompt), + correlationId: `${plan.id}:explore`, + continue: false, + resumeSessionSuffix: plan.resumeSessionSuffix, + ephemeral: true, + model: plan.model ?? undefined, + contextInjection: plan.contextInjection ?? undefined, + statisticsInteractionKind: StatisticsInteractionKind.CHAT_PLAN_TURN, + onDeltaText: async (delta) => { + draft += delta; + const liveMarkdown = resolvePlanMarkdownFromTurn(draft, undefined); + + if (liveMarkdown) { + await this.writeStreamingMarkdown(planId, liveMarkdown, ChatPlanPhase.DRAFT); + } + }, + }); + + if (await this.isCancelled(planId)) { + return; + } + + await this.applyTurnResult(planId, result.text, result.planTurnStatus); + } catch (error: unknown) { + if (await this.isCancelled(planId)) { + return; + } + + await this.failPlan(planId, ChatPlanFailureCode.AGENT_PROVIDER_ERROR, 'Plan explore turn failed'); + this.logger.warn(`Explore turn error for ${planId}: ${(error as Error).message}`); + } + } + + private async runRefine(planId: string, refineMessage: string): Promise { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if (plan.status === ChatPlanStatus.REFINING) { + this.logger.debug(`Ignoring concurrent refine for plan ${planId}`); + + return; + } + + if (plan.status !== ChatPlanStatus.READY) { + return; + } + + const message = refineMessage?.trim() ?? ''; + + if (!message) { + return; + } + + const started = await this.chatPlanService.updateIfStatus(planId, [ChatPlanStatus.READY], { + status: ChatPlanStatus.REFINING, + phase: ChatPlanPhase.REFINE, + completionSignalSeen: false, + finishedAt: null, + }); + + if (!started) { + return; + } + + let draft = ''; + + try { + const result = await this.remoteAgents.sendChatStreaming({ + clientId: plan.clientId, + agentId: plan.agentId, + message: buildRefinePrompt(message, plan.planMarkdown), + correlationId: `${plan.id}:refine:${randomUUID()}`, + continue: true, + resumeSessionSuffix: plan.resumeSessionSuffix, + ephemeral: true, + model: plan.model ?? undefined, + contextInjection: plan.contextInjection ?? undefined, + statisticsInteractionKind: StatisticsInteractionKind.CHAT_PLAN_TURN, + onDeltaText: async (delta) => { + draft += delta; + const liveMarkdown = resolvePlanMarkdownFromTurn(draft, undefined); + + if (liveMarkdown) { + await this.writeStreamingMarkdown(planId, liveMarkdown, ChatPlanPhase.REFINE); + } + }, + }); + + if (await this.isCancelled(planId)) { + return; + } + + await this.applyTurnResult(planId, result.text, result.planTurnStatus); + } catch (error: unknown) { + if (await this.isCancelled(planId)) { + return; + } + + await this.failPlan(planId, ChatPlanFailureCode.AGENT_PROVIDER_ERROR, 'Plan refine turn failed'); + this.logger.warn(`Refine turn error for ${planId}: ${(error as Error).message}`); + } + } + + private async runExecute(planId: string, correlationId?: string): Promise { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if ( + plan.status === ChatPlanStatus.EXECUTING || + plan.status === ChatPlanStatus.EXECUTED || + plan.status === ChatPlanStatus.CANCELLED + ) { + this.logger.debug(`Ignoring execute for plan ${planId} in status ${plan.status}`); + + return; + } + + if (plan.status !== ChatPlanStatus.READY) { + return; + } + + const markdown = plan.planMarkdown?.trim(); + + if (!markdown) { + await this.failPlan(planId, ChatPlanFailureCode.INVALID_STATUS, 'Plan has no markdown to execute'); + + return; + } + + const started = await this.chatPlanService.updateIfStatus(planId, [ChatPlanStatus.READY], { + status: ChatPlanStatus.EXECUTING, + }); + + if (!started) { + return; + } + + try { + await this.remoteAgents.sendChatStreaming({ + clientId: plan.clientId, + agentId: plan.agentId, + message: buildExecutePrompt(markdown, plan.sourcePrompt), + correlationId: correlationId ?? `${plan.id}:execute`, + continue: false, + chatId: plan.chatId, + ephemeral: false, + suppressUserMessage: true, + model: plan.model ?? undefined, + contextInjection: plan.contextInjection ?? undefined, + statisticsInteractionKind: StatisticsInteractionKind.CHAT_PLAN_EXECUTE, + }); + + const completed = await this.chatPlanService.updateIfStatus(planId, [ChatPlanStatus.EXECUTING], { + status: ChatPlanStatus.EXECUTED, + phase: ChatPlanPhase.READY, + finishedAt: new Date(), + }); + + if (completed) { + await this.emitThrottled(planId, true); + } + } catch (error: unknown) { + if (await this.isCancelled(planId)) { + return; + } + + await this.failPlan(planId, ChatPlanFailureCode.AGENT_PROVIDER_ERROR, 'Plan execute turn failed'); + this.logger.warn(`Execute turn error for ${planId}: ${(error as Error).message}`); + } + } + + private async applyTurnResult( + planId: string, + text: string, + turnStatus: AgenstraPlanTurnStatusPayload | undefined, + ): Promise { + const plan = await this.chatPlanService.getEntityOrThrow(planId); + + if ( + plan.status !== ChatPlanStatus.EXPLORING && + plan.status !== ChatPlanStatus.REFINING && + plan.status !== ChatPlanStatus.PENDING + ) { + return; + } + + const parsedFromText = parsePlanTurnStatusFromAssistantText(text); + const effectiveStatus: AgenstraPlanTurnStatusPayload | undefined = turnStatus?.planMarkdown + ? turnStatus + : { + status: turnStatus?.status ?? parsedFromText?.status ?? 'exploring', + planMarkdown: turnStatus?.planMarkdown ?? parsedFromText?.planMarkdown, + summary: turnStatus?.summary ?? parsedFromText?.summary, + }; + const markdown = resolvePlanMarkdownFromTurn(text, effectiveStatus); + const summary = + effectiveStatus?.summary && effectiveStatus.summary.trim() + ? effectiveStatus.summary.trim().slice(0, 512) + : undefined; + + if (effectiveStatus?.status === 'ready' || (markdown && effectiveStatus?.status !== 'exploring')) { + await this.chatPlanService.updateIfStatus(planId, STREAMING_STATUSES, { + ...(markdown ? { planMarkdown: markdown } : {}), + ...(summary ? { summary } : {}), + status: ChatPlanStatus.READY, + phase: ChatPlanPhase.READY, + completionSignalSeen: effectiveStatus?.status === 'ready', + finishedAt: null, + }); + } else if (markdown) { + await this.chatPlanService.updateIfStatus(planId, STREAMING_STATUSES, { + planMarkdown: markdown, + ...(summary ? { summary } : {}), + status: ChatPlanStatus.READY, + phase: ChatPlanPhase.DRAFT, + completionSignalSeen: false, + finishedAt: null, + }); + } else { + await this.chatPlanService.updateIfStatus(planId, STREAMING_STATUSES, { + status: ChatPlanStatus.FAILED, + failureCode: ChatPlanFailureCode.AGENT_NO_PLAN_STATUS, + failureMessage: 'Plan turn completed without usable plan content', + finishedAt: new Date(), + }); + } + + await this.emitThrottled(planId, true); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts new file mode 100644 index 000000000..67cc68754 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-realtime.service.ts @@ -0,0 +1,47 @@ +import { Injectable, Logger } from '@nestjs/common'; +import type { Server, Socket } from 'socket.io'; + +import { TicketBoardRealtimeService } from './ticket-board-realtime.service'; +import { CLIENT_CHAT_PLAN_EVENTS } from './client-chat-plan.constants'; + +/** + * Emits chat-plan timeline events on the **clients** Socket.IO namespace. + */ +@Injectable() +export class ChatPlanRealtimeService { + private readonly logger = new Logger(ChatPlanRealtimeService.name); + private server: Server | null = null; + + attachServer(server: Server): void { + this.server = server; + this.logger.log('Chat plan realtime attached to clients namespace server'); + } + + private emitToClientRoom(clientId: string, event: string, payload: unknown): void { + if (!this.server) { + this.logger.debug(`Skip ${event}: server not attached`); + + return; + } + + const room = TicketBoardRealtimeService.clientRoom(clientId); + + this.server.to(room).emit(event, payload); + } + + emitToClient(clientId: string, payload: unknown): void { + this.emitToClientRoom(clientId, CLIENT_CHAT_PLAN_EVENTS.chatPlanUpsert, payload); + } + + emitToSocket(socket: Socket, payload: unknown): void { + if (!socket.connected) { + return; + } + + try { + socket.emit(CLIENT_CHAT_PLAN_EVENTS.chatPlanUpsert, payload); + } catch (err) { + this.logger.warn(`emitToSocket failed: ${err}`); + } + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.spec.ts new file mode 100644 index 000000000..58d6741c3 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.spec.ts @@ -0,0 +1,124 @@ +import { ClientUsersRepository } from '@forepath/identity/backend'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ChatPlanEntity } from '../entities/chat-plan.entity'; +import { ChatPlanPhase, ChatPlanStatus } from '../entities/chat-plan.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; + +import { ChatPlanChatSyncService } from './chat-plan-chat-sync.service'; +import { ChatPlanService } from './chat-plan.service'; + +jest.mock('@forepath/identity/backend', () => { + const actual = jest.requireActual('@forepath/identity/backend'); + + return { + ...actual, + ensureClientAccess: jest.fn().mockResolvedValue(undefined), + getUserFromRequest: jest.fn().mockReturnValue({ userId: 'user-1', userRole: 'admin', isApiKeyAuth: false }), + }; +}); + +describe('ChatPlanService', () => { + let service: ChatPlanService; + const planRepo = { + findOne: jest.fn(), + find: jest.fn(), + save: jest.fn(), + create: jest.fn((x: unknown) => x), + }; + const chatPlanChatSync = { emitLiveUpdateFromEntity: jest.fn() }; + + beforeEach(async () => { + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ChatPlanService, + { provide: getRepositoryToken(ChatPlanEntity), useValue: planRepo }, + { provide: ClientsRepository, useValue: {} }, + { provide: ClientUsersRepository, useValue: {} }, + { provide: ChatPlanChatSyncService, useValue: chatPlanChatSync }, + ], + }).compile(); + + service = module.get(ChatPlanService); + }); + + it('mapPlan maps entity fields to DTO', () => { + const now = new Date('2020-01-01'); + const dto = service.mapPlan({ + id: 'p1', + clientId: 'c1', + agentId: 'a1', + chatId: 'chat-1', + status: ChatPlanStatus.READY, + phase: ChatPlanPhase.READY, + sourcePrompt: 'hello', + planMarkdown: '# Plan', + summary: 'sum', + contextInjection: { includeWorkspace: true }, + model: 'openai/gpt', + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: true, + failureCode: null, + failureMessage: null, + createdByUserId: 'user-1', + startedAt: now, + finishedAt: null, + createdAt: now, + updatedAt: now, + } as ChatPlanEntity); + + expect(dto.id).toBe('p1'); + expect(dto.chatId).toBe('chat-1'); + expect(dto.status).toBe(ChatPlanStatus.READY); + expect(dto.planMarkdown).toBe('# Plan'); + expect(dto.completionSignalSeen).toBe(true); + }); + + it('create rejects when an active exploring plan exists', async () => { + planRepo.findOne.mockResolvedValue({ + id: 'existing', + status: ChatPlanStatus.EXPLORING, + }); + + await expect( + service.create({ + clientId: 'c1', + agentId: 'a1', + chatId: 'chat-1', + message: 'plan this', + }), + ).rejects.toThrow(BadRequestException); + }); + + it('create inserts exploring plan with -plan-{id} suffix', async () => { + planRepo.findOne.mockResolvedValue(null); + planRepo.save.mockImplementation(async (row: ChatPlanEntity) => { + if (!row.id) { + return { ...row, id: 'plan-uuid', createdAt: new Date(), updatedAt: new Date() }; + } + + return { ...row, createdAt: new Date(), updatedAt: new Date() }; + }); + + const dto = await service.create({ + clientId: 'c1', + agentId: 'a1', + chatId: 'chat-1', + message: 'plan this', + model: 'openai/gpt', + }); + + expect(dto.id).toBe('plan-uuid'); + expect(dto.status).toBe(ChatPlanStatus.EXPLORING); + expect(dto.resumeSessionSuffix).toBe('-plan-plan-uuid'); + expect(chatPlanChatSync.emitLiveUpdateFromEntity).toHaveBeenCalled(); + }); + + it('get throws when plan missing', async () => { + planRepo.findOne.mockResolvedValue(null); + await expect(service.get('c1', 'a1', 'missing')).rejects.toThrow(NotFoundException); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts new file mode 100644 index 000000000..0790bf7b3 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts @@ -0,0 +1,250 @@ +import { + ClientUsersRepository, + ensureClientAccess, + getUserFromRequest, + type RequestWithUser, +} from '@forepath/identity/backend'; +import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { ChatPlanResponseDto } from '../dto/chat-plan'; +import { ChatPlanEntity, type ChatPlanContextInjectionJson } from '../entities/chat-plan.entity'; +import { + CHAT_PLAN_ACTIVE_STATUSES, + ChatPlanFailureCode, + ChatPlanPhase, + ChatPlanStatus, +} from '../entities/chat-plan.enums'; +import { ClientsRepository } from '../repositories/clients.repository'; +import { chatPlanEntityToDto } from '../utils/chat-plan-mappers'; +import { buildChatPlanResumeSessionSuffix } from '../utils/chat-plan-prompt.utils'; + +import { ChatPlanChatSyncService } from './chat-plan-chat-sync.service'; + +export type CreateChatPlanInput = { + clientId: string; + agentId: string; + chatId: string; + message: string; + model?: string | null; + contextInjection?: ChatPlanContextInjectionJson | null; + createdByUserId?: string | null; +}; + +@Injectable() +export class ChatPlanService { + constructor( + @InjectRepository(ChatPlanEntity) + private readonly planRepo: Repository, + private readonly clientsRepository: ClientsRepository, + private readonly clientUsersRepository: ClientUsersRepository, + private readonly chatPlanChatSync: ChatPlanChatSyncService, + ) {} + + /** Map entity → DTO. */ + mapPlan(row: ChatPlanEntity): ChatPlanResponseDto { + return chatPlanEntityToDto(row); + } + + private async assertClientAccess(clientId: string, req?: RequestWithUser): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, clientId, req); + } + + async findActiveForChat(agentId: string, chatId: string): Promise { + return await this.planRepo.findOne({ + where: { + agentId, + chatId, + status: In([...CHAT_PLAN_ACTIVE_STATUSES]), + }, + order: { startedAt: 'DESC' }, + }); + } + + async create(input: CreateChatPlanInput, req?: RequestWithUser): Promise { + await this.assertClientAccess(input.clientId, req); + + const message = input.message?.trim() ?? ''; + + if (!message) { + throw new BadRequestException('Plan prompt is required'); + } + + if (!input.chatId) { + throw new BadRequestException('chatId is required'); + } + + const active = await this.findActiveForChat(input.agentId, input.chatId); + + if (active) { + throw new BadRequestException('An active plan already exists for this chat'); + } + + const userId = input.createdByUserId ?? getUserFromRequest(req || ({} as RequestWithUser)).userId ?? null; + const now = new Date(); + // Insert with a temporary suffix, then set `-plan-{id}` after we know the UUID. + const row = await this.planRepo.save( + this.planRepo.create({ + clientId: input.clientId, + agentId: input.agentId, + chatId: input.chatId, + status: ChatPlanStatus.PENDING, + phase: ChatPlanPhase.EXPLORE, + sourcePrompt: message, + planMarkdown: null, + summary: null, + contextInjection: input.contextInjection ?? null, + model: input.model?.trim() || null, + resumeSessionSuffix: '-plan-pending', + completionSignalSeen: false, + createdByUserId: userId, + startedAt: now, + finishedAt: null, + }), + ); + + row.resumeSessionSuffix = buildChatPlanResumeSessionSuffix(row.id); + row.status = ChatPlanStatus.EXPLORING; + const saved = await this.planRepo.save(row); + + this.chatPlanChatSync.emitLiveUpdateFromEntity(saved); + + return this.mapPlan(saved); + } + + async get(clientId: string, agentId: string, planId: string, req?: RequestWithUser): Promise { + await this.assertClientAccess(clientId, req); + const row = await this.planRepo.findOne({ where: { id: planId, clientId, agentId } }); + + if (!row) { + throw new NotFoundException('Plan not found'); + } + + return this.mapPlan(row); + } + + async listByChat( + clientId: string, + agentId: string, + chatId: string, + req?: RequestWithUser, + ): Promise { + await this.assertClientAccess(clientId, req); + const rows = await this.planRepo.find({ + where: { clientId, agentId, chatId }, + order: { startedAt: 'DESC' }, + }); + + return rows.map((r) => this.mapPlan(r)); + } + + async getEntityOrThrow(planId: string): Promise { + const row = await this.planRepo.findOne({ where: { id: planId } }); + + if (!row) { + throw new NotFoundException('Plan not found'); + } + + return row; + } + + async cancel( + clientId: string, + agentId: string, + planId: string, + req?: RequestWithUser, + actorUserId?: string | null, + ): Promise { + if (req) { + await this.assertClientAccess(clientId, req); + } + + const info = getUserFromRequest(req || ({} as RequestWithUser)); + const userId = actorUserId ?? info.userId; + + // Gateway path passes req=undefined after socket auth; allow cancel without HTTP user context. + if (req && !userId && !info.isApiKeyAuth) { + throw new ForbiddenException('User context required to cancel'); + } + + const row = await this.planRepo.findOne({ where: { id: planId, clientId, agentId } }); + + if (!row) { + throw new NotFoundException('Plan not found'); + } + + if ( + row.status !== ChatPlanStatus.PENDING && + row.status !== ChatPlanStatus.EXPLORING && + row.status !== ChatPlanStatus.REFINING && + row.status !== ChatPlanStatus.EXECUTING + ) { + return this.mapPlan(row); + } + + row.status = ChatPlanStatus.CANCELLED; + row.finishedAt = new Date(); + row.failureCode = ChatPlanFailureCode.CANCELLED; + row.failureMessage = 'Cancelled by user'; + const saved = await this.planRepo.save(row); + + this.chatPlanChatSync.emitLiveUpdateFromEntity(saved); + + return this.mapPlan(saved); + } + + async saveAndEmit(row: ChatPlanEntity): Promise { + const saved = await this.planRepo.save(row); + + this.chatPlanChatSync.emitLiveUpdateFromEntity(saved); + + return saved; + } + + /** + * Conditionally patch a plan only when its current DB status is in `expectedStatuses`. + * Returns false when the row was cancelled/executed/etc. (prevents stale in-memory saves). + */ + async updateIfStatus( + planId: string, + expectedStatuses: readonly ChatPlanStatus[], + patch: Partial< + Pick< + ChatPlanEntity, + | 'status' + | 'phase' + | 'planMarkdown' + | 'summary' + | 'completionSignalSeen' + | 'failureCode' + | 'failureMessage' + | 'finishedAt' + | 'startedAt' + > + >, + ): Promise { + if (expectedStatuses.length === 0) { + return false; + } + + const result = await this.planRepo + .createQueryBuilder() + .update(ChatPlanEntity) + .set({ + ...patch, + updatedAt: () => 'CURRENT_TIMESTAMP', + }) + .where('id = :planId', { planId }) + .andWhere('status IN (:...statuses)', { statuses: [...expectedStatuses] }) + .execute(); + + if ((result.affected ?? 0) < 1) { + return false; + } + + await this.chatPlanChatSync.emitLiveUpdateByPlanId(planId); + + return true; + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-chat-plan.constants.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-chat-plan.constants.ts new file mode 100644 index 000000000..6593d791f --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-chat-plan.constants.ts @@ -0,0 +1,8 @@ +/** Socket.IO event names on the `clients` namespace for chat plan mode. */ +export const CLIENT_CHAT_PLAN_EVENTS = { + createChatPlan: 'createChatPlan', + refineChatPlan: 'refineChatPlan', + executeChatPlan: 'executeChatPlan', + cancelChatPlan: 'cancelChatPlan', + chatPlanUpsert: 'chatPlanUpsert', +} as const; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts index 0d6ab9c36..6dd2e728d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/remote-agents-session.service.ts @@ -10,6 +10,7 @@ import { ClientsRepository } from '../repositories/clients.repository'; import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; import { buildRemoteAgentsSocketUrl } from '../utils/remote-manager-url.utils'; import { extractAutomationTurnStatus } from '../utils/automation-turn-status'; +import { extractPlanTurnStatus, type AgenstraPlanTurnStatusPayload } from '../utils/chat-plan-turn-status'; import { ClientsService } from './clients.service'; import { StatisticsService } from './statistics.service'; @@ -45,6 +46,42 @@ export interface RemoteChatSyncResult { turnStatus?: AgenstraAutomationTurnStatus; } +export interface RemoteChatStreamingParams { + clientId: string; + agentId: string; + message: string; + correlationId: string; + continue?: boolean; + resumeSessionSuffix?: string; + /** Visible chat session id — when set (execute path), omit resumeSessionSuffix. */ + chatId?: string; + /** Defaults to true for hidden plan sessions; false for visible execute. */ + ephemeral?: boolean; + /** + * When true with ephemeral false, agent replies stay visible but the injected user prompt + * is not persisted/emitted (chat-plan execute). + */ + suppressUserMessage?: boolean; + /** Automation trust marker — must stay false/undefined for plan mode. */ + unattendedAutomation?: boolean; + model?: string; + statisticsInteractionKind?: StatisticsInteractionKind; + chatTimeoutMs?: number; + contextInjection?: { + includeWorkspace?: boolean; + environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; + ticketShas?: string[]; + }; + onDeltaText?: (delta: string) => void | Promise; + onEvent?: (event: unknown) => void | Promise; +} + +export interface RemoteChatStreamingResult { + text: string; + planTurnStatus?: AgenstraPlanTurnStatusPayload; +} + /** * Short-lived Socket.IO client to the client's agent-manager namespace for synchronous `chat` turns. * Extracts the credential + URL wiring from {@link ClientsGateway} without coupling to UI sockets. @@ -328,4 +365,251 @@ export class RemoteAgentsSessionService { } } } + + private extractChatEventDelta(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + return ''; + } + + const envelope = payload as { + success?: boolean; + data?: { kind?: string; payload?: { delta?: unknown; text?: unknown } }; + kind?: string; + payload?: { delta?: unknown; text?: unknown }; + }; + const data = envelope.data ?? envelope; + const kind = typeof data.kind === 'string' ? data.kind : ''; + + if (kind !== 'assistantDelta' && kind !== 'assistantMessage') { + return ''; + } + + const eventPayload = data.payload; + + if (!eventPayload || typeof eventPayload !== 'object') { + return ''; + } + + if (typeof eventPayload.delta === 'string') { + return eventPayload.delta; + } + + if (typeof eventPayload.text === 'string' && kind === 'assistantDelta') { + return eventPayload.text; + } + + return ''; + } + + private extractChatEventKind(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + return ''; + } + + const envelope = payload as { data?: { kind?: unknown }; kind?: unknown }; + const kind = envelope.data?.kind ?? envelope.kind; + + return typeof kind === 'string' ? kind : ''; + } + + private extractChatEventAssistantText(payload: unknown): string { + if (!payload || typeof payload !== 'object') { + return ''; + } + + const envelope = payload as { + data?: { kind?: string; payload?: { text?: unknown } }; + kind?: string; + payload?: { text?: unknown }; + }; + const data = envelope.data ?? envelope; + + if (data.kind !== 'assistantMessage') { + return ''; + } + + const text = data.payload?.text; + + return typeof text === 'string' ? text : ''; + } + + /** + * Streaming remote chat turn: listens to `chatEvent` deltas and settles on terminal + * `assistantMessage` chatEvent (OpenCode stream path) or final `chatMessage` (sync/single path). + * Used by chat plan explore/refine (ephemeral + resumeSessionSuffix) and execute (chatId visible). + */ + async sendChatStreaming(params: RemoteChatStreamingParams): Promise { + const client = await this.clientsRepository.findByIdOrThrow(params.clientId); + const authHeader = await this.getAuthHeader(params.clientId); + + await validateClientEndpointWithDnsOrThrow(client.endpoint); + const tlsPolicy = getClientEndpointTlsPolicy(this.logger); + const remoteUrl = this.buildAgentsWsUrl(client.endpoint); + const remote: ClientSocket = createCorrelationAwareSocketIoClient(remoteUrl, { + transports: ['websocket'], + extraHeaders: { Authorization: authHeader }, + rejectUnauthorized: tlsPolicy.rejectUnauthorized, + reconnection: false, + }); + const creds = await this.clientAgentCredentialsRepository.findByClientAndAgent(params.clientId, params.agentId); + + if (!creds?.password) { + throw new BadRequestException('No stored credentials for this agent'); + } + + const chatTimeoutMs = params.chatTimeoutMs ?? parseInt(process.env.REMOTE_AGENT_CHAT_TIMEOUT_MS || '600000', 10); + const ephemeral = params.ephemeral !== false; + const useChatId = typeof params.chatId === 'string' && params.chatId.length > 0; + + try { + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new BadRequestException('Remote socket connect timeout')), 15000); + + remote.once('connect', () => { + clearTimeout(t); + resolve(); + }); + remote.once('connect_error', (err: Error) => { + clearTimeout(t); + reject(err); + }); + }); + + await new Promise((resolve, reject) => { + const t = setTimeout(() => reject(new BadRequestException('Remote login timeout')), 10000); + + remote.once('loginSuccess', () => { + clearTimeout(t); + resolve(); + }); + remote.once('loginError', (err: unknown) => { + clearTimeout(t); + const msg = (err as { error?: { message?: string } })?.error?.message ?? 'login failed'; + + reject(new BadRequestException(msg)); + }); + remote.emit('login', { agentId: params.agentId, password: creds.password }); + }); + + const wordCount = params.message.trim().split(/\s+/).filter(Boolean).length; + const charCount = params.message.length; + const kind = params.statisticsInteractionKind ?? StatisticsInteractionKind.CHAT; + + await this.statisticsService.recordChatInput( + params.clientId, + params.agentId, + wordCount, + charCount, + undefined, + kind, + ); + + const output = await new Promise((resolve, reject) => { + let settled = false; + let lastText = ''; + let lastPlanStatus: AgenstraPlanTurnStatusPayload | undefined; + const t = setTimeout(() => { + if (!settled) { + settled = true; + remote.off('chatMessage', onChatMessage); + remote.off('chatEvent', onChatEvent); + reject(new BadRequestException('Timed out waiting for agent chat response')); + } + }, chatTimeoutMs); + const settle = (text: string, planTurnStatus?: AgenstraPlanTurnStatusPayload) => { + if (settled) { + return; + } + + settled = true; + clearTimeout(t); + remote.off('chatMessage', onChatMessage); + remote.off('chatEvent', onChatEvent); + resolve({ text, planTurnStatus }); + }; + const onChatEvent = (msg: unknown) => { + void Promise.resolve(params.onEvent?.(msg)).catch(() => undefined); + + const delta = this.extractChatEventDelta(msg); + + if (delta) { + lastText = `${lastText}${delta}`; + void Promise.resolve(params.onDeltaText?.(delta)).catch(() => undefined); + } + + const planStatus = extractPlanTurnStatus(msg); + + if (planStatus) { + lastPlanStatus = planStatus; + } + + const eventKind = this.extractChatEventKind(msg); + + // OpenCode stream path emits terminal `assistantMessage` (from `result` / idle) without `chatMessage`. + if (eventKind === 'assistantMessage') { + const assistantText = this.extractChatEventAssistantText(msg); + + if (assistantText) { + lastText = assistantText; + } + + settle(lastText, lastPlanStatus); + } + }; + const onChatMessage = (msg: unknown) => { + const text = this.extractAgentText(msg); + const planStatus = extractPlanTurnStatus(msg); + + if (text) { + lastText = text; + } + + if (planStatus) { + lastPlanStatus = planStatus; + } + + if (this.isFinalAgentResult(msg) || text) { + settle(lastText || text, lastPlanStatus ?? planStatus); + } + }; + + remote.on('chatEvent', onChatEvent); + remote.on('chatMessage', onChatMessage); + remote.emit('chat', { + message: params.message, + correlationId: params.correlationId, + responseMode: 'stream', + ephemeral, + continue: params.continue ?? false, + ...(useChatId ? { chatId: params.chatId } : { resumeSessionSuffix: params.resumeSessionSuffix }), + contextInjection: params.contextInjection, + ...(params.model ? { model: params.model } : {}), + ...(params.unattendedAutomation === true ? { unattendedAutomation: true } : {}), + ...(params.suppressUserMessage === true ? { suppressUserMessage: true } : {}), + }); + }); + const outWords = output.text.trim().split(/\s+/).filter(Boolean).length; + + await this.statisticsService.recordChatOutput( + params.clientId, + params.agentId, + outWords, + output.text.length, + undefined, + kind, + ); + + return output; + } catch (error: unknown) { + this.logger.warn(`sendChatStreaming failed: ${(error as Error).message}`); + throw error instanceof BadRequestException ? error : new BadRequestException('Remote chat failed'); + } finally { + try { + remote.removeAllListeners(); + remote.disconnect(); + } catch { + // ignore + } + } + } } diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-mappers.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-mappers.ts new file mode 100644 index 000000000..ee957ef5b --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-mappers.ts @@ -0,0 +1,27 @@ +import type { ChatPlanResponseDto } from '../dto/chat-plan'; +import type { ChatPlanEntity } from '../entities/chat-plan.entity'; + +export function chatPlanEntityToDto(row: ChatPlanEntity): ChatPlanResponseDto { + return { + id: row.id, + clientId: row.clientId, + agentId: row.agentId, + chatId: row.chatId, + status: row.status, + phase: row.phase, + sourcePrompt: row.sourcePrompt, + planMarkdown: row.planMarkdown ?? null, + summary: row.summary ?? null, + contextInjection: row.contextInjection ?? null, + model: row.model ?? null, + resumeSessionSuffix: row.resumeSessionSuffix, + completionSignalSeen: row.completionSignalSeen === true, + failureCode: row.failureCode ?? null, + failureMessage: row.failureMessage ?? null, + createdByUserId: row.createdByUserId ?? null, + startedAt: row.startedAt, + finishedAt: row.finishedAt ?? null, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + }; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.spec.ts new file mode 100644 index 000000000..1d9511642 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.spec.ts @@ -0,0 +1,35 @@ +import { + buildChatPlanResumeSessionSuffix, + buildExecutePrompt, + buildExplorePrompt, + buildRefinePrompt, +} from './chat-plan-prompt.utils'; + +describe('chat-plan-prompt.utils', () => { + it('buildChatPlanResumeSessionSuffix prefixes plan id', () => { + expect(buildChatPlanResumeSessionSuffix('abc-123')).toBe('-plan-abc-123'); + }); + + it('buildExplorePrompt includes explore-only preamble and source', () => { + const prompt = buildExplorePrompt('Add login page'); + + expect(prompt).toContain('explore-only'); + expect(prompt).toContain('Add login page'); + expect(prompt).toContain('ready'); + }); + + it('buildRefinePrompt includes current plan when present', () => { + const prompt = buildRefinePrompt('Add tests', '## Steps\n1. Foo'); + + expect(prompt).toContain('Add tests'); + expect(prompt).toContain('## Steps'); + }); + + it('buildExecutePrompt wraps plan for agent execute (user bubble suppressed separately)', () => { + const prompt = buildExecutePrompt('## Plan\nDo work', 'Original ask'); + + expect(prompt).toContain('Implement the following plan'); + expect(prompt).toContain('Original ask'); + expect(prompt).toContain('## Plan\nDo work'); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.ts new file mode 100644 index 000000000..2c329766d --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-prompt.utils.ts @@ -0,0 +1,68 @@ +import { AGENSTRA_PLAN_TURN_STATUS_SCHEMA } from '@forepath/agenstra/shared/util-opencode-config'; + +/** Prefix for chat plan-mode hidden OpenCode sessions (`-plan-{planId}`). */ +export const CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX = '-plan-'; + +export function buildChatPlanResumeSessionSuffix(planId: string): string { + return `${CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX}${planId}`; +} + +function planStatusEnumHint(): string { + return AGENSTRA_PLAN_TURN_STATUS_SCHEMA.properties.status.enum.join(' | '); +} + +/** + * Preamble for the first explore turn on a hidden `-plan-{id}` session. + */ +export function buildExplorePromptPreamble(): string { + return ( + `You are running an explore-only planning session. Do not create, edit, patch, or delete files. ` + + `Do not run shell commands that mutate the workspace. Use read, glob, grep, and similar explore tools only. ` + + `Investigate thoroughly, then produce a comprehensive implementation plan. ` + + `When finished with this turn, report structured status (${planStatusEnumHint()}) as JSON only ` + + `(keys: status, planMarkdown, summary). planMarkdown must be pure markdown for the user — ` + + `no preamble, no status/planMarkdown labels inside it. ` + + `Use "ready" only when planMarkdown is complete enough for the user to review and execute; otherwise "exploring". ` + + `Do not ask the user questions.\n\n` + ); +} + +export function buildExplorePrompt(sourcePrompt: string): string { + return `${buildExplorePromptPreamble()}User request:\n${sourcePrompt.trim()}\n`; +} + +/** + * Preamble for a refine turn continuing the same `-plan-{id}` session. + */ +export function buildRefinePromptPreamble(): string { + return ( + `Continue the explore-only planning session. Do not mutate files. ` + + `Incorporate the user's refine instructions into the plan. ` + + `When finished with this turn, report structured status (${planStatusEnumHint()}) as JSON only ` + + `(keys: status, planMarkdown, summary). planMarkdown must be pure markdown for the user. ` + + `Do not ask the user questions.\n\n` + ); +} + +export function buildRefinePrompt(refineMessage: string, currentPlanMarkdown?: string | null): string { + const planSection = + currentPlanMarkdown && currentPlanMarkdown.trim() ? `Current plan draft:\n${currentPlanMarkdown.trim()}\n\n` : ''; + + return `${buildRefinePromptPreamble()}${planSection}` + `Refine instructions:\n${refineMessage.trim()}\n`; +} + +/** + * Leading line of {@link buildExecutePrompt}. Used to hide already-persisted execute triggers in the UI. + */ +export const CHAT_PLAN_EXECUTE_PROMPT_PREFIX = + 'Implement the following plan in the repository. Stay scoped to the plan below.'; + +/** + * Wrapper sent to the agent when the user executes a ready plan. + * The user bubble is suppressed (`suppressUserMessage`); only agent replies appear in chat. + */ +export function buildExecutePrompt(planMarkdown: string, sourcePrompt?: string | null): string { + const requestSection = sourcePrompt && sourcePrompt.trim() ? `Original request:\n${sourcePrompt.trim()}\n\n` : ''; + + return `${CHAT_PLAN_EXECUTE_PROMPT_PREFIX}\n\n` + `${requestSection}` + `## Plan\n\n${planMarkdown.trim()}\n`; +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.spec.ts new file mode 100644 index 000000000..f69408f16 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.spec.ts @@ -0,0 +1,102 @@ +import { + extractPlanMarkdownBlock, + parsePlanTurnStatusFromAssistantText, + resolvePlanMarkdownFromTurn, +} from './chat-plan-turn-status'; + +describe('chat-plan-turn-status', () => { + const messyAssistantText = [ + 'I’ll verify the repository root state without making changes, then provide the requested short plan in the required structured status format.', + 'No file edits will be made in this turn.status: ready', + '', + 'planMarkdown: |', + '', + 'Implementation Plan', + 'Add a new file at the repository root named HELLO_PLAN.md.', + 'Set its contents to exactly:', + 'hello from plan mode', + 'Verify the file contains no extra text beyond that line.', + ].join('\n'); + + it('extracts ready status and planMarkdown from YAML-ish assistant text', () => { + expect(parsePlanTurnStatusFromAssistantText(messyAssistantText)).toEqual({ + status: 'ready', + planMarkdown: [ + 'Implementation Plan', + 'Add a new file at the repository root named HELLO_PLAN.md.', + 'Set its contents to exactly:', + 'hello from plan mode', + 'Verify the file contains no extra text beyond that line.', + ].join('\n'), + }); + }); + + it('resolvePlanMarkdownFromTurn ignores preamble and status wrappers', () => { + const md = resolvePlanMarkdownFromTurn(messyAssistantText, undefined); + + expect(md).toBe( + [ + 'Implementation Plan', + 'Add a new file at the repository root named HELLO_PLAN.md.', + 'Set its contents to exactly:', + 'hello from plan mode', + 'Verify the file contains no extra text beyond that line.', + ].join('\n'), + ); + expect(md).not.toContain('status: ready'); + expect(md).not.toContain('planMarkdown:'); + expect(md).not.toContain('I’ll verify'); + }); + + it('prefers structured turnStatus.planMarkdown when present', () => { + expect( + resolvePlanMarkdownFromTurn('noise', { + status: 'ready', + planMarkdown: '# Clean Plan\n\nDo the thing.', + }), + ).toBe('# Clean Plan\n\nDo the thing.'); + }); + + it('peels nested wrappers when structured planMarkdown is polluted', () => { + const md = resolvePlanMarkdownFromTurn('noise', { + status: 'ready', + planMarkdown: messyAssistantText, + }); + + expect(md).toBe( + [ + 'Implementation Plan', + 'Add a new file at the repository root named HELLO_PLAN.md.', + 'Set its contents to exactly:', + 'hello from plan mode', + 'Verify the file contains no extra text beyond that line.', + ].join('\n'), + ); + expect(md).not.toContain('I’ll verify'); + expect(md).not.toContain('status: ready'); + expect(md).not.toContain('planMarkdown:'); + }); + + it('extracts planMarkdown block during streaming before status arrives', () => { + const partial = 'planMarkdown: |\n\n## Steps\n1. Add file\n'; + + expect(extractPlanMarkdownBlock(partial)).toBe('## Steps\n1. Add file'); + expect(resolvePlanMarkdownFromTurn(partial, undefined)).toBe('## Steps\n1. Add file'); + }); + + it('returns null when text has only preamble chatter', () => { + expect(resolvePlanMarkdownFromTurn('I will explore the repo next.', undefined)).toBeNull(); + }); + + it('parses embedded JSON plan status', () => { + expect( + parsePlanTurnStatusFromAssistantText( + 'Done.\n```json\n{"status":"ready","planMarkdown":"# Plan\\nDo it","summary":"Ready"}\n```', + ), + ).toEqual({ + status: 'ready', + planMarkdown: '# Plan\nDo it', + summary: 'Ready', + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.ts new file mode 100644 index 000000000..7cbe67f5f --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/utils/chat-plan-turn-status.ts @@ -0,0 +1,255 @@ +import { + isAgenstraPlanTurnStatus, + parseAgenstraPlanTurnStatus, + type AgenstraPlanTurnStatusPayload, +} from '@forepath/agenstra/shared/util-opencode-config'; + +export type { AgenstraPlanTurnStatusPayload }; + +/** + * Extract plan turn status from a chatMessage envelope, chatEvent payload, or agent response object. + */ +export function extractPlanTurnStatus(payload: unknown): AgenstraPlanTurnStatusPayload | undefined { + if (!payload || typeof payload !== 'object') { + if (typeof payload === 'string') { + return parsePlanTurnStatusFromAssistantText(payload); + } + + return undefined; + } + + const envelope = payload as { + success?: boolean; + data?: { from?: string; response?: unknown; kind?: string; payload?: unknown }; + planTurnStatus?: unknown; + }; + + if (envelope.planTurnStatus !== undefined) { + return parseAgenstraPlanTurnStatus(envelope.planTurnStatus); + } + + const data = envelope.data; + + if (data && typeof data === 'object') { + const eventPayload = (data as { payload?: unknown }).payload; + + if (eventPayload !== undefined) { + const fromEvent = + typeof eventPayload === 'string' + ? parsePlanTurnStatusFromAssistantText(eventPayload) + : parseAgenstraPlanTurnStatus(eventPayload); + + if (fromEvent) { + return fromEvent; + } + } + } + + const response = envelope.data?.response ?? envelope; + + if (!response || typeof response !== 'object') { + return undefined; + } + + const record = response as Record; + + if (record['planTurnStatus'] !== undefined) { + return parseAgenstraPlanTurnStatus(record['planTurnStatus']); + } + + if (record['status'] !== undefined && isAgenstraPlanTurnStatus(record['status'])) { + return parseAgenstraPlanTurnStatus(record); + } + + if (record['type'] === 'result' && record['result'] !== undefined) { + if (typeof record['result'] === 'string') { + return parsePlanTurnStatusFromAssistantText(record['result']); + } + + return parseAgenstraPlanTurnStatus(record['result']); + } + + if (record['type'] === 'agenstra_turn' && Array.isArray(record['parts'])) { + for (const part of record['parts']) { + const status = extractPlanTurnStatus(part); + + if (status) { + return status; + } + } + } + + return undefined; +} + +/** + * Parse plan turn status from free-form assistant text. + * Handles pure JSON, embedded JSON, and YAML-ish `status` / `planMarkdown` blocks LLMs often emit. + */ +export function parsePlanTurnStatusFromAssistantText(text: string): AgenstraPlanTurnStatusPayload | undefined { + const trimmed = text.trim(); + + if (!trimmed) { + return undefined; + } + + const asStructured = parseAgenstraPlanTurnStatus(trimmed); + + if (asStructured?.planMarkdown || asStructured?.summary) { + return asStructured; + } + + const embeddedJson = extractEmbeddedPlanStatusJson(trimmed); + + if (embeddedJson) { + return embeddedJson; + } + + return extractYamlishPlanTurnStatus(trimmed) ?? asStructured; +} + +/** + * Prefer structured planMarkdown; otherwise extract it from assistant text. + * Never persist preamble / `status:` wrappers as the plan body. + */ +export function resolvePlanMarkdownFromTurn( + text: string, + turnStatus: AgenstraPlanTurnStatusPayload | undefined, +): string | null { + if (turnStatus?.planMarkdown && turnStatus.planMarkdown.trim()) { + return sanitizePlanMarkdown(turnStatus.planMarkdown); + } + + const fromText = parsePlanTurnStatusFromAssistantText(text); + + if (fromText?.planMarkdown && fromText.planMarkdown.trim()) { + return sanitizePlanMarkdown(fromText.planMarkdown); + } + + // Streaming drafts may only have the planMarkdown block so far (no status yet). + const blockOnly = extractPlanMarkdownBlock(text); + + if (blockOnly) { + return sanitizePlanMarkdown(blockOnly); + } + + return null; +} + +function extractEmbeddedPlanStatusJson(text: string): AgenstraPlanTurnStatusPayload | undefined { + const fenced = text.match(/```(?:json)?\s*([\s\S]*?)```/i); + + if (fenced?.[1]) { + const parsed = parseAgenstraPlanTurnStatus(fenced[1].trim()); + + if (parsed) { + return parsed; + } + } + + const start = text.indexOf('{'); + const end = text.lastIndexOf('}'); + + if (start >= 0 && end > start) { + const parsed = parseAgenstraPlanTurnStatus(text.slice(start, end + 1)); + + if (parsed) { + return parsed; + } + } + + return undefined; +} + +function extractYamlishPlanTurnStatus(text: string): AgenstraPlanTurnStatusPayload | undefined { + const statusMatch = text.match(/\bstatus:\s*(ready|exploring)\b/i); + const status = statusMatch?.[1]?.toLowerCase(); + const planMarkdown = extractPlanMarkdownBlock(text); + const summaryMatch = text.match(/(?:^|\n)summary:\s*([^\n]+)/i); + const summary = summaryMatch?.[1]?.trim(); + + if (!status && !planMarkdown) { + return undefined; + } + + if (status && isAgenstraPlanTurnStatus(status)) { + return { + status, + ...(planMarkdown ? { planMarkdown } : {}), + ...(summary ? { summary } : {}), + }; + } + + if (planMarkdown) { + // Block present without an explicit status — treat as draft plan content. + return { status: 'exploring', planMarkdown, ...(summary ? { summary } : {}) }; + } + + return undefined; +} + +/** + * Extract the planMarkdown field body from YAML-ish assistant output. + */ +export function extractPlanMarkdownBlock(text: string): string | null { + const pipeMatch = text.match(/\bplanMarkdown:\s*\|\s*\n([\s\S]*)$/i); + + if (pipeMatch?.[1] !== undefined) { + return trimPlanMarkdownBody(pipeMatch[1]); + } + + const quotedMatch = text.match(/\bplanMarkdown:\s*["']([\s\S]*?)["']\s*(?=\n(?:status|summary)\s*:|$)/i); + + if (quotedMatch?.[1] !== undefined) { + return trimPlanMarkdownBody(quotedMatch[1]); + } + + const plainMatch = text.match(/\bplanMarkdown:\s*([\s\S]+?)(?:\n(?:status|summary)\s*:|$)/i); + + if (plainMatch?.[1] !== undefined) { + const body = trimPlanMarkdownBody(plainMatch[1]); + + // Avoid treating a lone `|` marker as content. + if (body && body !== '|') { + return body; + } + } + + return null; +} + +function trimPlanMarkdownBody(value: string): string | null { + let body = value + .replace(/^\s*\n/, '') + .replace(/\n(?:status|summary)\s*:[\s\S]*$/i, '') + .trim(); + + // Drop a leading `|` when the model put the chomp marker on its own line. + if (body.startsWith('|')) { + body = body.replace(/^\|\s*\n?/, '').trim(); + } + + return body.length > 0 ? body : null; +} + +function sanitizePlanMarkdown(value: string): string { + let body = value.trim(); + + // Structured payloads sometimes embed the full YAML-ish turn (preamble + wrappers) + // inside planMarkdown. Peel nested planMarkdown blocks until stable. + for (let i = 0; i < 3; i += 1) { + const nested = extractPlanMarkdownBlock(body); + + if (!nested || nested === body) { + break; + } + + body = nested; + } + + return body + .replace(/^\s*status:\s*(ready|exploring)\s*$/gim, '') + .replace(/^\s*planMarkdown:\s*\|?\s*$/gim, '') + .replace(/^\s*summary:\s*.*$/gim, '') + .trim(); +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml index 7c242beba..6cd0ad68e 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml @@ -41,9 +41,12 @@ channels: $ref: '#/components/messages/Chat' description: | Client sends a chat message (requires prior login). Payload may include `responseMode` "single" | "stream" | "sync", - `ephemeral` (skip `agent_messages` persistence for the turn), `continue`, `resumeSessionSuffix`, `chatId`, `correlationId`, `model`. + `ephemeral` (skip `agent_messages` persistence for the turn), `suppressUserMessage` (send to agent but hide the user + bubble; used by chat-plan execute), `continue`, `resumeSessionSuffix`, `chatId`, `correlationId`, `model`. When `chatId` is set (and `resumeSessionSuffix` is not a reserved hidden ACP suffix), messages and events persist and emit under that user-visible chat session; otherwise the primary session is used. + Reserved hidden suffixes include `-prompt-enhance`, `-ticket-body`, `-ticket-auto-*`, and `-plan-{planId}` (explore-only + permissions; not automation allow-all). Those sessions are ephemeral/hidden and do not persist into `agent_messages`. When `ephemeral` is true, `chatMessage`, `chatEvent`, and `messageFilterResult` for that turn are emitted only to the requesting socket (not broadcast to other clients on the same agent). Otherwise those events are broadcast to every socket authenticated to the agent. The gateway forwards options to `sendMessage` as implemented in code. @@ -550,6 +553,12 @@ components: description: > When true, skips persisting user and assistant messages for this turn and emits chatMessage, chatEvent, and messageFilterResult for this turn only to the requesting socket (no fan-out to other viewers on the agent). + suppressUserMessage: + type: boolean + description: > + When true, the user prompt is still sent to the agent but is not persisted or emitted as a user chatMessage + / userMessage chatEvent. Assistant traffic still follows `ephemeral`. Used by chat-plan execute so the + injected plan prompt stays out of the visible thread while agent work appears in chat. continue: type: boolean description: Optional continuation flag forwarded to the agent sendMessage flow. @@ -560,7 +569,9 @@ components: type: string description: > Optional user-visible chat session id. When omitted, the primary session is used. - Ignored when resumeSessionSuffix is a reserved hidden ACP suffix (prompt enhance, ticket body, automation). + Ignored when resumeSessionSuffix is a reserved hidden ACP suffix + (prompt enhance, ticket body, automation `-ticket-auto-*`, or plan `-plan-{planId}`). + Plan suffixes use explore-only session permissions (deny writes); automation suffixes use allow-all. contextInjection: type: object description: > diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts index 3dbafe4ff..c695fe5ba 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts @@ -2,7 +2,9 @@ import { PRIMARY_CHAT_RESUME_SESSION_SUFFIX, RESERVED_CHAT_RESUME_SESSION_SUFFIXES, TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES, + buildChatPlanResumeSessionSuffix, buildUserChatResumeSessionSuffix, + isChatPlanResumeSessionSuffix, isReservedChatResumeSessionSuffix, isTicketAutomationLoopResumeSessionSuffix, isTicketAutomationResumeSessionSuffix, @@ -22,6 +24,10 @@ describe('chat-session.constants', () => { } }); + it('returns true for dynamic plan suffixes', () => { + expect(isReservedChatResumeSessionSuffix('-plan-11111111-2222-4333-8444-555555555555')).toBe(true); + }); + it('returns false for primary empty suffix', () => { expect(isReservedChatResumeSessionSuffix(PRIMARY_CHAT_RESUME_SESSION_SUFFIX)).toBe(false); }); @@ -35,6 +41,20 @@ describe('chat-session.constants', () => { }); }); + describe('isChatPlanResumeSessionSuffix', () => { + it('returns true only for -plan-{id} suffixes', () => { + expect(isChatPlanResumeSessionSuffix('-plan-abc')).toBe(true); + expect(isChatPlanResumeSessionSuffix('-plan-')).toBe(false); + expect(isChatPlanResumeSessionSuffix('-ticket-auto-loop')).toBe(false); + expect(isChatPlanResumeSessionSuffix('-chat-abc')).toBe(false); + expect(isChatPlanResumeSessionSuffix(undefined)).toBe(false); + }); + + it('is not classified as ticket automation', () => { + expect(isTicketAutomationResumeSessionSuffix('-plan-abc')).toBe(false); + }); + }); + describe('isTicketAutomationResumeSessionSuffix', () => { it('returns true for ticket automation suffixes only', () => { for (const suffix of TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES) { @@ -62,4 +82,12 @@ describe('chat-session.constants', () => { expect(buildUserChatResumeSessionSuffix(chatId)).toBe(`-chat-${chatId}`); }); }); + + describe('buildChatPlanResumeSessionSuffix', () => { + it('builds -plan-{id} suffix', () => { + const planId = '11111111-2222-4333-8444-555555555555'; + + expect(buildChatPlanResumeSessionSuffix(planId)).toBe(`-plan-${planId}`); + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts index b7177de59..60eba7696 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts @@ -1,6 +1,6 @@ /** * User-visible chat session kinds stored in `agent_chat_sessions`. - * Hidden ACP suffixes (prompt enhance, ticket body, automation) are not persisted as rows. + * Hidden ACP suffixes (prompt enhance, ticket body, automation, plan) are not persisted as rows. */ export const AGENT_CHAT_SESSION_KINDS = ['primary', 'user'] as const; @@ -12,9 +12,13 @@ export const PRIMARY_CHAT_RESUME_SESSION_SUFFIX = ''; /** Prefix for user-created chat ACP suffixes (`-chat-{uuid}`). */ export const USER_CHAT_RESUME_SESSION_SUFFIX_PREFIX = '-chat-'; +/** Prefix for chat plan-mode hidden OpenCode sessions (`-plan-{planId}`). */ +export const CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX = '-plan-'; + /** * Reserved ACP resumeSessionSuffix values used by background/hidden flows. * These must never collide with user-visible chat session suffixes. + * Dynamic `-plan-{uuid}` suffixes are also reserved via {@link isChatPlanResumeSessionSuffix}. */ export const RESERVED_CHAT_RESUME_SESSION_SUFFIXES = [ '-prompt-enhance', @@ -31,11 +35,30 @@ export const TICKET_AUTOMATION_RESUME_SESSION_SUFFIXES = [ '-ticket-auto-commit-msg', ] as const; +export function isChatPlanResumeSessionSuffix(suffix: string | undefined): boolean { + if (suffix === undefined || suffix === PRIMARY_CHAT_RESUME_SESSION_SUFFIX) { + return false; + } + + return ( + suffix.startsWith(CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX) && + suffix.length > CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX.length + ); +} + +export function buildChatPlanResumeSessionSuffix(planId: string): string { + return `${CHAT_PLAN_RESUME_SESSION_SUFFIX_PREFIX}${planId}`; +} + export function isReservedChatResumeSessionSuffix(suffix: string | undefined): boolean { if (suffix === undefined || suffix === PRIMARY_CHAT_RESUME_SESSION_SUFFIX) { return false; } + if (isChatPlanResumeSessionSuffix(suffix)) { + return true; + } + return (RESERVED_CHAT_RESUME_SESSION_SUFFIXES as readonly string[]).includes(suffix); } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts index 1370e6e83..6c4d37328 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.spec.ts @@ -29,6 +29,7 @@ interface ChatPayload { model?: string; responseMode?: 'stream' | 'sync' | 'single'; ephemeral?: boolean; + suppressUserMessage?: boolean; correlationId?: string; resumeSessionSuffix?: string; chatId?: string; @@ -2424,6 +2425,76 @@ describe('AgentsGateway', () => { expect(mockAgentMessageEventsService.persistEvent).toHaveBeenCalled(); }); + + it('suppresses user chatMessage when suppressUserMessage is true but still emits agent reply', async () => { + const requesterSocketId = mockSocket.id || 'test-socket-id'; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(requesterSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(requesterSocketId, mockSocket); + + agentsService.findOne.mockResolvedValue(mockAgentResponse); + agentsRepository.findById.mockResolvedValue(mockAgent); + agentMessagesService.getChatHistory.mockResolvedValue([ + { + id: 'msg-1', + agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', + agent: mockAgent, + actor: 'user', + message: 'Previous message', + filtered: false, + createdAt: new Date(), + updatedAt: new Date(), + }, + ] as any); + + const mockAgentResponseJson = JSON.stringify({ + type: 'result', + subtype: 'success', + is_error: false, + result: 'Plan executed', + }); + const mockParsedResponse = { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Plan executed', + }; + + mockAgentProvider.sendMessage.mockResolvedValue(mockAgentResponseJson); + mockAgentProvider.toParseableStrings.mockReturnValue([mockAgentResponseJson]); + mockAgentProvider.toUnifiedResponse.mockReturnValue(mockParsedResponse); + + await gateway.handleChat( + { + message: 'Implement the following plan in the repository. Stay scoped to the plan below.\n\n## Plan\nDo it', + suppressUserMessage: true, + responseMode: 'sync', + correlationId: 'plan-execute-corr-1', + }, + mockSocket as Socket, + ); + + expect(mockSocket.emit).not.toHaveBeenCalledWith( + 'chatMessage', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ from: 'user' }), + }), + ); + expect(mockSocket.emit).toHaveBeenCalledWith( + 'chatMessage', + expect.objectContaining({ + success: true, + data: expect.objectContaining({ from: 'agent' }), + }), + ); + expect(agentMessagesService.createUserMessage).not.toHaveBeenCalled(); + expect(agentMessagesService.createAgentMessage).toHaveBeenCalled(); + expect(mockAgentProvider.sendMessage).toHaveBeenCalled(); + }); }); describe('streaming mode', () => { diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts index d957dd97b..7ed37e1f1 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts @@ -62,6 +62,11 @@ interface ChatPayload { responseMode?: AgentResponseMode; /** When true, do not persist user/agent rows in `agent_messages` (background / autonomous runs). */ ephemeral?: boolean; + /** + * When true, send the message to the agent but do not persist/emit the user chatMessage + * (or userMessage chatEvent). Agent responses still follow `ephemeral`. Used for chat-plan execute. + */ + suppressUserMessage?: boolean; continue?: boolean; resumeSessionSuffix?: string; /** @@ -1644,6 +1649,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, // Reserved ACP suffixes must never persist/broadcast into user-visible chat history. const ephemeral = data.ephemeral === true || chatContext.hidden; + const suppressUserMessage = data.suppressUserMessage === true; const { chatSessionId, chatId } = chatContext; const chatIdFields = chatId ? { chatId } : {}; // Apply incoming filters before processing (single hook point for incoming messages) @@ -1730,24 +1736,26 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, this.agentsWithFirstMessageSent.add(agentUuid); } - await this.emitUserChatMessage(agentUuid, ephemeral, socket, filteredMessage, chatTimestamp, { - chatSessionId, - chatIdFields, - filtered: incomingFilterResult.status === 'filtered', - }); + if (!suppressUserMessage) { + await this.emitUserChatMessage(agentUuid, ephemeral, socket, filteredMessage, chatTimestamp, { + chatSessionId, + chatIdFields, + filtered: incomingFilterResult.status === 'filtered', + }); - this.emitOrPersistChatEvent( - agentUuid, - ephemeral, - socket, - { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'userMessage', - payload: { text: filteredMessage }, - }, - chatSessionId, - chatId, - ); + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'userMessage', + payload: { text: filteredMessage }, + }, + chatSessionId, + chatId, + ); + } if (contextInjection) { this.emitOrPersistChatEvent( diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts index 755b657d2..9305451c5 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-config-sync.service.ts @@ -15,6 +15,8 @@ import { resolveProviderAuthSecrets, AGENSTRA_TICKET_AUTOMATION_SKILL_ABS_DIR, AGENSTRA_TICKET_AUTOMATION_SKILL_MD, + AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR, + AGENSTRA_CHAT_PLAN_SKILL_MD, type JsonObject, } from '@forepath/agenstra/shared/util-opencode-config'; import { mcpServerConfigKey } from '@forepath/agenstra/shared/util-opencode-mcp-servers'; @@ -417,6 +419,7 @@ export class OpenCodeConfigSyncService { } await this.ensurePlatformAutomationSkillFiles(containerId); + await this.ensurePlatformPlanSkillFiles(containerId); const client = await this.clientFactory.getClient(agentId, containerId); const authSecrets = resolveProviderAuthSecrets(secrets, prepared); @@ -555,6 +558,76 @@ mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, ); } + /** Install the platform chat-plan skill so OpenCode can load it regardless of UI overlays. */ + private async ensurePlatformPlanSkillFiles(containerId: string): Promise { + const filePath = `${AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR}/SKILL.md`; + + try { + await this.installPlatformSkillFile( + containerId, + filePath, + ['/opt', '/opt/agenstra', '/opt/agenstra/skills'], + AGENSTRA_CHAT_PLAN_SKILL_MD, + ); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Failed to install platform plan skill at ${filePath}: ${message}`); + } + } + + private async installPlatformSkillFile( + containerId: string, + filePath: string, + parents: string[], + skillMarkdown: string, + ): Promise { + const dir = filePath.slice(0, filePath.lastIndexOf('/')); + const base64 = Buffer.from(skillMarkdown, 'utf8').toString('base64'); + const parentList = parents.map((parent) => JSON.stringify(parent)).join(' '); + + await this.dockerService.sendCommandToContainer( + containerId, + [ + '/usr/bin/env', + '-i', + 'PATH=/usr/bin:/bin', + '/bin/sh', + '-c', + `set -eu +for parent in ${parentList}; do + if [ -L "$parent" ]; then + printf 'Unsafe platform skill ancestor: %s\\n' "$parent" >&2 + exit 1 + fi + if [ ! -e "$parent" ]; then + mkdir -m 755 -- "$parent" + fi + owner=$(stat -c %u -- "$parent") + mode=$(stat -c %a -- "$parent") + if [ ! -d "$parent" ] || [ "$owner" != 0 ] || [ "$((0$mode & 022))" -ne 0 ]; then + printf 'Untrusted platform skill ancestor: %s\\n' "$parent" >&2 + exit 1 + fi +done +dir=${JSON.stringify(dir)} +if [ -L "$dir" ] || { [ -e "$dir" ] && [ ! -d "$dir" ]; }; then + printf 'Unsafe platform skill directory: %s\\n' "$dir" >&2 + exit 1 +fi +install -d -m 755 -o root -g root -- "$dir" +tmp=$(mktemp "$dir/.SKILL.md.XXXXXX") +trap 'rm -f -- "$tmp"' EXIT +printf '%s' ${JSON.stringify(base64)} | base64 -d > "$tmp" +chmod 644 -- "$tmp" +mv -fT -- "$tmp" ${JSON.stringify(filePath)}`, + ], + undefined, + true, + { user: '0' }, + ); + } + /** * Apply network proxy/CA secrets, provider credential env vars, and MCP secretEnv * via Docker Env. Inline PEM CA material is written to a file; env points at that path. diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts index 7ccff3d33..0f2379bda 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-runtime.service.ts @@ -2,11 +2,14 @@ import { Injectable, Logger, Optional } from '@nestjs/common'; import { AGENSTRA_AUTOMATION_AGENT_NAME, AGENSTRA_AUTOMATION_TURN_STATUS_SCHEMA, + isAgenstraPlanExplorePermission, + isAgenstraPlanWritePermission, parseAgenstraAutomationTurnStatus, type AgenstraAutomationTurnStatus, } from '@forepath/agenstra/shared/util-opencode-config'; import { + isChatPlanResumeSessionSuffix, isTicketAutomationLoopResumeSessionSuffix, isTicketAutomationResumeSessionSuffix, } from '../../constants/chat-session.constants'; @@ -285,6 +288,50 @@ export class OpenCodeRuntimeService { void run(); } + private extractPermissionType(obj: AgentResponseObject): string | undefined { + const result = obj.result; + + if (result && typeof result === 'object' && 'permissionType' in result) { + const permissionType = (result as { permissionType?: unknown }).permissionType; + + return typeof permissionType === 'string' ? permissionType : undefined; + } + + return undefined; + } + + /** + * Plan sessions: auto-allow explore permissions, reject write/mutation (and unknown) asks. + * Non-permission questions are rejected so the explore turn cannot stall on interactive forms. + */ + private autoReplyPlanInteraction( + key: OpenCodeSessionKey, + questionId: string, + kind: PendingInteractionKind, + permissionType: string | undefined, + ): void { + const run = async (): Promise => { + try { + if (kind === 'permission') { + const reply: PermissionReply = + isAgenstraPlanExplorePermission(permissionType) && !isAgenstraPlanWritePermission(permissionType) + ? 'always' + : 'reject'; + + await this.replyPermission(key.agentId, key.containerId, questionId, reply); + } else { + await this.replyQuestion(key.agentId, key.containerId, questionId, undefined, true); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + + this.logger.warn(`Plan auto-reply failed for ${kind} ${questionId} on agent ${key.agentId}: ${message}`); + } + }; + + void run(); + } + private async *promptAndDrain( key: OpenCodeSessionKey, message: string, @@ -300,6 +347,7 @@ export class OpenCodeRuntimeService { let automationTurnStatus: AgenstraAutomationTurnStatus | undefined; const automationSession = isTicketAutomationResumeSessionSuffix(key.resumeSessionSuffix); const automationLoop = isTicketAutomationLoopResumeSessionSuffix(key.resumeSessionSuffix); + const planSession = isChatPlanResumeSessionSuffix(key.resumeSessionSuffix); const queue: AgentResponseObject[] = []; const notify = (() => { @@ -331,6 +379,8 @@ export class OpenCodeRuntimeService { if (automationSession) { this.autoReplyAutomationInteraction(key, obj.questionId, kind); + } else if (planSession) { + this.autoReplyPlanInteraction(key, obj.questionId, kind, this.extractPermissionType(obj)); } } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts index caa6d76dd..48e156f01 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.spec.ts @@ -60,4 +60,40 @@ describe('OpenCodeSessionService', () => { }, }); }); + + it('creates plan sessions with explore-only permission and platform plan agent', async () => { + const create = jest.fn().mockResolvedValue({ data: { id: 'ses_plan' } }); + const clientFactory = { + getClient: jest.fn().mockResolvedValue({ + session: { + get: jest.fn().mockRejectedValue(new Error('missing')), + create, + }, + }), + }; + const agentsRepository = { + findPersistedAcpSessionId: jest.fn().mockResolvedValue(null), + saveAcpSession: jest.fn().mockResolvedValue(undefined), + }; + const service = new OpenCodeSessionService(clientFactory as never, agentsRepository as never); + + await service.getOrCreateSessionId({ + agentId: 'agent-1', + containerId: 'ctr-1', + resumeSessionSuffix: '-plan-11111111-2222-4333-8444-555555555555', + }); + + expect(create).toHaveBeenCalledWith({ + body: { + title: 'agenstra (-plan-11111111-2222-4333-8444-555555555555)', + agent: 'agenstra-plan', + permission: expect.arrayContaining([ + { permission: 'read', pattern: '*', action: 'allow' }, + { permission: 'edit', pattern: '*', action: 'deny' }, + { permission: 'write', pattern: '*', action: 'deny' }, + { permission: 'bash', pattern: '*', action: 'deny' }, + ]), + }, + }); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts index aa340509e..2a787f563 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/opencode/opencode-session.service.ts @@ -2,9 +2,14 @@ import { Injectable, Logger } from '@nestjs/common'; import { AGENSTRA_AUTOMATION_AGENT_NAME, AGENSTRA_AUTOMATION_SESSION_PERMISSION_RULESET, + AGENSTRA_PLAN_AGENT_NAME, + AGENSTRA_PLAN_SESSION_PERMISSION_RULESET, } from '@forepath/agenstra/shared/util-opencode-config'; -import { isTicketAutomationResumeSessionSuffix } from '../../constants/chat-session.constants'; +import { + isChatPlanResumeSessionSuffix, + isTicketAutomationResumeSessionSuffix, +} from '../../constants/chat-session.constants'; import { AgentsRepository } from '../../repositories/agents.repository'; import type { AgentProviderOptions } from '../agent-provider.interface'; @@ -68,6 +73,7 @@ export class OpenCodeSessionService { private async createSession(client: OpencodeClient, key: OpenCodeSessionKey): Promise { const titleSuffix = key.resumeSessionSuffix ? ` (${key.resumeSessionSuffix})` : ''; const automation = isTicketAutomationResumeSessionSuffix(key.resumeSessionSuffix); + const plan = isChatPlanResumeSessionSuffix(key.resumeSessionSuffix); const result = await client.session.create({ body: { title: `agenstra${titleSuffix}`, @@ -76,7 +82,12 @@ export class OpenCodeSessionService { agent: AGENSTRA_AUTOMATION_AGENT_NAME, permission: [...AGENSTRA_AUTOMATION_SESSION_PERMISSION_RULESET], } - : {}), + : plan + ? { + agent: AGENSTRA_PLAN_AGENT_NAME, + permission: [...AGENSTRA_PLAN_SESSION_PERMISSION_RULESET], + } + : {}), }, }); @@ -84,7 +95,7 @@ export class OpenCodeSessionService { const message = result.error && typeof result.error === 'object' && 'message' in result.error ? String((result.error as { message?: unknown }).message) - : 'Unknown OpenCode session create error'; + : 'Unknown OpenCode session creation error'; throw new Error(`OpenCode session creation failed: ${message}`); } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts index 3a8b7487f..5b00157a6 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/index.ts @@ -199,6 +199,7 @@ export * from './lib/state/knowledge/knowledge.reducer'; export * from './lib/state/knowledge/knowledge.selectors'; export * from './lib/state/knowledge/knowledge.types'; export * from './lib/state/container-socket/client-chat-automation.constants'; +export * from './lib/state/container-socket/client-chat-plan.constants'; export * from './lib/state/container-socket/container-socket.actions'; export * from './lib/state/container-socket/container-socket.effects'; export * from './lib/state/container-socket/container-socket.facade'; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.actions.ts index a4d1d7f53..a10aedde9 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.actions.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.actions.ts @@ -2,6 +2,7 @@ import { createAction, props } from '@ngrx/store'; import type { AgentEventEnvelope, + ChatPlanChatEventPayload, ChatTimelineBatchMessage, ChatTimelineCorrelationResult, ChatTimelineFilterResult, @@ -50,6 +51,11 @@ export const chatTimelineAutomationUpsert = createAction( props<{ payload: TicketAutomationRunChatEventPayload }>(), ); +export const chatTimelinePlanUpsert = createAction( + '[Chat Timeline] Plan Upsert', + props<{ payload: ChatPlanChatEventPayload }>(), +); + export const chatTimelineRestoreSuccess = createAction( '[Chat Timeline] Restore Success', props<{ diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.facade.ts index 7fcd616f7..183975f33 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.facade.ts @@ -11,10 +11,12 @@ import { import { selectChatEnhancementLastResult, selectChatEnhancementPending, + selectChatPlanBusyForSelectedChat, selectChatTimelineError, selectChatTimelineEvents, selectChatTimelineMessages, selectChatTimelineOrdered, + selectChatTimelinePlans, selectHasMoreOlder, selectLoadingInitial, selectLoadingOlder, @@ -29,6 +31,7 @@ import type { ChatTimelineEventRow, ChatTimelineFilterResult, ChatTimelineMessageRow, + ChatTimelinePlanRow, } from './chat-timeline.types'; @Injectable({ @@ -42,6 +45,8 @@ export class ChatTimelineFacade { readonly events$: Observable = this.store.select(selectChatTimelineEvents); readonly messageFilterResults$: Observable = this.store.select(selectMessageFilterResults); + readonly plans$: Observable = this.store.select(selectChatTimelinePlans); + readonly chatPlanBusy$: Observable = this.store.select(selectChatPlanBusyForSelectedChat); readonly hasMoreOlder$: Observable = this.store.select(selectHasMoreOlder); readonly oldestMessageId$: Observable = this.store.select(selectOldestMessageId); readonly loadingInitial$: Observable = this.store.select(selectLoadingInitial); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.spec.ts index eab3c65ac..2b77331a2 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.spec.ts @@ -5,6 +5,7 @@ import { chatTimelineBatchReceived, chatTimelineClear, chatTimelineMessageReceived, + chatTimelinePlanUpsert, chatTimelineRestoreRequested, } from './chat-timeline.actions'; import { chatTimelineReducer, initialChatTimelineState, type ChatTimelineState } from './chat-timeline.reducer'; @@ -31,7 +32,7 @@ describe('chatTimelineReducer', () => { expect(state).toEqual(initialChatTimelineState); }); - it('clears timeline to initial state', () => { + it('clears timeline to initial state including plans', () => { const prev: ChatTimelineState = { ...initialChatTimelineState, messages: [ @@ -43,6 +44,39 @@ describe('chatTimelineReducer', () => { chatId, }, ], + plans: [ + { + event: 'chatPlanUpsert', + timestamp: 500, + payload: { + timelineAt: new Date(500).toISOString(), + hydrate: false, + plan: { + id: 'p1', + clientId: 'c1', + agentId: 'a1', + chatId, + status: 'exploring', + phase: 'explore', + sourcePrompt: 'x', + planMarkdown: null, + summary: null, + contextInjection: null, + model: null, + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: false, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: new Date(500).toISOString(), + finishedAt: null, + createdAt: new Date(500).toISOString(), + updatedAt: new Date(500).toISOString(), + }, + actions: [], + }, + }, + ], hasMoreOlder: true, oldestMessageId: 'm1', loadingOlder: true, @@ -52,6 +86,7 @@ describe('chatTimelineReducer', () => { const state = chatTimelineReducer(prev, chatTimelineClear()); expect(state).toEqual(initialChatTimelineState); + expect(state.plans).toEqual([]); }); it('sets loadingInitial or loadingOlder on restore requested', () => { @@ -265,4 +300,57 @@ describe('chatTimelineReducer', () => { expect(state.automations).toHaveLength(1); expect(state.automations[0]?.payload.run.status).toBe('succeeded'); }); + + it('dedupes plans by plan.id', () => { + const basePayload = { + timelineAt: new Date(500).toISOString(), + hydrate: false, + plan: { + id: 'p1', + clientId: 'c1', + agentId: 'a1', + chatId, + status: 'exploring' as const, + phase: 'explore' as const, + sourcePrompt: 'plan', + planMarkdown: null, + summary: null, + contextInjection: null, + model: null, + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: false, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: new Date(500).toISOString(), + finishedAt: null, + createdAt: new Date(500).toISOString(), + updatedAt: new Date(500).toISOString(), + }, + actions: [] as [], + }; + + let state = chatTimelineReducer(initialChatTimelineState, chatTimelinePlanUpsert({ payload: basePayload })); + + state = chatTimelineReducer( + state, + chatTimelinePlanUpsert({ + payload: { + ...basePayload, + timelineAt: new Date(900).toISOString(), + plan: { + ...basePayload.plan, + status: 'ready', + phase: 'ready', + planMarkdown: '# Done', + updatedAt: new Date(900).toISOString(), + }, + }, + }), + ); + + expect(state.plans).toHaveLength(1); + expect(state.plans[0]?.payload.plan.status).toBe('ready'); + expect(state.plans[0]?.payload.plan.planMarkdown).toBe('# Done'); + }); }); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.ts index dd3ba3460..34a386fff 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.reducer.ts @@ -1,6 +1,7 @@ import { createReducer, on } from '@ngrx/store'; import { CLIENT_CHAT_AUTOMATION_SOCKET_EVENT } from '../container-socket/client-chat-automation.constants'; +import { CLIENT_CHAT_PLAN_SOCKET_EVENT } from '../container-socket/client-chat-plan.constants'; import { chatEnhancementStarted, @@ -13,6 +14,7 @@ import { chatTimelineForwardEnhanceFailure, chatTimelineForwardTicketBodyFailure, chatTimelineMessageReceived, + chatTimelinePlanUpsert, chatTimelineRestoreRequested, chatTimelineRestoreSuccess, chatTimelineTicketBodyResult, @@ -21,12 +23,14 @@ import { import type { AgentEventEnvelope, ChatMessageData, + ChatPlanChatEventPayload, ChatTimelineAutomationRow, ChatTimelineBatchMessage, ChatTimelineCorrelationResult, ChatTimelineEventRow, ChatTimelineFilterResult, ChatTimelineMessageRow, + ChatTimelinePlanRow, MessageFilterResultData, SuccessResponse, TicketAutomationRunChatEventPayload, @@ -37,6 +41,7 @@ export interface ChatTimelineState { events: ChatTimelineEventRow[]; filterResults: ChatTimelineFilterResult[]; automations: ChatTimelineAutomationRow[]; + plans: ChatTimelinePlanRow[]; hasMoreOlder: boolean; oldestMessageId: string | null; loadingInitial: boolean; @@ -53,6 +58,7 @@ export const initialChatTimelineState: ChatTimelineState = { events: [], filterResults: [], automations: [], + plans: [], hasMoreOlder: false, oldestMessageId: null, loadingInitial: false, @@ -199,6 +205,32 @@ function upsertAutomation( return next; } +function upsertPlan(existing: ChatTimelinePlanRow[], payload: ChatPlanChatEventPayload): ChatTimelinePlanRow[] { + const planId = payload.plan?.id; + const timestamp = parseTimestamp(payload.timelineAt, Date.now()); + const row: ChatTimelinePlanRow = { + event: CLIENT_CHAT_PLAN_SOCKET_EVENT, + payload, + timestamp, + }; + + if (!planId) { + return [...existing, row]; + } + + const index = existing.findIndex((item) => item.payload.plan?.id === planId); + + if (index < 0) { + return [...existing, row]; + } + + const next = [...existing]; + + next[index] = row; + + return next; +} + export const chatTimelineReducer = createReducer( initialChatTimelineState, on(chatTimelineClear, () => ({ ...initialChatTimelineState })), @@ -282,6 +314,10 @@ export const chatTimelineReducer = createReducer( ...state, automations: upsertAutomation(state.automations, payload), })), + on(chatTimelinePlanUpsert, (state, { payload }) => ({ + ...state, + plans: upsertPlan(state.plans, payload), + })), on(chatTimelineRestoreSuccess, (state, { hasMoreOlder, oldestMessageId }) => ({ ...state, hasMoreOlder, diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.spec.ts index 95c4ecc54..637df9a1f 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.spec.ts @@ -1,8 +1,13 @@ import { CLIENT_CHAT_AUTOMATION_SOCKET_EVENT } from '../container-socket/client-chat-automation.constants'; -import { ChatActor, type ForwardedEventPayload } from '../container-socket/container-socket.types'; +import { CLIENT_CHAT_PLAN_SOCKET_EVENT } from '../container-socket/client-chat-plan.constants'; +import { + ChatActor, + type ChatPlanChatEventPayload, + type ForwardedEventPayload, +} from '../container-socket/container-socket.types'; -import { selectChatTimelineOrdered } from './chat-timeline.selectors'; -import type { ChatTimelineAutomationRow, ChatTimelineMessageRow } from './chat-timeline.types'; +import { selectChatPlanBusyForSelectedChat, selectChatTimelineOrdered } from './chat-timeline.selectors'; +import type { ChatTimelineAutomationRow, ChatTimelineMessageRow, ChatTimelinePlanRow } from './chat-timeline.types'; const primarySession = { id: 'chat-primary', @@ -87,18 +92,68 @@ function automationRow(agentId: string, timelineAtMs: number, status = 'running' }; } +function planRow( + agentId: string, + chatId: string, + timelineAtMs: number, + status: ChatPlanChatEventPayload['plan']['status'] = 'exploring', + planId = 'p1', +): ChatTimelinePlanRow { + const iso = new Date(timelineAtMs).toISOString(); + + return { + event: CLIENT_CHAT_PLAN_SOCKET_EVENT, + timestamp: timelineAtMs, + payload: { + timelineAt: iso, + hydrate: false, + plan: { + id: planId, + clientId: 'c1', + agentId, + chatId, + status, + phase: status === 'ready' ? 'ready' : 'explore', + sourcePrompt: 'plan this', + planMarkdown: status === 'ready' ? '# Plan' : null, + summary: 'Summary', + contextInjection: null, + model: null, + resumeSessionSuffix: `-plan-${planId}`, + completionSignalSeen: false, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: iso, + finishedAt: null, + createdAt: iso, + updatedAt: iso, + }, + actions: [{ type: 'openChatPlan', planId, chatId, label: 'View plan' }], + }, + }; +} + describe('selectChatTimelineOrdered', () => { it('orders chat and automation by semantic timestamp and dedupes automation by run id', () => { const messages = [chatMessageRow('hi', 1000)]; const automations = [automationRow('a1', 500, 'running'), automationRow('a1', 800, 'succeeded')]; - const out = selectChatTimelineOrdered.projector(messages, automations, 'a1', 'c1', { 'c1:a1': null }, sessionsMap); + const out = selectChatTimelineOrdered.projector( + messages, + automations, + [], + 'a1', + 'c1', + { 'c1:a1': null }, + sessionsMap, + ); expect(out.map((r) => r.event)).toEqual([CLIENT_CHAT_AUTOMATION_SOCKET_EVENT, 'chatMessage']); expect((out[0]?.payload as ReturnType['payload']).run.status).toBe('succeeded'); }); it('filters automation when selected agent does not match', () => { - const out = selectChatTimelineOrdered.projector([], [automationRow('other', 500)], 'a1', 'c1', {}, sessionsMap); + const out = selectChatTimelineOrdered.projector([], [automationRow('other', 500)], [], 'a1', 'c1', {}, sessionsMap); expect(out).toHaveLength(0); }); @@ -109,6 +164,7 @@ describe('selectChatTimelineOrdered', () => { const onPrimary = selectChatTimelineOrdered.projector( [], automations, + [], 'a1', 'c1', { 'c1:a1': primarySession.id }, @@ -117,6 +173,7 @@ describe('selectChatTimelineOrdered', () => { const onUser = selectChatTimelineOrdered.projector( [], automations, + [], 'a1', 'c1', { 'c1:a1': userSession.id }, @@ -127,10 +184,58 @@ describe('selectChatTimelineOrdered', () => { expect(onUser).toHaveLength(0); }); + it('merges chat-scoped plan cards and keeps automation primary-only', () => { + const plans = [planRow('a1', userSession.id, 700)]; + const automations = [automationRow('a1', 500)]; + + const onUser = selectChatTimelineOrdered.projector( + [chatMessageRow('hi', 1000, userSession.id)], + automations, + plans, + 'a1', + 'c1', + { 'c1:a1': userSession.id }, + sessionsMap, + ); + const onPrimary = selectChatTimelineOrdered.projector( + [], + automations, + [planRow('a1', primarySession.id, 700)], + 'a1', + 'c1', + { 'c1:a1': primarySession.id }, + sessionsMap, + ); + + expect(onUser.map((r) => r.event)).toEqual([CLIENT_CHAT_PLAN_SOCKET_EVENT, 'chatMessage']); + expect(onPrimary.map((r) => r.event)).toEqual([CLIENT_CHAT_AUTOMATION_SOCKET_EVENT, CLIENT_CHAT_PLAN_SOCKET_EVENT]); + }); + + it('filters plans by selected chatId and agent', () => { + const plans = [ + planRow('a1', 'chat-a', 500, 'ready', 'p-a'), + planRow('a1', 'chat-b', 600, 'ready', 'p-b'), + planRow('other', 'chat-a', 700, 'ready', 'p-other'), + ]; + const out = selectChatTimelineOrdered.projector([], [], plans, 'a1', 'c1', { 'c1:a1': 'chat-a' }, sessionsMap); + + expect(out).toHaveLength(1); + expect((out[0]?.payload as ChatPlanChatEventPayload).plan.id).toBe('p-a'); + }); + + it('dedupes plans by plan id keeping latest timelineAt', () => { + const plans = [planRow('a1', 'chat-a', 500, 'exploring', 'p1'), planRow('a1', 'chat-a', 800, 'ready', 'p1')]; + const out = selectChatTimelineOrdered.projector([], [], plans, 'a1', 'c1', { 'c1:a1': 'chat-a' }, sessionsMap); + + expect(out).toHaveLength(1); + expect((out[0]?.payload as ChatPlanChatEventPayload).plan.status).toBe('ready'); + }); + it('filters chat messages by selected chatId when present', () => { const out = selectChatTimelineOrdered.projector( [chatMessageRow('keep', 1000, 'chat-a', 'm1'), chatMessageRow('drop', 2000, 'chat-b', 'm2')], [], + [], 'a1', 'c1', { 'c1:a1': 'chat-a' }, @@ -147,6 +252,7 @@ describe('selectChatTimelineOrdered', () => { const out = selectChatTimelineOrdered.projector( [chatMessageRow('legacy', 1000, undefined, 'm1')], [], + [], 'a1', 'c1', { 'c1:a1': 'chat-a' }, @@ -156,3 +262,26 @@ describe('selectChatTimelineOrdered', () => { expect(out).toHaveLength(0); }); }); + +describe('selectChatPlanBusyForSelectedChat', () => { + it('is true when selected chat has an exploring plan for the agent', () => { + const busy = selectChatPlanBusyForSelectedChat.projector([planRow('a1', 'chat-a', 500, 'exploring')], 'a1', 'c1', { + 'c1:a1': 'chat-a', + }); + + expect(busy).toBe(true); + }); + + it('is false for ready plans or other chats', () => { + expect( + selectChatPlanBusyForSelectedChat.projector([planRow('a1', 'chat-a', 500, 'ready')], 'a1', 'c1', { + 'c1:a1': 'chat-a', + }), + ).toBe(false); + expect( + selectChatPlanBusyForSelectedChat.projector([planRow('a1', 'chat-b', 500, 'exploring')], 'a1', 'c1', { + 'c1:a1': 'chat-a', + }), + ).toBe(false); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.ts index bd3b12279..4f900a75e 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.selectors.ts @@ -3,8 +3,14 @@ import { createFeatureSelector, createSelector } from '@ngrx/store'; import { getClientAgentKey } from '../chat-sessions/chat-sessions.reducer'; import { selectChatSessionsMap, selectSelectedChatIdsMap } from '../chat-sessions/chat-sessions.selectors'; import { CLIENT_CHAT_AUTOMATION_SOCKET_EVENT } from '../container-socket/client-chat-automation.constants'; +import { CLIENT_CHAT_PLAN_SOCKET_EVENT } from '../container-socket/client-chat-plan.constants'; import { selectSelectedAgentId, selectSelectedClientId } from '../container-socket/container-socket.selectors'; -import type { ChatMessageData, TicketAutomationRunChatEventPayload } from '../container-socket/container-socket.types'; +import type { + ChatMessageData, + ChatPlanChatEventPayload, + ChatPlanStatus, + TicketAutomationRunChatEventPayload, +} from '../container-socket/container-socket.types'; import type { ChatTimelineState } from './chat-timeline.reducer'; @@ -18,6 +24,8 @@ export const selectMessageFilterResults = createSelector(selectChatTimelineState export const selectChatTimelineAutomations = createSelector(selectChatTimelineState, (state) => state.automations); +export const selectChatTimelinePlans = createSelector(selectChatTimelineState, (state) => state.plans); + export const selectHasMoreOlder = createSelector(selectChatTimelineState, (state) => state.hasMoreOlder); export const selectOldestMessageId = createSelector(selectChatTimelineState, (state) => state.oldestMessageId); @@ -48,6 +56,9 @@ export const selectTicketBodyLastResult = createSelector( (state) => state.ticketBodyLastResult, ); +/** Statuses that block creating another plan (and disable the plan toolbar button). */ +export const CHAT_PLAN_BUSY_STATUSES: readonly ChatPlanStatus[] = ['pending', 'exploring', 'refining', 'executing']; + export type ChatTimelineOrderedRow = { event: string; payload: import('../container-socket/container-socket.types').ForwardedEventPayload; @@ -81,18 +92,32 @@ function semanticSortKey(row: { event: string; payload: unknown; timestamp: numb } } + if (row.event === CLIENT_CHAT_PLAN_SOCKET_EVENT) { + const p = row.payload as ChatPlanChatEventPayload | undefined; + + if (p?.timelineAt) { + const t = Date.parse(p.timelineAt); + + if (!Number.isNaN(t)) { + return t; + } + } + } + return row.timestamp; } /** - * Chat messages merged with ticket automation chat events, ordered by semantic time. + * Chat messages merged with ticket automation + chat plan cards, ordered by semantic time. * Automation rows are deduped by `run.id` (latest `timelineAt` wins). Filtered to `run.agentId === selectedAgentId` * when an agent is selected, and shown only on the primary chat session (main thread). + * Plan rows are deduped by `plan.id`, filtered to matching agent, and shown when `plan.chatId === selectedChatId`. * Chat messages are filtered to the selected chat session when a chatId is selected and present on the message. */ export const selectChatTimelineOrdered = createSelector( selectChatTimelineMessages, selectChatTimelineAutomations, + selectChatTimelinePlans, selectSelectedAgentId, selectSelectedClientId, selectSelectedChatIdsMap, @@ -100,6 +125,7 @@ export const selectChatTimelineOrdered = createSelector( ( messages, automations, + plans, selectedAgentId, selectedClientId, selectedChatIds, @@ -154,8 +180,41 @@ export const selectChatTimelineOrdered = createSelector( } } + const byPlan = new Map(); + + for (const e of plans) { + const plan = (e.payload as ChatPlanChatEventPayload | undefined)?.plan; + + if (!plan?.id || !plan.agentId || !plan.chatId) { + continue; + } + + if (selectedAgentId && plan.agentId !== selectedAgentId) { + continue; + } + + if (selectedChatId && plan.chatId !== selectedChatId) { + continue; + } + + const prev = byPlan.get(plan.id); + + if (!prev) { + byPlan.set(plan.id, e); + continue; + } + + const prevT = semanticSortKey(prev); + const curT = semanticSortKey(e); + + if (curT >= prevT) { + byPlan.set(plan.id, e); + } + } + const automationRows = [...byRun.values()]; - const merged: ChatTimelineOrderedRow[] = [...chatMsgs, ...automationRows].map((e) => ({ + const planRows = [...byPlan.values()]; + const merged: ChatTimelineOrderedRow[] = [...chatMsgs, ...automationRows, ...planRows].map((e) => ({ event: e.event, payload: e.payload, timestamp: e.timestamp, @@ -168,3 +227,32 @@ export const selectChatTimelineOrdered = createSelector( return merged; }, ); + +/** + * True when the selected chat already has a busy plan (pending/exploring/refining/executing) + * for the selected agent — used to disable the plan toolbar button. + */ +export const selectChatPlanBusyForSelectedChat = createSelector( + selectChatTimelinePlans, + selectSelectedAgentId, + selectSelectedClientId, + selectSelectedChatIdsMap, + (plans, selectedAgentId, selectedClientId, selectedChatIds): boolean => { + const agentKey = selectedClientId && selectedAgentId ? getClientAgentKey(selectedClientId, selectedAgentId) : null; + const selectedChatId = agentKey ? (selectedChatIds[agentKey] ?? null) : null; + + if (!selectedAgentId || !selectedChatId) { + return false; + } + + return plans.some((row) => { + const plan = row.payload.plan; + + return ( + plan.agentId === selectedAgentId && + plan.chatId === selectedChatId && + CHAT_PLAN_BUSY_STATUSES.includes(plan.status) + ); + }); + }, +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.types.ts index 07072941a..31ea8cae6 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-timeline/chat-timeline.types.ts @@ -1,6 +1,7 @@ import type { AgentEventEnvelope, ChatMessageData, + ChatPlanChatEventPayload, ForwardedEventPayload, MessageFilterResultData, SuccessResponse, @@ -10,6 +11,7 @@ import type { export type { AgentEventEnvelope, ChatMessageData, + ChatPlanChatEventPayload, ForwardedEventPayload, MessageFilterResultData, SuccessResponse, @@ -62,6 +64,12 @@ export interface ChatTimelineAutomationRow { timestamp: number; } +export interface ChatTimelinePlanRow { + event: string; + payload: ChatPlanChatEventPayload; + timestamp: number; +} + export interface ChatTimelineCorrelationResult { correlationId: string; success: boolean; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/client-chat-plan.constants.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/client-chat-plan.constants.ts new file mode 100644 index 000000000..7adc01514 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/client-chat-plan.constants.ts @@ -0,0 +1,11 @@ +/** Controller → clients namespace: chat plan card upsert / hydrate. */ +export const CLIENT_CHAT_PLAN_SOCKET_EVENT = 'chatPlanUpsert' as const; + +/** Client → controller (clients namespace) chat plan commands. */ +export const CLIENT_CHAT_PLAN_EVENTS = { + createChatPlan: 'createChatPlan', + refineChatPlan: 'refineChatPlan', + executeChatPlan: 'executeChatPlan', + cancelChatPlan: 'cancelChatPlan', + chatPlanUpsert: CLIENT_CHAT_PLAN_SOCKET_EVENT, +} as const; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.effects.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.effects.ts index a1a290922..80929cfe6 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.effects.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.effects.ts @@ -32,6 +32,7 @@ import { chatTimelineEventReceived, chatTimelineFilterReceived, chatTimelineMessageReceived, + chatTimelinePlanUpsert, chatTimelineRestoreSuccess, chatTimelineTicketBodyResult, } from '../chat-timeline/chat-timeline.actions'; @@ -50,6 +51,7 @@ import { CLIENT_CHAT_AUTOMATION_SOCKET_EVENT, CLIENT_CHAT_TICKET_UPSERT_SOCKET_EVENT, } from './client-chat-automation.constants'; +import { CLIENT_CHAT_PLAN_SOCKET_EVENT } from './client-chat-plan.constants'; import { connectSocket, connectSocketFailure, @@ -79,6 +81,7 @@ import { type ChatEnhanceResultPayload, type ChatMessageBatchData, type ChatMessageData, + type ChatPlanChatEventPayload, type FileUpdateNotificationData, type ForwardedEventPayload, type MessageFilterResultData, @@ -656,6 +659,10 @@ export const routeForwardedEvents$ = createEffect( routed.push(chatTimelineAutomationUpsert({ payload: payload as TicketAutomationRunChatEventPayload })); } + if (event === CLIENT_CHAT_PLAN_SOCKET_EVENT && payload && typeof payload === 'object') { + routed.push(chatTimelinePlanUpsert({ payload: payload as ChatPlanChatEventPayload })); + } + return from(routed); }), ), diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts index 006a35b3c..6b138a00b 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts @@ -9,6 +9,7 @@ import { chatTimelineForwardTicketBodyFailure, } from '../chat-timeline/chat-timeline.actions'; +import { CLIENT_CHAT_PLAN_EVENTS } from './client-chat-plan.constants'; import { connectSocket, disconnectSocket, @@ -115,6 +116,82 @@ export class ContainerSocketFacade { }); } + /** + * Create a chat plan (controller-local on clients namespace — not via `forward`). + */ + createChatPlan( + agentId: string, + chatId: string, + message: string, + correlationId: string, + model?: string | null, + contextInjection?: ContextInjectionPayload, + ): void { + const socket = getSocketInstance(); + + if (!socket || !socket.connected) { + console.warn('Socket not connected. Cannot create chat plan.'); + + return; + } + + const effectiveModel = model ?? this.currentChatModel ?? undefined; + const payload: { + agentId: string; + chatId: string; + message: string; + correlationId: string; + model?: string; + contextInjection?: ContextInjectionPayload; + } = { agentId, chatId, message, correlationId }; + + if (effectiveModel !== undefined && effectiveModel !== null && effectiveModel !== '') { + payload.model = effectiveModel; + } + + if (contextInjection) { + payload.contextInjection = contextInjection; + } + + socket.emit(CLIENT_CHAT_PLAN_EVENTS.createChatPlan, payload); + } + + refineChatPlan(agentId: string, planId: string, message: string, correlationId: string): void { + const socket = getSocketInstance(); + + if (!socket || !socket.connected) { + console.warn('Socket not connected. Cannot refine chat plan.'); + + return; + } + + socket.emit(CLIENT_CHAT_PLAN_EVENTS.refineChatPlan, { agentId, planId, message, correlationId }); + } + + executeChatPlan(agentId: string, planId: string, correlationId: string): void { + const socket = getSocketInstance(); + + if (!socket || !socket.connected) { + console.warn('Socket not connected. Cannot execute chat plan.'); + + return; + } + + socket.emit(CLIENT_CHAT_PLAN_EVENTS.executeChatPlan, { agentId, planId, correlationId }); + } + + cancelChatPlan(agentId: string, planId: string): void { + const socket = getSocketInstance(); + + if (!socket || !socket.connected) { + console.warn('Socket not connected. Cannot cancel chat plan.'); + + return; + } + + socket.emit(CLIENT_CHAT_PLAN_EVENTS.cancelChatPlan, { agentId, planId }); + } + forwardEvent(event: ForwardableEvent, payload?: ForwardableEventPayload, agentId?: string): void { const socket = getSocketInstance(); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts index b70dbfedc..92a39ca75 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.types.ts @@ -452,6 +452,70 @@ export interface ContainerStatsPayload { timestamp: string; } +/** Chat plan lifecycle status (mirrors backend `ChatPlanStatus`). */ +export type ChatPlanStatus = + | 'pending' + | 'exploring' + | 'ready' + | 'refining' + | 'executing' + | 'executed' + | 'failed' + | 'cancelled'; + +/** Coarse chat plan phase for UI badges (mirrors backend `ChatPlanPhase`). */ +export type ChatPlanPhase = 'explore' | 'draft' | 'refine' | 'ready'; + +/** REST / WS plan aggregate (dates are ISO-8601 strings on the wire). */ +export interface ChatPlanResponse { + id: string; + clientId: string; + agentId: string; + chatId: string; + status: ChatPlanStatus; + phase: ChatPlanPhase; + sourcePrompt: string; + planMarkdown: string | null; + summary: string | null; + contextInjection: ContextInjectionPayload | null; + model: string | null; + resumeSessionSuffix: string; + completionSignalSeen: boolean; + failureCode: string | null; + failureMessage: string | null; + createdByUserId: string | null; + startedAt: string; + finishedAt: string | null; + createdAt: string; + updatedAt: string; +} + +export type ChatPlanChatActionType = 'openChatPlan' | 'executeChatPlan'; + +export interface ChatPlanChatOpenAction { + type: 'openChatPlan'; + planId: string; + chatId: string; + label: string; +} + +export interface ChatPlanChatExecuteAction { + type: 'executeChatPlan'; + planId: string; + chatId: string; + label: string; +} + +export type ChatPlanChatAction = ChatPlanChatOpenAction | ChatPlanChatExecuteAction; + +/** Controller-originated `chatPlanUpsert` payload (clients namespace). */ +export interface ChatPlanChatEventPayload { + timelineAt: string; + hydrate: boolean; + plan: ChatPlanResponse; + actions: ChatPlanChatAction[]; +} + /** Controller-originated `ticketAutomationRunChatUpsert` payload (clients namespace). */ export interface TicketAutomationRunChatTicketSummary { id: string; @@ -514,4 +578,5 @@ export type ForwardedEventPayload = | SuccessResponse // containerStats | SuccessResponse // chatEvent | TicketAutomationRunChatEventPayload // ticketAutomationRunChatUpsert + | ChatPlanChatEventPayload // chatPlanUpsert | ErrorResponse; // error diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts new file mode 100644 index 000000000..addd8eb5d --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts @@ -0,0 +1,41 @@ +/** + * Human-readable labels for chat plan status / phase (mirrors backend chat-plan.enums). + */ + +export function chatPlanStatusLabel(status: string): string { + switch (status) { + case 'pending': + return $localize`:@@featureChat-planStatusPending:Pending`; + case 'exploring': + return $localize`:@@featureChat-planStatusExploring:Exploring`; + case 'ready': + return $localize`:@@featureChat-planStatusReady:Ready`; + case 'refining': + return $localize`:@@featureChat-planStatusRefining:Refining`; + case 'executing': + return $localize`:@@featureChat-planStatusExecuting:Executing`; + case 'executed': + return $localize`:@@featureChat-planStatusExecuted:Executed`; + case 'failed': + return $localize`:@@featureChat-planStatusFailed:Failed`; + case 'cancelled': + return $localize`:@@featureChat-planStatusCancelled:Cancelled`; + default: + return status; + } +} + +export function chatPlanPhaseLabel(phase: string): string { + switch (phase) { + case 'explore': + return $localize`:@@featureChat-planPhaseExplore:Explore`; + case 'draft': + return $localize`:@@featureChat-planPhaseDraft:Draft`; + case 'refine': + return $localize`:@@featureChat-planPhaseRefine:Refine`; + case 'ready': + return $localize`:@@featureChat-planPhaseReady:Ready`; + default: + return phase; + } +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.spec.ts index 6fe5c0e74..00474495d 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.spec.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.spec.ts @@ -45,6 +45,20 @@ describe('buildChatDisplayThread', () => { expect(rowCount).toBeGreaterThanOrEqual(1); } }); + + it('hides chat-plan execute trigger user messages', () => { + const items = buildChatDisplayThread([ + chatMsg( + 'user', + 'Implement the following plan in the repository. Stay scoped to the plan below.\n\n## Plan\nDo it', + 1, + ), + chatMsg('agent', { type: 'result', result: 'done' }, 2), + ]); + + expect(items).toHaveLength(1); + expect(items[0]?.kind).toBe('agentTurn'); + }); }); describe('buildAgentTurnView', () => { @@ -397,4 +411,53 @@ describe('buildMergedChatDisplayThread', () => { expect(thread[1]?.kind).toBe('ticketAutomationRun'); }); + + it('interleaves chatPlan cards between user and agent by semantic order', () => { + const u = chatMsg('user', 'hi', 1); + const a = chatMsg('agent', { type: 'result', result: 'ok' }, 4); + const planPayload = { + timelineAt: new Date(2).toISOString(), + hydrate: false, + plan: { + id: 'p1', + clientId: 'c1', + agentId: 'aid', + chatId: 'chat-1', + status: 'ready' as const, + phase: 'ready' as const, + sourcePrompt: 'plan', + planMarkdown: '# Plan', + summary: 'Do the thing', + contextInjection: null, + model: null, + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: false, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: new Date(2).toISOString(), + finishedAt: null, + createdAt: new Date(2).toISOString(), + updatedAt: new Date(2).toISOString(), + }, + actions: [ + { type: 'openChatPlan' as const, planId: 'p1', chatId: 'chat-1', label: 'View plan' }, + { type: 'executeChatPlan' as const, planId: 'p1', chatId: 'chat-1', label: 'Execute plan' }, + ], + }; + const ordered: ChatTimelineOrderedRowLike[] = [ + { ...u, semanticTimestamp: 1 }, + { + event: 'chatPlanUpsert', + payload: planPayload, + timestamp: 2, + semanticTimestamp: 2, + }, + { ...a, semanticTimestamp: 4 }, + ]; + const thread = buildMergedChatDisplayThread(ordered, [u, a]); + + expect(thread.map((i) => i.kind)).toEqual(['user', 'chatPlan', 'agentTurn']); + expect(thread[1]?.kind === 'chatPlan' && thread[1].payload.plan.summary).toBe('Do the thing'); + }); }); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts index 29608df24..0555cc9b7 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-thread-display.ts @@ -1,6 +1,7 @@ import type { AgentResponseObject, ChatMessageData, + ChatPlanChatEventPayload, ForwardedEventPayload, TicketAutomationRunChatEventPayload, } from '@forepath/agenstra/frontend/data-access-agent-console'; @@ -14,6 +15,7 @@ export interface ChatTimelineOrderedRowLike { } const TICKET_AUTOMATION_RUN_CHAT_UPSERT = 'ticketAutomationRunChatUpsert'; +const CHAT_PLAN_UPSERT = 'chatPlanUpsert'; import { AGENT_CHAT_EVENT_KIND_LABELS, @@ -57,7 +59,8 @@ export type ChatMessageWithFilter = { export type ChatDisplayThreadItem = | { kind: 'user'; msg: ChatMessageWithFilter } | { kind: 'agentTurn'; msgs: ChatMessageWithFilter[]; view: AgentTurnView } - | { kind: 'ticketAutomationRun'; sortTime: number; payload: TicketAutomationRunChatEventPayload }; + | { kind: 'ticketAutomationRun'; sortTime: number; payload: TicketAutomationRunChatEventPayload } + | { kind: 'chatPlan'; sortTime: number; payload: ChatPlanChatEventPayload }; /** Ordered slices of an agent turn: structured rows and prose markdown interleaved as produced. */ export type AgentTurnSegment = @@ -306,6 +309,23 @@ function isUserPayload(payload: ForwardedEventPayload): boolean { return false; } +/** + * Chat-plan execute injects a system prompt into the agent turn; it must not appear as a user bubble. + * Keep in sync with CHAT_PLAN_EXECUTE_PROMPT_PREFIX on the controller. + */ +const CHAT_PLAN_EXECUTE_PROMPT_PREFIX = + 'Implement the following plan in the repository. Stay scoped to the plan below.'; + +export function isSuppressedChatPlanExecuteUserMessage(payload: ForwardedEventPayload): boolean { + const data = getChatMessageData(payload); + + if (!data || data.from !== 'user' || typeof data.text !== 'string') { + return false; + } + + return data.text.trimStart().startsWith(CHAT_PLAN_EXECUTE_PROMPT_PREFIX); +} + function isAgentPayload(payload: ForwardedEventPayload): boolean { if ('success' in payload && payload.success && 'data' in payload) { const data = payload.data as ChatMessageData; @@ -720,6 +740,10 @@ export function buildChatDisplayThread(messages: ChatMessageWithFilter[]): ChatD for (const msg of messages) { if (isUserPayload(msg.payload)) { + if (isSuppressedChatPlanExecuteUserMessage(msg.payload)) { + continue; + } + flushAgent(); out.push({ kind: 'user', msg }); } else if (isAgentPayload(msg.payload)) { @@ -733,7 +757,8 @@ export function buildChatDisplayThread(messages: ChatMessageWithFilter[]): ChatD } /** - * Merges ordered chat + automation timeline rows into display items (automation rows break agent turns). + * Merges ordered chat + automation + plan timeline rows into display items + * (automation/plan rows break agent turns). */ export function buildMergedChatDisplayThread( orderedRows: ChatTimelineOrderedRowLike[], @@ -779,6 +804,14 @@ export function buildMergedChatDisplayThread( continue; } + if (row.event === CHAT_PLAN_UPSERT) { + flushAgent(); + const payload = row.payload as ChatPlanChatEventPayload; + + out.push({ kind: 'chatPlan', sortTime: row.semanticTimestamp, payload }); + continue; + } + if (row.event !== 'chatMessage') { continue; } @@ -790,6 +823,10 @@ export function buildMergedChatDisplayThread( } if (isUserPayload(msg.payload)) { + if (isSuppressedChatPlanExecuteUserMessage(msg.payload)) { + continue; + } + flushAgent(); out.push({ kind: 'user', msg }); } else if (isAgentPayload(msg.payload)) { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html index 5ce87730d..2f6426127 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -1087,6 +1087,45 @@
+ } @else if (item.kind === 'chatPlan') { +
+
+
+
+ {{ item.payload.plan.summary || item.payload.plan.sourcePrompt }} +
+
+ {{ + chatPlanStatusLabel(item.payload.plan.status) + }} + {{ chatPlanPhaseLabel(item.payload.plan.phase) }} +
+
+ @for (act of item.payload.actions; track act.type + act.planId) { + @if (act.type === 'openChatPlan') { + + {{ act.label }} + + } @else if (act.type === 'executeChatPlan') { + + {{ act.label }} + + } + } +
+ {{ + formatTimestamp(item.sortTime) + }} +
+
+
} } @@ -1316,6 +1355,29 @@
> + + + + + + + @if (planDetailPlan$ | async; as plan) { +
+ {{ chatPlanStatusLabel(plan.status) }} + {{ chatPlanPhaseLabel(plan.phase) }} +
+ @if (plan.summary) { +

{{ plan.summary }}

+ } + @if (plan.failureMessage) { + + } +
+ @if (plan.planMarkdown; as md) { + @if (parseMarkdownFromString(md); as planHtml) { +
+ } + } @else { +

{{ chatPlanEmptyMarkdown }}

+ } +
+ @if (plan.status === 'ready' || plan.status === 'refining') { + + + + } + } @else { +
+ +
+ } +
+ @if (planDetailPlan$ | async; as plan) { +
+ @if (isChatPlanCancellable(plan.status)) { + + {{ chatPlanCancelLabel }} + + } +
+
+ @if (plan.status === 'ready') { + + {{ chatPlanRefineSubmitLabel }} + + + {{ chatPlanExecuteLabel }} + + } +
+ } +
+
+ = this.socketsFacade.chatForwarding$; readonly chatResponseMode$ = this.socketsFacade.chatResponseMode$; readonly chatEnhancementPending$: Observable = this.chatTimelineFacade.chatEnhancementPending$; + readonly chatPlanBusy$: Observable = this.chatTimelineFacade.chatPlanBusy$; + + /** Plan detail modal: selected plan id (live updates via plans$). */ + readonly planDetailPlanId = signal(null); + readonly planDetailModalOpen = signal(false); + readonly planRefineMessage = signal(''); + + /** Live plan entity for the open plan detail modal (updates on chatPlanUpsert). */ + readonly planDetailPlan$: Observable = combineLatest([ + this.chatTimelineFacade.plans$, + toObservable(this.planDetailPlanId), + ]).pipe( + map(([rows, planId]) => { + if (!planId) { + return null; + } + + return rows.find((row) => row.payload.plan.id === planId)?.payload.plan ?? null; + }), + ); readonly socketError$: Observable = this.socketsFacade.error$; // Remote connection reconnection state (per clientId) @@ -3039,6 +3073,94 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.socketsFacade.forwardEnhanceChat(message, agentId, correlationId, normalizedModel); } + onPlanMessage(): void { + let message = this.chatMessage().trim(); + const selectedCmd = this.selectedCommand(); + + if (selectedCmd) { + message = message ? `${selectedCmd}\n${message}` : selectedCmd; + } + + if (!message) { + return; + } + + const agentId = this.selectedAgentId(); + + if (!agentId) { + return; + } + + const correlationId = crypto.randomUUID(); + const model = this.selectedChatModel(); + const normalizedModel = model === null || model === '' ? null : model; + + this.selectedChatId$.pipe(take(1), takeUntilDestroyed(this.destroyRef)).subscribe((chatId) => { + if (!chatId) { + return; + } + + this.socketsFacade.createChatPlan( + agentId, + chatId, + message, + correlationId, + normalizedModel, + this.buildContextInjection(agentId), + ); + + this.chatMessage.set(''); + }); + } + + openChatPlanFromChat(payload: ChatPlanChatEventPayload): void { + this.planRefineMessage.set(''); + this.planDetailPlanId.set(payload.plan.id); + this.planDetailModalOpen.set(true); + } + + onClosePlanDetailModal(): void { + this.planDetailModalOpen.set(false); + this.planDetailPlanId.set(null); + this.planRefineMessage.set(''); + } + + onExecuteChatPlan(planId: string): void { + const agentId = this.selectedAgentId(); + + if (!agentId || !planId) { + return; + } + + this.socketsFacade.executeChatPlan(agentId, planId, crypto.randomUUID()); + } + + onCancelChatPlan(planId: string): void { + const agentId = this.selectedAgentId(); + + if (!agentId || !planId) { + return; + } + + this.socketsFacade.cancelChatPlan(agentId, planId); + } + + onRefineChatPlan(planId: string): void { + const message = this.planRefineMessage().trim(); + const agentId = this.selectedAgentId(); + + if (!agentId || !planId || !message) { + return; + } + + this.socketsFacade.refineChatPlan(agentId, planId, message, crypto.randomUUID()); + this.planRefineMessage.set(''); + } + + isChatPlanCancellable(status: string): boolean { + return status === 'exploring' || status === 'refining' || status === 'pending' || status === 'executing'; + } + onChatInputKeydown(event: KeyboardEvent): void { if (this.slashCommandMenuOpen()) { const filtered = this.filteredSlashCommands(); @@ -5441,6 +5563,10 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return `ar-${item.payload.run.id}-${item.sortTime}`; } + if (item.kind === 'chatPlan') { + return `cp-${item.payload.plan.id}-${item.sortTime}`; + } + const firstTs = item.msgs[0]?.timestamp ?? item.view.displayTimestamp; const lastTs = item.msgs[item.msgs.length - 1]?.timestamp ?? firstTs; diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts index b5c5782a6..198057d38 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.spec.ts @@ -581,7 +581,12 @@ describe('materializeModelAllowDeny', () => { }); expect(prepared.skills).toEqual({ - paths: ['/opt/skills/local', '/opt/agenstra/skills/agenstra-ticket-automation'], + paths: [ + '/opt/skills/local', + '/opt/agenstra/skills/agenstra-ticket-automation', + '/opt/agenstra/skills/agenstra-chat-plan', + '.opencode/skills/agenstra-chat-plan', + ], urls: ['https://example.com/skill'], }); expect((prepared.agent as Record)['agenstra-automation']).toMatchObject({ @@ -589,6 +594,10 @@ describe('materializeModelAllowDeny', () => { hidden: true, permission: 'allow', }); + expect((prepared.agent as Record)['agenstra-plan']).toMatchObject({ + mode: 'primary', + hidden: true, + }); expect(prepared.mcp).toEqual({ docs: { type: 'local', command: ['echo'], timeout: 1500, enabled: false }, remote: { @@ -779,6 +788,19 @@ describe('prepareConfigForSync wire mapping', () => { hidden: true, permission: 'allow', }, + 'agenstra-plan': { + description: 'Explore-only Agenstra chat plan mode (platform-managed)', + mode: 'primary', + hidden: true, + permission: { + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'deny', + write: 'deny', + bash: 'deny', + }, + }, }); expect(prepared.command).toEqual({ hello: { template: 'hi', subtask: true }, diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts index 15ce5cb33..cb2e7de7c 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/agenstra-shared-util-opencode-config.ts @@ -8,3 +8,4 @@ export * from './credentials'; export * from './materialize'; export * from './enforce-allow-deny'; export * from './automation-platform'; +export * from './plan-platform'; diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts index 24c604421..0b8220c82 100644 --- a/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/materialize.ts @@ -3,6 +3,7 @@ import { migrateConfigV1ToV2 } from './migrate-v1-to-v2'; import { wireMcpOauthForOpenCode } from './mcp-wire'; import { isMcpServerAllowed, resolveMcpAllowDenyIdentity } from '@forepath/agenstra/shared/util-opencode-mcp-servers'; import { injectPlatformAutomationConfig } from './automation-platform'; +import { injectPlatformPlanConfig } from './plan-platform'; /** Platform / UI-only roots that OpenCode Config rejects or silently drops. */ const UNSUPPORTED_WIRE_ROOTS = [ @@ -875,13 +876,15 @@ export function toOpencodeWireConfig(input: JsonObject): JsonObject { /** * Prepare a config document for the OpenCode worker: - * V2 migrate → model materialization → MCP allow/deny filter → platform automation inject → OpenCode Config wire. - * Platform automation is injected after migrate/materialize so a second migrate pass cannot strip it. + * V2 migrate → model materialization → MCP allow/deny filter → platform automation + plan inject → OpenCode Config wire. + * Platform agents/skills are injected after migrate/materialize so a second migrate pass cannot strip them. */ export function prepareConfigForSync(input: JsonObject | null | undefined): JsonObject { return toOpencodeWireConfig( - injectPlatformAutomationConfig( - materializeMcpAllowDeny(materializeModelAllowDeny(migrateConfigV1ToV2(input ?? {}))), + injectPlatformPlanConfig( + injectPlatformAutomationConfig( + materializeMcpAllowDeny(materializeModelAllowDeny(migrateConfigV1ToV2(input ?? {}))), + ), ), ); } diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.spec.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.spec.ts new file mode 100644 index 000000000..480309252 --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.spec.ts @@ -0,0 +1,65 @@ +import { + AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR, + AGENSTRA_CHAT_PLAN_SKILL_REL_DIR, + AGENSTRA_PLAN_AGENT_NAME, + AGENSTRA_PLAN_SESSION_PERMISSION_RULESET, + injectPlatformPlanConfig, + isAgenstraPlanExplorePermission, + isAgenstraPlanWritePermission, + parseAgenstraPlanTurnStatus, +} from './plan-platform'; + +describe('plan-platform', () => { + it('injects platform plan agent and skill paths even when overlays omit them', () => { + const injected = injectPlatformPlanConfig({ + agents: { build: { mode: 'primary' } }, + skills: ['/opt/skills/local'], + }); + + expect(injected.agents).toMatchObject({ + build: { mode: 'primary' }, + [AGENSTRA_PLAN_AGENT_NAME]: { + mode: 'primary', + hidden: true, + }, + }); + expect(injected.skills).toEqual({ + paths: ['/opt/skills/local', AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR, AGENSTRA_CHAT_PLAN_SKILL_REL_DIR], + urls: [], + }); + }); + + it('session ruleset allows explore tools and denies write tools', () => { + const byPermission = new Map( + AGENSTRA_PLAN_SESSION_PERMISSION_RULESET.map((rule) => [rule.permission, rule.action]), + ); + + expect(byPermission.get('read')).toBe('allow'); + expect(byPermission.get('glob')).toBe('allow'); + expect(byPermission.get('grep')).toBe('allow'); + expect(byPermission.get('edit')).toBe('deny'); + expect(byPermission.get('write')).toBe('deny'); + expect(byPermission.get('patch')).toBe('deny'); + expect(byPermission.get('bash')).toBe('deny'); + expect(byPermission.get('*')).toBe('deny'); + }); + + it('classifies explore vs write permission residual asks', () => { + expect(isAgenstraPlanExplorePermission('read')).toBe(true); + expect(isAgenstraPlanExplorePermission('grep')).toBe(true); + expect(isAgenstraPlanWritePermission('edit')).toBe(true); + expect(isAgenstraPlanWritePermission('bash')).toBe(true); + expect(isAgenstraPlanWritePermission('read')).toBe(false); + expect(isAgenstraPlanWritePermission('unknown-tool')).toBe(true); + }); + + it('parses structured plan turn status payloads', () => { + expect(parseAgenstraPlanTurnStatus({ status: 'ready', planMarkdown: '# Plan', summary: 'Done' })).toEqual({ + status: 'ready', + planMarkdown: '# Plan', + summary: 'Done', + }); + expect(parseAgenstraPlanTurnStatus('{"status":"exploring"}')).toEqual({ status: 'exploring' }); + expect(parseAgenstraPlanTurnStatus({ status: 'nope' })).toBeUndefined(); + }); +}); diff --git a/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.ts b/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.ts new file mode 100644 index 000000000..17c45394c --- /dev/null +++ b/libs/domains/agenstra/shared/util-opencode-config/src/lib/plan-platform.ts @@ -0,0 +1,253 @@ +import type { JsonObject } from './types'; + +/** OpenCode primary agent used exclusively for Agenstra chat plan-mode sessions. */ +export const AGENSTRA_PLAN_AGENT_NAME = 'agenstra-plan'; + +/** Platform skill that documents explore-only planning + structured ready protocol. */ +export const AGENSTRA_CHAT_PLAN_SKILL_NAME = 'agenstra-chat-plan'; + +/** + * Absolute skill path installed on workers (root-owned layer emit / sync). + * Relative discovery under `.opencode/skills` is also listed for OpenCode path concat. + */ +export const AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR = '/opt/agenstra/skills/agenstra-chat-plan'; + +export const AGENSTRA_CHAT_PLAN_SKILL_REL_DIR = '.opencode/skills/agenstra-chat-plan'; + +/** Explore-oriented permission names auto-allowed when residual asks appear on plan sessions. */ +export const AGENSTRA_PLAN_EXPLORE_PERMISSION_NAMES = [ + 'read', + 'glob', + 'grep', + 'list', + 'codesearch', + 'webfetch', + 'websearch', +] as const; + +/** Mutating permission names that must be denied / rejected on plan sessions. */ +export const AGENSTRA_PLAN_WRITE_PERMISSION_NAMES = ['edit', 'write', 'patch', 'bash', 'task', 'todowrite'] as const; + +/** + * Session-scoped OpenCode permission ruleset for plan explore/refine sessions. + * Allow read/explore tools; deny write/mutation tools (including bash — cannot safely scope shell). + */ +export const AGENSTRA_PLAN_SESSION_PERMISSION_RULESET = [ + ...AGENSTRA_PLAN_EXPLORE_PERMISSION_NAMES.map((permission) => ({ + permission, + pattern: '*', + action: 'allow' as const, + })), + ...AGENSTRA_PLAN_WRITE_PERMISSION_NAMES.map((permission) => ({ + permission, + pattern: '*', + action: 'deny' as const, + })), + { permission: '*', pattern: '*', action: 'deny' as const }, +]; + +/** Structured turn-status schema for OpenCode `format: json_schema` on plan prompts. */ +export const AGENSTRA_PLAN_TURN_STATUS_SCHEMA = { + type: 'object', + additionalProperties: false, + properties: { + status: { + type: 'string', + enum: ['exploring', 'ready'], + description: 'exploring = more investigation needed; ready = plan is complete for user review/execute', + }, + planMarkdown: { + type: 'string', + description: 'Full implementation plan in markdown (required when status is ready)', + }, + summary: { + type: 'string', + description: 'Short status line for the chat card', + }, + }, + required: ['status'], +} as const; + +export type AgenstraPlanTurnStatus = 'exploring' | 'ready'; + +export function isAgenstraPlanTurnStatus(value: unknown): value is AgenstraPlanTurnStatus { + return value === 'exploring' || value === 'ready'; +} + +export interface AgenstraPlanTurnStatusPayload { + status: AgenstraPlanTurnStatus; + planMarkdown?: string; + summary?: string; +} + +/** + * Parse structured plan turn status from OpenCode structured output, tool payloads, or JSON text. + */ +export function parseAgenstraPlanTurnStatus(payload: unknown): AgenstraPlanTurnStatusPayload | undefined { + if (typeof payload === 'string') { + const trimmed = payload.trim(); + + if (isAgenstraPlanTurnStatus(trimmed)) { + return { status: trimmed }; + } + + try { + return parseAgenstraPlanTurnStatus(JSON.parse(trimmed) as unknown); + } catch { + return undefined; + } + } + + if (!payload || typeof payload !== 'object') { + return undefined; + } + + const record = payload as Record; + + if (!isAgenstraPlanTurnStatus(record['status'])) { + return undefined; + } + + const planMarkdown = typeof record['planMarkdown'] === 'string' ? record['planMarkdown'] : undefined; + const summary = typeof record['summary'] === 'string' ? record['summary'] : undefined; + + return { + status: record['status'], + ...(planMarkdown !== undefined ? { planMarkdown } : {}), + ...(summary !== undefined ? { summary } : {}), + }; +} + +/** True when a residual permission ask should be auto-allowed on a plan session. */ +export function isAgenstraPlanExplorePermission(permissionType: string | undefined): boolean { + if (!permissionType) { + return false; + } + + const normalized = permissionType.trim().toLowerCase(); + + return (AGENSTRA_PLAN_EXPLORE_PERMISSION_NAMES as readonly string[]).includes(normalized); +} + +/** True when a residual permission ask must be rejected on a plan session. */ +export function isAgenstraPlanWritePermission(permissionType: string | undefined): boolean { + if (!permissionType) { + return true; + } + + const normalized = permissionType.trim().toLowerCase(); + + if ((AGENSTRA_PLAN_EXPLORE_PERMISSION_NAMES as readonly string[]).includes(normalized)) { + return false; + } + + if ((AGENSTRA_PLAN_WRITE_PERMISSION_NAMES as readonly string[]).includes(normalized)) { + return true; + } + + // Unknown tools default to deny for plan sessions (fail closed). + return true; +} + +export const AGENSTRA_CHAT_PLAN_SKILL_MD = `--- +name: agenstra-chat-plan +description: Explore-only planning protocol for Agenstra chat plan mode +compatibility: opencode +metadata: + audience: planning + workflow: chat-plan +--- + +# Agenstra chat plan mode + +You are running an explore-only planning session. Do **not** create, edit, patch, or delete files. +Do **not** run shell commands that mutate the workspace. Use read, glob, grep, and similar explore tools only. + +## Goal + +Investigate the repository (and any injected context) thoroughly, then produce a comprehensive implementation plan +the user can review, refine, and later execute in their visible chat. + +## Turn status (required) + +After each planning turn, Agenstra collects structured output: + +- \`status: "exploring"\` — more investigation is still needed +- \`status: "ready"\` — the plan is complete enough for the user to review / execute + +When \`ready\`, always include \`planMarkdown\` with the full plan (sections, files to touch, risks, test plan). +Include a short \`summary\` for the chat card whenever possible. +Do not ask the user questions; proceed with reasonable defaults. Prefer finishing the plan in as few turns as possible. +`; + +/** + * Inject platform plan agent + skill paths into an effective V2 overlay before wire sync. + * Always applied so UI overlays cannot remove the plan agent or skill discovery path. + */ +export function injectPlatformPlanConfig(config: JsonObject): JsonObject { + const next: JsonObject = { ...config }; + const agents = isPlainObject(next['agents']) ? { ...(next['agents'] as JsonObject) } : {}; + + agents[AGENSTRA_PLAN_AGENT_NAME] = { + description: 'Explore-only Agenstra chat plan mode (platform-managed)', + mode: 'primary', + hidden: true, + permission: { + read: 'allow', + glob: 'allow', + grep: 'allow', + edit: 'deny', + write: 'deny', + bash: 'deny', + }, + }; + next['agents'] = agents; + + const skills = normalizeSkillsRoot(next['skills']); + const paths = new Set(skills.paths); + paths.add(AGENSTRA_CHAT_PLAN_SKILL_ABS_DIR); + paths.add(AGENSTRA_CHAT_PLAN_SKILL_REL_DIR); + next['skills'] = { paths: [...paths], urls: skills.urls }; + + return next; +} + +function isPlainObject(value: unknown): value is JsonObject { + return !!value && typeof value === 'object' && !Array.isArray(value); +} + +function normalizeSkillsRoot(value: unknown): { paths: string[]; urls: string[] } { + if (Array.isArray(value)) { + const paths: string[] = []; + const urls: string[] = []; + + for (const entry of value) { + if (typeof entry !== 'string' || !entry.trim()) { + continue; + } + + const trimmed = entry.trim(); + + if (/^https?:\/\//i.test(trimmed)) { + urls.push(trimmed); + } else { + paths.push(trimmed); + } + } + + return { paths, urls }; + } + + if (!isPlainObject(value)) { + return { paths: [], urls: [] }; + } + + const paths = Array.isArray(value['paths']) + ? value['paths'].filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + const urls = Array.isArray(value['urls']) + ? value['urls'].filter((entry): entry is string => typeof entry === 'string' && entry.trim().length > 0) + : []; + + return { paths, urls }; +} From 112d6cc61097440e4656833ea00a0fc9e60adb05 Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Sat, 10 Oct 2026 19:03:39 +0200 Subject: [PATCH 2/2] fix(agenstra): plan mode ui improved plan refinement inputs and plan outputs --- .../src/i18n/messages.de.xlf | 100 ++++++++++ .../src/i18n/messages.xlf | 17 +- .../applications/frontend-agent-console.md | 1 + docs/agenstra/features/chat-interface.md | 9 +- docs/agenstra/features/chat-plan-mode.md | 16 +- graph/graph.json | 22 ++- .../src/lib/gateways/clients.gateway.spec.ts | 77 ++++++++ .../src/lib/gateways/clients.gateway.ts | 30 ++- .../chat-plan-chat-sync.service.spec.ts | 41 ++++ .../services/chat-plan-chat-sync.service.ts | 4 +- .../chat-plan-orchestrator.service.ts | 26 ++- .../src/lib/services/chat-plan.service.ts | 1 + .../container-socket.facade.spec.ts | 42 +++++ .../container-socket.facade.ts | 23 ++- .../src/lib/chat/chat-markdown-html.spec.ts | 17 ++ .../src/lib/chat/chat-markdown-html.ts | 8 + .../src/lib/chat/chat-plan-labels.spec.ts | 34 ++++ .../src/lib/chat/chat-plan-labels.ts | 35 ++-- .../src/lib/chat/chat.component.html | 177 ++++++++++++++---- .../src/lib/chat/chat.component.scss | 33 ++++ .../src/lib/chat/chat.component.ts | 107 +++++++++-- 21 files changed, 735 insertions(+), 85 deletions(-) create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.spec.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.ts create mode 100644 libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.spec.ts diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf index 2a158d468..2d960ea51 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.de.xlf @@ -1051,6 +1051,106 @@ Enhance prompt with AI Prompt mit KI verbessern + + Create plan with AI + Plan mit KI erstellen + + + Create plan with AI + Plan mit KI erstellen + + + Plan + Plan + + + Describe changes to the plan… + Änderungen am Plan beschreiben … + + + Refine plan + Plan verfeinern + + + Refine plan + Plan verfeinern + + + Refine plan + Plan verfeinern + + + Execute + Ausführen + + + Execute plan + Plan ausführen + + + Execute plan + Plan ausführen + + + Cancel plan + Plan abbrechen + + + Plan content will appear here as the agent explores… + Der Planinhalt erscheint hier, während der Agent den Bestand erkundet … + + + Pending + Ausstehend + + + Exploring + Erkundet + + + Ready + Bereit + + + Refining + Wird verfeinert + + + Executing + Wird ausgeführt + + + Executed + Ausgeführt + + + Failed + Fehlgeschlagen + + + Cancelled + Abgebrochen + + + Explore + Erkunden + + + Draft + Entwurf + + + Refine + Verfeinern + + + Ready + Bereit + + + Draft available + Entwurf verfügbar + Model Modell diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index c0cffbfd7..7a073e8c7 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -840,11 +840,23 @@ Describe changes to the plan… - Refine + Refine plan + + + Refine plan + + + Refine plan Execute + + Execute plan + + + Execute plan + Cancel plan @@ -887,6 +899,9 @@ Ready + + Draft available + Model diff --git a/docs/agenstra/applications/frontend-agent-console.md b/docs/agenstra/applications/frontend-agent-console.md index 6a4c8a068..29a4908dc 100644 --- a/docs/agenstra/applications/frontend-agent-console.md +++ b/docs/agenstra/applications/frontend-agent-console.md @@ -11,6 +11,7 @@ This application provides a comprehensive user interface for managing clients, a This application provides: - **Chat Interface** Real-time bidirectional communication with AI agents via WebSocket +- **Chat plan mode** Composer-driven explore/refine/execute planning flow with chat-scoped timeline cards and modal refinement UI ([Chat plan mode](../features/chat-plan-mode.md)) - **Monaco Editor** Integrated code editor with syntax highlighting and code completion - **File Management** Browse, read, write, create, and delete files in agent containers - **Version Control** Full Git operations (status, branches, commit, push, pull, rebase) diff --git a/docs/agenstra/features/chat-interface.md b/docs/agenstra/features/chat-interface.md index e8c87ca6a..194dd8a95 100644 --- a/docs/agenstra/features/chat-interface.md +++ b/docs/agenstra/features/chat-interface.md @@ -63,6 +63,7 @@ On the manager, the same operations live under `/api/agents/:agentId/chats`. - View chat history per session - Markdown rendering - Code block syntax highlighting +- Create **chat plans** from the composer prompt, inspect their timeline cards, refine them in a detail modal, and execute the approved plan back into the visible chat ### Message Types @@ -111,9 +112,11 @@ Known OpenCode tools (`bash`, `read`, `glob`, `grep`, `edit`, `write`, `apply_pa ### Sending a Message 1. Type your message in the input field -2. Press Enter or click Send -3. Message is sent to the agent via WebSocket (`forward` → `chat`, with the active `chatId` when applicable) -4. Response is received and displayed +2. Optional: add **Select context** references (workspace, environments, tickets, knowledge) or use **Enhance prompt with AI** +3. Optional: use **Create plan with AI** to open the explore/refine plan workflow for the current prompt and selected context +4. Press Enter or click Send +5. Message is sent to the agent via WebSocket (`forward` → `chat`, with the active `chatId` when applicable) +6. Response is received and displayed ### Viewing History diff --git a/docs/agenstra/features/chat-plan-mode.md b/docs/agenstra/features/chat-plan-mode.md index 86f6a5f44..ce7ff986b 100644 --- a/docs/agenstra/features/chat-plan-mode.md +++ b/docs/agenstra/features/chat-plan-mode.md @@ -1,6 +1,6 @@ # Chat plan mode -Operators can turn the current chat composer prompt (plus selected context) into an **explore-then-plan** workflow. A hidden OpenCode session investigates the repository with **explore-only** permissions, a chat-scoped timeline card shows live status and plan markdown, a detail modal supports refine, and **Execute plan** injects the plan into the same visible chat. +Operators can turn the current chat composer prompt (plus selected context) into an **explore-then-plan** workflow. A hidden OpenCode session investigates the repository with **explore-only** permissions, a chat-scoped timeline card shows a single human-readable status plus plan title, a detail modal supports refine, and **Execute plan** injects the plan into the same visible chat. This is an Agenstra productivity feature built on OpenCode (not a native OpenCode “plan mode” product API). It mirrors ticket-automation durability and hydrate patterns, but is user-triggered and chat-session scoped. @@ -12,7 +12,19 @@ This is an Agenstra productivity feature built on OpenCode (not a native OpenCod ## Context injection -Plan creation uses the **same composer context** as Send (`ContextInjectionPayload`): workspace, related environments, ticket SHAs, knowledge SHAs, and auto-enrichment. The controller stores a snapshot on `chat_plan.context_injection` and reuses it for explore, refine, and execute turns. Refine may optionally send a new snapshot to replace the stored one. +Plan creation uses the **same composer context** as Send (`ContextInjectionPayload`): workspace, related environments, ticket SHAs, knowledge SHAs, and auto-enrichment. The controller stores a snapshot on `chat_plan.context_injection` and reuses it for explore, refine, and execute turns. Refine may optionally send a new snapshot to replace the stored one via the same **Select context** modal pattern used by the main composer. + +## Console UX + +- Chat cards show **one consolidated status badge** (for example `Exploring`, `Draft ready`, `Executed`) before the plan title instead of separate status/phase badges. +- Card actions place **View plan** and the icon-only **Execute plan** action on one full-width row. +- The detail modal uses the same title-row pattern (badge + title) and keeps the markdown body separate from actions with spacing equivalent to the main chat cards. +- Refinement is handled through an **inline composer** inside the modal with: + - the plan refinement textarea + - **Select context** + - **Enhance prompt with AI** + - an icon-only **Refine plan** submit action +- The footer keeps terminal actions only (for example **Cancel plan**, icon-only **Execute plan**). ## Phases (high level) diff --git a/graph/graph.json b/graph/graph.json index d67eefa39..7660756bd 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-10-10T15:18:53.973Z", + "generatedAt": "2026-10-10T17:28:24.970Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -25848,6 +25848,16 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-console-ux", + "type": "concept", + "attrs": { + "title": "Console UX", + "docPath": "docs/agenstra/features/chat-plan-mode.md", + "sectionAnchor": "console-ux", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-phases-high-level", "type": "concept", @@ -48320,6 +48330,11 @@ "to": "concept:agenstra-context-injection", "type": "contains" }, + { + "from": "file:docs/agenstra/features/chat-plan-mode.md", + "to": "concept:agenstra-console-ux", + "type": "contains" + }, { "from": "file:docs/agenstra/features/chat-plan-mode.md", "to": "concept:agenstra-phases-high-level", @@ -80795,6 +80810,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-console-ux", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-phases-high-level", "to": "domain:agenstra", diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts index 4b11a154c..0060498a1 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.spec.ts @@ -290,6 +290,83 @@ describe('ClientsGateway', () => { ); }); + describe('handleRefineChatPlan', () => { + const planEntity = { id: 'plan-1', clientId: 'client-uuid', agentId: 'agent-uuid' }; + + const setupRefine = () => { + const socket = createMockSocket(); + const chatPlanService = (gateway as any).chatPlanService as { getEntityOrThrow: jest.Mock }; + const orchestrator = (gateway as any).chatPlanOrchestrator as { startRefine: jest.Mock }; + + (gateway as any).selectedClientBySocket.set(socket.id, 'client-uuid'); + chatPlanService.getEntityOrThrow.mockResolvedValue(planEntity); + + return { socket, orchestrator }; + }; + + it('should start refine without replacing the stored context when none is sent', async () => { + const { socket, orchestrator } = setupRefine(); + + await gateway.handleRefineChatPlan( + { agentId: 'agent-uuid', planId: 'plan-1', message: ' tweak step 2 ', correlationId: 'c-1' }, + socket, + ); + + expect(orchestrator.startRefine).toHaveBeenCalledWith('plan-1', 'tweak step 2', undefined); + expect(mockAutoContextResolverService.resolve).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith( + 'forwardAck', + expect.objectContaining({ received: true, event: 'refineChatPlan', planId: 'plan-1' }), + ); + }); + + it('should enrich and forward a replacement context snapshot', async () => { + const { socket, orchestrator } = setupRefine(); + + await gateway.handleRefineChatPlan( + { + agentId: 'agent-uuid', + planId: 'plan-1', + message: 'include the ticket', + correlationId: 'c-2', + contextInjection: { includeWorkspace: true, environmentIds: ['agent-uuid'], ticketShas: ['329ec4f'] }, + }, + socket, + ); + + expect(mockAutoContextResolverService.resolve).toHaveBeenCalledWith( + expect.objectContaining({ clientId: 'client-uuid', prompt: 'include the ticket' }), + ); + expect(orchestrator.startRefine).toHaveBeenCalledWith( + 'plan-1', + 'include the ticket', + expect.objectContaining({ includeWorkspace: true, environmentIds: ['agent-uuid'] }), + ); + }); + + it('should reject refine for a plan owned by another client', async () => { + const { socket, orchestrator } = setupRefine(); + const chatPlanService = (gateway as any).chatPlanService as { getEntityOrThrow: jest.Mock }; + + chatPlanService.getEntityOrThrow.mockResolvedValue({ ...planEntity, clientId: 'other-client' }); + + await gateway.handleRefineChatPlan( + { + agentId: 'agent-uuid', + planId: 'plan-1', + message: 'x', + correlationId: 'c-3', + contextInjection: { includeWorkspace: true }, + }, + socket, + ); + + expect(orchestrator.startRefine).not.toHaveBeenCalled(); + expect(mockAutoContextResolverService.resolve).not.toHaveBeenCalled(); + expect(socket.emit).toHaveBeenCalledWith('error', { message: 'Plan not found' }); + }); + }); + it('should enrich forwarded chat context with ticket prompt trees by sha', async () => { const socket = createMockSocket(); const { io } = jest.requireMock('socket.io-client') as { io: jest.Mock }; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts index 1670a729c..0d162bdbf 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/gateways/clients.gateway.ts @@ -1125,7 +1125,20 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat @SubscribeMessage(CLIENT_CHAT_PLAN_EVENTS.refineChatPlan) async handleRefineChatPlan( - @MessageBody() data: { agentId?: string; planId?: string; message?: string; correlationId?: string }, + @MessageBody() + data: { + agentId?: string; + planId?: string; + message?: string; + correlationId?: string; + contextInjection?: { + includeWorkspace?: boolean; + environmentIds?: string[]; + autoEnrichmentEnabled?: boolean; + ticketShas?: string[]; + knowledgeShas?: string[]; + }; + }, @ConnectedSocket() socket: Socket, ): Promise { const clientId = this.selectedClientBySocket.get(socket.id); @@ -1154,7 +1167,20 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat return; } - this.chatPlanOrchestrator.startRefine(planId, message); + let contextInjection: ChatPlanContextInjectionJson | undefined; + + if (data.contextInjection) { + const enrichedPayload = (await this.enrichForwardPayloadWithTicketContext(clientId, { + message, + contextInjection: data.contextInjection, + })) as { + contextInjection?: ChatPlanContextInjectionJson; + }; + + contextInjection = enrichedPayload.contextInjection ?? data.contextInjection; + } + + this.chatPlanOrchestrator.startRefine(planId, message, contextInjection); socket.emit('forwardAck', { received: true, event: CLIENT_CHAT_PLAN_EVENTS.refineChatPlan, planId }); } catch (error: unknown) { socket.emit('error', { message: 'Plan not found' }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts index b0b74a420..afd6ed78d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.spec.ts @@ -78,10 +78,51 @@ describe('ChatPlanChatSyncService', () => { const payload = (chatRealtime.emitToSocket as jest.Mock).mock.calls[0][1] as { hydrate: boolean; plan: { id: string; chatId: string }; + actions: Array<{ type: string }>; }; expect(payload.hydrate).toBe(true); expect(payload.plan.id).toBe('p1'); expect(payload.plan.chatId).toBe('chat-1'); + expect(payload.actions.some((action: { type: string }) => action.type === 'executeChatPlan')).toBe(true); + }); + + it('does not offer execution for plans with draft content awaiting agent completion', async () => { + const plan = { + id: 'p1', + clientId: 'c1', + agentId: 'a1', + chatId: 'chat-1', + status: ChatPlanStatus.READY, + phase: ChatPlanPhase.DRAFT, + sourcePrompt: 'hi', + planMarkdown: '# Draft', + summary: null, + contextInjection: null, + model: null, + resumeSessionSuffix: '-plan-p1', + completionSignalSeen: false, + failureCode: null, + failureMessage: null, + createdByUserId: null, + startedAt: new Date('2020-01-01'), + finishedAt: null, + createdAt: new Date('2020-01-01'), + updatedAt: new Date('2020-01-02'), + } as ChatPlanEntity; + + const qb = planRepo.createQueryBuilder(); + + qb.getMany.mockResolvedValue([plan]); + const socket = { connected: true, emit: jest.fn() } as never; + + await service.hydrateForAgentClient(socket, 'c1', 'a1'); + const payload = (chatRealtime.emitToSocket as jest.Mock).mock.calls[0][1] as { + plan: { phase: string }; + actions: Array<{ type: string }>; + }; + + expect(payload.plan.phase).toBe(ChatPlanPhase.DRAFT); + expect(payload.actions.some((action) => action.type === 'executeChatPlan')).toBe(false); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts index 1ce5b45e4..6bd82b26a 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-chat-sync.service.ts @@ -5,7 +5,7 @@ import { Repository } from 'typeorm'; import type { ChatPlanChatEventDto } from '../dto/chat-plan/chat-plan-chat-event.dto'; import { ChatPlanEntity } from '../entities/chat-plan.entity'; -import { ChatPlanStatus } from '../entities/chat-plan.enums'; +import { ChatPlanPhase, ChatPlanStatus } from '../entities/chat-plan.enums'; import { chatPlanEntityToDto } from '../utils/chat-plan-mappers'; import { AgentConsoleStatusService } from './agent-console-status.service'; @@ -83,7 +83,7 @@ export class ChatPlanChatSyncService { }, ]; - if (plan.status === ChatPlanStatus.READY) { + if (plan.status === ChatPlanStatus.READY && plan.phase === ChatPlanPhase.READY) { actions.push({ type: 'executeChatPlan', planId: plan.id, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts index 1c2ca1315..d3f50fb4e 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan-orchestrator.service.ts @@ -1,6 +1,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { randomUUID } from 'crypto'; +import type { ChatPlanContextInjectionJson } from '../entities/chat-plan.entity'; import { StatisticsInteractionKind } from '../entities/statistics-chat-io.entity'; import { ChatPlanFailureCode, ChatPlanPhase, ChatPlanStatus } from '../entities/chat-plan.enums'; import { buildExecutePrompt, buildExplorePrompt, buildRefinePrompt } from '../utils/chat-plan-prompt.utils'; @@ -42,8 +43,12 @@ export class ChatPlanOrchestratorService { }); } - startRefine(planId: string, refineMessage: string): void { - void this.runRefine(planId, refineMessage).catch((err) => { + /** + * @param contextInjection Optional replacement snapshot; when set it is persisted on the plan and reused by + * subsequent refine/execute turns. + */ + startRefine(planId: string, refineMessage: string, contextInjection?: ChatPlanContextInjectionJson): void { + void this.runRefine(planId, refineMessage, contextInjection).catch((err) => { this.logger.warn(`startRefine failed for ${planId}: ${(err as Error).message}`); }); } @@ -161,7 +166,11 @@ export class ChatPlanOrchestratorService { } } - private async runRefine(planId: string, refineMessage: string): Promise { + private async runRefine( + planId: string, + refineMessage: string, + contextInjection?: ChatPlanContextInjectionJson, + ): Promise { const plan = await this.chatPlanService.getEntityOrThrow(planId); if (plan.status === ChatPlanStatus.REFINING) { @@ -185,12 +194,15 @@ export class ChatPlanOrchestratorService { phase: ChatPlanPhase.REFINE, completionSignalSeen: false, finishedAt: null, + ...(contextInjection ? { contextInjection } : {}), }); if (!started) { return; } + const effectiveContextInjection = contextInjection ?? plan.contextInjection ?? undefined; + let draft = ''; try { @@ -203,7 +215,7 @@ export class ChatPlanOrchestratorService { resumeSessionSuffix: plan.resumeSessionSuffix, ephemeral: true, model: plan.model ?? undefined, - contextInjection: plan.contextInjection ?? undefined, + contextInjection: effectiveContextInjection, statisticsInteractionKind: StatisticsInteractionKind.CHAT_PLAN_TURN, onDeltaText: async (delta) => { draft += delta; @@ -247,6 +259,12 @@ export class ChatPlanOrchestratorService { return; } + if (plan.phase !== ChatPlanPhase.READY) { + this.logger.debug(`Ignoring execute for plan ${planId} in phase ${plan.phase}`); + + return; + } + const markdown = plan.planMarkdown?.trim(); if (!markdown) { diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts index 0790bf7b3..51491cc97 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/chat-plan.service.ts @@ -221,6 +221,7 @@ export class ChatPlanService { | 'failureMessage' | 'finishedAt' | 'startedAt' + | 'contextInjection' > >, ): Promise { diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.spec.ts index fd012486e..80218cc79 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.spec.ts @@ -58,4 +58,46 @@ describe('ContainerSocketFacade', () => { expect(store.dispatch).toHaveBeenCalledWith(setClient({ clientId: 'c1' })); expect(emit).toHaveBeenCalledWith('setClient', { clientId: 'c1' }); }); + + describe('refineChatPlan', () => { + it('should emit refineChatPlan without contextInjection when none is given', () => { + const emit = jest.fn(); + (getSocketInstance as jest.Mock).mockReturnValue({ connected: true, emit }); + + facade.refineChatPlan('a1', 'p1', 'tweak', 'corr-1'); + + expect(emit).toHaveBeenCalledWith('refineChatPlan', { + agentId: 'a1', + planId: 'p1', + message: 'tweak', + correlationId: 'corr-1', + }); + }); + + it('should include contextInjection when given', () => { + const emit = jest.fn(); + (getSocketInstance as jest.Mock).mockReturnValue({ connected: true, emit }); + const contextInjection = { includeWorkspace: true, environmentIds: ['a1'], ticketShas: ['abc'] }; + + facade.refineChatPlan('a1', 'p1', 'tweak', 'corr-2', contextInjection); + + expect(emit).toHaveBeenCalledWith('refineChatPlan', { + agentId: 'a1', + planId: 'p1', + message: 'tweak', + correlationId: 'corr-2', + contextInjection, + }); + }); + + it('should not emit when the socket is disconnected', () => { + const emit = jest.fn(); + (getSocketInstance as jest.Mock).mockReturnValue({ connected: false, emit }); + jest.spyOn(console, 'warn').mockImplementation(() => undefined); + + facade.refineChatPlan('a1', 'p1', 'tweak', 'corr-3'); + + expect(emit).not.toHaveBeenCalled(); + }); + }); }); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts index 6b138a00b..e1fc69ba9 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/container-socket/container-socket.facade.ts @@ -156,7 +156,14 @@ export class ContainerSocketFacade { socket.emit(CLIENT_CHAT_PLAN_EVENTS.createChatPlan, payload); } - refineChatPlan(agentId: string, planId: string, message: string, correlationId: string): void { + /** When `contextInjection` is set, the controller replaces the plan's stored context snapshot. */ + refineChatPlan( + agentId: string, + planId: string, + message: string, + correlationId: string, + contextInjection?: ContextInjectionPayload, + ): void { const socket = getSocketInstance(); if (!socket || !socket.connected) { @@ -165,7 +172,19 @@ export class ContainerSocketFacade { return; } - socket.emit(CLIENT_CHAT_PLAN_EVENTS.refineChatPlan, { agentId, planId, message, correlationId }); + const payload: { + agentId: string; + planId: string; + message: string; + correlationId: string; + contextInjection?: ContextInjectionPayload; + } = { agentId, planId, message, correlationId }; + + if (contextInjection) { + payload.contextInjection = contextInjection; + } + + socket.emit(CLIENT_CHAT_PLAN_EVENTS.refineChatPlan, payload); } executeChatPlan(agentId: string, planId: string, correlationId: string): void { diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.spec.ts new file mode 100644 index 000000000..a14ba52e5 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.spec.ts @@ -0,0 +1,17 @@ +import { SecurityContext } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { DomSanitizer } from '@angular/platform-browser'; + +import { sanitizeAndTrustMarkdownHtml } from './chat-markdown-html'; + +describe('sanitizeAndTrustMarkdownHtml', () => { + it('removes executable markup while preserving safe markdown HTML', () => { + const sanitizer = TestBed.inject(DomSanitizer); + const html = '

Plan

open'; + const result = sanitizer.sanitize(SecurityContext.HTML, sanitizeAndTrustMarkdownHtml(sanitizer, html)); + + expect(result).toContain('

Plan

'); + expect(result).not.toContain('onerror'); + expect(result).not.toContain('href="javascript:'); + }); +}); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.ts new file mode 100644 index 000000000..0ade54e93 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-markdown-html.ts @@ -0,0 +1,8 @@ +import { SecurityContext } from '@angular/core'; +import { DomSanitizer, SafeHtml } from '@angular/platform-browser'; + +export function sanitizeAndTrustMarkdownHtml(sanitizer: DomSanitizer, html: string): SafeHtml { + const sanitizedHtml = sanitizer.sanitize(SecurityContext.HTML, html) ?? ''; + + return sanitizer.bypassSecurityTrustHtml(sanitizedHtml); +} diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.spec.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.spec.ts new file mode 100644 index 000000000..56ba84891 --- /dev/null +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.spec.ts @@ -0,0 +1,34 @@ +import { chatPlanStateBadgeColor, chatPlanStateLabel } from './chat-plan-labels'; + +describe('chatPlanStateLabel', () => { + it('returns a consolidated label for draft plans that are not ready to execute', () => { + expect(chatPlanStateLabel('ready', 'draft')).toBe('Draft available'); + }); + + it('falls back to the status label for regular ready plans', () => { + expect(chatPlanStateLabel('ready', 'ready')).toBe('Ready'); + }); + + it('falls back to whichever raw value is available for unknown states', () => { + expect(chatPlanStateLabel('custom-status', 'custom-phase')).toBe('custom-phase'); + expect(chatPlanStateLabel('custom-status', '')).toBe('custom-status'); + }); +}); + +describe('chatPlanStateBadgeColor', () => { + it('maps successful terminal states to success badges', () => { + expect(chatPlanStateBadgeColor('ready', 'ready')).toBe('success'); + expect(chatPlanStateBadgeColor('executed', 'ready')).toBe('success'); + }); + + it('maps error states to danger badges', () => { + expect(chatPlanStateBadgeColor('failed', 'ready')).toBe('danger'); + expect(chatPlanStateBadgeColor('cancelled', 'ready')).toBe('danger'); + }); + + it('maps in-progress states to secondary/warning badges', () => { + expect(chatPlanStateBadgeColor('executing', 'ready')).toBe('warning'); + expect(chatPlanStateBadgeColor('exploring', 'explore')).toBe('secondary'); + expect(chatPlanStateBadgeColor('ready', 'draft')).toBe('info'); + }); +}); diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts index addd8eb5d..b03ea53b3 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat-plan-labels.ts @@ -1,8 +1,13 @@ +import type { FpcBadgeColor } from '@forepath/shared/frontend/ui-components'; + /** - * Human-readable labels for chat plan status / phase (mirrors backend chat-plan.enums). + * Human-readable state labels for chat plans. This consolidates status + phase into one operator-facing summary. */ +export function chatPlanStateLabel(status: string, phase: string): string { + if (status === 'ready' && phase === 'draft') { + return $localize`:@@featureChat-planStateDraftAvailable:Draft available`; + } -export function chatPlanStatusLabel(status: string): string { switch (status) { case 'pending': return $localize`:@@featureChat-planStatusPending:Pending`; @@ -21,21 +26,25 @@ export function chatPlanStatusLabel(status: string): string { case 'cancelled': return $localize`:@@featureChat-planStatusCancelled:Cancelled`; default: - return status; + return phase || status; } } -export function chatPlanPhaseLabel(phase: string): string { - switch (phase) { - case 'explore': - return $localize`:@@featureChat-planPhaseExplore:Explore`; - case 'draft': - return $localize`:@@featureChat-planPhaseDraft:Draft`; - case 'refine': - return $localize`:@@featureChat-planPhaseRefine:Refine`; +export function chatPlanStateBadgeColor(status: string, phase: string): FpcBadgeColor { + if (status === 'ready' && phase === 'draft') { + return 'info'; + } + + switch (status) { case 'ready': - return $localize`:@@featureChat-planPhaseReady:Ready`; + case 'executed': + return 'success'; + case 'failed': + case 'cancelled': + return 'danger'; + case 'executing': + return 'warning'; default: - return phase; + return 'secondary'; } } diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html index 2f6426127..3e7ca174f 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.html @@ -1091,31 +1091,41 @@
-
- {{ item.payload.plan.summary || item.payload.plan.sourcePrompt }} -
-
- {{ - chatPlanStatusLabel(item.payload.plan.status) - }} - {{ chatPlanPhaseLabel(item.payload.plan.phase) }} +
+ {{ chatPlanStateLabel(item.payload.plan.status, item.payload.plan.phase) }} +
+ {{ getChatPlanTitle(item.payload.plan) }} +
-
+
@for (act of item.payload.actions; track act.type + act.planId) { @if (act.type === 'openChatPlan') { {{ act.label }} } @else if (act.type === 'executeChatPlan') { - - {{ act.label }} + + } } @@ -1662,22 +1672,25 @@
}
- + Done
- + @if (planDetailPlan$ | async; as plan) { -
- {{ chatPlanStatusLabel(plan.status) }} - {{ chatPlanPhaseLabel(plan.phase) }} +
+ {{ + chatPlanStateLabel(plan.status, plan.phase) + }} + + {{ getChatPlanTitle(plan) || chatPlanModalTitle }} +
- @if (plan.summary) { -

{{ plan.summary }}

- } + } + @if (planDetailPlan$ | async; as plan) { @if (plan.failureMessage) { } @@ -1691,18 +1704,101 @@
}
@if (plan.status === 'ready' || plan.status === 'refining') { - - + @if (planRefineEnhanceError(); as enhErr) { + + } + +
+ @if (contextSelectionToolbarChips().length > 0) { + + @for (chip of contextSelectionToolbarChips(); track chip.trackKey) { + {{ chip.label }} + } + + } + + + + + + + + + +
+
} } @else {
@@ -1719,17 +1815,18 @@
}
- @if (plan.status === 'ready') { + @if (isChatPlanExecutable(plan.status, plan.phase)) { - {{ chatPlanRefineSubmitLabel }} - - - {{ chatPlanExecuteLabel }} + }
diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss index 8e2ae0bd7..51981c125 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.scss @@ -241,6 +241,39 @@ padding-top: 0.25rem; } +.chat-plan-refine-composer { + display: flex; + flex-direction: column; + gap: 0.75rem; + border: 1px solid var(--bs-border-color); + border-radius: 0.375rem; + background-color: var(--bs-body-bg); + padding: 0.75rem; + + &:focus-within { + border-color: rgba(var(--bs-primary-rgb), 0.5); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15); + } +} + +.chat-plan-refine-textarea { + min-height: 4.5rem; + resize: vertical; + overflow-wrap: break-word; + background-color: transparent; + border: none; + outline: none; + box-shadow: none; + padding: 0; + margin: 0; + width: 100%; +} + +.chat-plan-refine-toolbar { + border-top: 1px solid var(--bs-border-color); + padding-top: 0.75rem; +} + // Input area - prevent horizontal overflow .input-group { max-width: 100%; diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts index f31db66f9..998225876 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/chat/chat.component.ts @@ -171,10 +171,11 @@ import { } from './agent-chat-response-markdown'; import { accumulateStreamingTurnFromEvents } from './agent-chat-streaming-aggregate'; import { AgentChatTodosExpandCoordinator } from './agent-chat-todos-expand.coordinator'; +import { sanitizeAndTrustMarkdownHtml } from './chat-markdown-html'; import { mergeTicketAutomationChatCardPayload } from './chat-automation-card-merge'; import { - chatPlanPhaseLabel as chatPlanPhaseLabelFn, - chatPlanStatusLabel as chatPlanStatusLabelFn, + chatPlanStateBadgeColor as chatPlanStateBadgeColorFn, + chatPlanStateLabel as chatPlanStateLabelFn, } from './chat-plan-labels'; import { buildMergedChatDisplayThread, type ChatDisplayThreadItem } from './chat-thread-display'; @@ -266,13 +267,13 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe readonly ticketWorkflowLaneLabel = ticketLaneStatusLabel; readonly automationRunStatusLabel = ticketAutomationRunStatusLabelFn; readonly automationRunPhaseLabel = ticketAutomationRunPhaseLabelFn; - readonly chatPlanStatusLabel = chatPlanStatusLabelFn; - readonly chatPlanPhaseLabel = chatPlanPhaseLabelFn; + readonly chatPlanStateLabel = chatPlanStateLabelFn; + readonly chatPlanStateBadgeColor = chatPlanStateBadgeColorFn; readonly openTicketFromChatButtonLabel = $localize`:@@featureChat-openTicketFromAutomationCard:Open ticket`; readonly chatPlanModalTitle = $localize`:@@featureChat-planModalTitle:Plan`; readonly chatPlanRefinePlaceholder = $localize`:@@featureChat-planRefinePlaceholder:Describe changes to the plan…`; - readonly chatPlanRefineSubmitLabel = $localize`:@@featureChat-planRefineSubmit:Refine`; + readonly chatPlanRefineSubmitLabel = $localize`:@@featureChat-planRefineSubmit:Refine plan`; readonly chatPlanExecuteLabel = $localize`:@@featureChat-planExecute:Execute`; readonly chatPlanCancelLabel = $localize`:@@featureChat-planCancel:Cancel plan`; readonly chatPlanEmptyMarkdown = $localize`:@@featureChat-planEmptyMarkdown:Plan content will appear here as the agent explores…`; @@ -712,7 +713,10 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe /** Plan detail modal: selected plan id (live updates via plans$). */ readonly planDetailPlanId = signal(null); readonly planDetailModalOpen = signal(false); + readonly planDetailContextSwapState: AgentModalSwapState = { suspended: false }; readonly planRefineMessage = signal(''); + readonly planRefineEnhanceError = signal(null); + readonly pendingEnhancementTarget = signal<'chat' | 'planRefine'>('chat'); /** Live plan entity for the open plan detail modal (updates on chatPlanUpsert). */ readonly planDetailPlan$: Observable = combineLatest([ @@ -1241,6 +1245,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe readonly chatSessionToRenameTitle = signal(''); readonly contextSelectionModalOpen = signal(false); + readonly contextSelectionReturnTarget = signal<'chat' | 'planRefine'>('chat'); readonly deleteClientModalOpen = signal(false); readonly deleteAgentModalOpen = signal(false); readonly deleteChatSessionModalOpen = signal(false); @@ -1689,12 +1694,25 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return; } + const target = this.pendingEnhancementTarget(); + if (r.success && r.enhancedText !== undefined) { - this.chatMessage.set(r.enhancedText); - this.enhanceErrorMessage.set(null); + if (target === 'planRefine') { + this.planRefineMessage.set(r.enhancedText); + this.planRefineEnhanceError.set(null); + } else { + this.chatMessage.set(r.enhancedText); + this.enhanceErrorMessage.set(null); + } } else { - this.enhanceErrorMessage.set(r.errorMessage ?? 'Enhancement failed'); + if (target === 'planRefine') { + this.planRefineEnhanceError.set(r.errorMessage ?? 'Enhancement failed'); + } else { + this.enhanceErrorMessage.set(r.errorMessage ?? 'Enhancement failed'); + } } + + this.pendingEnhancementTarget.set('chat'); }); // Load clients on init only when not already cached (avoids spinner on route reuse) @@ -3048,10 +3066,22 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } onEnhanceMessage(): void { - let message = this.chatMessage().trim(); + this.enhanceText(this.chatMessage(), 'chat', true); + } + + clearPlanRefineEnhanceError(): void { + this.planRefineEnhanceError.set(null); + } + + onEnhancePlanRefineMessage(): void { + this.enhanceText(this.planRefineMessage(), 'planRefine'); + } + + private enhanceText(rawMessage: string, target: 'chat' | 'planRefine', includeSlashCommand = false): void { + let message = rawMessage.trim(); const selectedCmd = this.selectedCommand(); - if (selectedCmd) { + if (includeSlashCommand && selectedCmd) { message = message ? `${selectedCmd}\n${message}` : selectedCmd; } @@ -3065,7 +3095,14 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return; } - this.enhanceErrorMessage.set(null); + this.pendingEnhancementTarget.set(target); + + if (target === 'planRefine') { + this.planRefineEnhanceError.set(null); + } else { + this.enhanceErrorMessage.set(null); + } + const correlationId = crypto.randomUUID(); const model = this.selectedChatModel(); const normalizedModel = model === null || model === '' ? null : model; @@ -3113,16 +3150,23 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe }); } + getChatPlanTitle(plan: Pick): string { + return plan.summary?.trim() || plan.sourcePrompt; + } + openChatPlanFromChat(payload: ChatPlanChatEventPayload): void { this.planRefineMessage.set(''); + this.planRefineEnhanceError.set(null); this.planDetailPlanId.set(payload.plan.id); this.planDetailModalOpen.set(true); } onClosePlanDetailModal(): void { + this.planDetailContextSwapState.suspended = false; this.planDetailModalOpen.set(false); this.planDetailPlanId.set(null); this.planRefineMessage.set(''); + this.planRefineEnhanceError.set(null); } onExecuteChatPlan(planId: string): void { @@ -3153,14 +3197,25 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe return; } - this.socketsFacade.refineChatPlan(agentId, planId, message, crypto.randomUUID()); + this.socketsFacade.refineChatPlan( + agentId, + planId, + message, + crypto.randomUUID(), + this.buildContextInjection(agentId), + ); this.planRefineMessage.set(''); + this.planRefineEnhanceError.set(null); } isChatPlanCancellable(status: string): boolean { return status === 'exploring' || status === 'refining' || status === 'pending' || status === 'executing'; } + isChatPlanExecutable(status: string, phase: string): boolean { + return status === 'ready' && phase === 'ready'; + } + onChatInputKeydown(event: KeyboardEvent): void { if (this.slashCommandMenuOpen()) { const filtered = this.filteredSlashCommands(); @@ -3246,7 +3301,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.autoEnrichmentEnabled.set(enabled); } - onOpenContextSelectionModal(): void { + onOpenContextSelectionModal(returnTarget: 'chat' | 'planRefine' = 'chat'): void { const clientId = this.activeClientIdSignal(); if (clientId) { @@ -3254,10 +3309,32 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe this.knowledgeFacade.loadTree(clientId); } + this.contextSelectionReturnTarget.set(returnTarget); + + if (returnTarget === 'planRefine') { + swapToOverlayAgentModal({ + underlyingOpen: this.planDetailModalOpen, + overlayOpen: this.contextSelectionModalOpen, + swapState: this.planDetailContextSwapState, + }); + + return; + } + showAgentModal(this.contextSelectionModalOpen); } onCloseContextSelectionModal(): void { + this.contextSelectionModalOpen.set(false); + + if (this.contextSelectionReturnTarget() === 'planRefine') { + restoreUnderlyingAgentModal({ + underlyingOpen: this.planDetailModalOpen, + swapState: this.planDetailContextSwapState, + }); + } + + this.contextSelectionReturnTarget.set('chat'); this.ticketContextInput.set(''); this.ticketContextSuggestionsOpen.set(false); this.knowledgeContextInput.set(''); @@ -5750,7 +5827,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe breaks: true, gfm: true, }); - const safe = this.sanitizer.bypassSecurityTrustHtml(html || ''); + const safe = sanitizeAndTrustMarkdownHtml(this.sanitizer, html || ''); this.markdownHtmlCache.set(result, safe); this.trimHtmlCache(this.markdownHtmlCache); @@ -5759,7 +5836,7 @@ export class AgentConsoleChatComponent implements OnInit, AfterViewChecked, OnDe } catch (error) { console.warn('Error parsing markdown:', error); const escaped = result.replace(//g, '>'); - const safe = this.sanitizer.bypassSecurityTrustHtml(escaped); + const safe = sanitizeAndTrustMarkdownHtml(this.sanitizer, escaped); this.markdownHtmlCache.set(result, safe); this.trimHtmlCache(this.markdownHtmlCache);