From 382d2bf299d9c6b0178c2d673fe85b36f026b3da Mon Sep 17 00:00:00 2001 From: Marcel Menk Date: Thu, 20 Aug 2026 22:07:15 +0200 Subject: [PATCH 1/3] feat(agenstra): environment multichat support support holding multiple simultaneous conversations with the same environment --- .../backend-agent-manager/package.json | 1 + ...1200000000_CreateAgentChatSessionsTable.ts | 229 +++++ .../src/typeorm.config.ts | 2 + .../src/i18n/messages.xlf | 45 + .../ai-agents/agent-client-protocol.md | 6 +- .../applications/backend-agent-controller.md | 16 +- .../applications/backend-agent-manager.md | 34 +- .../applications/frontend-agent-console.md | 4 +- docs/agenstra/features/authentication.md | 14 +- docs/agenstra/features/chat-interface.md | 83 +- docs/agenstra/features/webhooks.md | 24 +- .../features/websocket-communication.md | 60 +- graph/graph.json | 877 +++++++++++++++++- .../spec/openapi.yaml | 366 +++++++- .../src/lib/auth/agenstra-pat.scopes.spec.ts | 1 + .../src/lib/auth/agenstra-pat.scopes.ts | 1 + .../controllers/clients.controller.spec.ts | 150 +++ .../src/lib/controllers/clients.controller.ts | 128 +++ .../src/lib/gateways/clients.gateway.ts | 3 + .../src/lib/modules/clients.module.ts | 3 + .../agenstra-notification.events.spec.ts | 3 + .../agenstra-notification.events.ts | 3 + .../agenstra-notification.publisher.spec.ts | 43 + .../agenstra-notification.publisher.ts | 38 +- .../client-agent-chats-proxy.service.spec.ts | 382 ++++++++ .../client-agent-chats-proxy.service.ts | 253 +++++ .../client-agent-proxy.service.spec.ts | 2 + .../feature-agent-manager/spec/asyncapi.yaml | 82 +- .../feature-agent-manager/spec/openapi.yaml | 294 +++++- .../feature-agent-manager/src/index.ts | 7 + .../constants/chat-session.constants.spec.ts | 42 + .../lib/constants/chat-session.constants.ts | 37 + .../agents-chat-sessions.controller.spec.ts | 198 ++++ .../agents-chat-sessions.controller.ts | 107 +++ .../lib/controllers/agents.controller.spec.ts | 9 + .../src/lib/dto/agent-response.dto.ts | 9 + .../src/lib/dto/chat-session-response.dto.ts | 25 + .../src/lib/dto/create-chat-session.dto.ts | 11 + .../src/lib/dto/update-chat-session.dto.ts | 11 + .../lib/entities/agent-chat-session.entity.ts | 50 + .../entities/agent-message-event.entity.ts | 8 + .../src/lib/entities/agent-message.entity.ts | 10 +- .../src/lib/gateways/agents.gateway.spec.ts | 397 ++++++-- .../src/lib/gateways/agents.gateway.ts | 384 ++++++-- .../src/lib/modules/agents.module.spec.ts | 11 + .../src/lib/modules/agents.module.ts | 10 + .../src/lib/providers/agent-events.types.ts | 2 + .../agent-chat-sessions.repository.ts | 103 ++ .../agent-message-events.repository.ts | 5 +- .../agent-messages.repository.spec.ts | 10 + .../repositories/agent-messages.repository.ts | 25 + .../agent-chat-sessions.service.spec.ts | 275 ++++++ .../services/agent-chat-sessions.service.ts | 207 +++++ .../agent-file-system.service.spec.ts | 9 + .../services/agent-message-events.service.ts | 18 +- .../services/agent-messages.service.spec.ts | 63 +- .../lib/services/agent-messages.service.ts | 64 +- .../src/lib/services/agents.service.spec.ts | 57 ++ .../src/lib/services/agents.service.ts | 42 +- .../data-access-agent-console/src/index.ts | 7 + .../src/lib/services/agents.service.spec.ts | 2 + .../services/chat-sessions.service.spec.ts | 188 ++++ .../src/lib/services/chat-sessions.service.ts | 104 +++ .../lib/state/agents/agents.effects.spec.ts | 2 + .../lib/state/agents/agents.facade.spec.ts | 4 + .../lib/state/agents/agents.reducer.spec.ts | 4 + .../lib/state/agents/agents.selectors.spec.ts | 4 + .../src/lib/state/agents/agents.types.ts | 6 + .../chat-sessions/chat-sessions.actions.ts | 94 ++ .../chat-sessions.effects.spec.ts | 180 ++++ .../chat-sessions/chat-sessions.effects.ts | 104 +++ .../chat-sessions.facade.spec.ts | 96 ++ .../chat-sessions/chat-sessions.facade.ts | 109 +++ .../chat-sessions.reducer.spec.ts | 188 ++++ .../chat-sessions/chat-sessions.reducer.ts | 242 +++++ .../chat-sessions.selectors.spec.ts | 77 ++ .../chat-sessions/chat-sessions.selectors.ts | 75 ++ .../chat-sessions/chat-sessions.types.ts | 44 + .../lib/state/sockets/sockets.facade.spec.ts | 38 + .../src/lib/state/sockets/sockets.facade.ts | 35 +- .../src/lib/state/sockets/sockets.reducer.ts | 34 +- .../sockets.selectors.chat-timeline.spec.ts | 47 +- .../lib/state/sockets/sockets.selectors.ts | 34 +- .../src/lib/state/sockets/sockets.types.ts | 20 +- .../src/lib/agent-console.routes.ts | 12 + .../src/lib/chat/chat.component.html | 199 +++- .../src/lib/chat/chat.component.scss | 22 +- .../src/lib/chat/chat.component.ts | 306 +++++- .../domains/shared/frontend/icon-buttons.scss | 9 + 89 files changed, 7337 insertions(+), 302 deletions(-) create mode 100644 apps/agenstra/backend-agent-manager/src/migrations/1781200000000_CreateAgentChatSessionsTable.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.spec.ts create mode 100644 libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.spec.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.actions.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.spec.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.spec.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.spec.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.spec.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.ts create mode 100644 libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.types.ts diff --git a/apps/agenstra/backend-agent-manager/package.json b/apps/agenstra/backend-agent-manager/package.json index 5b2cbe409..dcf1606b0 100644 --- a/apps/agenstra/backend-agent-manager/package.json +++ b/apps/agenstra/backend-agent-manager/package.json @@ -18,6 +18,7 @@ "@bull-board/nestjs": "7.1.5", "@bull-board/ui": "7.1.5", "@nestjs/bullmq": "11.0.4", + "@opensearch-project/opensearch": "3.6.0", "@opentelemetry/sdk-node": "0.220.0", "axios": "1.12.2", "bullmq": "5.76.10", diff --git a/apps/agenstra/backend-agent-manager/src/migrations/1781200000000_CreateAgentChatSessionsTable.ts b/apps/agenstra/backend-agent-manager/src/migrations/1781200000000_CreateAgentChatSessionsTable.ts new file mode 100644 index 000000000..59384efa1 --- /dev/null +++ b/apps/agenstra/backend-agent-manager/src/migrations/1781200000000_CreateAgentChatSessionsTable.ts @@ -0,0 +1,229 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Creates agent_chat_sessions, backfills a primary session per agent, + * and attaches chat_session_id to agent_messages and agent_message_events. + */ +export class CreateAgentChatSessionsTable1781200000000 implements MigrationInterface { + name = 'CreateAgentChatSessionsTable1781200000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'agent_chat_sessions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { + name: 'agent_id', + type: 'uuid', + isNullable: false, + }, + { + name: 'title', + type: 'varchar', + length: '255', + isNullable: true, + }, + { + name: 'kind', + type: 'varchar', + length: '16', + isNullable: false, + }, + { + name: 'resume_session_suffix', + type: 'varchar', + length: '128', + isNullable: false, + default: "''", + }, + { + name: 'last_message_at', + type: 'timestamptz', + isNullable: true, + }, + { + name: 'created_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + { + name: 'updated_at', + type: 'timestamp', + default: 'CURRENT_TIMESTAMP', + isNullable: false, + }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'agent_chat_sessions', + new TableForeignKey({ + columnNames: ['agent_id'], + referencedColumnNames: ['id'], + referencedTableName: 'agents', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'agent_chat_sessions', + new TableIndex({ + name: 'IDX_agent_chat_sessions_agent_id', + columnNames: ['agent_id'], + }), + ); + + await queryRunner.createIndex( + 'agent_chat_sessions', + new TableIndex({ + name: 'UQ_agent_chat_sessions_agent_suffix', + columnNames: ['agent_id', 'resume_session_suffix'], + isUnique: true, + }), + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX "UQ_agent_chat_sessions_agent_primary" + ON "agent_chat_sessions" ("agent_id") + WHERE "kind" = 'primary' + `); + + await queryRunner.query(` + INSERT INTO "agent_chat_sessions" ("agent_id", "title", "kind", "resume_session_suffix", "created_at", "updated_at") + SELECT "id", 'Chat', 'primary', '', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP + FROM "agents" + `); + + await queryRunner.query(` + ALTER TABLE "agent_messages" + ADD COLUMN "chat_session_id" uuid + `); + + await queryRunner.query(` + ALTER TABLE "agent_message_events" + ADD COLUMN "chat_session_id" uuid + `); + + await queryRunner.query(` + UPDATE "agent_messages" AS m + SET "chat_session_id" = s."id" + FROM "agent_chat_sessions" AS s + WHERE s."agent_id" = m."agent_id" AND s."kind" = 'primary' + `); + + await queryRunner.query(` + UPDATE "agent_message_events" AS e + SET "chat_session_id" = s."id" + FROM "agent_chat_sessions" AS s + WHERE s."agent_id" = e."agent_id" AND s."kind" = 'primary' + `); + + await queryRunner.query(` + UPDATE "agent_chat_sessions" AS s + SET "last_message_at" = sub."max_created" + FROM ( + SELECT "chat_session_id", MAX("created_at") AS "max_created" + FROM "agent_messages" + WHERE "chat_session_id" IS NOT NULL + GROUP BY "chat_session_id" + ) AS sub + WHERE s."id" = sub."chat_session_id" + `); + + await queryRunner.query(` + ALTER TABLE "agent_messages" + ALTER COLUMN "chat_session_id" SET NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE "agent_message_events" + ALTER COLUMN "chat_session_id" SET NOT NULL + `); + + await queryRunner.createForeignKey( + 'agent_messages', + new TableForeignKey({ + name: 'FK_agent_messages_chat_session_id', + columnNames: ['chat_session_id'], + referencedColumnNames: ['id'], + referencedTableName: 'agent_chat_sessions', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'agent_message_events', + new TableForeignKey({ + name: 'FK_agent_message_events_chat_session_id', + columnNames: ['chat_session_id'], + referencedColumnNames: ['id'], + referencedTableName: 'agent_chat_sessions', + onDelete: 'CASCADE', + onUpdate: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'agent_messages', + new TableIndex({ + name: 'IDX_agent_messages_agent_chat_created', + columnNames: ['agent_id', 'chat_session_id', 'created_at'], + }), + ); + + await queryRunner.createIndex( + 'agent_message_events', + new TableIndex({ + name: 'IDX_agent_message_events_agent_chat_created', + columnNames: ['agent_id', 'chat_session_id', 'created_at'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('agent_message_events', 'IDX_agent_message_events_agent_chat_created'); + await queryRunner.dropIndex('agent_messages', 'IDX_agent_messages_agent_chat_created'); + + const messagesTable = await queryRunner.getTable('agent_messages'); + const messagesFk = messagesTable?.foreignKeys.find((fk) => fk.columnNames.includes('chat_session_id')); + + if (messagesFk) { + await queryRunner.dropForeignKey('agent_messages', messagesFk); + } + + const eventsTable = await queryRunner.getTable('agent_message_events'); + const eventsFk = eventsTable?.foreignKeys.find((fk) => fk.columnNames.includes('chat_session_id')); + + if (eventsFk) { + await queryRunner.dropForeignKey('agent_message_events', eventsFk); + } + + await queryRunner.query(`ALTER TABLE "agent_message_events" DROP COLUMN "chat_session_id"`); + await queryRunner.query(`ALTER TABLE "agent_messages" DROP COLUMN "chat_session_id"`); + + await queryRunner.query(`DROP INDEX IF EXISTS "UQ_agent_chat_sessions_agent_primary"`); + await queryRunner.dropIndex('agent_chat_sessions', 'UQ_agent_chat_sessions_agent_suffix'); + await queryRunner.dropIndex('agent_chat_sessions', 'IDX_agent_chat_sessions_agent_id'); + + const sessionsTable = await queryRunner.getTable('agent_chat_sessions'); + const sessionsFk = sessionsTable?.foreignKeys.find((fk) => fk.columnNames.includes('agent_id')); + + if (sessionsFk) { + await queryRunner.dropForeignKey('agent_chat_sessions', sessionsFk); + } + + await queryRunner.dropTable('agent_chat_sessions'); + } +} diff --git a/apps/agenstra/backend-agent-manager/src/typeorm.config.ts b/apps/agenstra/backend-agent-manager/src/typeorm.config.ts index 72d46cc95..08f1021f2 100644 --- a/apps/agenstra/backend-agent-manager/src/typeorm.config.ts +++ b/apps/agenstra/backend-agent-manager/src/typeorm.config.ts @@ -1,4 +1,5 @@ import { + AgentChatSessionEntity, AgentEntity, AgentEnvironmentVariableEntity, AgentMessageEntity, @@ -41,6 +42,7 @@ export const typeormConfig: DataSourceOptions = { database: process.env.DB_DATABASE || 'agent_manager', entities: [ AgentEntity, + AgentChatSessionEntity, AgentEnvironmentVariableEntity, AgentMessageEntity, AgentMessageEventEntity, diff --git a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf index 78e56072f..55d143f4f 100644 --- a/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf +++ b/apps/agenstra/frontend-agent-console/src/i18n/messages.xlf @@ -659,6 +659,51 @@ Chat + + Chat sessions + + + Chat + + + Primary + + + Untitled chat + + + (primary) + + + Rename Chat Session + + + Save + + + Rename chat session + + + Rename + + + Delete Chat Session + + + Are you sure you want to delete the chat session ? + + + Delete chat session + + + Delete + + + New chat + + + New chat + Start environment diff --git a/docs/agenstra/ai-agents/agent-client-protocol.md b/docs/agenstra/ai-agents/agent-client-protocol.md index a7f893e10..f1840a23f 100644 --- a/docs/agenstra/ai-agents/agent-client-protocol.md +++ b/docs/agenstra/ai-agents/agent-client-protocol.md @@ -38,7 +38,11 @@ Built-in providers: - **Version:** ACP protocol version 1 (stable) - **Transport:** newline-delimited JSON-RPC 2.0 over stdio - **Session flow:** `initialize` → `session/new` (or `session/load` with a persisted agent-issued id) → `session/prompt` → `session/update` notifications -- **Session resume:** ACP session ids are stored per agent on `acp_sessions` (jsonb), keyed by `resumeSessionSuffix` (empty key = primary chat). After an API restart, the manager opens a new stdio transport and calls `session/load` when the container id still matches. That covers main chat and background automation sessions (`-ticket-auto-loop`, `-ticket-auto-pre`, etc.) the same way in-memory reuse already did within a process. +- **Session resume:** ACP session ids are stored per agent on `acp_sessions` (jsonb), keyed by `resumeSessionSuffix`: + - **Empty suffix** (`''`) — primary user-visible chat session + - **User chats** — `-chat-{uuid}` where `{uuid}` is the chat session id (one ACP session per user-created thread) + - **Reserved / hidden** — not listed in agent `chats` / UI: `-prompt-enhance`, `-ticket-body`, and `-ticket-auto-*` (for example `-ticket-auto-pre`, `-ticket-auto-loop`, `-ticket-auto-commit-msg`) + After an API restart, the manager opens a new stdio transport and calls `session/load` when the container id still matches. That covers primary, user, and background sessions the same way in-memory reuse already did within a process. - **Permissions:** `session/request_permission` is auto-approved when `ACP_AUTO_APPROVE` is not `false` (default for headless agents) ## Configuration diff --git a/docs/agenstra/applications/backend-agent-controller.md b/docs/agenstra/applications/backend-agent-controller.md index 50bd8eab3..427765c28 100644 --- a/docs/agenstra/applications/backend-agent-controller.md +++ b/docs/agenstra/applications/backend-agent-controller.md @@ -80,6 +80,20 @@ In api-key mode, users do not play a role; these endpoints are not applicable. - `DELETE /api/clients/:id/agents/:agentId/environment/:envVarId` - Delete environment variable (proxied) - `DELETE /api/clients/:id/agents/:agentId/environment` - Delete all environment variables (proxied) +### Proxied chat sessions + +User-visible chat sessions for an agent environment. See [Chat Interface](../features/chat-interface.md). PAT scope: `agents:chats`. + +- `GET /api/clients/:id/agents/:agentId/chats` - List sessions (proxied; `limit`, `offset`) +- `GET /api/clients/:id/agents/:agentId/chats/count` - Count sessions (proxied) +- `POST /api/clients/:id/agents/:agentId/chats` - Create a user session (proxied) +- `GET /api/clients/:id/agents/:agentId/chats/:chatId` - Get a session (proxied) +- `PUT /api/clients/:id/agents/:agentId/chats/:chatId` - Update session title (proxied) +- `DELETE /api/clients/:id/agents/:agentId/chats/:chatId` - Delete a session (proxied) +- `GET /api/clients/:id/agents/:agentId/chats/:chatId/messages` - List session messages (proxied) + +Agent profile responses from the manager include `chats` and `primaryChatId`. Create/update/delete publish `chat_session.*` webhooks. + ### Proxied deployments (CI/CD) The console calls the controller; the controller proxies to each client’s agent-manager. Typical paths: @@ -227,7 +241,7 @@ Unauthenticated connections are rejected with `connect_error` "Unauthorized". Th #### Client → Server - `setClient` - Set the client context for subsequent operations (requires access to the client) -- `forward` - Forward an event to the remote agent-manager WebSocket (same event names as on the manager, e.g. `chat`, `login`) +- `forward` - Forward an event to the remote agent-manager WebSocket (same event names as on the manager, e.g. `chat`, `login`, `restoreChat`; chat/login payloads may include `chatId`) #### Server → Client diff --git a/docs/agenstra/applications/backend-agent-manager.md b/docs/agenstra/applications/backend-agent-manager.md index fb2ca4328..fa1a8c23f 100644 --- a/docs/agenstra/applications/backend-agent-manager.md +++ b/docs/agenstra/applications/backend-agent-manager.md @@ -86,6 +86,20 @@ Regex rules scoped to this manager instance (distinct from controller global rul - `GET .../deployments/runs/:runId/jobs/:jobId/logs` - Job logs - `POST .../deployments/runs/:runId/cancel` - Cancel run +### Chat sessions + +User-visible chat sessions per agent environment (primary plus operator-created threads). Hidden ACP suffixes are not exposed here. See [Chat Interface](../features/chat-interface.md). + +- `GET /api/agents/:agentId/chats` - List sessions (`limit`, `offset`) +- `GET /api/agents/:agentId/chats/count` - Count sessions +- `POST /api/agents/:agentId/chats` - Create a user session (`201 Created`) +- `GET /api/agents/:agentId/chats/:chatId` - Get a session +- `PUT /api/agents/:agentId/chats/:chatId` - Update session title +- `DELETE /api/agents/:agentId/chats/:chatId` - Delete a session +- `GET /api/agents/:agentId/chats/:chatId/messages` - List messages for a session (`limit`, `offset`) + +Agent profile responses include `chats` and `primaryChatId`. + ### Messages metadata - `GET /api/agents/:id/messages/latest-agent` - Latest agent chat message metadata for unread tracking @@ -136,25 +150,23 @@ The Socket.IO WebSocket gateway is available at `http://localhost:8080/agents` ( #### Client → Server -- `login` - Authenticate with agent ID (UUID or name) and password -- `chat` - Send chat message (requires authentication) +- `login` - Authenticate with agent ID (UUID or name) and password; optional `chatId` restores that session (primary when omitted) +- `chat` - Send chat message (requires authentication); optional `chatId` scopes the turn to a user-visible session +- `restoreChat` - Re-emit history for `{ chatId }` after the client clears its local thread - `enhanceChat` / `generateTicketBody` - Isolated prompt helpers (unicast results) - Additional events for files, terminals, and tooling as described in the agent-manager AsyncAPI (for example `fileUpdate`, `createTerminal`, `terminalInput`, `closeTerminal`) #### Server → Client -- `loginSuccess` / `loginError` / `chatMessage` / `chatEvent` / `messageFilterResult` -- `chatEnhanceResult` / `ticketBodyResult` -- `gitStateChanged` - Workspace git may have changed; refresh dirty indicators -- `fileUpdateNotification` / `containerStats` / terminal events / `error` - -#### Server → Client - - `loginSuccess` - Emitted on successful authentication - `loginError` - Emitted on authentication failure -- `chatMessage` - Chat traffic (broadcast or unicast depending on message options) +- `chatMessage` - Chat traffic (broadcast or unicast depending on message options); includes `chatId` when tied to a persisted session +- `restoreChatSuccess` - Acknowledgement after history restore for a `chatId` +- `chatEvent` / `messageFilterResult` +- `chatEnhanceResult` / `ticketBodyResult` +- `gitStateChanged` - Workspace git may have changed; refresh dirty indicators - `containerStats`, container status and resource usage; first event after login, then every 15 seconds by default (`CONTAINER_STATS_SCHEDULER_INTERVAL` in ms) -- File and terminal notifications, and other provider-specific events per AsyncAPI +- `fileUpdateNotification` / terminal events, and other provider-specific events per AsyncAPI - `error` - Emitted on authorization or processing errors For complete WebSocket event specifications, authentication flow, and usage examples, see the application and API reference docs linked below. diff --git a/docs/agenstra/applications/frontend-agent-console.md b/docs/agenstra/applications/frontend-agent-console.md index cb23942cc..791f897af 100644 --- a/docs/agenstra/applications/frontend-agent-console.md +++ b/docs/agenstra/applications/frontend-agent-console.md @@ -152,7 +152,7 @@ Main container component that provides the layout and routing structure. ### AgentConsoleChatComponent -Main workspace shell: chat, file tree, Git, environment variables, deployments entry, statistics, and modals for client and agent management. +Main workspace shell: chat (including a **session switcher** for multiple user-visible chat sessions per environment), file tree, Git, environment variables, deployments entry, statistics, and modals for client and agent management. Sessions are managed via controller `/clients/{id}/agents/{agentId}/chats` and restored over WebSocket with `login` / `restoreChat` `chatId` (see [Chat Interface](../features/chat-interface.md)). ### TicketsBoardComponent @@ -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 and ticket automation cards from the backend as implemented in NgRx effects +5. Receives chat history for the active session (`chatId` / primary) and ticket automation cards from the backend as implemented in NgRx effects; session switches use `restoreChat` ## Authentication diff --git a/docs/agenstra/features/authentication.md b/docs/agenstra/features/authentication.md index de4a53498..d69954521 100644 --- a/docs/agenstra/features/authentication.md +++ b/docs/agenstra/features/authentication.md @@ -81,13 +81,13 @@ DISABLE_SIGNUP=false # Set to true to disable self-registration User-bound tokens for scripts and CI. They are **not** a console login password. **Not available** when `AUTHENTICATION_METHOD=api-key`. -| Concern | Behavior | -| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Create / update / revoke | Console **Personal Access Tokens** page (`/settings/tokens`); interactive console session only (password JWT or Keycloak OIDC) | -| Exchange | `POST /auth/token` with `{ "token": "fp_pat_…" }` only (no email) → JWT with `amr: ["pat"]` and `scopes` | -| Console | Login rejects `fp_pat_` secrets; SPA rejects JWTs whose `amr` includes `pat`; WebSockets reject PAT JWTs | -| Keycloak | Local `users` row is synced on first authenticated request (`keycloakSub`); PAT CRUD and exchange require `JWT_SECRET` | -| Scopes | `AGENSTRA_PAT_SCOPES` covers clients/tickets/knowledge/agents/imports/statistics + `users:admin` / `webhooks:admin`; each enforced via `@RequireScopes` | +| Concern | Behavior | +| ------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Create / update / revoke | Console **Personal Access Tokens** page (`/settings/tokens`); interactive console session only (password JWT or Keycloak OIDC) | +| Exchange | `POST /auth/token` with `{ "token": "fp_pat_…" }` only (no email) → JWT with `amr: ["pat"]` and `scopes` | +| Console | Login rejects `fp_pat_` secrets; SPA rejects JWTs whose `amr` includes `pat`; WebSockets reject PAT JWTs | +| Keycloak | Local `users` row is synced on first authenticated request (`keycloakSub`); PAT CRUD and exchange require `JWT_SECRET` | +| Scopes | `AGENSTRA_PAT_SCOPES` covers clients/tickets/knowledge/agents (including `agents:chats` for chat-session REST)/imports/statistics + `users:admin` / `webhooks:admin`; each enforced via `@RequireScopes` | Compared to `STATIC_API_KEY`, PATs are per-user, scoped, rotatable, and do not grant cross-tenant admin by default. diff --git a/docs/agenstra/features/chat-interface.md b/docs/agenstra/features/chat-interface.md index 0d5602f8c..23408d9f3 100644 --- a/docs/agenstra/features/chat-interface.md +++ b/docs/agenstra/features/chat-interface.md @@ -1,18 +1,58 @@ # Chat Interface -AI chat functionality with real-time responses. Send messages to agents and receive instant responses. +AI chat functionality with real-time responses. Send messages to agents and receive instant responses. Each agent environment supports multiple user-visible chat sessions. ## Overview -The chat interface enables real-time bidirectional communication with AI agents. Messages are sent via WebSocket and responses are received instantly. Chat history is automatically maintained and restored on reconnection. +The chat interface enables real-time bidirectional communication with AI agents. Messages are sent via WebSocket and responses are received instantly. Chat history is maintained per chat session and can be restored on login or when switching sessions. + +## Chat sessions + +Each agent (environment) has: + +- **Primary session** — created with the agent; `kind: primary`. Default target when `chatId` is omitted. +- **User sessions** — additional threads (`kind: user`) that operators create, rename, and delete. + +Agent profile responses include: + +- `chats` — summaries of user-visible sessions +- `primaryChatId` — id of the primary session + +### Hidden ACP sessions + +Background and helper flows use reserved ACP `resumeSessionSuffix` values. Those sessions are **not** listed in `chats` and are not shown in the console session switcher: + +- `-prompt-enhance` +- `-ticket-body` +- `-ticket-auto-*` (for example `-ticket-auto-pre`, `-ticket-auto-loop`, `-ticket-auto-commit-msg`) + +See [Agent Client Protocol](../ai-agents/agent-client-protocol.md) for suffix rules. + +### REST API + +Console and API clients manage sessions through the agent controller (proxied to the manager): + +- Base path: `/clients/{id}/agents/{agentId}/chats` +- List, create, get, update (title), delete +- `GET .../chats/count` +- `GET .../chats/{chatId}/messages` — paginated message history for a session + +On the manager, the same operations live under `/api/agents/:agentId/chats`. + +### Restoring a session + +- **Login** may include optional `chatId`; omitted means the primary session. +- **`restoreChat`** with `{ chatId }` clears the local thread and re-emits recent history for that session (`chatMessage`, related filter/event traffic, then `restoreChatSuccess`). +- Switching sessions in the console uses the same restore path so history stays scoped to the selected `chatId`. ## Features ### Real-time Communication -- Send messages to agents +- Send messages to agents (optionally scoped with `chatId`) - Receive instant responses -- View chat history +- Switch among multiple sessions per environment +- View chat history per session - Markdown rendering - Code block syntax highlighting @@ -26,7 +66,8 @@ Messages sent by the user to the agent: { from: 'user', text: 'Hello, agent!', - timestamp: '2024-01-01T00:00:00Z' + timestamp: '2024-01-01T00:00:00Z', + chatId: 'chat-session-uuid' // present when tied to a persisted session } ``` @@ -41,15 +82,16 @@ Responses from the agent: type: 'text', result: 'Hello, user!' }, - timestamp: '2024-01-01T00:00:00Z' + timestamp: '2024-01-01T00:00:00Z', + chatId: 'chat-session-uuid' } ``` ### Chat History -- Chat history is automatically saved -- History is restored on reconnection -- History is cleared when switching agents +- History is saved per chat session +- History is restored on login (for the selected or primary `chatId`) and via `restoreChat` when switching sessions +- History is cleared locally when switching agents or sessions (before restore) to avoid duplicates - Old events are cleared to prevent duplicates ## Usage @@ -58,14 +100,14 @@ Responses from the agent: 1. Type your message in the input field 2. Press Enter or click Send -3. Message is sent to the agent via WebSocket +3. Message is sent to the agent via WebSocket (`forward` → `chat`, with the active `chatId` when applicable) 4. Response is received and displayed ### Viewing History -- Scroll through chat history -- View previous messages -- See message timestamps +- Scroll through chat history for the active session +- Use the session switcher to open another thread and restore its history +- View previous messages and timestamps - View formatted responses (markdown, code blocks) ## Message Flow @@ -79,15 +121,15 @@ sequenceDiagram participant A as Agent Container U->>F: Send Message - F->>AC: forward (chat, message) - AC->>AM: forward (chat, message) + F->>AC: forward (chat, message, chatId) + AC->>AM: forward (chat, message, chatId) AM->>A: Send Message (stdin) A-->>AM: Agent Response - AM->>AM: Save Messages - AM-->>AC: chatMessage (user) - AM-->>AC: chatMessage (agent) - AC-->>F: chatMessage (user) - AC-->>F: chatMessage (agent) + AM->>AM: Save Messages (session) + AM-->>AC: chatMessage (user, chatId) + AM-->>AC: chatMessage (agent, chatId) + AC-->>F: chatMessage (user, chatId) + AC-->>F: chatMessage (agent, chatId) F->>F: Display Messages U->>U: View Response ``` @@ -97,6 +139,7 @@ sequenceDiagram - **[WebSocket Communication](./websocket-communication.md)** Real-time communication details - **[Message Filter Rules](./message-filter-rules.md)** Regex policies affecting chat - **[Agent Management](./agent-management.md)** Agent authentication +- **[Agent Client Protocol](../ai-agents/agent-client-protocol.md)** ACP session suffixes --- diff --git a/docs/agenstra/features/webhooks.md b/docs/agenstra/features/webhooks.md index 3687a96fa..1de68115b 100644 --- a/docs/agenstra/features/webhooks.md +++ b/docs/agenstra/features/webhooks.md @@ -42,7 +42,8 @@ Events are published from the **agent-controller** service after proxied operati ### Chat and filter rules -- `chat_message.created`, user or agent chat message in a workspace session +- `chat_message.created`, user or agent chat message in a workspace session (payload may include `chatId`) +- `chat_session.created`, `chat_session.updated`, `chat_session.deleted` — user-visible chat session lifecycle for an agent environment - `filter_rule.created`, `filter_rule.updated`, `filter_rule.deleted` - `filter_rule.triggered`, a filter rule dropped or flagged a message @@ -92,11 +93,28 @@ Author email is never included. Ticket body `content` is never included on ticke "direction": "incoming", "source": "user", "message": "Hello agent", - "userId": "user-uuid" + "userId": "user-uuid", + "chatId": "chat-session-uuid" +} +``` + +`direction` is `incoming` or `outgoing`. `source` is `user` or `agent`. `chatId` is the user-visible chat session when the message is tied to one (may be null for some flows). + +### `chat_session.created` / `chat_session.updated` / `chat_session.deleted` + +```json +{ + "id": "chat-session-uuid", + "agentId": "agent-uuid", + "title": "Investigation", + "kind": "user", + "lastMessageAt": "2026-07-01T11:00:00.000Z", + "createdAt": "2026-07-01T10:00:00.000Z", + "updatedAt": "2026-07-01T11:00:00.000Z" } ``` -`direction` is `incoming` or `outgoing`. `source` is `user` or `agent`. +`kind` is `primary` or `user`. Delete events always include `id` and `agentId`; other fields may be null when only identifiers are available. ### `filter_rule.triggered` diff --git a/docs/agenstra/features/websocket-communication.md b/docs/agenstra/features/websocket-communication.md index ed46e478d..d0ef8b3f4 100644 --- a/docs/agenstra/features/websocket-communication.md +++ b/docs/agenstra/features/websocket-communication.md @@ -91,11 +91,17 @@ Forward an event to the remote agent-manager: ```typescript socket.emit('forward', { event: 'chat', - payload: { message: 'Hello, agent!' }, + payload: { message: 'Hello, agent!', chatId: 'chat-session-uuid' }, // chatId optional; primary when omitted agentId: 'agent-uuid', // Optional, for auto-login }); ``` +Common forwarded agent-manager events for chat: + +- `login` — credentials plus optional `chatId` (defaults to the agent’s primary session) +- `chat` — message payload; optional `chatId` scopes persistence and emits to that user-visible session +- `restoreChat` — `{ chatId }` to re-emit history for a specific session after the client clears its local thread + ### Controller → Frontend (`clients` namespace) #### setClientSuccess @@ -135,14 +141,15 @@ Authentication successful: #### chatMessage -Chat messages (user or agent): +Chat messages (user or agent). When the turn belongs to a persisted user-visible session, `data` includes `chatId`: ```typescript // User message { from: 'user', text: 'Hello, agent!', - timestamp: '2024-01-01T00:00:00Z' + timestamp: '2024-01-01T00:00:00Z', + chatId: 'chat-session-uuid' } // Agent message @@ -152,6 +159,23 @@ Chat messages (user or agent): type: 'text', result: 'Hello, user!' }, + timestamp: '2024-01-01T00:00:00Z', + chatId: 'chat-session-uuid' +} +``` + +#### restoreChat / restoreChatSuccess + +Client requests history for a session; server acknowledges after replaying events: + +```typescript +// Client → Manager (via forward) +{ chatId: 'chat-session-uuid' } + +// Manager → Client +{ + success: true, + data: { chatId: 'chat-session-uuid', message: '...' }, timestamp: '2024-01-01T00:00:00Z' } ``` @@ -199,10 +223,10 @@ When the frontend reconnects to the controller: 1. WebSocket automatically reconnects (repeat for `tickets` if used) 2. Frontend sends `setClient` event to restore context on each namespace -3. Frontend sends `forward` event with `login` to restore agent login (`clients` only) +3. Frontend sends `forward` event with `login` (optional `chatId`) to restore agent login (`clients` only) 4. Controller forwards login to manager -5. Manager restores chat history -6. Frontend clears old events to prevent duplicates +5. Manager restores chat history for the selected or primary session +6. Frontend clears old events to prevent duplicates; switching sessions uses `restoreChat` with the target `chatId` ### Controller-to-Manager Reconnection @@ -215,12 +239,12 @@ When the controller reconnects to a manager: ## Chat History Restoration -Chat history is automatically restored on reconnection: +Chat history is restored per session: -1. When an agent logs in, the manager loads and emits all chat history -2. History is sent as `chatMessage` events -3. Frontend receives and displays the history -4. Old events are cleared to prevent duplicates +1. On `login`, the manager loads recent history for the requested `chatId` (or the primary session when omitted) +2. History is sent as `chatMessage` events (with `chatId` when applicable) +3. To switch sessions without re-login, the client emits `restoreChat` with `{ chatId }`; the manager re-emits history and then `restoreChatSuccess` +4. Frontend clears the previous local thread before restore to prevent duplicates ## Event Forwarding @@ -238,15 +262,15 @@ sequenceDiagram participant AM as Agent Manager participant A as Agent Container - F->>AC: forward (chat, message) - AC->>AM: forward (chat, message) + F->>AC: forward (chat, message, chatId) + AC->>AM: forward (chat, message, chatId) AM->>A: Send Message (stdin) A-->>AM: Agent Response - AM->>AM: Save Messages - AM-->>AC: chatMessage (user) - AM-->>AC: chatMessage (agent) - AC-->>F: chatMessage (user) - AC-->>F: chatMessage (agent) + AM->>AM: Save Messages (session) + AM-->>AC: chatMessage (user, chatId) + AM-->>AC: chatMessage (agent, chatId) + AC-->>F: chatMessage (user, chatId) + AC-->>F: chatMessage (agent, chatId) ``` ## Error Handling diff --git a/graph/graph.json b/graph/graph.json index 92de6fe11..2280ce778 100644 --- a/graph/graph.json +++ b/graph/graph.json @@ -1,6 +1,6 @@ { "version": 1, - "generatedAt": "2026-08-17T22:41:15.178Z", + "generatedAt": "2026-08-20T19:04:50.200Z", "nodes": [ { "id": "project:@forepath/test/mounted-plugin-fixture", @@ -3434,6 +3434,15 @@ "projectName": "agenstra-frontend-data-access-agent-console" } }, + { + "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-frontend-data-access-agent-console" + } + }, { "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.ts", "type": "service", @@ -3551,6 +3560,24 @@ ] } }, + { + "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions", + "type": "state", + "attrs": { + "path": "libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions", + "languageOrKind": "ts", + "projectName": "agenstra-frontend-data-access-agent-console", + "sliceName": "chat-sessions", + "memberFiles": [ + "chat-sessions.actions.ts", + "chat-sessions.effects.ts", + "chat-sessions.facade.ts", + "chat-sessions.reducer.ts", + "chat-sessions.selectors.ts", + "chat-sessions.types.ts" + ] + } + }, { "id": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy", "type": "state", @@ -5214,6 +5241,15 @@ "projectName": "agenstra-backend-feature-agent-controller" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-controller" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-deployments-proxy.service.ts", "type": "service", @@ -9046,6 +9082,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts", + "type": "controller", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-deployments.controller.ts", "type": "controller", @@ -9163,6 +9208,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/commit.dto.ts", "type": "dto", @@ -9208,6 +9262,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-environment-variable.dto.ts", "type": "dto", @@ -9415,6 +9478,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts", + "type": "dto", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-environment-variable.dto.ts", "type": "dto", @@ -9460,6 +9532,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts", + "type": "entity", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-environment-variable.entity.ts", "type": "entity", @@ -9622,6 +9703,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", + "type": "repository", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", "type": "repository", @@ -9703,6 +9793,15 @@ "projectName": "agenstra-backend-feature-agent-manager" } }, + { + "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "service", + "attrs": { + "path": "libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "languageOrKind": "ts", + "projectName": "agenstra-backend-feature-agent-manager" + } + }, { "id": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", "type": "service", @@ -14319,6 +14418,83 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats", + "operationId": "listClientAgentChatSessions", + "summary": "List chat sessions for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/clients/{id}/agents/{agentId}/chats", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats", + "operationId": "createClientAgentChatSession", + "summary": "Create chat session for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/count", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/count", + "operationId": "countClientAgentChatSessions", + "summary": "Get count of chat sessions for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/{chatId}", + "operationId": "getClientAgentChatSession", + "summary": "Get chat session for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "PUT", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/{chatId}", + "operationId": "updateClientAgentChatSession", + "summary": "Update chat session for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/{chatId}", + "operationId": "deleteClientAgentChatSession", + "summary": "Delete chat session for an agent (proxied)", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/messages", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/clients/{id}/agents/{agentId}/chats/{chatId}/messages", + "operationId": "listClientAgentChatSessionMessages", + "summary": "List messages for a chat session (proxied)", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/clients/{id}/configuration-overrides", "type": "endpoint", @@ -15040,6 +15216,33 @@ "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" } }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.created", + "type": "webhook-event", + "attrs": { + "eventName": "chat_session.created", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.deleted", + "type": "webhook-event", + "attrs": { + "eventName": "chat_session.deleted", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, + { + "id": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.updated", + "type": "webhook-event", + "attrs": { + "eventName": "chat_session.updated", + "projectName": "agenstra-backend-feature-agent-controller", + "catalogPath": "libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts" + } + }, { "id": "webhook-event:agenstra-backend-feature-agent-controller:client_user.created", "type": "webhook-event", @@ -18573,7 +18776,25 @@ "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`, `correlationId`, `model`.\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), `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", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/restoreChat", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/restoreChat", + "summary": "Client requests restoration of a specific chat session's history (requires prior login). The client clears its local thread; the server re-emits recent chatMessage / messageFilterResult / chatEvent events for that chatId.\n", + "specKind": "asyncapi" + } + }, + { + "id": "api:channel:agents/restoreChatSuccess", + "type": "channel", + "attrs": { + "pathOrChannel": "agents/restoreChatSuccess", + "summary": "Server acknowledges successful chat history restoration for the requested chatId.", "specKind": "asyncapi" } }, @@ -19087,6 +19308,83 @@ "specKind": "openapi" } }, + { + "id": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/agents/{agentId}/chats", + "operationId": "listChatSessions", + "summary": "List chat sessions", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "endpoint", + "attrs": { + "method": "POST", + "pathOrChannel": "/agents/{agentId}/chats", + "operationId": "createChatSession", + "summary": "Create chat session", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/agents/{agentId}/chats/count", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/agents/{agentId}/chats/count", + "operationId": "countChatSessions", + "summary": "Get count of chat sessions", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/agents/{agentId}/chats/{chatId}", + "operationId": "getChatSession", + "summary": "Get chat session", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:PUT:/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "PUT", + "pathOrChannel": "/agents/{agentId}/chats/{chatId}", + "operationId": "updateChatSession", + "summary": "Update chat session", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:DELETE:/agents/{agentId}/chats/{chatId}", + "type": "endpoint", + "attrs": { + "method": "DELETE", + "pathOrChannel": "/agents/{agentId}/chats/{chatId}", + "operationId": "deleteChatSession", + "summary": "Delete chat session", + "specKind": "openapi" + } + }, + { + "id": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}/messages", + "type": "endpoint", + "attrs": { + "method": "GET", + "pathOrChannel": "/agents/{agentId}/chats/{chatId}/messages", + "operationId": "listChatSessionMessages", + "summary": "List messages for a chat session", + "specKind": "openapi" + } + }, { "id": "api:HTTP:GET:/configuration-overrides", "type": "endpoint", @@ -21916,6 +22214,16 @@ "domain": "agenstra" } }, + { + "id": "concept:agenstra-chat-sessions", + "type": "concept", + "attrs": { + "title": "Chat sessions", + "docPath": "docs/agenstra/features/chat-interface.md", + "sectionAnchor": "chat-sessions", + "domain": "agenstra" + } + }, { "id": "concept:agenstra-message-flow", "type": "concept", @@ -31458,6 +31766,11 @@ "to": "package:@nestjs/websockets", "type": "depends_on" }, + { + "from": "project:agenstra-backend-agent-manager", + "to": "package:@opensearch-project/opensearch", + "type": "depends_on" + }, { "from": "project:agenstra-backend-agent-manager", "to": "package:@opentelemetry/api", @@ -32838,6 +33151,11 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.ts", "type": "contains" }, + { + "from": "project:agenstra-frontend-data-access-agent-console", + "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "type": "contains" + }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.ts", @@ -32898,6 +33216,11 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents", "type": "contains" }, + { + "from": "project:agenstra-frontend-data-access-agent-console", + "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions", + "type": "contains" + }, { "from": "project:agenstra-frontend-data-access-agent-console", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/client-agent-autonomy", @@ -33728,6 +34051,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-autonomy.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-chats-proxy.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-deployments-proxy.service.ts", @@ -35848,6 +36176,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-deployments.controller.ts", @@ -35913,6 +36246,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/commit.dto.ts", @@ -35938,6 +36276,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-branch.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-environment-variable.dto.ts", @@ -36053,6 +36396,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-agent.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-environment-variable.dto.ts", @@ -36078,6 +36426,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/write-file.dto.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-environment-variable.entity.ts", @@ -36168,6 +36521,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/pipelines/gitlab.provider.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", @@ -36213,6 +36571,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agenstra-manager-metrics-collector.service.ts", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-manager", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", @@ -37953,6 +38316,41 @@ "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/environment/{envVarId}", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/chats", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/count", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml", + "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}/configuration-overrides", @@ -38293,6 +38691,21 @@ "to": "webhook-event:agenstra-backend-feature-agent-controller:chat_message.created", "type": "contains" }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.created", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.deleted", + "type": "contains" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "webhook-event:agenstra-backend-feature-agent-controller:chat_session.updated", + "type": "contains" + }, { "from": "project:agenstra-backend-feature-agent-controller", "to": "webhook-event:agenstra-backend-feature-agent-controller:client_user.created", @@ -40133,6 +40546,16 @@ "to": "api:channel:agents/chat", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/restoreChat", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", + "to": "api:channel:agents/restoreChatSuccess", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml", "to": "api:channel:agents/enhanceChat", @@ -40383,6 +40806,41 @@ "to": "api:HTTP:DELETE:/agents/{agentId}/environment/{id}", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/agents/{agentId}/chats/count", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:PUT:/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:DELETE:/agents/{agentId}/chats/{chatId}", + "type": "contains" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}/messages", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml", "to": "api:HTTP:GET:/configuration-overrides", @@ -42303,6 +42761,11 @@ "to": "concept:agenstra-overview", "type": "contains" }, + { + "from": "file:docs/agenstra/features/chat-interface.md", + "to": "concept:agenstra-chat-sessions", + "type": "contains" + }, { "from": "file:docs/agenstra/features/chat-interface.md", "to": "concept:agenstra-features", @@ -47778,6 +48241,76 @@ "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/environment/{envVarId}", "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", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats", + "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}/chats", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/chats", + "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/count", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/count", + "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}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/chats/{chatId}", + "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}/messages", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-controller", + "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}/configuration-overrides", @@ -51503,6 +52036,76 @@ "to": "api:HTTP:DELETE:/agents/{agentId}/environment/{id}", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/count", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:GET:/agents/{agentId}/chats/count", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:PUT:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:PUT:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:DELETE:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:DELETE:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}/messages", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}/messages", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-messages.controller.ts", "to": "api:HTTP:GET:/agents/{agentId}/vcs/status", @@ -51903,6 +52506,41 @@ "to": "api:HTTP:DELETE:/agents/{agentId}/environment/{id}", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/count", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:PUT:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:DELETE:/agents/{agentId}/chats/{chatId}", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", + "to": "api:HTTP:GET:/agents/{agentId}/chats/{chatId}/messages", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.ts", "to": "api:HTTP:GET:/agents/{agentId}/vcs/status", @@ -52973,6 +53611,26 @@ "to": "api:channel:agents/chat", "type": "implements" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/restoreChat", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/restoreChat", + "type": "implements" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "api:channel:agents/restoreChatSuccess", + "type": "implements" + }, + { + "from": "project:agenstra-backend-feature-agent-manager", + "to": "api:channel:agents/restoreChatSuccess", + "type": "implements" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", "to": "api:channel:agents/enhanceChat", @@ -53288,6 +53946,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-environment-variables-proxy.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/provisioning.service.ts", @@ -53648,6 +54311,16 @@ "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/client-agent-chats-proxy.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.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/client-agent-deployments-proxy.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/clients.service.ts", @@ -56908,6 +57581,11 @@ "to": "file:libs/domains/decabill/backend/feature-billing-manager/src/lib/repositories/backorders.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-deployments.controller.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", @@ -56988,6 +57666,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/gateways/agents.gateway.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/prompt-context-composer.service.ts", @@ -57023,6 +57706,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/acp/acp-agent-messaging.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agents.repository.ts", + "type": "injects" + }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", @@ -57073,11 +57771,21 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents-filters.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/regex-filter-rules.repository.ts", @@ -57143,6 +57851,11 @@ "to": "file:libs/domains/identity/backend/util-auth/src/lib/password.service.ts", "type": "injects" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "injects" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/deployments.service.ts", @@ -57733,6 +58446,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-environment-variables-proxy.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-chats-proxy.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-workspace-configuration-overrides-proxy.service.ts", @@ -59128,6 +59846,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-environment-variables.controller.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-filters.controller.ts", @@ -59183,6 +59906,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-session-hydration.service.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-environment-variables.service.ts", @@ -59243,6 +59971,11 @@ "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts", "type": "provides" }, + { + "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", + "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts", + "type": "provides" + }, { "from": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts", "to": "file:libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-environment-variables.repository.ts", @@ -60238,6 +60971,41 @@ "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/restart", "type": "calls" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/count", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/chats", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:PUT:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}/chats/{chatId}", + "type": "calls" + }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats/{chatId}/messages", + "type": "calls" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.ts", "to": "api:HTTP:GET:/clients", @@ -60953,6 +61721,11 @@ "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.ts", "type": "contains" }, + { + "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions", + "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts", + "type": "contains" + }, { "from": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/clients", "to": "file:libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/clients.service.ts", @@ -62298,6 +63071,91 @@ "to": "api:HTTP:POST:/auth/register", "type": "documents" }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/clients", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:DELETE:/clients/{id}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/clients/{id}/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:DELETE:/clients/{id}/agents/{agentId}", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/clients/{id}/agents/{agentId}/chats", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/clients/{id}/agents/{agentId}/chats", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/agents", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:GET:/agents/{agentId}/chats", + "type": "documents" + }, + { + "from": "concept:agenstra-chat-sessions", + "to": "api:HTTP:POST:/agents/{agentId}/chats", + "type": "documents" + }, { "from": "concept:agenstra-supported-providers", "to": "api:HTTP:POST:/auth/register", @@ -62393,6 +63251,11 @@ "to": "api:HTTP:POST:/auth/register", "type": "documents" }, + { + "from": "concept:agenstra-events", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, { "from": "concept:agenstra-file-operation-flow", "to": "api:HTTP:GET:/clients", @@ -62718,6 +63581,11 @@ "to": "api:HTTP:POST:/knowledge", "type": "documents" }, + { + "from": "concept:agenstra-chat-history-restoration", + "to": "api:HTTP:POST:/auth/login", + "type": "documents" + }, { "from": "concept:agenstra-in-another-terminal-start-agent-manager", "to": "project:agenstra-backend-agent-manager", @@ -70363,6 +71231,11 @@ "to": "domain:agenstra", "type": "belongs_to" }, + { + "from": "concept:agenstra-chat-sessions", + "to": "domain:agenstra", + "type": "belongs_to" + }, { "from": "concept:agenstra-message-flow", "to": "domain:agenstra", 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 d98dbda6b..b133cfb1f 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-controller/spec/openapi.yaml @@ -2659,6 +2659,285 @@ paths: description: No client access, or workspace management rights required '404': description: Client, agent, or environment variable not found + /clients/{id}/agents/{agentId}/chats: + get: + summary: List chat sessions for an agent (proxied) + operationId: listClientAgentChatSessions + 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: query + name: limit + schema: + type: integer + minimum: 0 + description: Maximum number of chat sessions to return (default 50) + - in: query + name: offset + schema: + type: integer + minimum: 0 + description: Number of chat sessions to skip (default 0) + responses: + '200': + description: Array of chat sessions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatSessionResponseDto' + '403': + description: User does not have access to this client + '404': + description: Client or agent not found + post: + summary: Create chat session for an agent (proxied) + operationId: createClientAgentChatSession + 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 + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChatSessionDto' + responses: + '201': + description: Created chat session + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '400': + description: Invalid request + '403': + description: User does not have access to this client + '404': + description: Client or agent not found + /clients/{id}/agents/{agentId}/chats/count: + get: + summary: Get count of chat sessions for an agent (proxied) + operationId: countClientAgentChatSessions + 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 + responses: + '200': + description: Count of chat sessions + content: + application/json: + schema: + type: object + required: [count] + properties: + count: + type: integer + '403': + description: User does not have access to this client + '404': + description: Client or agent not found + /clients/{id}/agents/{agentId}/chats/{chatId}: + get: + summary: Get chat session for an agent (proxied) + operationId: getClientAgentChatSession + 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 chat session + responses: + '200': + description: Chat session + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '403': + description: User does not have access to this client + '404': + description: Client, agent, or chat session not found + put: + summary: Update chat session for an agent (proxied) + operationId: updateClientAgentChatSession + 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 chat session to update + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateChatSessionDto' + responses: + '200': + description: Updated chat session + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '400': + description: Invalid request + '403': + description: User does not have access to this client + '404': + description: Client, agent, or chat session not found + delete: + summary: Delete chat session for an agent (proxied) + operationId: deleteClientAgentChatSession + 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 chat session to delete + responses: + '204': + description: Chat session deleted successfully + '403': + description: User does not have access to this client + '404': + description: Client, agent, or chat session not found + /clients/{id}/agents/{agentId}/chats/{chatId}/messages: + get: + summary: List messages for a chat session (proxied) + operationId: listClientAgentChatSessionMessages + 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 chat session + - in: query + name: limit + schema: + type: integer + minimum: 0 + description: Maximum number of messages to return (default 50) + - in: query + name: offset + schema: + type: integer + minimum: 0 + description: Number of messages to skip (default 0) + responses: + '200': + description: Array of chat session messages + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatSessionMessageResponseDto' + '403': + description: User does not have access to this client + '404': + description: Client, agent, or chat session not found /clients/{id}/configuration-overrides: get: summary: List effective workspace configuration overrides (proxied) @@ -5031,7 +5310,7 @@ components: description: Model catalog for an agent; keys are model identifiers, values are display names AgentResponseDto: type: object - required: [id, name, createdAt, updatedAt] + required: [id, name, createdAt, updatedAt, chats, primaryChatId] properties: id: type: string @@ -5056,6 +5335,15 @@ components: enum: [clone, empty] description: How the workspace repository was initialized description: Git information when the agent has an associated repository or empty local repo + chats: + type: array + description: User-visible chat sessions for this environment + items: + $ref: '#/components/schemas/AgentChatSessionSummaryDto' + primaryChatId: + type: string + format: uuid + description: Id of the primary chat session for this environment createdAt: type: string format: date-time @@ -5570,6 +5858,82 @@ components: updatedAt: type: string format: date-time + CreateChatSessionDto: + type: object + properties: + title: + type: string + maxLength: 255 + description: Optional title for the new chat session + UpdateChatSessionDto: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: New title for the chat session + ChatSessionResponseDto: + type: object + required: [id, agentId, kind, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + agentId: + type: string + format: uuid + title: + type: string + kind: + type: string + enum: [primary, user] + lastMessageAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + AgentChatSessionSummaryDto: + type: object + required: [id, kind, createdAt] + properties: + id: + type: string + format: uuid + title: + type: string + kind: + type: string + enum: [primary, user] + lastMessageAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + ChatSessionMessageResponseDto: + type: object + required: [id, actor, message, filtered, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + actor: + type: string + message: + type: string + filtered: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time WorkspaceConfigurationSettingKey: type: string enum: diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts index b0fdc2ffb..93b55cc20 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.spec.ts @@ -18,6 +18,7 @@ describe('AGENSTRA_PAT_SCOPES', () => { 'agents:lifecycle', 'agents:files', 'agents:environment', + 'agents:chats', 'agents:vcs', 'agents:deployments', 'imports:write', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts index 500741774..0b2919d2d 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/auth/agenstra-pat.scopes.ts @@ -17,6 +17,7 @@ export const AGENSTRA_PAT_SCOPES = [ 'agents:lifecycle', 'agents:files', 'agents:environment', + 'agents:chats', 'agents:vcs', 'agents:deployments', 'imports:write', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts index d912dd069..684d87341 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.spec.ts @@ -1,8 +1,10 @@ import { AgentResponseDto, + ChatSessionResponseDto, ContainerType, CreateAgentDto, CreateAgentResponseDto, + CreateChatSessionDto, CreateEnvironmentVariableDto, CreateFileDto, EnvironmentVariableResponseDto, @@ -10,6 +12,7 @@ import { FileNodeDto, MoveFileDto, UpdateAgentDto, + UpdateChatSessionDto, UpdateEnvironmentVariableDto, WriteFileDto, } from '@forepath/agenstra/backend/feature-agent-manager'; @@ -35,6 +38,7 @@ import { UpdateClientDto } from '../dto/update-client.dto'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; import { ProvisioningProvider } from '../providers/provisioning-provider.interface'; import { ClientsRepository } from '../repositories/clients.repository'; +import { ClientAgentChatsProxyService } from '../services/client-agent-chats-proxy.service'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; @@ -90,6 +94,8 @@ describe('ClientsController', () => { description: 'Test Agent Description', agentType: 'cursor', containerType: ContainerType.GENERIC, + chats: [], + primaryChatId: 'primary-chat-uuid', createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; @@ -132,6 +138,15 @@ describe('ClientsController', () => { deleteEnvironmentVariable: jest.fn(), deleteAllEnvironmentVariables: jest.fn(), }; + const mockChatsProxyService = { + list: jest.fn(), + count: jest.fn(), + create: jest.fn(), + get: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + listMessages: jest.fn(), + }; const mockProvisioningService = { provisionServer: jest.fn(), deleteProvisionedServer: jest.fn(), @@ -176,6 +191,10 @@ describe('ClientsController', () => { provide: ClientAgentEnvironmentVariablesProxyService, useValue: mockEnvironmentVariablesProxyService, }, + { + provide: ClientAgentChatsProxyService, + useValue: mockChatsProxyService, + }, { provide: ProvisioningService, useValue: mockProvisioningService, @@ -1185,6 +1204,137 @@ describe('ClientsController', () => { }); }); + describe('Agent Chat Sessions Proxy', () => { + const clientId = 'client-uuid'; + const agentId = 'agent-uuid'; + const chatId = 'chat-uuid'; + const mockChatSession: ChatSessionResponseDto = { + id: chatId, + agentId, + title: 'My chat', + kind: 'user', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + + describe('listClientAgentChatSessions', () => { + it('should proxy list chat sessions request', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.list.mockResolvedValue([mockChatSession]); + + const result = await controller.listClientAgentChatSessions(clientId, agentId, 50, 0, mockReq); + + expect(result).toEqual([mockChatSession]); + expect(mockChatsProxyService.list).toHaveBeenCalledWith(clientId, agentId, 50, 0); + }); + }); + + describe('countClientAgentChatSessions', () => { + it('should proxy count chat sessions request', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.count.mockResolvedValue({ count: 5 }); + + const result = await controller.countClientAgentChatSessions(clientId, agentId, mockReq); + + expect(result).toEqual({ count: 5 }); + expect(mockChatsProxyService.count).toHaveBeenCalledWith(clientId, agentId); + }); + }); + + describe('createClientAgentChatSession', () => { + it('should proxy create chat session request', async () => { + const createDto: CreateChatSessionDto = { title: 'My chat' }; + const mockReq = { apiKeyAuthenticated: true } as any; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.create.mockResolvedValue(mockChatSession); + + const result = await controller.createClientAgentChatSession(clientId, agentId, createDto, mockReq); + + expect(result).toEqual(mockChatSession); + expect(mockChatsProxyService.create).toHaveBeenCalledWith(clientId, agentId, createDto); + }); + }); + + describe('getClientAgentChatSession', () => { + it('should proxy get chat session request', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.get.mockResolvedValue(mockChatSession); + + const result = await controller.getClientAgentChatSession(clientId, agentId, chatId, mockReq); + + expect(result).toEqual(mockChatSession); + expect(mockChatsProxyService.get).toHaveBeenCalledWith(clientId, agentId, chatId); + }); + }); + + describe('updateClientAgentChatSession', () => { + it('should proxy update chat session request', async () => { + const updateDto: UpdateChatSessionDto = { title: 'Renamed' }; + const mockReq = { apiKeyAuthenticated: true } as any; + const updated = { ...mockChatSession, title: 'Renamed' }; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.update.mockResolvedValue(updated); + + const result = await controller.updateClientAgentChatSession(clientId, agentId, chatId, updateDto, mockReq); + + expect(result).toEqual(updated); + expect(mockChatsProxyService.update).toHaveBeenCalledWith(clientId, agentId, chatId, updateDto); + }); + }); + + describe('deleteClientAgentChatSession', () => { + it('should proxy delete chat session request', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.delete.mockResolvedValue(undefined); + + await controller.deleteClientAgentChatSession(clientId, agentId, chatId, mockReq); + + expect(mockChatsProxyService.delete).toHaveBeenCalledWith(clientId, agentId, chatId); + }); + }); + + describe('listClientAgentChatSessionMessages', () => { + it('should proxy list chat session messages request', async () => { + const mockReq = { apiKeyAuthenticated: true } as any; + const mockMessages = [ + { + id: 'msg-1', + actor: 'user', + message: 'Hello', + filtered: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + ]; + + clientsRepository.findById.mockResolvedValue({ id: clientId, userId: null } as any); + clientUsersRepository.findUserClientAccess.mockResolvedValue(null); + mockChatsProxyService.listMessages.mockResolvedValue(mockMessages); + + const result = await controller.listClientAgentChatSessionMessages(clientId, agentId, chatId, 50, 0, mockReq); + + expect(result).toEqual(mockMessages); + expect(mockChatsProxyService.listMessages).toHaveBeenCalledWith(clientId, agentId, chatId, 50, 0); + }); + }); + }); + describe('Client User Management', () => { const clientId = 'client-uuid'; const relationshipId = 'relationship-uuid'; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts index 8af20f53e..284f52c95 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/controllers/clients.controller.ts @@ -1,8 +1,10 @@ import { AgentModelsResponseDto, AgentResponseDto, + ChatSessionResponseDto, CreateAgentDto, CreateAgentResponseDto, + CreateChatSessionDto, CreateEnvironmentVariableDto, CreateFileDto, EnvironmentVariableResponseDto, @@ -12,6 +14,7 @@ import { parseAgentFileManagerContext, type AgentFileManagerContext, UpdateAgentDto, + UpdateChatSessionDto, UpdateEnvironmentVariableDto, WriteFileDto, } from '@forepath/agenstra/backend/feature-agent-manager'; @@ -55,6 +58,7 @@ import { ProvisionedServerResponseDto } from '../dto/provisioned-server-response import { UpdateClientDto } from '../dto/update-client.dto'; import { ProvisioningProviderFactory } from '../providers/provisioning-provider.factory'; import { ClientsRepository } from '../repositories/clients.repository'; +import { ChatSessionMessageResponse, ClientAgentChatsProxyService } from '../services/client-agent-chats-proxy.service'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; import { ClientAgentProxyService } from '../services/client-agent-proxy.service'; @@ -72,6 +76,7 @@ export class ClientsController { private readonly clientAgentProxyService: ClientAgentProxyService, private readonly clientAgentFileSystemProxyService: ClientAgentFileSystemProxyService, private readonly clientAgentEnvironmentVariablesProxyService: ClientAgentEnvironmentVariablesProxyService, + private readonly clientAgentChatsProxyService: ClientAgentChatsProxyService, private readonly provisioningService: ProvisioningService, private readonly provisioningProviderFactory: ProvisioningProviderFactory, private readonly clientUsersService: ClientUsersService, @@ -939,6 +944,129 @@ export class ClientsController { return await this.clientAgentEnvironmentVariablesProxyService.deleteAllEnvironmentVariables(id, agentId); } + /** + * List chat sessions for an agent with pagination (proxied). + * Only accessible if the user has access to the client. + */ + @Get(':id/agents/:agentId/chats') + @RequireScopes('agents:chats') + async listClientAgentChatSessions( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.list(id, agentId, limit ?? 50, offset ?? 0); + } + + /** + * Get count of chat sessions for an agent (proxied). + * Only accessible if the user has access to the client. + */ + @Get(':id/agents/:agentId/chats/count') + @RequireScopes('agents:chats') + async countClientAgentChatSessions( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Req() req?: RequestWithUser, + ): Promise<{ count: number }> { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.count(id, agentId); + } + + /** + * Create a new chat session for an agent (proxied). + * Only accessible if the user has access to the client. + */ + @Post(':id/agents/:agentId/chats') + @HttpCode(HttpStatus.CREATED) + @RequireScopes('agents:chats') + async createClientAgentChatSession( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() createDto: CreateChatSessionDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.create(id, agentId, createDto); + } + + /** + * Get a chat session by ID (proxied). + * Only accessible if the user has access to the client. + */ + @Get(':id/agents/:agentId/chats/:chatId') + @RequireScopes('agents:chats') + async getClientAgentChatSession( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.get(id, agentId, chatId); + } + + /** + * Update a chat session title (proxied). + * Only accessible if the user has access to the client. + */ + @Put(':id/agents/:agentId/chats/:chatId') + @RequireScopes('agents:chats') + async updateClientAgentChatSession( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Body() updateDto: UpdateChatSessionDto, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.update(id, agentId, chatId, updateDto); + } + + /** + * Delete a chat session by ID (proxied). + * Only accessible if the user has access to the client. + */ + @Delete(':id/agents/:agentId/chats/:chatId') + @HttpCode(HttpStatus.NO_CONTENT) + @RequireScopes('agents:chats') + async deleteClientAgentChatSession( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + await this.clientAgentChatsProxyService.delete(id, agentId, chatId); + } + + /** + * List messages for a chat session (proxied). + * Only accessible if the user has access to the client. + */ + @Get(':id/agents/:agentId/chats/:chatId/messages') + @RequireScopes('agents:chats') + async listClientAgentChatSessionMessages( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + @Req() req?: RequestWithUser, + ): Promise { + await ensureClientAccess(this.clientsRepository, this.clientUsersRepository, id, req); + + return await this.clientAgentChatsProxyService.listMessages(id, agentId, chatId, limit ?? 50, offset ?? 0); + } + /** * Get all users associated with a client. * Only accessible if the user has access to the client. 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 5a64df5f1..fc59d2584 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 @@ -427,6 +427,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat source: 'agent', message: text, userId: userId ?? null, + chatId: typeof payload?.chatId === 'string' ? payload.chatId : null, }); void this.agentConsoleStatusService @@ -1126,6 +1127,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat if (event === 'chat' && agentId) { const message = (payloadWithContext as { message?: string; contextInjection?: ContextInjectionPayload })?.message ?? ''; + const chatId = (payloadWithContext as { chatId?: string })?.chatId; const wordCount = message.trim().split(/\s+/).filter(Boolean).length; const charCount = message.length; const userInfo = (socket as Socket & { data?: { userInfo?: { userId?: string } } }).data?.userInfo; @@ -1139,6 +1141,7 @@ export class ClientsGateway implements OnGatewayInit, OnGatewayConnection, OnGat source: 'user', message, userId: userInfo?.userId ?? null, + chatId: typeof chatId === 'string' ? chatId : null, }); this.lastAgentIdBySocket.set(socket.id, agentId); this.lastChatMessageBySocket.set(socket.id, message); 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 b5bee7143..e2e03532c 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 @@ -70,6 +70,7 @@ import { AgenstraMetricsCollectorService } from '../services/agenstra-metrics-co import { AutoContextResolverService } from '../services/auto-context-resolver.service'; import { AutonomousRunOrchestratorService } from '../services/autonomous-run-orchestrator.service'; import { ClientAgentAutonomyService } from '../services/client-agent-autonomy.service'; +import { ClientAgentChatsProxyService } from '../services/client-agent-chats-proxy.service'; import { ClientAgentDeploymentsProxyService } from '../services/client-agent-deployments-proxy.service'; import { ClientAgentEnvironmentVariablesProxyService } from '../services/client-agent-environment-variables-proxy.service'; import { ClientAgentFileSystemProxyService } from '../services/client-agent-file-system-proxy.service'; @@ -177,6 +178,7 @@ const authMethod = getAuthenticationMethod(); ClientAgentVcsProxyService, ClientAgentDeploymentsProxyService, ClientAgentEnvironmentVariablesProxyService, + ClientAgentChatsProxyService, ClientWorkspaceConfigurationOverridesProxyService, ClientAgentCredentialsRepository, ClientAgentCredentialsService, @@ -236,6 +238,7 @@ const authMethod = getAuthenticationMethod(); ClientAgentVcsProxyService, ClientAgentDeploymentsProxyService, ClientAgentEnvironmentVariablesProxyService, + ClientAgentChatsProxyService, ClientWorkspaceConfigurationOverridesProxyService, ClientAgentCredentialsRepository, ClientAgentCredentialsService, diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts index 12bf77b08..7b8ae95d6 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.spec.ts @@ -21,6 +21,9 @@ describe('AGENSTRA_NOTIFICATION_EVENTS', () => { 'environment.created', 'environment.updated', 'environment.deleted', + 'chat_session.created', + 'chat_session.updated', + 'chat_session.deleted', 'ticket.comment.created', 'application.update_available', 'application.update_check_failed', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts index f54e69144..4cbdcb636 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.events.ts @@ -20,6 +20,9 @@ export const AGENSTRA_NOTIFICATION_EVENTS = [ 'environment.created', 'environment.updated', 'environment.deleted', + 'chat_session.created', + 'chat_session.updated', + 'chat_session.deleted', 'client_user.created', 'client_user.deleted', 'application.update_available', diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts index bfffd431d..dc0ae17af 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.spec.ts @@ -149,6 +149,7 @@ describe('AgenstraNotificationPublisher', () => { source: 'user', message: 'Hello agent', userId: 'user-1', + chatId: 'chat-1', }); expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ @@ -159,6 +160,7 @@ describe('AgenstraNotificationPublisher', () => { agentId: 'agent-1', message: 'Hello agent', source: 'user', + chatId: 'chat-1', }), }); }); @@ -316,4 +318,45 @@ describe('AgenstraNotificationPublisher', () => { }, }); }); + + it('publishes chat session lifecycle events', () => { + const dispatcher = { + publishFireAndForget: jest.fn(), + } as unknown as NotificationDispatcherService; + const publisher = new AgenstraNotificationPublisher(dispatcher); + + publisher.publishChatSession('chat_session.created', 'client-1', { + id: 'chat-1', + agentId: 'agent-1', + title: 'Planning', + kind: 'user', + lastMessageAt: new Date('2026-07-01T11:00:00.000Z'), + createdAt: new Date('2026-07-01T10:00:00.000Z'), + updatedAt: new Date('2026-07-02T10:00:00.000Z'), + }); + + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith({ + type: 'chat_session.created', + scopeKey: INSTANCE_SCOPE_KEY, + clientId: 'client-1', + data: { + id: 'chat-1', + agentId: 'agent-1', + title: 'Planning', + kind: 'user', + lastMessageAt: '2026-07-01T11:00:00.000Z', + createdAt: '2026-07-01T10:00:00.000Z', + updatedAt: '2026-07-02T10:00:00.000Z', + }, + }); + expect(dispatcher.publishFireAndForget).toHaveBeenCalledWith( + expect.objectContaining({ + data: expect.not.objectContaining({ + message: expect.anything(), + body: expect.anything(), + content: expect.anything(), + }), + }), + ); + }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts index d756c2b34..c73e82a43 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/notifications/agenstra-notification.publisher.ts @@ -2,7 +2,10 @@ import { ClientEntity, IIdentityNotificationPublisher } from '@forepath/identity import { INSTANCE_SCOPE_KEY, NotificationDispatcherService } from '@forepath/shared/backend'; import { Injectable } from '@nestjs/common'; -import type { EnvironmentVariableResponseDto } from '@forepath/agenstra/backend/feature-agent-manager'; +import type { + ChatSessionResponseDto, + EnvironmentVariableResponseDto, +} from '@forepath/agenstra/backend/feature-agent-manager'; import type { FilterRuleResponseDto } from '../dto/filter-rules/filter-rule-response.dto'; import type { ClientResponseDto } from '../dto/client-response.dto'; @@ -24,6 +27,7 @@ export type ChatMessageNotificationPayload = { message: string; userId?: string | null; interactionKind?: string | null; + chatId?: string | null; }; export type FilterRuleTriggeredNotificationPayload = { @@ -47,6 +51,16 @@ export type EnvironmentNotificationPayload = { updatedAt?: string | null; }; +export type ChatSessionNotificationPayload = { + id: string; + agentId: string; + title?: string | null; + kind?: string | null; + lastMessageAt?: string | Date | null; + createdAt?: string | Date | null; + updatedAt?: string | Date | null; +}; + export type TicketCommentNotificationPayload = { id: string; ticketId: string; @@ -134,6 +148,14 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli this.publish(type, this.toEnvironmentPayload(environment), clientId); } + publishChatSession( + type: 'chat_session.created' | 'chat_session.updated' | 'chat_session.deleted', + clientId: string, + chatSession: ChatSessionResponseDto | ChatSessionNotificationPayload, + ): void { + this.publish(type, this.toChatSessionPayload(chatSession), clientId); + } + publishUserCreated(data: Record): void { this.publish('user.created', data); } @@ -223,6 +245,20 @@ export class AgenstraNotificationPublisher implements IIdentityNotificationPubli }; } + private toChatSessionPayload( + chatSession: ChatSessionResponseDto | ChatSessionNotificationPayload, + ): Record { + return { + id: chatSession.id, + agentId: chatSession.agentId, + title: 'title' in chatSession ? (chatSession.title ?? null) : null, + kind: 'kind' in chatSession ? (chatSession.kind ?? null) : null, + lastMessageAt: this.toIsoString('lastMessageAt' in chatSession ? chatSession.lastMessageAt : null), + createdAt: this.toIsoString(chatSession.createdAt), + updatedAt: this.toIsoString(chatSession.updatedAt), + }; + } + private toIsoString(value: Date | string | null | undefined): string | null { if (value == null) { return null; diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.spec.ts new file mode 100644 index 000000000..3baa4f156 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.spec.ts @@ -0,0 +1,382 @@ +import { + ChatSessionResponseDto, + CreateChatSessionDto, + UpdateChatSessionDto, +} from '@forepath/agenstra/backend/feature-agent-manager'; +import { AuthenticationType, ClientEntity } from '@forepath/identity/backend'; +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import axios, { AxiosError } from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; + +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; +import { ClientAgentChatsProxyService } from './client-agent-chats-proxy.service'; +import { ClientsService } from './clients.service'; + +jest.mock('axios'); +const mockedAxios = axios as jest.Mocked; + +describe('ClientAgentChatsProxyService', () => { + let service: ClientAgentChatsProxyService; + let clientsService: jest.Mocked; + let clientsRepository: jest.Mocked; + const mockClientId = 'test-client-uuid'; + const mockAgentId = 'test-agent-uuid'; + const mockChatId = 'test-chat-uuid'; + const mockClientEntity: ClientEntity = { + id: mockClientId, + name: 'Test Client', + description: 'Test Description', + endpoint: 'https://example.com/api', + authenticationType: AuthenticationType.API_KEY, + apiKey: 'test-api-key', + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + const mockChatSession: ChatSessionResponseDto = { + id: mockChatId, + agentId: mockAgentId, + title: 'My chat', + kind: 'user', + lastMessageAt: new Date('2024-01-02'), + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + const mockClientsService = { + getAccessToken: jest.fn(), + }; + const mockClientsRepository = { + findByIdOrThrow: jest.fn(), + }; + const mockNotificationPublisher = { + publishChatSession: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClientAgentChatsProxyService, + { + provide: ClientsService, + useValue: mockClientsService, + }, + { + provide: ClientsRepository, + useValue: mockClientsRepository, + }, + { + provide: AgenstraNotificationPublisher, + useValue: mockNotificationPublisher, + }, + ], + }).compile(); + + service = module.get(ClientAgentChatsProxyService); + clientsService = module.get(ClientsService); + clientsRepository = module.get(ClientsRepository); + + jest.clearAllMocks(); + }); + + describe('list', () => { + it('should proxy list chat sessions request successfully with API_KEY auth', async () => { + const mockSessions: ChatSessionResponseDto[] = [mockChatSession]; + + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: mockSessions, + } as any); + + const result = await service.list(mockClientId, mockAgentId, 50, 0); + + expect(result).toEqual(mockSessions); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + params: { limit: 50, offset: 0 }, + }), + ); + }); + + it('should use default pagination parameters', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: [], + } as any); + + await service.list(mockClientId, mockAgentId); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + params: { limit: 50, offset: 0 }, + }), + ); + }); + + it('should throw NotFoundException on 404 response', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 404, + data: { message: 'Agent not found' }, + } as any); + + await expect(service.list(mockClientId, mockAgentId)).rejects.toThrow(NotFoundException); + }); + + it('should throw BadRequestException on 400 response', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 400, + data: { message: 'Invalid request' }, + } as any); + + await expect(service.list(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + }); + + describe('count', () => { + it('should proxy count chat sessions request successfully', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: { count: 5 }, + } as any); + + const result = await service.count(mockClientId, mockAgentId); + + expect(result).toEqual({ count: 5 }); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`/api/agents/${mockAgentId}/chats/count`), + }), + ); + }); + }); + + describe('create', () => { + it('should proxy create chat session request successfully', async () => { + const createDto: CreateChatSessionDto = { + title: 'My chat', + }; + + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 201, + data: mockChatSession, + } as any); + + const result = await service.create(mockClientId, mockAgentId, createDto); + + expect(result).toEqual(mockChatSession); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'POST', + data: createDto, + }), + ); + expect(mockNotificationPublisher.publishChatSession).toHaveBeenCalledWith( + 'chat_session.created', + mockClientId, + mockChatSession, + ); + }); + }); + + describe('get', () => { + it('should proxy get chat session request successfully', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: mockChatSession, + } as any); + + const result = await service.get(mockClientId, mockAgentId, mockChatId); + + expect(result).toEqual(mockChatSession); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`/api/agents/${mockAgentId}/chats/${mockChatId}`), + }), + ); + }); + }); + + describe('update', () => { + it('should proxy update chat session request successfully', async () => { + const updateDto: UpdateChatSessionDto = { + title: 'Renamed chat', + }; + const updatedSession: ChatSessionResponseDto = { + ...mockChatSession, + title: 'Renamed chat', + }; + + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: updatedSession, + } as any); + + const result = await service.update(mockClientId, mockAgentId, mockChatId, updateDto); + + expect(result).toEqual(updatedSession); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'PUT', + url: expect.stringContaining(`/api/agents/${mockAgentId}/chats/${mockChatId}`), + data: updateDto, + }), + ); + expect(mockNotificationPublisher.publishChatSession).toHaveBeenCalledWith( + 'chat_session.updated', + mockClientId, + updatedSession, + ); + }); + }); + + describe('delete', () => { + it('should proxy delete chat session request successfully', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 204, + data: undefined, + } as any); + + await service.delete(mockClientId, mockAgentId, mockChatId); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'DELETE', + url: expect.stringContaining(`/api/agents/${mockAgentId}/chats/${mockChatId}`), + }), + ); + expect(mockNotificationPublisher.publishChatSession).toHaveBeenCalledWith('chat_session.deleted', mockClientId, { + id: mockChatId, + agentId: mockAgentId, + }); + }); + }); + + describe('listMessages', () => { + it('should proxy list chat session messages request successfully', async () => { + const mockMessages = [ + { + id: 'msg-1', + actor: 'user', + message: 'Hello', + filtered: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + ]; + + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: mockMessages, + } as any); + + const result = await service.listMessages(mockClientId, mockAgentId, mockChatId, 50, 0); + + expect(result).toEqual(mockMessages); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + method: 'GET', + url: expect.stringContaining(`/api/agents/${mockAgentId}/chats/${mockChatId}/messages`), + params: { limit: 50, offset: 0 }, + }), + ); + }); + }); + + describe('authentication', () => { + it('should use API key for API_KEY authentication type', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: [], + } as any); + + await service.list(mockClientId, mockAgentId); + + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer test-api-key', + }), + }), + ); + }); + + it('should use Keycloak token for KEYCLOAK authentication type', async () => { + const keycloakClient: ClientEntity = { + ...mockClientEntity, + authenticationType: AuthenticationType.KEYCLOAK, + }; + + clientsRepository.findByIdOrThrow.mockResolvedValue(keycloakClient); + clientsService.getAccessToken.mockResolvedValue('keycloak-token'); + mockedAxios.request.mockResolvedValue({ + status: 200, + data: [], + } as any); + + await service.list(mockClientId, mockAgentId); + + expect(clientsService.getAccessToken).toHaveBeenCalledWith(mockClientId); + expect(mockedAxios.request).toHaveBeenCalledWith( + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Bearer keycloak-token', + }), + }), + ); + }); + + it('should throw BadRequestException when API key is missing', async () => { + const clientWithoutApiKey: ClientEntity = { + ...mockClientEntity, + apiKey: undefined, + }; + + clientsRepository.findByIdOrThrow.mockResolvedValue(clientWithoutApiKey); + + await expect(service.list(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + expect(mockedAxios.request).not.toHaveBeenCalled(); + }); + }); + + describe('error handling', () => { + it('should handle axios network errors', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + + const axiosError = new Error('Network error') as AxiosError; + + axiosError.request = {}; + mockedAxios.request.mockRejectedValue(axiosError); + + await expect(service.list(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + + it('should handle axios response errors', async () => { + clientsRepository.findByIdOrThrow.mockResolvedValue(mockClientEntity); + + const axiosError = new Error('Request failed') as AxiosError; + + axiosError.response = { + status: 500, + data: { message: 'Internal server error' }, + } as any; + mockedAxios.request.mockRejectedValue(axiosError); + + await expect(service.list(mockClientId, mockAgentId)).rejects.toThrow(BadRequestException); + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts new file mode 100644 index 000000000..a0e628e3c --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-chats-proxy.service.ts @@ -0,0 +1,253 @@ +/* eslint-disable @typescript-eslint/no-var-requires */ +import { + ChatSessionResponseDto, + CreateChatSessionDto, + UpdateChatSessionDto, +} from '@forepath/agenstra/backend/feature-agent-manager'; +import { AuthenticationType } from '@forepath/identity/backend'; +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import axios, { AxiosError, AxiosRequestConfig } from 'axios'; + +import { ClientsRepository } from '../repositories/clients.repository'; +import { getClientEndpointTlsPolicy, validateClientEndpointWithDnsOrThrow } from '../utils/client-endpoint-security'; +import { buildClientProxyRequestHeaders } from '../utils/client-proxy-request-headers'; + +import { AgenstraNotificationPublisher } from '../notifications/agenstra-notification.publisher'; +import { ClientsService } from './clients.service'; + +export type ChatSessionMessageResponse = { + id: string; + actor: string; + message: string; + filtered: boolean; + createdAt: Date; + updatedAt: Date; +}; + +/** + * Service for proxying agent chat session requests to client endpoints. + * Handles authentication (API key or Keycloak JWT) and forwards chat session requests to the client's agent-manager service. + */ +@Injectable() +export class ClientAgentChatsProxyService { + private readonly logger = new Logger(ClientAgentChatsProxyService.name); + + constructor( + private readonly clientsService: ClientsService, + private readonly clientsRepository: ClientsRepository, + private readonly notificationPublisher: AgenstraNotificationPublisher, + ) {} + + /** + * Get authentication header for a client. + * @param clientId - The UUID of the client + * @returns Authorization header value + * @throws BadRequestException if client authentication is not properly configured + */ + private async getAuthHeader(clientId: string): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + + if (clientEntity.authenticationType === AuthenticationType.API_KEY) { + if (!clientEntity.apiKey) { + throw new BadRequestException('API key is not configured for this client'); + } + + return `Bearer ${clientEntity.apiKey}`; + } else if (clientEntity.authenticationType === AuthenticationType.KEYCLOAK) { + const token = await this.clientsService.getAccessToken(clientId); + + return `Bearer ${token}`; + } else { + throw new BadRequestException(`Unsupported authentication type: ${clientEntity.authenticationType}`); + } + } + + /** + * Build the base URL for agent chat session API requests. + * @param endpoint - The client's endpoint URL + * @param agentId - The UUID of the agent + * @returns The base URL for agent chat session API requests + */ + private buildAgentChatsApiUrl(endpoint: string, agentId: string): string { + const baseUrl = endpoint.replace(/\/$/, ''); + + return `${baseUrl}/api/agents/${agentId}/chats`; + } + + /** + * Make an HTTP request to the client's agent-manager service for chat session operations. + * @param clientId - The UUID of the client + * @param agentId - The UUID of the agent + * @param config - Axios request configuration + * @returns The response data + * @throws NotFoundException if client or agent is not found + * @throws BadRequestException if request fails + */ + private async makeRequest(clientId: string, agentId: string, config: AxiosRequestConfig): Promise { + const clientEntity = await this.clientsRepository.findByIdOrThrow(clientId); + + await validateClientEndpointWithDnsOrThrow(clientEntity.endpoint); + const authHeader = await this.getAuthHeader(clientId); + const baseUrl = this.buildAgentChatsApiUrl(clientEntity.endpoint, agentId); + const tlsPolicy = getClientEndpointTlsPolicy(this.logger); + + try { + this.logger.debug( + `Proxying chat session request to ${baseUrl}${config.url || ''} for client ${clientId}, agent ${agentId}`, + ); + + const response = await axios.request({ + ...config, + url: config.url ? `${baseUrl}${config.url}` : baseUrl, + headers: buildClientProxyRequestHeaders(config.headers, authHeader), + validateStatus: (status) => status < 500, + httpsAgent: baseUrl.startsWith('https://') + ? new (require('https').Agent)({ + rejectUnauthorized: tlsPolicy.rejectUnauthorized, + }) + : undefined, + }); + + if (response.status >= 400) { + const errorMessage = (response.data as { message?: string })?.message || 'Request failed'; + + this.logger.error( + `Request to ${baseUrl}${config.url || ''} failed with status ${response.status}: ${errorMessage}`, + ); + + if (response.status === 404) { + throw new NotFoundException(errorMessage); + } else if (response.status === 400) { + throw new BadRequestException(errorMessage); + } else { + throw new BadRequestException(`Request failed: ${errorMessage}`); + } + } + + return response.data; + } catch (error) { + if (error instanceof NotFoundException || error instanceof BadRequestException) { + throw error; + } + + const axiosError = error as AxiosError; + + if (axiosError.response) { + const errorMessage = + (axiosError.response.data as { message?: string })?.message || axiosError.message || 'Request failed'; + + this.logger.error(`Request to ${baseUrl}${config.url || ''} failed: ${errorMessage}`, axiosError.response.data); + + if (axiosError.response.status === 404) { + throw new NotFoundException(errorMessage); + } else if (axiosError.response.status === 400) { + throw new BadRequestException(errorMessage); + } else { + throw new BadRequestException(`Request failed: ${errorMessage}`); + } + } else if (axiosError.request) { + this.logger.error(`No response received from ${baseUrl}${config.url || ''}: ${axiosError.message}`); + throw new BadRequestException(`Failed to connect to client endpoint: ${axiosError.message}`); + } else { + this.logger.error(`Error setting up request to ${baseUrl}${config.url || ''}: ${axiosError.message}`); + throw new BadRequestException(`Request setup failed: ${axiosError.message}`); + } + } + } + + /** + * List chat sessions for an agent with pagination. + */ + async list(clientId: string, agentId: string, limit = 50, offset = 0): Promise { + return await this.makeRequest(clientId, agentId, { + method: 'GET', + params: { limit, offset }, + }); + } + + /** + * Get count of chat sessions for an agent. + */ + async count(clientId: string, agentId: string): Promise<{ count: number }> { + return await this.makeRequest<{ count: number }>(clientId, agentId, { + method: 'GET', + url: '/count', + }); + } + + /** + * Create a new user chat session for an agent. + */ + async create(clientId: string, agentId: string, createDto: CreateChatSessionDto): Promise { + const created = await this.makeRequest(clientId, agentId, { + method: 'POST', + data: createDto, + }); + + this.notificationPublisher.publishChatSession('chat_session.created', clientId, created); + + return created; + } + + /** + * Get a chat session by ID. + */ + async get(clientId: string, agentId: string, chatId: string): Promise { + return await this.makeRequest(clientId, agentId, { + method: 'GET', + url: `/${chatId}`, + }); + } + + /** + * Update a chat session title. + */ + async update( + clientId: string, + agentId: string, + chatId: string, + updateDto: UpdateChatSessionDto, + ): Promise { + const updated = await this.makeRequest(clientId, agentId, { + method: 'PUT', + url: `/${chatId}`, + data: updateDto, + }); + + this.notificationPublisher.publishChatSession('chat_session.updated', clientId, updated); + + return updated; + } + + /** + * Delete a chat session by ID. + */ + async delete(clientId: string, agentId: string, chatId: string): Promise { + await this.makeRequest(clientId, agentId, { + method: 'DELETE', + url: `/${chatId}`, + }); + + this.notificationPublisher.publishChatSession('chat_session.deleted', clientId, { + id: chatId, + agentId, + }); + } + + /** + * List messages for a chat session with pagination. + */ + async listMessages( + clientId: string, + agentId: string, + chatId: string, + limit = 50, + offset = 0, + ): Promise { + return await this.makeRequest(clientId, agentId, { + method: 'GET', + url: `/${chatId}/messages`, + params: { limit, offset }, + }); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts index 743e54d53..7706d77cb 100644 --- a/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-controller/src/lib/services/client-agent-proxy.service.spec.ts @@ -55,6 +55,8 @@ describe('ClientAgentProxyService', () => { description: 'Test Agent Description', agentType: 'cursor', containerType: ContainerType.GENERIC, + chats: [], + primaryChatId: 'primary-chat-uuid', createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; 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 fe804effa..2d9ffdbe5 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/asyncapi.yaml @@ -40,10 +40,26 @@ 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`, `correlationId`, `model`. + `ephemeral` (skip `agent_messages` persistence for the turn), `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. 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. + agents/restoreChat: + address: agents/restoreChat + messages: + restoreChatCommand: + $ref: '#/components/messages/RestoreChat' + description: > + Client requests restoration of a specific chat session's history (requires prior login). + The client clears its local thread; the server re-emits recent chatMessage / messageFilterResult / chatEvent events for that chatId. + agents/restoreChatSuccess: + address: agents/restoreChatSuccess + messages: + restoreChatSuccessEvent: + $ref: '#/components/messages/RestoreChatSuccess' + description: Server acknowledges successful chat history restoration for the requested chatId. agents/enhanceChat: address: agents/enhanceChat messages: @@ -204,6 +220,18 @@ operations: $ref: '#/channels/agents~1chat' messages: - $ref: '#/channels/agents~1chat/messages/chatCommand' + clientSendsRestoreChat: + action: send + channel: + $ref: '#/channels/agents~1restoreChat' + messages: + - $ref: '#/channels/agents~1restoreChat/messages/restoreChatCommand' + serverEmitsRestoreChatSuccess: + action: receive + channel: + $ref: '#/channels/agents~1restoreChatSuccess' + messages: + - $ref: '#/channels/agents~1restoreChatSuccess/messages/restoreChatSuccessEvent' clientSendsEnhanceChat: action: send channel: @@ -345,6 +373,9 @@ components: description: Agent UUID or name password: type: string + chatId: + type: string + description: Optional chat session id to restore on login; defaults to the agent's primary session when omitted. LoginSuccess: name: LoginSuccess title: Login success event @@ -434,6 +465,11 @@ components: resumeSessionSuffix: type: string description: Optional suffix to isolate or resume session routing for this chat turn. + chatId: + 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). contextInjection: type: object description: > @@ -462,6 +498,41 @@ components: autoEnrichmentEnabled: type: boolean description: Enable prompt-based automatic enrichment (default true when omitted). + RestoreChat: + name: RestoreChat + title: Restore chat command + contentType: application/json + payload: + type: object + required: [chatId] + properties: + chatId: + type: string + description: Chat session id whose history should be re-emitted to the requesting socket + RestoreChatSuccess: + name: RestoreChatSuccess + title: Restore chat success event + contentType: application/json + payload: + type: object + required: [success, data, timestamp] + properties: + success: + type: boolean + enum: [true] + data: + type: object + required: [chatId, message] + properties: + chatId: + type: string + description: Restored chat session id + message: + type: string + description: Human-readable confirmation + timestamp: + type: string + description: ISO timestamp EnhanceChat: name: EnhanceChat title: Prompt enhancement command @@ -659,6 +730,9 @@ components: timestamp: type: string description: ISO timestamp + chatId: + type: string + description: User-visible chat session id when the message belongs to a persisted chat - type: object required: [from, response, timestamp] properties: @@ -706,6 +780,9 @@ components: timestamp: type: string description: ISO timestamp + chatId: + type: string + description: User-visible chat session id when the message belongs to a persisted chat timestamp: type: string description: ISO timestamp @@ -739,6 +816,9 @@ components: agentId: type: string description: Agent UUID + chatId: + type: string + description: User-visible chat session id when the event belongs to a persisted chat correlationId: type: string description: Correlates events that belong to a single user request diff --git a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml index 62fb49430..daa510223 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml +++ b/libs/domains/agenstra/backend/feature-agent-manager/spec/openapi.yaml @@ -702,6 +702,212 @@ paths: description: Environment variable deleted '404': description: Agent or environment variable not found + /agents/{agentId}/chats: + get: + summary: List chat sessions + operationId: listChatSessions + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 0 + description: Maximum number of chat sessions to return (default 50) + - in: query + name: offset + schema: + type: integer + minimum: 0 + description: Number of chat sessions to skip (default 0) + responses: + '200': + description: Array of chat sessions + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatSessionResponseDto' + '404': + description: Agent not found + post: + summary: Create chat session + operationId: createChatSession + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/CreateChatSessionDto' + responses: + '201': + description: Chat session created + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '400': + description: Invalid request + '404': + description: Agent not found + /agents/{agentId}/chats/count: + get: + summary: Get count of chat sessions + operationId: countChatSessions + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Count of chat sessions + content: + application/json: + schema: + type: object + required: [count] + properties: + count: + type: integer + description: Total count of chat sessions for the agent + '404': + description: Agent not found + /agents/{agentId}/chats/{chatId}: + get: + summary: Get chat session + operationId: getChatSession + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: chatId + required: true + schema: + type: string + format: uuid + responses: + '200': + description: Chat session + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '404': + description: Agent or chat session not found + put: + summary: Update chat session + operationId: updateChatSession + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: chatId + required: true + schema: + type: string + format: uuid + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/UpdateChatSessionDto' + responses: + '200': + description: Chat session updated + content: + application/json: + schema: + $ref: '#/components/schemas/ChatSessionResponseDto' + '400': + description: Invalid request + '404': + description: Agent or chat session not found + delete: + summary: Delete chat session + operationId: deleteChatSession + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: chatId + required: true + schema: + type: string + format: uuid + responses: + '204': + description: Chat session deleted + '404': + description: Agent or chat session not found + /agents/{agentId}/chats/{chatId}/messages: + get: + summary: List messages for a chat session + operationId: listChatSessionMessages + parameters: + - in: path + name: agentId + required: true + schema: + type: string + format: uuid + - in: path + name: chatId + required: true + schema: + type: string + format: uuid + - in: query + name: limit + schema: + type: integer + minimum: 0 + description: Maximum number of messages to return (default 50) + - in: query + name: offset + schema: + type: integer + minimum: 0 + description: Number of messages to skip (default 0) + responses: + '200': + description: Array of chat session messages + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ChatSessionMessageResponseDto' + '404': + description: Agent or chat session not found /configuration-overrides: get: summary: List effective workspace configuration overrides @@ -1609,7 +1815,8 @@ components: description: Model catalog for an agent; keys are model identifiers, values are display names AgentResponseDto: type: object - required: [id, name, agentType, createdAt, updatedAt] + required: + [id, name, agentType, createdAt, updatedAt, chats, primaryChatId] properties: id: type: string @@ -1634,6 +1841,15 @@ components: enum: [clone, empty] description: How the workspace repository was initialized description: Git information when the agent has an associated repository or empty local repo + chats: + type: array + description: User-visible chat sessions for this environment + items: + $ref: '#/components/schemas/AgentChatSessionSummaryDto' + primaryChatId: + type: string + format: uuid + description: Id of the primary chat session for this environment createdAt: type: string format: date-time @@ -2289,6 +2505,82 @@ components: updatedAt: type: string format: date-time + CreateChatSessionDto: + type: object + properties: + title: + type: string + maxLength: 255 + description: Optional title for the new chat session + UpdateChatSessionDto: + type: object + required: [title] + properties: + title: + type: string + maxLength: 255 + description: New title for the chat session + ChatSessionResponseDto: + type: object + required: [id, agentId, kind, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + agentId: + type: string + format: uuid + title: + type: string + kind: + type: string + enum: [primary, user] + lastMessageAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time + AgentChatSessionSummaryDto: + type: object + required: [id, kind, createdAt] + properties: + id: + type: string + format: uuid + title: + type: string + kind: + type: string + enum: [primary, user] + lastMessageAt: + type: string + format: date-time + createdAt: + type: string + format: date-time + ChatSessionMessageResponseDto: + type: object + required: [id, actor, message, filtered, createdAt, updatedAt] + properties: + id: + type: string + format: uuid + actor: + type: string + message: + type: string + filtered: + type: boolean + createdAt: + type: string + format: date-time + updatedAt: + type: string + format: date-time WorkspaceConfigurationSettingKey: type: string enum: diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts index 2e75a5a64..c4f978388 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/index.ts @@ -6,8 +6,10 @@ export * from './lib/dto/create-agent-response.dto'; export * from './lib/dto/create-agent.dto'; export * from './lib/dto/create-branch.dto'; export * from './lib/dto/create-regex-filter-rule.dto'; +export * from './lib/dto/create-chat-session.dto'; export * from './lib/dto/create-environment-variable.dto'; export * from './lib/dto/create-file.dto'; +export * from './lib/dto/chat-session-response.dto'; export * from './lib/dto/deployment-configuration.dto'; export * from './lib/dto/deployment-run.dto'; export * from './lib/dto/environment-variable-response.dto'; @@ -27,11 +29,13 @@ export * from './lib/dto/stage-files.dto'; export * from './lib/dto/unstage-files.dto'; export * from './lib/dto/update-agent.dto'; export * from './lib/dto/update-regex-filter-rule.dto'; +export * from './lib/dto/update-chat-session.dto'; export * from './lib/dto/update-environment-variable.dto'; export * from './lib/dto/upsert-workspace-configuration-override.dto'; export * from './lib/dto/workspace-configuration-setting-response.dto'; export * from './lib/dto/regex-filter-rule-response.dto'; export * from './lib/dto/write-file.dto'; +export * from './lib/entities/agent-chat-session.entity'; export * from './lib/entities/agent-environment-variable.entity'; export * from './lib/entities/agent-message.entity'; export * from './lib/entities/agent-message-event.entity'; @@ -43,8 +47,10 @@ export * from './lib/entities/workspace-configuration-override.entity'; export * from './lib/gateways/agents.gateway'; export * from './lib/modules/agents.module'; export * from './lib/modules/agenstra-otel.module'; +export * from './lib/repositories/agent-chat-sessions.repository'; export * from './lib/repositories/agent-messages.repository'; export * from './lib/repositories/agents.repository'; +export * from './lib/services/agent-chat-sessions.service'; export * from './lib/services/agent-messages.service'; export * from './lib/services/agents-vcs.service'; export * from './lib/services/agents.service'; @@ -53,6 +59,7 @@ export * from './lib/services/docker.service'; export * from './lib/services/workspace-configuration-overrides.service'; export * from './lib/utils/agent-file-manager-context'; export * from './lib/utils/regex-filter-rule.utils'; +export * from './lib/constants/chat-session.constants'; export * from './lib/constants/git-repository-setup-mode'; export * from './lib/constants/workspace-configuration-settings'; 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 new file mode 100644 index 000000000..5805986e7 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.spec.ts @@ -0,0 +1,42 @@ +import { + PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + RESERVED_CHAT_RESUME_SESSION_SUFFIXES, + buildUserChatResumeSessionSuffix, + isReservedChatResumeSessionSuffix, +} from './chat-session.constants'; + +describe('chat-session.constants', () => { + describe('PRIMARY_CHAT_RESUME_SESSION_SUFFIX', () => { + it('is an empty string', () => { + expect(PRIMARY_CHAT_RESUME_SESSION_SUFFIX).toBe(''); + }); + }); + + describe('isReservedChatResumeSessionSuffix', () => { + it('returns true for each reserved suffix', () => { + for (const suffix of RESERVED_CHAT_RESUME_SESSION_SUFFIXES) { + expect(isReservedChatResumeSessionSuffix(suffix)).toBe(true); + } + }); + + it('returns false for primary empty suffix', () => { + expect(isReservedChatResumeSessionSuffix(PRIMARY_CHAT_RESUME_SESSION_SUFFIX)).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isReservedChatResumeSessionSuffix(undefined)).toBe(false); + }); + + it('returns false for user chat suffixes', () => { + expect(isReservedChatResumeSessionSuffix('-chat-abc-123')).toBe(false); + }); + }); + + describe('buildUserChatResumeSessionSuffix', () => { + it('builds -chat-{id} suffix', () => { + const chatId = '11111111-2222-4333-8444-555555555555'; + + expect(buildUserChatResumeSessionSuffix(chatId)).toBe(`-chat-${chatId}`); + }); + }); +}); 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 new file mode 100644 index 000000000..e311e3f05 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/constants/chat-session.constants.ts @@ -0,0 +1,37 @@ +/** + * User-visible chat session kinds stored in `agent_chat_sessions`. + * Hidden ACP suffixes (prompt enhance, ticket body, automation) are not persisted as rows. + */ +export const AGENT_CHAT_SESSION_KINDS = ['primary', 'user'] as const; + +export type AgentChatSessionKind = (typeof AGENT_CHAT_SESSION_KINDS)[number]; + +/** Empty suffix is the primary ACP chat session. */ +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-'; + +/** + * Reserved ACP resumeSessionSuffix values used by background/hidden flows. + * These must never collide with user-visible chat session suffixes. + */ +export const RESERVED_CHAT_RESUME_SESSION_SUFFIXES = [ + '-prompt-enhance', + '-ticket-body', + '-ticket-auto-pre', + '-ticket-auto-loop', + '-ticket-auto-commit-msg', +] as const; + +export function isReservedChatResumeSessionSuffix(suffix: string | undefined): boolean { + if (suffix === undefined || suffix === PRIMARY_CHAT_RESUME_SESSION_SUFFIX) { + return false; + } + + return (RESERVED_CHAT_RESUME_SESSION_SUFFIXES as readonly string[]).includes(suffix); +} + +export function buildUserChatResumeSessionSuffix(chatId: string): string { + return `${USER_CHAT_RESUME_SESSION_SUFFIX_PREFIX}${chatId}`; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.spec.ts new file mode 100644 index 000000000..eb149884d --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.spec.ts @@ -0,0 +1,198 @@ +import { Test, TestingModule } from '@nestjs/testing'; + +import { CreateChatSessionDto } from '../dto/create-chat-session.dto'; +import { ChatSessionResponseDto } from '../dto/chat-session-response.dto'; +import { UpdateChatSessionDto } from '../dto/update-chat-session.dto'; +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; + +import { AgentsChatSessionsController } from './agents-chat-sessions.controller'; + +describe('AgentsChatSessionsController', () => { + let controller: AgentsChatSessionsController; + let service: jest.Mocked; + const mockAgentId = 'test-agent-uuid'; + const mockChatId = 'test-chat-uuid'; + const mockChatSessionEntity: AgentChatSessionEntity = { + id: mockChatId, + agentId: mockAgentId, + title: 'Chat', + kind: 'user', + resumeSessionSuffix: `-chat-${mockChatId}`, + lastMessageAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + } as AgentChatSessionEntity; + const mockChatSessionResponse: ChatSessionResponseDto = { + id: mockChatId, + agentId: mockAgentId, + title: 'Chat', + kind: 'user', + lastMessageAt: undefined, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + const mockService = { + listSessions: jest.fn(), + countSessions: jest.fn(), + createUserSession: jest.fn(), + getSession: jest.fn(), + updateSessionTitle: jest.fn(), + deleteSession: jest.fn(), + getMessagesForSession: jest.fn(), + mapToResponseDto: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [AgentsChatSessionsController], + providers: [ + { + provide: AgentChatSessionsService, + useValue: mockService, + }, + ], + }).compile(); + + controller = module.get(AgentsChatSessionsController); + service = module.get(AgentChatSessionsService); + mockService.mapToResponseDto.mockImplementation((entity: AgentChatSessionEntity) => ({ + id: entity.id, + agentId: entity.agentId, + title: entity.title ?? undefined, + kind: entity.kind, + lastMessageAt: entity.lastMessageAt ?? undefined, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + })); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('listChatSessions', () => { + it('should return array of chat sessions', async () => { + service.listSessions.mockResolvedValue([mockChatSessionEntity]); + + const result = await controller.listChatSessions(mockAgentId, 50, 0); + + expect(result).toEqual([mockChatSessionResponse]); + expect(service.listSessions).toHaveBeenCalledWith(mockAgentId, 50, 0); + }); + + it('should use default pagination values', async () => { + service.listSessions.mockResolvedValue([mockChatSessionEntity]); + + const result = await controller.listChatSessions(mockAgentId); + + expect(result).toEqual([mockChatSessionResponse]); + expect(service.listSessions).toHaveBeenCalledWith(mockAgentId, 50, 0); + }); + + it('should use custom pagination parameters', async () => { + service.listSessions.mockResolvedValue([mockChatSessionEntity]); + + const result = await controller.listChatSessions(mockAgentId, 100, 10); + + expect(result).toEqual([mockChatSessionResponse]); + expect(service.listSessions).toHaveBeenCalledWith(mockAgentId, 100, 10); + }); + }); + + describe('countChatSessions', () => { + it('should return count of chat sessions', async () => { + service.countSessions.mockResolvedValue(5); + + const result = await controller.countChatSessions(mockAgentId); + + expect(result).toEqual({ count: 5 }); + expect(service.countSessions).toHaveBeenCalledWith(mockAgentId); + }); + + it('should return zero when no chat sessions exist', async () => { + service.countSessions.mockResolvedValue(0); + + const result = await controller.countChatSessions(mockAgentId); + + expect(result).toEqual({ count: 0 }); + }); + }); + + describe('createChatSession', () => { + it('should create new chat session', async () => { + const createDto: CreateChatSessionDto = { + title: 'New Chat', + }; + + service.createUserSession.mockResolvedValue(mockChatSessionEntity); + + const result = await controller.createChatSession(mockAgentId, createDto); + + expect(result).toEqual(mockChatSessionResponse); + expect(service.createUserSession).toHaveBeenCalledWith(mockAgentId, createDto.title); + }); + }); + + describe('getChatSession', () => { + it('should return chat session', async () => { + service.getSession.mockResolvedValue(mockChatSessionEntity); + + const result = await controller.getChatSession(mockAgentId, mockChatId); + + expect(result).toEqual(mockChatSessionResponse); + expect(service.getSession).toHaveBeenCalledWith(mockAgentId, mockChatId); + }); + }); + + describe('updateChatSession', () => { + it('should update chat session title', async () => { + const updateDto: UpdateChatSessionDto = { + title: 'Renamed Chat', + }; + const updatedEntity = { + ...mockChatSessionEntity, + title: 'Renamed Chat', + }; + + service.updateSessionTitle.mockResolvedValue(updatedEntity); + + const result = await controller.updateChatSession(mockAgentId, mockChatId, updateDto); + + expect(result.title).toBe('Renamed Chat'); + expect(service.updateSessionTitle).toHaveBeenCalledWith(mockAgentId, mockChatId, updateDto.title); + }); + }); + + describe('deleteChatSession', () => { + it('should delete chat session', async () => { + service.deleteSession.mockResolvedValue(undefined); + + await controller.deleteChatSession(mockAgentId, mockChatId); + + expect(service.deleteSession).toHaveBeenCalledWith(mockAgentId, mockChatId); + }); + }); + + describe('listChatSessionMessages', () => { + it('should return messages for chat session', async () => { + const messages = [ + { + id: 'msg-1', + actor: 'user', + message: 'hello', + filtered: false, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }, + ]; + + service.getMessagesForSession.mockResolvedValue(messages); + + const result = await controller.listChatSessionMessages(mockAgentId, mockChatId, 50, 0); + + expect(result).toEqual(messages); + expect(service.getMessagesForSession).toHaveBeenCalledWith(mockAgentId, mockChatId, 50, 0); + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts new file mode 100644 index 000000000..0fc90c886 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents-chat-sessions.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Delete, + Get, + HttpCode, + HttpStatus, + Param, + ParseIntPipe, + ParseUUIDPipe, + Post, + Put, + Query, +} from '@nestjs/common'; + +import { CreateChatSessionDto } from '../dto/create-chat-session.dto'; +import { ChatSessionResponseDto } from '../dto/chat-session-response.dto'; +import { UpdateChatSessionDto } from '../dto/update-chat-session.dto'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; + +/** + * Controller for user-visible chat sessions nested under an agent. + */ +@Controller('agents/:agentId/chats') +export class AgentsChatSessionsController { + constructor(private readonly agentChatSessionsService: AgentChatSessionsService) {} + + @Get() + async listChatSessions( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + ): Promise { + const sessions = await this.agentChatSessionsService.listSessions(agentId, limit ?? 50, offset ?? 0); + + return sessions.map((session) => this.agentChatSessionsService.mapToResponseDto(session)); + } + + @Get('count') + async countChatSessions( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + ): Promise<{ count: number }> { + const count = await this.agentChatSessionsService.countSessions(agentId); + + return { count }; + } + + @Post() + @HttpCode(HttpStatus.CREATED) + async createChatSession( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Body() createDto: CreateChatSessionDto, + ): Promise { + const session = await this.agentChatSessionsService.createUserSession(agentId, createDto.title); + + return this.agentChatSessionsService.mapToResponseDto(session); + } + + @Get(':chatId') + async getChatSession( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + ): Promise { + const session = await this.agentChatSessionsService.getSession(agentId, chatId); + + return this.agentChatSessionsService.mapToResponseDto(session); + } + + @Put(':chatId') + async updateChatSession( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Body() updateDto: UpdateChatSessionDto, + ): Promise { + const session = await this.agentChatSessionsService.updateSessionTitle(agentId, chatId, updateDto.title); + + return this.agentChatSessionsService.mapToResponseDto(session); + } + + @Delete(':chatId') + @HttpCode(HttpStatus.NO_CONTENT) + async deleteChatSession( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + ): Promise { + await this.agentChatSessionsService.deleteSession(agentId, chatId); + } + + @Get(':chatId/messages') + async listChatSessionMessages( + @Param('agentId', new ParseUUIDPipe({ version: '4' })) agentId: string, + @Param('chatId', new ParseUUIDPipe({ version: '4' })) chatId: string, + @Query('limit', new ParseIntPipe({ optional: true })) limit?: number, + @Query('offset', new ParseIntPipe({ optional: true })) offset?: number, + ): Promise< + { + id: string; + actor: string; + message: string; + filtered: boolean; + createdAt: Date; + updatedAt: Date; + }[] + > { + return await this.agentChatSessionsService.getMessagesForSession(agentId, chatId, limit ?? 50, offset ?? 0); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.spec.ts index d17ee2727..72bce65f2 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/controllers/agents.controller.spec.ts @@ -19,6 +19,15 @@ describe('AgentsController', () => { description: 'Test Description', agentType: 'cursor', containerType: ContainerType.GENERIC, + chats: [ + { + id: 'primary-chat-id', + title: 'Chat', + kind: 'primary', + createdAt: new Date('2024-01-01'), + }, + ], + primaryChatId: 'primary-chat-id', createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts index 9f4eeb9cd..fd0bd1e86 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/agent-response.dto.ts @@ -1,6 +1,7 @@ import { GitRepositorySetupMode } from '../constants/git-repository-setup-mode'; import { ContainerType } from '../entities/agent.entity'; +import type { AgentChatSessionSummaryDto } from './chat-session-response.dto'; import type { AgentTypeCapabilities } from './config-response.dto'; /** @@ -29,6 +30,14 @@ export class AgentResponseDto { repositoryUrl?: string; setupMode: GitRepositorySetupMode; }; + /** + * User-visible chat sessions for this environment (excludes hidden ACP suffixes). + */ + chats!: AgentChatSessionSummaryDto[]; + /** + * Id of the primary chat session for this environment. + */ + primaryChatId!: string; createdAt!: Date; updatedAt!: Date; } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts new file mode 100644 index 000000000..cfb1758c1 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/chat-session-response.dto.ts @@ -0,0 +1,25 @@ +import type { AgentChatSessionKind } from '../constants/chat-session.constants'; + +/** + * DTO for chat session API responses and embedded agent profile summaries. + */ +export class ChatSessionResponseDto { + id!: string; + agentId!: string; + title?: string; + kind!: AgentChatSessionKind; + lastMessageAt?: Date; + createdAt!: Date; + updatedAt!: Date; +} + +/** + * Compact chat summary embedded on AgentResponseDto. + */ +export class AgentChatSessionSummaryDto { + id!: string; + title?: string; + kind!: AgentChatSessionKind; + lastMessageAt?: Date; + createdAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts new file mode 100644 index 000000000..aec8182cf --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/create-chat-session.dto.ts @@ -0,0 +1,11 @@ +import { IsOptional, IsString, MaxLength } from 'class-validator'; + +/** + * DTO for creating a user-visible chat session under an agent. + */ +export class CreateChatSessionDto { + @IsOptional() + @IsString({ message: 'Title must be a string' }) + @MaxLength(255, { message: 'Title must be at most 255 characters' }) + title?: string; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts new file mode 100644 index 000000000..7c17259ee --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/dto/update-chat-session.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString, MaxLength } from 'class-validator'; + +/** + * DTO for renaming a user-visible chat session. + */ +export class UpdateChatSessionDto { + @IsNotEmpty({ message: 'Title is required' }) + @IsString({ message: 'Title must be a string' }) + @MaxLength(255, { message: 'Title must be at most 255 characters' }) + title!: string; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts new file mode 100644 index 000000000..b2e8e6755 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-chat-session.entity.ts @@ -0,0 +1,50 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +import type { AgentChatSessionKind } from '../constants/chat-session.constants'; + +import { AgentEntity } from './agent.entity'; + +/** + * User-visible chat session bound to an agent (environment). + * Primary session uses empty resumeSessionSuffix; user sessions use `-chat-{id}`. + */ +@Entity('agent_chat_sessions') +@Index(['agentId', 'resumeSessionSuffix'], { unique: true }) +export class AgentChatSessionEntity { + @PrimaryGeneratedColumn('uuid', { name: 'id' }) + id!: string; + + @Column({ type: 'uuid', name: 'agent_id' }) + agentId!: string; + + @ManyToOne(() => AgentEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'agent_id' }) + agent!: AgentEntity; + + @Column({ type: 'varchar', length: 255, nullable: true, name: 'title' }) + title?: string | null; + + @Column({ type: 'varchar', length: 16, name: 'kind' }) + kind!: AgentChatSessionKind; + + @Column({ type: 'varchar', length: 128, name: 'resume_session_suffix', default: '' }) + resumeSessionSuffix!: string; + + @Column({ type: 'timestamptz', nullable: true, name: 'last_message_at' }) + lastMessageAt?: Date | null; + + @CreateDateColumn({ name: 'created_at' }) + createdAt!: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt!: Date; +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message-event.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message-event.entity.ts index 4c8bd30b1..56ff0b920 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message-event.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message-event.entity.ts @@ -9,6 +9,7 @@ import { UpdateDateColumn, } from 'typeorm'; +import { AgentChatSessionEntity } from './agent-chat-session.entity'; import { AgentEntity } from './agent.entity'; @Entity('agent_message_events') @@ -24,6 +25,13 @@ export class AgentMessageEventEntity { @JoinColumn({ name: 'agent_id' }) agent!: AgentEntity; + @Column({ type: 'uuid', name: 'chat_session_id' }) + chatSessionId!: string; + + @ManyToOne(() => AgentChatSessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'chat_session_id' }) + chatSession!: AgentChatSessionEntity; + @Column({ type: 'varchar', length: 64, name: 'correlation_id' }) correlationId!: string; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts index 7feab89d7..5d9642872 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/entities/agent-message.entity.ts @@ -8,10 +8,11 @@ import { UpdateDateColumn, } from 'typeorm'; +import { AgentChatSessionEntity } from './agent-chat-session.entity'; import { AgentEntity } from './agent.entity'; /** - * Agent message entity representing a chat message bound to an agent. + * Agent message entity representing a chat message bound to an agent chat session. * Each message has an actor (user or agent) and the message content. */ @Entity('agent_messages') @@ -26,6 +27,13 @@ export class AgentMessageEntity { @JoinColumn({ name: 'agent_id' }) agent!: AgentEntity; + @Column({ type: 'uuid', name: 'chat_session_id' }) + chatSessionId!: string; + + @ManyToOne(() => AgentChatSessionEntity, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + @JoinColumn({ name: 'chat_session_id' }) + chatSession!: AgentChatSessionEntity; + @Column({ type: 'varchar', length: 50, name: 'actor' }) actor!: string; 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 9703c03a4..02145289f 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 @@ -8,6 +8,7 @@ import { AgentProvider, AgentResponseObject } from '../providers/agent-provider. import { ChatFilterFactory } from '../providers/chat-filter.factory'; import { ChatFilter, FilterDirection } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; @@ -24,6 +25,8 @@ interface ChatPayload { responseMode?: 'stream' | 'sync' | 'single'; ephemeral?: boolean; correlationId?: string; + resumeSessionSuffix?: string; + chatId?: string; } describe('AgentsGateway', () => { @@ -36,6 +39,16 @@ describe('AgentsGateway', () => { let chatFilterFactory: jest.Mocked; let mockServer: Partial; let mockSocket: Partial; + const mockPrimaryChatSession = { + id: 'primary-chat-id', + agentId: 'test-uuid-123', + title: 'Chat', + kind: 'primary' as const, + resumeSessionSuffix: '', + lastMessageAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; const mockAgent: AgentEntity = { id: 'test-uuid-123', name: 'Test Agent', @@ -55,6 +68,8 @@ describe('AgentsGateway', () => { containerType: mockAgent.containerType, createdAt: mockAgent.createdAt, updatedAt: mockAgent.updatedAt, + chats: [], + primaryChatId: 'primary-chat-id', }; const mockAgentsService = { verifyCredentials: jest.fn(), @@ -77,6 +92,10 @@ describe('AgentsGateway', () => { persistEvent: jest.fn(), listRecentEvents: jest.fn().mockResolvedValue([]), }; + const mockAgentChatSessionsService = { + resolveSessionForChat: jest.fn().mockResolvedValue(mockPrimaryChatSession), + ensurePrimarySession: jest.fn().mockResolvedValue(mockPrimaryChatSession), + }; const mockAgentProvider: jest.Mocked = { getType: jest.fn().mockReturnValue('cursor'), getDisplayName: jest.fn().mockReturnValue('Cursor'), @@ -153,6 +172,10 @@ describe('AgentsGateway', () => { provide: AgentMessageEventsService, useValue: mockAgentMessageEventsService, }, + { + provide: AgentChatSessionsService, + useValue: mockAgentChatSessionsService, + }, { provide: AgentProviderFactory, useValue: mockAgentProviderFactory, @@ -199,7 +222,7 @@ describe('AgentsGateway', () => { }; // Setup default mocks - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); agentMessagesService.countMessages.mockResolvedValue(0); // Mock getContainerStats and getContainerStatus dockerService.getContainerStats = jest.fn(); @@ -229,7 +252,7 @@ describe('AgentsGateway', () => { } // Reset default mocks - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); agentMessagesService.countMessages.mockResolvedValue(0); }); @@ -319,6 +342,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', @@ -328,6 +352,7 @@ describe('AgentsGateway', () => { { id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: '{"type":"response","result":"Hi there!"}', @@ -337,6 +362,7 @@ describe('AgentsGateway', () => { { id: 'msg-3', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'How are you?', @@ -352,8 +378,8 @@ describe('AgentsGateway', () => { // Verify chat history was fetched // With 3 messages, offset = max(0, 3 - 20) = 0 - expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id); - expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0); + expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id, 'primary-chat-id'); + expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0, 'primary-chat-id'); // Verify messages were emitted in chronological order expect(mockSocket.emit).toHaveBeenCalledTimes(4); // loginSuccess + 3 chat messages @@ -364,11 +390,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello', timestamp: '2024-01-01T10:00:00.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -378,11 +405,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'response', result: 'Hi there!' }, timestamp: '2024-01-01T10:00:01.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -392,11 +420,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'How are you?', timestamp: '2024-01-01T10:00:02.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); }); @@ -412,6 +441,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: 'Plain text response', // Already cleaned (no { or } to clean) @@ -432,11 +462,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: 'Plain text response', // Cleaned string (same as input since no { or }) timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); }); @@ -452,6 +483,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: 'Some prefix text {"type":"response","result":"Success"} some suffix', @@ -471,11 +503,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'response', result: 'Success' }, // Parsed JSON object timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); }); @@ -485,14 +518,14 @@ describe('AgentsGateway', () => { agentsService.verifyCredentials.mockResolvedValue(true); agentsService.findOne.mockResolvedValue(mockAgentResponse); agentMessagesService.countMessages.mockResolvedValue(0); - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); await gateway.handleLogin({ agentId: mockAgent.id, password: 'password123' }, mockSocket as Socket); // Verify chat history was fetched // With 0 messages, offset = max(0, 0 - 20) = 0 - expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id); - expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0); + expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id, 'primary-chat-id'); + expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0, 'primary-chat-id'); // Only loginSuccess should be emitted expect(mockSocket.emit).toHaveBeenCalledTimes(1); @@ -529,6 +562,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Filtered message', @@ -539,6 +573,7 @@ describe('AgentsGateway', () => { { id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Normal message', @@ -559,7 +594,7 @@ describe('AgentsGateway', () => { 'messageFilterResult', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ direction: 'incoming', status: 'filtered', message: 'Filtered message', @@ -567,7 +602,8 @@ describe('AgentsGateway', () => { matchedFilter: undefined, action: 'flag', timestamp: '2024-01-01T10:00:00.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -577,11 +613,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Filtered message', timestamp: '2024-01-01T10:00:00.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -591,11 +628,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Normal message', timestamp: '2024-01-01T10:00:01.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -616,6 +654,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: '{"type":"response","result":"Filtered response"}', @@ -626,6 +665,7 @@ describe('AgentsGateway', () => { { id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: '{"type":"response","result":"Normal response"}', @@ -646,7 +686,7 @@ describe('AgentsGateway', () => { 'messageFilterResult', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ direction: 'outgoing', status: 'filtered', message: '{"type":"response","result":"Filtered response"}', @@ -654,7 +694,8 @@ describe('AgentsGateway', () => { matchedFilter: undefined, action: 'flag', timestamp: '2024-01-01T10:00:00.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -664,11 +705,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'response', result: 'Filtered response' }, timestamp: '2024-01-01T10:00:00.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -678,11 +720,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'response', result: 'Normal response' }, timestamp: '2024-01-01T10:00:01.000Z', - }, + chatId: 'primary-chat-id', + }), }), ); @@ -703,6 +746,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'First message', @@ -713,6 +757,7 @@ describe('AgentsGateway', () => { { id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Filtered message', @@ -723,6 +768,7 @@ describe('AgentsGateway', () => { { id: 'msg-3', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: '{"type":"response","result":"Agent response"}', @@ -733,6 +779,7 @@ describe('AgentsGateway', () => { { id: 'msg-4', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Last message', @@ -909,6 +956,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -916,7 +964,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentSessionHydrationService.consumePendingSummary .mockReturnValueOnce('- preserve decision history') .mockReturnValue(undefined); @@ -957,6 +1005,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -964,7 +1013,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const mockAgentResponseJson = JSON.stringify({ type: 'result', subtype: 'success', @@ -992,11 +1041,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello, world!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -1007,7 +1057,7 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'result', @@ -1016,7 +1066,8 @@ describe('AgentsGateway', () => { result: 'Hello from agent!', }, timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -1037,6 +1088,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1044,7 +1096,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue(''); await gateway.handleChat({ message: 'Use custom model', model: 'gpt-4.1' }, mockSocket as Socket); @@ -1150,6 +1202,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1157,7 +1210,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue(''); const loggerLogSpy = jest.spyOn(gateway['logger'], 'log').mockImplementation(); @@ -1168,11 +1221,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -1208,13 +1262,14 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'dropped-msg', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Message was dropped by filter: Test filter matched', filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'test-filter' }, mockSocket as Socket); @@ -1235,6 +1290,7 @@ describe('AgentsGateway', () => { mockAgent.id, expect.stringContaining('Message was dropped by filter: Test filter matched'), false, + 'primary-chat-id', ); // Should NOT create agent message for dropped user messages @@ -1245,11 +1301,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: expect.stringContaining('Message was dropped by filter: Test filter matched'), timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); @@ -1275,6 +1332,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1282,7 +1340,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue('{}'); const mockFilter: jest.Mocked = { @@ -1300,23 +1358,25 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'test-filter', filtered: true, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); agentMessagesService.createAgentMessage.mockResolvedValue({ id: 'filter-result-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: JSON.stringify({ type: 'filter-result', direction: 'incoming', status: 'filtered' }), filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'test-filter' }, mockSocket as Socket); @@ -1333,7 +1393,12 @@ describe('AgentsGateway', () => { ); // Message should be processed but flagged - expect(agentMessagesService.createUserMessage).toHaveBeenCalledWith(mockAgent.id, 'test-filter', true); + expect(agentMessagesService.createUserMessage).toHaveBeenCalledWith( + mockAgent.id, + 'test-filter', + true, + 'primary-chat-id', + ); expect(mockFilter.filter).toHaveBeenCalledWith('test-filter', { agentId: mockAgent.id, actor: 'user', @@ -1353,6 +1418,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1360,7 +1426,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue('{}'); const modifiedMessage = 'Modified message content'; @@ -1380,13 +1446,14 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: modifiedMessage, filtered: true, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'original message' }, mockSocket as Socket); @@ -1409,16 +1476,22 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: modifiedMessage, // Modified message, not original timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); // Modified message should be persisted - expect(agentMessagesService.createUserMessage).toHaveBeenCalledWith(mockAgent.id, modifiedMessage, true); + expect(agentMessagesService.createUserMessage).toHaveBeenCalledWith( + mockAgent.id, + modifiedMessage, + true, + 'primary-chat-id', + ); // Modified message should be sent to agent expect(mockAgentProvider.sendMessage).toHaveBeenCalledWith( @@ -1442,6 +1515,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1449,7 +1523,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const originalResponse = { type: 'response', result: 'Original response' }; const modifiedResponseString = JSON.stringify({ type: 'response', result: 'Modified response' }); @@ -1484,24 +1558,26 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); agentMessagesService.createAgentMessage.mockResolvedValue({ id: 'msg-3', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: modifiedResponseString, filtered: true, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'Hello' }, mockSocket as Socket); @@ -1524,11 +1600,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: { type: 'response', result: 'Modified response' }, // Modified response, not original timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); @@ -1537,6 +1614,7 @@ describe('AgentsGateway', () => { mockAgent.id, { type: 'response', result: 'Modified response' }, true, + 'primary-chat-id', ); }); @@ -1564,13 +1642,14 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'dropped-msg', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Message was dropped by filter: Message should be dropped', filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'test' }, mockSocket as Socket); @@ -1580,6 +1659,7 @@ describe('AgentsGateway', () => { mockAgent.id, 'Message was dropped by filter: Message should be dropped', false, + 'primary-chat-id', ); // createAgentMessage should not be called for incoming dropped messages @@ -1599,6 +1679,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1606,7 +1687,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue('{}'); // First filter: modifies "bad" to "***" @@ -1638,13 +1719,14 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'This is a *** term', filtered: true, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'This is a bad word' }, mockSocket as Socket); @@ -1665,6 +1747,7 @@ describe('AgentsGateway', () => { mockAgent.id, 'This is a *** term', // Final modified message true, + 'primary-chat-id', ); expect(mockAgentProvider.sendMessage).toHaveBeenCalledWith( @@ -1702,6 +1785,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1709,7 +1793,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const mockIncomingFilter: jest.Mocked = { getType: jest.fn().mockReturnValue('incoming-filter'), @@ -1745,23 +1829,25 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); agentMessagesService.createAgentMessage.mockResolvedValue({ id: 'filter-result-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: JSON.stringify({ type: 'filter-result', direction: 'outgoing', status: 'dropped' }), filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'Hello' }, mockSocket as Socket); @@ -1787,6 +1873,7 @@ describe('AgentsGateway', () => { message: expect.stringContaining('Message was dropped by filter'), }), false, + 'primary-chat-id', ); // Fake agent response should be broadcast @@ -1829,6 +1916,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1836,7 +1924,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const mockOutgoingFilter: jest.Mocked = { getType: jest.fn().mockReturnValue('outgoing-filter'), @@ -1866,13 +1954,14 @@ describe('AgentsGateway', () => { agentMessagesService.createUserMessage.mockResolvedValue({ id: 'msg-2', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', filtered: false, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); // Mock filter factory to return empty array for incoming (no filters) chatFilterFactory.getFiltersByDirection.mockImplementation((direction) => { if (direction === FilterDirection.INCOMING) { @@ -1886,33 +1975,36 @@ describe('AgentsGateway', () => { .mockResolvedValueOnce({ id: 'filter-result-incoming', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: JSON.stringify({ type: 'filter-result', direction: 'incoming', status: 'allowed' }), filtered: false, createdAt: new Date(), updatedAt: new Date(), - }) + } as any) .mockResolvedValueOnce({ id: 'filter-result-outgoing', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: JSON.stringify({ type: 'filter-result', direction: 'outgoing', status: 'filtered' }), filtered: false, createdAt: new Date(), updatedAt: new Date(), - }) + } as any) .mockResolvedValueOnce({ id: 'msg-3', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'agent', message: agentResponseJson, filtered: true, createdAt: new Date(), updatedAt: new Date(), - }); + } as any); await gateway.handleChat({ message: 'Hello' }, mockSocket as Socket); @@ -1945,6 +2037,7 @@ describe('AgentsGateway', () => { mockAgent.id, expect.objectContaining({ type: 'result' }), true, + 'primary-chat-id', ); expect(mockOutgoingFilter.filter).toHaveBeenCalledWith( agentResponseJson, @@ -1967,6 +2060,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -1974,7 +2068,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const invalidJsonResponse = 'Invalid JSON response'; mockAgentProvider.sendMessage.mockResolvedValue(invalidJsonResponse); @@ -1992,11 +2086,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -2005,11 +2100,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'agent', response: 'Invalid JSON response', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -2034,6 +2130,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -2041,7 +2138,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockRejectedValue(new Error('Container error')); const loggerErrorSpy = jest.spyOn(gateway['logger'], 'error').mockImplementation(); const loggerLogSpy = jest.spyOn(gateway['logger'], 'log').mockImplementation(); @@ -2053,11 +2150,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -2085,6 +2183,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -2092,7 +2191,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const loggerLogSpy = jest.spyOn(gateway['logger'], 'log').mockImplementation(); await gateway.handleChat({ message: ' Hello, world! ' }, mockSocket as Socket); @@ -2102,11 +2201,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello, world!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), timestamp: expect.any(String), }), ); @@ -2139,6 +2239,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -2146,7 +2247,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const mockAgentResponseJson = JSON.stringify({ type: 'result', @@ -2201,6 +2302,107 @@ describe('AgentsGateway', () => { expect(mockAgentMessageEventsService.persistEvent).not.toHaveBeenCalled(); }); + it('treats reserved resumeSessionSuffix as hidden (no persist/broadcast) even without ephemeral', async () => { + const requesterSocketId = mockSocket.id || 'test-socket-id'; + const otherSocketId = 'other-viewer-socket-id'; + const otherViewerSocket: Partial = { + id: otherSocketId, + emit: jest.fn(), + connected: true, + }; + + // 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); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(otherSocketId, mockAgent.id); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).socketById.set(otherSocketId, otherViewerSocket); + + 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: 'Hidden ACP reply', + }); + const mockParsedResponse = { + type: 'result', + subtype: 'success', + is_error: false, + result: 'Hidden ACP reply', + }; + + mockAgentProvider.sendMessage.mockResolvedValue(mockAgentResponseJson); + mockAgentProvider.toParseableStrings.mockReturnValue([mockAgentResponseJson]); + mockAgentProvider.toUnifiedResponse.mockReturnValue(mockParsedResponse); + + await gateway.handleChat( + { + message: 'Improve ticket', + resumeSessionSuffix: '-ticket-auto-loop', + // Intentionally omit ephemeral; reserved must still be hidden. + chatId: '11111111-2222-4333-8444-555555555555', + responseMode: 'sync', + correlationId: 'reserved-corr-1', + }, + mockSocket as Socket, + ); + + expect(mockAgentChatSessionsService.resolveSessionForChat).not.toHaveBeenCalled(); + expect(agentMessagesService.createUserMessage).not.toHaveBeenCalled(); + expect(agentMessagesService.createAgentMessage).not.toHaveBeenCalled(); + expect(mockAgentMessageEventsService.persistEvent).not.toHaveBeenCalled(); + + const otherEmits = (otherViewerSocket.emit as jest.Mock).mock.calls; + + expect(otherEmits.filter(([event]) => event === 'chatMessage')).toHaveLength(0); + expect(mockAgentProvider.sendMessage).toHaveBeenCalledWith( + mockAgent.id, + mockAgent.containerId, + expect.any(String), + expect.objectContaining({ resumeSessionSuffix: '-ticket-auto-loop' }), + ); + }); + + it('rejects invalid chatId UUID before processing', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (gateway as any).authenticatedClients.set(mockSocket.id || 'test-socket-id', mockAgent.id); + + await gateway.handleChat( + { + message: 'Hello', + chatId: 'not-a-uuid', + }, + mockSocket as Socket, + ); + + expect(mockSocket.emit).toHaveBeenCalledWith( + 'error', + expect.objectContaining({ + success: false, + error: expect.objectContaining({ code: 'INVALID_CHAT_ID' }), + }), + ); + expect(mockAgentProvider.sendMessage).not.toHaveBeenCalled(); + }); + it('should broadcast chat traffic to all authenticated sockets when ephemeral is false', async () => { const requesterSocketId = mockSocket.id || 'test-socket-id'; const otherSocketId = 'other-viewer-socket-id'; @@ -2226,6 +2428,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -2233,7 +2436,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); const mockAgentResponseJson = JSON.stringify({ type: 'result', @@ -2382,6 +2585,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous', @@ -2389,7 +2593,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.getCapabilities.mockReturnValue({ supportsChat: true, @@ -2454,6 +2658,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Say hi', @@ -2461,7 +2666,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.getCapabilities.mockReturnValue({ supportsChat: true, @@ -2509,7 +2714,7 @@ describe('AgentsGateway', () => { agentsService.findOne.mockResolvedValue(mockAgentResponse); agentsRepository.findById.mockResolvedValue(mockAgent); // Mock empty chat history (first message) - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); mockAgentProvider.sendInitialization.mockResolvedValue(); mockAgentProvider.sendMessage.mockResolvedValue(''); const loggerDebugSpy = jest.spyOn(gateway['logger'], 'debug').mockImplementation(); @@ -2528,11 +2733,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello, world!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); loggerDebugSpy.mockRestore(); @@ -2553,6 +2759,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Previous message', @@ -2560,7 +2767,7 @@ describe('AgentsGateway', () => { createdAt: new Date(), updatedAt: new Date(), }, - ]); + ] as any); mockAgentProvider.sendMessage.mockResolvedValue(''); const loggerDebugSpy = jest.spyOn(gateway['logger'], 'debug').mockImplementation(); @@ -2595,7 +2802,7 @@ describe('AgentsGateway', () => { agentsService.findOne.mockResolvedValue(mockAgentResponse); agentsRepository.findById.mockResolvedValue(mockAgent); // Mock empty chat history (but agent already initialized) - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); mockAgentProvider.sendMessage.mockResolvedValue(''); const loggerDebugSpy = jest.spyOn(gateway['logger'], 'debug').mockImplementation(); @@ -2624,7 +2831,7 @@ describe('AgentsGateway', () => { agentsService.findOne.mockResolvedValue(mockAgentResponse); agentsRepository.findById.mockResolvedValue(mockAgent); // Mock empty chat history (first message) - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); // First call fails (initialization), second call succeeds (user message) mockAgentProvider.sendInitialization.mockRejectedValueOnce(new Error('Initialization failed')); mockAgentProvider.sendMessage.mockResolvedValueOnce(''); @@ -2643,11 +2850,12 @@ describe('AgentsGateway', () => { 'chatMessage', expect.objectContaining({ success: true, - data: { + data: expect.objectContaining({ from: 'user', text: 'Hello, world!', timestamp: expect.any(String), - }, + chatId: 'primary-chat-id', + }), }), ); // Should mark agent as initialized even if initialization message failed @@ -2667,7 +2875,7 @@ describe('AgentsGateway', () => { agentsService.findOne.mockResolvedValue(mockAgentResponse); agentsRepository.findById.mockResolvedValue(mockAgent); // Mock empty chat history (first message) - agentMessagesService.getChatHistory.mockResolvedValue([]); + agentMessagesService.getChatHistory.mockResolvedValue([] as any); mockAgentProvider.sendInitialization.mockResolvedValue(); mockAgentProvider.sendMessage.mockResolvedValue(''); @@ -3381,6 +3589,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', @@ -3435,6 +3644,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', @@ -3480,6 +3690,7 @@ describe('AgentsGateway', () => { { id: 'msg-1', agentId: mockAgent.id, + chatSessionId: 'primary-chat-id', agent: mockAgent, actor: 'user', message: 'Hello', @@ -3502,8 +3713,8 @@ describe('AgentsGateway', () => { // Verify chat history WAS fetched (normal login scenario) // With 1 message, offset = max(0, 1 - 20) = 0 - expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id); - expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0); + expect(agentMessagesService.countMessages).toHaveBeenCalledWith(mockAgent.id, 'primary-chat-id'); + expect(agentMessagesService.getChatHistory).toHaveBeenCalledWith(mockAgent.id, 20, 0, 'primary-chat-id'); // Verify chat message was emitted const chatMessageCalls = (newSocket.emit as jest.Mock).mock.calls.filter( 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 68b69d7d0..e66dc7b75 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 @@ -1,4 +1,4 @@ -import { Logger, OnModuleInit } from '@nestjs/common'; +import { BadRequestException, Logger, OnModuleInit } from '@nestjs/common'; import { ConnectedSocket, MessageBody, @@ -12,6 +12,7 @@ import { Server, Socket } from 'socket.io'; import { v4 as uuidv4 } from 'uuid'; import { GIT_STATE_CHANGED_EVENT, toolMayMutateGitWorkspace } from '../constants/agent-git-state.constants'; +import { isReservedChatResumeSessionSuffix } from '../constants/chat-session.constants'; import { AgentEventEnvelope, AgentInteractionQueryPayload, AgentResponseMode } from '../providers/agent-events.types'; import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentResponseObject } from '../providers/agent-provider.interface'; @@ -23,6 +24,7 @@ import { FilterDirection, } from '../providers/chat-filter.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; import { AgentMessageEventsService } from '../services/agent-message-events.service'; import { AgentMessagesService } from '../services/agent-messages.service'; @@ -39,6 +41,8 @@ import { PROMPT_TICKET_BODY_RESUME_SESSION_SUFFIX } from '../utils/ticket-body-p interface LoginPayload { agentId: string; password: string; + /** Restore this session; defaults to primary when omitted. */ + chatId?: string; } interface ChatPayload { @@ -50,9 +54,15 @@ interface ChatPayload { ephemeral?: boolean; continue?: boolean; resumeSessionSuffix?: string; + /** User-visible chat session id; defaults to primary when omitted (ignored for reserved ACP suffixes). */ + chatId?: string; contextInjection?: ContextInjectionPayload; } +interface RestoreChatPayload { + chatId: string; +} + interface EnhanceChatPayload { model?: string; message: string; @@ -149,12 +159,14 @@ interface UserChatMessageData { from: ChatActor.USER; text: string; timestamp: string; + chatId?: string; } interface AgentChatMessageData { from: ChatActor.AGENT; response: AgentResponseObject | string; // Parsed JSON object or raw string if parsing fails timestamp: string; + chatId?: string; } type ChatMessageData = UserChatMessageData | AgentChatMessageData; @@ -184,6 +196,12 @@ interface MessageFilterResultData { }; action?: 'drop' | 'flag'; timestamp: string; + chatId?: string; +} + +interface RestoreChatSuccessData { + chatId: string; + message: string; } // Helper functions to create standardized responses @@ -261,6 +279,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, private readonly dockerService: DockerService, private readonly agentMessagesService: AgentMessagesService, private readonly agentMessageEventsService: AgentMessageEventsService, + private readonly agentChatSessionsService: AgentChatSessionsService, private readonly agentProviderFactory: AgentProviderFactory, private readonly chatFilterFactory: ChatFilterFactory, private readonly promptContextComposer: PromptContextComposerService, @@ -389,9 +408,9 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } } - private broadcastChatEvent(agentUuid: string, event: AgentEventEnvelope): void { + private broadcastChatEvent(agentUuid: string, event: AgentEventEnvelope, chatSessionId?: string): void { this.broadcastToAgent(agentUuid, 'chatEvent', createSuccessResponse(event)); - void this.agentMessageEventsService.persistEvent(agentUuid, event); + void this.agentMessageEventsService.persistEvent(agentUuid, event, chatSessionId); if (event.kind === 'toolResult' && !event.payload.isError && toolMayMutateGitWorkspace(event.payload.name)) { this.gitStateBroadcast.notifyGitStateMayHaveChanged(agentUuid); @@ -434,14 +453,58 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ephemeral: boolean, requestSocket: Socket, envelope: AgentEventEnvelope, + chatSessionId?: string, + chatId?: string, ): void { + const event: AgentEventEnvelope = chatId ? { ...envelope, chatId } : envelope; + if (ephemeral) { - requestSocket.emit('chatEvent', createSuccessResponse(envelope)); + requestSocket.emit('chatEvent', createSuccessResponse(event)); return; } - this.broadcastChatEvent(agentUuid, envelope); + this.broadcastChatEvent(agentUuid, event, chatSessionId); + } + + private async resolveChatContext( + agentId: string, + data: { chatId?: string; resumeSessionSuffix?: string; ephemeral?: boolean }, + ): Promise<{ + resumeSessionSuffix: string | undefined; + chatSessionId: string | undefined; + chatId: string | undefined; + /** Hidden ACP suffixes must never persist or broadcast into user-visible chats. */ + hidden: boolean; + }> { + if (isReservedChatResumeSessionSuffix(data.resumeSessionSuffix)) { + // Ignore chatId: reserved suffixes are isolated ACP sessions, not user chat rows. + return { + resumeSessionSuffix: data.resumeSessionSuffix, + chatSessionId: undefined, + chatId: undefined, + hidden: true, + }; + } + + const rawChatId = typeof data.chatId === 'string' ? data.chatId.trim() : ''; + + if (rawChatId && !AgentsGateway.isUuidV4(rawChatId)) { + throw new BadRequestException('chatId must be a valid UUID'); + } + + const session = await this.agentChatSessionsService.resolveSessionForChat(agentId, rawChatId || undefined); + + return { + resumeSessionSuffix: session.resumeSessionSuffix || undefined, + chatSessionId: session.id, + chatId: session.id, + hidden: false, + }; + } + + private static isUuidV4(value: string): boolean { + return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); } /** @@ -600,6 +663,8 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, agentUuid: string, agentResponseTimestamp: string, finalResponse: AgentResponseObject, + chatSessionId?: string, + chatId?: string, ): Promise { const outgoingFilterResult = await this.applyFilters(JSON.stringify(finalResponse), FilterDirection.OUTGOING, { agentId: agentUuid, @@ -612,6 +677,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, createSuccessResponse({ direction: 'outgoing', ...outgoingFilterResult, + ...(chatId ? { chatId } : {}), }), ); @@ -631,6 +697,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, agentUuid, responseToUse, outgoingFilterResult.status === 'filtered', + chatSessionId, ); } catch (persistError) { const err = persistError as { message?: string }; @@ -645,6 +712,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response: responseToUse, timestamp: agentResponseTimestamp, + ...(chatId ? { chatId } : {}), }), ); } @@ -965,8 +1033,14 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, // 2. The socket was not already authenticated (to avoid restoring history twice) // This prevents duplicate messages when logging in after reconnection if (!wasRecovered && !wasAlreadyAuthenticated) { - // Restore chat history - await this.restoreChatHistory(agentUuid, socket); + // Restore chat history for the requested (or primary) session + try { + await this.restoreChatHistory(agentUuid, socket, data.chatId); + } catch (restoreError) { + const err = restoreError as { message?: string; stack?: string }; + + this.logger.warn(`Failed to restore chat history for agent ${agentUuid} on login: ${err.message}`, err.stack); + } } else { this.logger.debug( `Skipping chat history restoration for agent ${agentUuid} on socket ${socket.id} because socket was ${wasRecovered ? 'recovered' : 'already authenticated'}`, @@ -984,15 +1058,18 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } /** - * Restore and re-emit chat history for an agent. + * Restore and re-emit chat history for an agent chat session. * Messages are emitted in chronological order by their creation date. * @param agentUuid - The UUID of the agent * @param socket - The socket instance to emit messages to + * @param chatId - Optional chat session id; defaults to primary */ - private async restoreChatHistory(agentUuid: string, socket: Socket): Promise { + private async restoreChatHistory(agentUuid: string, socket: Socket, chatId?: string): Promise { + const session = await this.agentChatSessionsService.resolveSessionForChat(agentUuid, chatId); + try { // Get total message count to calculate offset for latest messages - const totalCount = await this.agentMessagesService.countMessages(agentUuid); + const totalCount = await this.agentMessagesService.countMessages(agentUuid, session.id); const limit = 20; // Calculate offset to get the latest 20 messages // If totalCount <= 20, offset is 0 (get all messages) @@ -1000,15 +1077,15 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const offset = Math.max(0, totalCount - limit); // Fetch chat history (ordered chronologically by createdAt ASC) // Only restore the most recent 20 messages - const chatHistory = await this.agentMessagesService.getChatHistory(agentUuid, limit, offset); + const chatHistory = await this.agentMessagesService.getChatHistory(agentUuid, limit, offset, session.id); if (chatHistory.length === 0) { - this.logger.debug(`No chat history found for agent ${agentUuid}`); + this.logger.debug(`No chat history found for agent ${agentUuid} chat ${session.id}`); return; } - this.logger.log(`Restoring ${chatHistory.length} messages for agent ${agentUuid}`); + this.logger.log(`Restoring ${chatHistory.length} messages for agent ${agentUuid} chat ${session.id}`); // Emit each message in chronological order for (const messageEntity of chatHistory) { @@ -1027,6 +1104,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, matchedFilter: undefined, action: 'flag', // Since it was persisted, it must have been flagged, not dropped timestamp, + chatId: session.id, }; socket.emit('messageFilterResult', createSuccessResponse(filterResult)); @@ -1040,6 +1118,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.USER, text: messageEntity.message, timestamp, + chatId: session.id, }), ); } else if (messageEntity.actor === 'agent') { @@ -1081,6 +1160,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response, timestamp, + chatId: session.id, }), ); } @@ -1091,19 +1171,76 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const since = chatHistory[0]?.createdAt; const persistedToolEvents = await this.agentMessageEventsService.listRecentEvents(agentUuid, 400, { kinds: ['toolCall', 'toolResult'], + chatSessionId: session.id, ...(since ? { since } : {}), }); for (const event of persistedToolEvents) { - socket.emit('chatEvent', createSuccessResponse(event)); + socket.emit( + 'chatEvent', + createSuccessResponse({ + ...event, + chatId: session.id, + }), + ); } - this.logger.debug(`Successfully restored ${chatHistory.length} messages for agent ${agentUuid}`); + this.logger.debug( + `Successfully restored ${chatHistory.length} messages for agent ${agentUuid} chat ${session.id}`, + ); } catch (error) { const err = error as { message?: string; stack?: string }; - this.logger.warn(`Failed to restore chat history for agent ${agentUuid}: ${err.message}`, err.stack); - // Don't fail login if history restoration fails + this.logger.warn( + `Failed to restore chat history for agent ${agentUuid} chat ${session.id}: ${err.message}`, + err.stack, + ); + // Don't fail login if history restoration fails after session resolve + } + } + + /** + * Restore a specific chat session's history for the authenticated client. + * Client is responsible for clearing the local thread before calling. + */ + @SubscribeMessage('restoreChat') + async handleRestoreChat(@MessageBody() data: RestoreChatPayload, @ConnectedSocket() socket: Socket) { + const agentUuid = this.authenticatedClients.get(socket.id); + + if (!agentUuid) { + socket.emit('error', createErrorResponse('Unauthorized. Please login first.', 'UNAUTHORIZED')); + + return; + } + + const chatId = typeof data?.chatId === 'string' ? data.chatId.trim() : ''; + + if (!chatId) { + socket.emit('error', createErrorResponse('chatId is required', 'INVALID_PAYLOAD')); + + return; + } + + if (!AgentsGateway.isUuidV4(chatId)) { + socket.emit('error', createErrorResponse('chatId must be a valid UUID', 'INVALID_CHAT_ID')); + + return; + } + + try { + await this.restoreChatHistory(agentUuid, socket, chatId); + socket.emit( + 'restoreChatSuccess', + createSuccessResponse({ + chatId, + message: 'Chat history restored', + }), + ); + } catch (error) { + socket.emit('error', createErrorResponse('Failed to restore chat history', 'RESTORE_CHAT_ERROR')); + const err = error as { message?: string; stack?: string }; + + this.logger.error(`Restore chat error for agent ${agentUuid}: ${err.message}`, err.stack); } } @@ -1131,12 +1268,28 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const correlationId = typeof data.correlationId === 'string' && data.correlationId.trim() ? data.correlationId.trim() : uuidv4(); - const ephemeral = data.ephemeral === true; const wantsStream = data.responseMode === 'stream'; const responseMode: AgentResponseMode = wantsStream ? 'stream' : data.responseMode === 'sync' ? 'sync' : 'single'; let sequence = 0; // Create timestamp immediately for consistent message ordering const chatTimestamp = new Date().toISOString(); + let chatContext: Awaited>; + + try { + chatContext = await this.resolveChatContext(agentUuid, data); + } catch (error) { + const err = error as { message?: string }; + const code = error instanceof BadRequestException ? 'INVALID_CHAT_ID' : 'CHAT_ERROR'; + + socket.emit('error', createErrorResponse(err.message || 'Invalid or unknown chat session', code)); + + return; + } + + // Reserved ACP suffixes must never persist/broadcast into user-visible chat history. + const ephemeral = data.ephemeral === true || chatContext.hidden; + const { chatSessionId, chatId } = chatContext; + const chatIdFields = chatId ? { chatId } : {}; // Apply incoming filters before processing (single hook point for incoming messages) const incomingFilterResult = await this.applyFilters(message, FilterDirection.INCOMING, { agentId: agentUuid, @@ -1151,6 +1304,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, createSuccessResponse({ direction: 'incoming', ...incomingFilterResult, + ...chatIdFields, }), ); @@ -1167,7 +1321,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, if (!ephemeral) { try { - await this.agentMessagesService.createUserMessage(agentUuid, fakeUserMessage, false); + await this.agentMessagesService.createUserMessage(agentUuid, fakeUserMessage, false, chatSessionId); } catch (persistError) { const err = persistError as { message?: string }; @@ -1184,14 +1338,22 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.USER, text: fakeUserMessage, timestamp: droppedResponseTimestamp, + ...chatIdFields, }), ); - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'userMessage', - payload: { text: fakeUserMessage }, - }); + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'userMessage', + payload: { text: fakeUserMessage }, + }, + chatSessionId, + chatId, + ); return; } @@ -1213,62 +1375,91 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.USER, text: filteredMessage, timestamp: chatTimestamp, + ...chatIdFields, }), ); - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'userMessage', - payload: { text: filteredMessage }, - }); + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'userMessage', + payload: { text: filteredMessage }, + }, + chatSessionId, + chatId, + ); if (contextInjection) { - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'toolCall', - payload: { - toolCallId: `enrichment-${correlationId}`, - name: 'enrichment', - args: { - includeWorkspace: contextInjection.includeWorkspace === true, - environmentIds: contextInjection.environmentIds ?? [], - workspaceContainerType: contextInjection.workspaceContainerType, - environmentContainerTypes: contextInjection.environmentContainerTypes ?? [], - ticketShas: contextInjection.ticketShas ?? [], - ticketContextCount: contextInjection.ticketContexts?.length ?? 0, - knowledgeShas: contextInjection.knowledgeShas ?? [], - knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'toolCall', + payload: { + toolCallId: `enrichment-${correlationId}`, + name: 'enrichment', + args: { + includeWorkspace: contextInjection.includeWorkspace === true, + environmentIds: contextInjection.environmentIds ?? [], + workspaceContainerType: contextInjection.workspaceContainerType, + environmentContainerTypes: contextInjection.environmentContainerTypes ?? [], + ticketShas: contextInjection.ticketShas ?? [], + ticketContextCount: contextInjection.ticketContexts?.length ?? 0, + knowledgeShas: contextInjection.knowledgeShas ?? [], + knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + }, + status: 'succeeded', }, - status: 'succeeded', }, - }); - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'toolResult', - payload: { - toolCallId: `enrichment-${correlationId}`, - name: 'enrichment', - result: { - applied: true, - includeWorkspace: contextInjection.includeWorkspace === true, - environmentIds: contextInjection.environmentIds ?? [], - workspaceContainerType: contextInjection.workspaceContainerType, - environmentContainerTypes: contextInjection.environmentContainerTypes ?? [], - ticketShas: contextInjection.ticketShas ?? [], - ticketContextCount: contextInjection.ticketContexts?.length ?? 0, - knowledgeShas: contextInjection.knowledgeShas ?? [], - knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + chatSessionId, + chatId, + ); + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'toolResult', + payload: { + toolCallId: `enrichment-${correlationId}`, + name: 'enrichment', + result: { + applied: true, + includeWorkspace: contextInjection.includeWorkspace === true, + environmentIds: contextInjection.environmentIds ?? [], + workspaceContainerType: contextInjection.workspaceContainerType, + environmentContainerTypes: contextInjection.environmentContainerTypes ?? [], + ticketShas: contextInjection.ticketShas ?? [], + ticketContextCount: contextInjection.ticketContexts?.length ?? 0, + knowledgeShas: contextInjection.knowledgeShas ?? [], + knowledgeContextCount: contextInjection.knowledgeContexts?.length ?? 0, + }, + isError: false, }, - isError: false, }, - }); + chatSessionId, + chatId, + ); } - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, { - ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), - kind: 'thinking', - payload: {}, - }); + this.emitOrPersistChatEvent( + agentUuid, + ephemeral, + socket, + { + ...toAgentEventEnvelopeBase(agentUuid, correlationId, sequence++), + kind: 'thinking', + payload: {}, + }, + chatSessionId, + chatId, + ); try { // Get agent details for display @@ -1320,6 +1511,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, agentUuid, filteredMessage, incomingFilterResult.status === 'filtered', + chatSessionId, ); } catch (persistError) { const err = persistError as { message?: string }; @@ -1372,7 +1564,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, } } - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev, chatSessionId, chatId); } if ( @@ -1387,7 +1579,13 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ); if (built) { - await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, built); + await this.persistFilteredAgentChatResponse( + agentUuid, + agentResponseTimestamp, + built, + chatSessionId, + chatId, + ); streamingTurnPersisted = true; } } @@ -1398,7 +1596,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, for await (const parsed of provider.streamChatEvents!(agent.id, containerId, messageToUse, { model: data.model, continue: data.continue, - resumeSessionSuffix: data.resumeSessionSuffix, + resumeSessionSuffix: chatContext.resumeSessionSuffix, })) { await consumeParsedResponse(parsed); } @@ -1427,7 +1625,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, for await (const chunk of provider.sendMessageStream(agent.id, containerId, messageToUse, { model: data.model, continue: data.continue, - resumeSessionSuffix: data.resumeSessionSuffix, + resumeSessionSuffix: chatContext.resumeSessionSuffix, })) { buffered += chunk; const parts = buffered.split('\n'); @@ -1452,7 +1650,13 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ); if (finalResponse) { - await this.persistFilteredAgentChatResponse(agentUuid, agentResponseTimestamp, finalResponse); + await this.persistFilteredAgentChatResponse( + agentUuid, + agentResponseTimestamp, + finalResponse, + chatSessionId, + chatId, + ); } else { const finalTextLen = aggregatedText.trim().length; @@ -1466,7 +1670,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, const agentResponse = await provider.sendMessage(agent.id, containerId, messageToUse, { model: data.model, continue: data.continue, - resumeSessionSuffix: data.resumeSessionSuffix, + resumeSessionSuffix: chatContext.resumeSessionSuffix, }); if (agentResponse && agentResponse.trim()) { @@ -1494,6 +1698,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, createSuccessResponse({ direction: 'outgoing', ...outgoingFilterResult, + ...chatIdFields, }), ); @@ -1511,7 +1716,12 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, if (!ephemeral) { try { - await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); + await this.agentMessagesService.createAgentMessage( + agentUuid, + fakeAgentResponse, + false, + chatSessionId, + ); } catch (persistError) { const err = persistError as { message?: string }; @@ -1528,6 +1738,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response: fakeAgentResponse, timestamp: agentResponseTimestamp, + ...chatIdFields, }), ); @@ -1539,7 +1750,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ); for (const ev of events) { - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev, chatSessionId, chatId); } return; @@ -1561,6 +1772,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, agentUuid, responseToUse, outgoingFilterResult.status === 'filtered', + chatSessionId, ); } catch (persistError) { const err = persistError as { message?: string }; @@ -1578,13 +1790,14 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response: responseToUse, timestamp: agentResponseTimestamp, + ...chatIdFields, }), ); const events = this.agentResponseToChatEvents(agentUuid, correlationId, sequence++, responseToUse); for (const ev of events) { - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev, chatSessionId, chatId); } } catch (parseError) { const parseErr = parseError as { message?: string }; @@ -1604,6 +1817,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, createSuccessResponse({ direction: 'outgoing', ...outgoingFilterResult, + ...chatIdFields, }), ); @@ -1617,7 +1831,12 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, if (!ephemeral) { try { - await this.agentMessagesService.createAgentMessage(agentUuid, fakeAgentResponse, false); + await this.agentMessagesService.createAgentMessage( + agentUuid, + fakeAgentResponse, + false, + chatSessionId, + ); } catch (persistError) { const err = persistError as { message?: string }; @@ -1634,6 +1853,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response: fakeAgentResponse, timestamp: agentResponseTimestamp, + ...chatIdFields, }), ); @@ -1645,7 +1865,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ); for (const ev of events) { - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev, chatSessionId, chatId); } return; @@ -1659,6 +1879,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, agentUuid, stringResponseToUse, outgoingFilterResult.status === 'filtered', + chatSessionId, ); } catch (persistError) { const err = persistError as { message?: string }; @@ -1676,6 +1897,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, from: ChatActor.AGENT, response: stringResponseToUse, timestamp: agentResponseTimestamp, + ...chatIdFields, }), ); @@ -1687,7 +1909,7 @@ export class AgentsGateway implements OnGatewayConnection, OnGatewayDisconnect, ); for (const ev of events) { - this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev); + this.emitOrPersistChatEvent(agentUuid, ephemeral, socket, ev, chatSessionId, chatId); } } } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts index 8cddf2316..4f83dd1a3 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.spec.ts @@ -6,6 +6,7 @@ import { AgentsDeploymentsController } from '../controllers/agents-deployments.c import { AgentsMessagesController } from '../controllers/agents-messages.controller'; import { AgentsController } from '../controllers/agents.controller'; import { InstanceStatusController } from '../controllers/instance-status.controller'; +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; import { AgentMessageEventEntity } from '../entities/agent-message-event.entity'; import { AgentMessageEntity } from '../entities/agent-message.entity'; @@ -33,6 +34,7 @@ import { AgentMessagesRepository } from '../repositories/agent-messages.reposito import { AgentsRepository } from '../repositories/agents.repository'; import { DeploymentConfigurationsRepository } from '../repositories/deployment-configurations.repository'; import { DeploymentRunsRepository } from '../repositories/deployment-runs.repository'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentEnvironmentVariablesService } from '../services/agent-environment-variables.service'; import { AgentMessagesService } from '../services/agent-messages.service'; import { AgentsService } from '../services/agents.service'; @@ -79,6 +81,8 @@ describe('AgentsModule', () => { .useValue(mockRepository) .overrideProvider(getRepositoryToken(AgentMessageEntity)) .useValue(mockRepository) + .overrideProvider(getRepositoryToken(AgentChatSessionEntity)) + .useValue(mockRepository) .overrideProvider(getRepositoryToken(AgentEnvironmentVariableEntity)) .useValue(mockRepository) .overrideProvider(getRepositoryToken(AgentMessageEventEntity)) @@ -343,6 +347,13 @@ describe('AgentsModule', () => { expect(service).toBeInstanceOf(AgentMessagesService); }); + it('should provide AgentChatSessionsService', () => { + const service = module.get(AgentChatSessionsService); + + expect(service).toBeDefined(); + expect(service).toBeInstanceOf(AgentChatSessionsService); + }); + it('should provide AgentMessagesRepository', () => { const repository = module.get(AgentMessagesRepository); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts index 2bb9dc734..680986533 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/modules/agents.module.ts @@ -6,6 +6,7 @@ import { PasswordService } from '@forepath/identity/backend'; import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +import { AgentsChatSessionsController } from '../controllers/agents-chat-sessions.controller'; import { AgentsDeploymentsController } from '../controllers/agents-deployments.controller'; import { InstanceStatusController } from '../controllers/instance-status.controller'; import { AgentsEnvironmentVariablesController } from '../controllers/agents-environment-variables.controller'; @@ -17,6 +18,7 @@ import { AgentsVerificationController } from '../controllers/agents-verification import { AgentsController } from '../controllers/agents.controller'; import { ConfigController } from '../controllers/config.controller'; import { WorkspaceConfigurationOverridesController } from '../controllers/workspace-configuration-overrides.controller'; +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; import { AgentEnvironmentVariableEntity } from '../entities/agent-environment-variable.entity'; import { AgentMessageEventEntity } from '../entities/agent-message-event.entity'; import { AgentMessageEntity } from '../entities/agent-message.entity'; @@ -48,6 +50,7 @@ import type { PipelineProvider } from '../providers/pipeline-provider.interface' import type { ChatFilter } from '../providers/chat-filter.interface'; import { GitHubProvider } from '../providers/pipelines/github.provider'; import { GitLabProvider } from '../providers/pipelines/gitlab.provider'; +import { AgentChatSessionsRepository } from '../repositories/agent-chat-sessions.repository'; import { AgentEnvironmentVariablesRepository } from '../repositories/agent-environment-variables.repository'; import { AgentMessageEventsRepository } from '../repositories/agent-message-events.repository'; import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; @@ -56,6 +59,7 @@ import { DeploymentConfigurationsRepository } from '../repositories/deployment-c import { DeploymentRunsRepository } from '../repositories/deployment-runs.repository'; import { RegexFilterRulesRepository } from '../repositories/regex-filter-rules.repository'; import { WorkspaceConfigurationOverridesRepository } from '../repositories/workspace-configuration-overrides.repository'; +import { AgentChatSessionsService } from '../services/agent-chat-sessions.service'; import { AgentEnvironmentVariablesService } from '../services/agent-environment-variables.service'; import { AgentFileSystemService } from '../services/agent-file-system.service'; import { AgentGitStateBroadcastService } from '../services/agent-git-state-broadcast.service'; @@ -85,6 +89,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co AgentEntity, AgentMessageEntity, AgentMessageEventEntity, + AgentChatSessionEntity, AgentEnvironmentVariableEntity, DeploymentConfigurationEntity, DeploymentRunEntity, @@ -100,6 +105,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co AgentsVerificationController, AgentsDeploymentsController, AgentsEnvironmentVariablesController, + AgentsChatSessionsController, AgentsFiltersController, ConfigController, InstanceStatusController, @@ -113,6 +119,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co PromptContextComposerService, AgentMessageEventsService, AgentSessionHydrationService, + AgentChatSessionsService, AgentEnvironmentVariablesService, AgentGitStateBroadcastService, AgentFileSystemService, @@ -125,6 +132,7 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co AgentsRepository, AgentMessagesRepository, AgentMessageEventsRepository, + AgentChatSessionsRepository, AgentEnvironmentVariablesRepository, DeploymentConfigurationsRepository, DeploymentRunsRepository, @@ -243,10 +251,12 @@ import { WorkspaceConfigurationOverridesService } from '../services/workspace-co ], exports: [ AgentsService, + AgentChatSessionsService, AgentEnvironmentVariablesService, AgentMessagesService, DeploymentsService, AgentsRepository, + AgentChatSessionsRepository, AgentEnvironmentVariablesRepository, AgentMessagesRepository, DeploymentConfigurationsRepository, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts index ad186318d..ceb4d4780 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/providers/agent-events.types.ts @@ -13,6 +13,8 @@ export type AgentEventKind = | 'error'; export interface AgentEventEnvelopeBase { + /** User-visible chat session id when the event belongs to a persisted chat. */ + chatId?: string; /** * Unique id for this event (uuid). */ diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts new file mode 100644 index 000000000..6a4db67eb --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-chat-sessions.repository.ts @@ -0,0 +1,103 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { In, Repository } from 'typeorm'; + +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; + +/** + * Repository for agent chat session database operations. + */ +@Injectable() +export class AgentChatSessionsRepository { + constructor( + @InjectRepository(AgentChatSessionEntity) + private readonly repository: Repository, + ) {} + + async findByIdOrThrow(id: string): Promise { + const session = await this.repository.findOne({ where: { id } }); + + if (!session) { + throw new NotFoundException(`Chat session with ID ${id} not found`); + } + + return session; + } + + async findById(id: string): Promise { + return await this.repository.findOne({ where: { id } }); + } + + async findByAgentIdAndId(agentId: string, id: string): Promise { + return await this.repository.findOne({ where: { agentId, id } }); + } + + async findByAgentIdAndIdOrThrow(agentId: string, id: string): Promise { + const session = await this.findByAgentIdAndId(agentId, id); + + if (!session) { + throw new NotFoundException(`Chat session with ID ${id} not found for agent ${agentId}`); + } + + return session; + } + + async findPrimaryByAgentId(agentId: string): Promise { + return await this.repository.findOne({ where: { agentId, kind: 'primary' } }); + } + + async findByAgentId(agentId: string, limit = 50, offset = 0): Promise { + return await this.repository.find({ + where: { agentId }, + take: limit, + skip: offset, + order: { lastMessageAt: 'DESC', createdAt: 'ASC' }, + }); + } + + async findAllByAgentId(agentId: string): Promise { + return await this.repository.find({ + where: { agentId }, + order: { lastMessageAt: 'DESC', createdAt: 'ASC' }, + }); + } + + async findAllByAgentIds(agentIds: string[]): Promise { + if (agentIds.length === 0) { + return []; + } + + return await this.repository.find({ + where: { agentId: In(agentIds) }, + order: { lastMessageAt: 'DESC', createdAt: 'ASC' }, + }); + } + + async countByAgentId(agentId: string): Promise { + return await this.repository.count({ where: { agentId } }); + } + + async create(dto: Partial): Promise { + const session = this.repository.create(dto); + + return await this.repository.save(session); + } + + async update(id: string, dto: Partial): Promise { + const session = await this.findByIdOrThrow(id); + + Object.assign(session, dto); + + return await this.repository.save(session); + } + + async delete(id: string): Promise { + const session = await this.findByIdOrThrow(id); + + await this.repository.remove(session); + } + + async touchLastMessageAt(id: string, at: Date = new Date()): Promise { + await this.repository.update({ id }, { lastMessageAt: at }); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts index eb762c4c1..1976210cd 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-message-events.repository.ts @@ -12,7 +12,7 @@ export class AgentMessageEventsRepository { ) {} async create( - entity: Omit, + entity: Omit, ): Promise { const created = this.repository.create(entity); @@ -22,10 +22,11 @@ export class AgentMessageEventsRepository { async listRecent( agentId: string, limit: number, - opts?: { kinds?: string[]; since?: Date }, + opts?: { kinds?: string[]; since?: Date; chatSessionId?: string }, ): Promise { const where: FindOptionsWhere = { agentId, + ...(opts?.chatSessionId ? { chatSessionId: opts.chatSessionId } : {}), ...(opts?.kinds?.length ? { kind: In(opts.kinds) } : {}), ...(opts?.since ? { eventTimestamp: MoreThanOrEqual(opts.since) } : {}), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts index d02ffecbc..73375e632 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.spec.ts @@ -25,6 +25,16 @@ describe('AgentMessagesRepository', () => { id: 'message-uuid-123', agentId: 'agent-uuid-123', agent: mockAgent, + chatSessionId: 'primary-chat-id', + chatSession: { + id: 'primary-chat-id', + agentId: 'agent-uuid-123', + title: 'Chat', + kind: 'primary', + resumeSessionSuffix: '', + createdAt: new Date(), + updatedAt: new Date(), + } as AgentMessageEntity['chatSession'], actor: 'user', message: 'Test message content', filtered: false, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts index 8bc54c360..8b2a65067 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/repositories/agent-messages.repository.ts @@ -57,6 +57,31 @@ export class AgentMessagesRepository { }); } + async findByAgentIdAndChatSessionId( + agentId: string, + chatSessionId: string, + limit = 50, + offset = 0, + ): Promise { + return await this.repository.find({ + where: { agentId, chatSessionId }, + take: limit, + skip: offset, + order: { createdAt: 'ASC' }, + relations: ['agent'], + }); + } + + async countByAgentIdAndChatSessionId(agentId: string, chatSessionId: string): Promise { + return await this.repository.count({ where: { agentId, chatSessionId } }); + } + + async deleteByChatSessionId(chatSessionId: string): Promise { + const result = await this.repository.delete({ chatSessionId }); + + return result.affected || 0; + } + /** * Find all messages with pagination. * @param limit - Maximum number of messages to return diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.spec.ts new file mode 100644 index 000000000..a1616d629 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.spec.ts @@ -0,0 +1,275 @@ +import { BadRequestException } from '@nestjs/common'; +import { Test, TestingModule } from '@nestjs/testing'; +import { QueryFailedError } from 'typeorm'; + +import { + PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + buildUserChatResumeSessionSuffix, +} from '../constants/chat-session.constants'; +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; +import { AgentChatSessionsRepository } from '../repositories/agent-chat-sessions.repository'; +import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; +import { AgentsRepository } from '../repositories/agents.repository'; + +import { AgentChatSessionsService } from './agent-chat-sessions.service'; + +describe('AgentChatSessionsService', () => { + let service: AgentChatSessionsService; + const agentId = 'agent-uuid-123'; + const mockPrimarySession: AgentChatSessionEntity = { + id: 'primary-chat-id', + agentId, + title: 'Chat', + kind: 'primary', + resumeSessionSuffix: PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + lastMessageAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + } as AgentChatSessionEntity; + const mockUserSession: AgentChatSessionEntity = { + id: 'user-chat-id', + agentId, + title: 'My Chat', + kind: 'user', + resumeSessionSuffix: buildUserChatResumeSessionSuffix('user-chat-id'), + lastMessageAt: null, + createdAt: new Date('2024-01-02'), + updatedAt: new Date('2024-01-02'), + } as AgentChatSessionEntity; + const mockChatSessionsRepository = { + findPrimaryByAgentId: jest.fn(), + create: jest.fn(), + findByAgentIdAndIdOrThrow: jest.fn(), + findByAgentId: jest.fn(), + countByAgentId: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findAllByAgentIds: jest.fn(), + touchLastMessageAt: jest.fn(), + }; + const mockAgentsRepository = { + findByIdOrThrow: jest.fn(), + clearAcpSession: jest.fn(), + }; + const mockAgentMessagesRepository = { + findByAgentIdAndChatSessionId: jest.fn(), + countByAgentIdAndChatSessionId: jest.fn(), + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + AgentChatSessionsService, + { + provide: AgentChatSessionsRepository, + useValue: mockChatSessionsRepository, + }, + { + provide: AgentsRepository, + useValue: mockAgentsRepository, + }, + { + provide: AgentMessagesRepository, + useValue: mockAgentMessagesRepository, + }, + ], + }).compile(); + + service = module.get(AgentChatSessionsService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('ensurePrimarySession', () => { + it('creates primary session when missing', async () => { + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(null); + mockAgentsRepository.findByIdOrThrow.mockResolvedValue({ id: agentId }); + mockChatSessionsRepository.create.mockResolvedValue(mockPrimarySession); + + const result = await service.ensurePrimarySession(agentId); + + expect(result).toEqual(mockPrimarySession); + expect(mockAgentsRepository.findByIdOrThrow).toHaveBeenCalledWith(agentId); + expect(mockChatSessionsRepository.create).toHaveBeenCalledWith({ + agentId, + title: 'Chat', + kind: 'primary', + resumeSessionSuffix: PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + }); + }); + + it('returns existing primary session without creating', async () => { + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(mockPrimarySession); + + const result = await service.ensurePrimarySession(agentId); + + expect(result).toEqual(mockPrimarySession); + expect(mockAgentsRepository.findByIdOrThrow).not.toHaveBeenCalled(); + expect(mockChatSessionsRepository.create).not.toHaveBeenCalled(); + }); + + it('recovers when concurrent create hits unique violation', async () => { + const uniqueError = new QueryFailedError('', [], Object.assign(new Error('duplicate'), { code: '23505' })); + + mockChatSessionsRepository.findPrimaryByAgentId + .mockResolvedValueOnce(null) + .mockResolvedValueOnce(mockPrimarySession); + mockAgentsRepository.findByIdOrThrow.mockResolvedValue({ id: agentId }); + mockChatSessionsRepository.create.mockRejectedValue(uniqueError); + + const result = await service.ensurePrimarySession(agentId); + + expect(result).toEqual(mockPrimarySession); + expect(mockChatSessionsRepository.create).toHaveBeenCalled(); + }); + }); + + describe('createUserSession', () => { + it('creates session with kind user and -chat-{id} suffix', async () => { + mockAgentsRepository.findByIdOrThrow.mockResolvedValue({ id: agentId }); + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(mockPrimarySession); + mockChatSessionsRepository.create.mockImplementation(async (dto: Partial) => ({ + ...mockUserSession, + id: dto.id as string, + title: dto.title, + kind: dto.kind, + resumeSessionSuffix: dto.resumeSessionSuffix, + agentId: dto.agentId as string, + })); + + const result = await service.createUserSession(agentId, ' My Chat '); + + expect(result.kind).toBe('user'); + expect(result.resumeSessionSuffix).toBe(buildUserChatResumeSessionSuffix(result.id)); + expect(mockChatSessionsRepository.create).toHaveBeenCalledWith( + expect.objectContaining({ + agentId, + title: 'My Chat', + kind: 'user', + id: expect.any(String), + resumeSessionSuffix: expect.stringMatching(/^-chat-/), + }), + ); + }); + }); + + describe('updateSessionTitle', () => { + it('updates trimmed title', async () => { + const updated = { ...mockUserSession, title: 'Renamed' }; + + mockChatSessionsRepository.findByAgentIdAndIdOrThrow.mockResolvedValue(mockUserSession); + mockChatSessionsRepository.update.mockResolvedValue(updated); + + const result = await service.updateSessionTitle(agentId, mockUserSession.id, ' Renamed '); + + expect(result).toEqual(updated); + expect(mockChatSessionsRepository.update).toHaveBeenCalledWith(mockUserSession.id, { title: 'Renamed' }); + }); + + it('rejects empty title', async () => { + mockChatSessionsRepository.findByAgentIdAndIdOrThrow.mockResolvedValue(mockUserSession); + + await expect(service.updateSessionTitle(agentId, mockUserSession.id, ' ')).rejects.toThrow(BadRequestException); + expect(mockChatSessionsRepository.update).not.toHaveBeenCalled(); + }); + }); + + describe('deleteSession', () => { + it('rejects primary session with BadRequestException', async () => { + mockChatSessionsRepository.findByAgentIdAndIdOrThrow.mockResolvedValue(mockPrimarySession); + + await expect(service.deleteSession(agentId, mockPrimarySession.id)).rejects.toThrow(BadRequestException); + expect(mockChatSessionsRepository.delete).not.toHaveBeenCalled(); + expect(mockAgentsRepository.clearAcpSession).not.toHaveBeenCalled(); + }); + + it('deletes user session and clears ACP session', async () => { + mockChatSessionsRepository.findByAgentIdAndIdOrThrow.mockResolvedValue(mockUserSession); + mockChatSessionsRepository.delete.mockResolvedValue(undefined); + mockAgentsRepository.clearAcpSession.mockResolvedValue(undefined); + + await service.deleteSession(agentId, mockUserSession.id); + + expect(mockChatSessionsRepository.delete).toHaveBeenCalledWith(mockUserSession.id); + expect(mockAgentsRepository.clearAcpSession).toHaveBeenCalledWith(agentId, mockUserSession.resumeSessionSuffix); + }); + }); + + describe('resolveSessionForChat', () => { + it('defaults to primary when chatId omitted', async () => { + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(mockPrimarySession); + + const result = await service.resolveSessionForChat(agentId); + + expect(result).toEqual(mockPrimarySession); + expect(mockChatSessionsRepository.findByAgentIdAndIdOrThrow).not.toHaveBeenCalled(); + }); + + it('resolves specific session when chatId provided', async () => { + mockChatSessionsRepository.findByAgentIdAndIdOrThrow.mockResolvedValue(mockUserSession); + + const result = await service.resolveSessionForChat(agentId, mockUserSession.id); + + expect(result).toEqual(mockUserSession); + expect(mockChatSessionsRepository.findByAgentIdAndIdOrThrow).toHaveBeenCalledWith(agentId, mockUserSession.id); + }); + }); + + describe('listSessions', () => { + it('ensures primary then lists sessions', async () => { + mockAgentsRepository.findByIdOrThrow.mockResolvedValue({ id: agentId }); + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(mockPrimarySession); + mockChatSessionsRepository.findByAgentId.mockResolvedValue([mockPrimarySession, mockUserSession]); + + const result = await service.listSessions(agentId, 25, 5); + + expect(result).toEqual([mockPrimarySession, mockUserSession]); + expect(mockChatSessionsRepository.findByAgentId).toHaveBeenCalledWith(agentId, 25, 5); + }); + }); + + describe('countSessions', () => { + it('ensures primary then counts sessions', async () => { + mockAgentsRepository.findByIdOrThrow.mockResolvedValue({ id: agentId }); + mockChatSessionsRepository.findPrimaryByAgentId.mockResolvedValue(mockPrimarySession); + mockChatSessionsRepository.countByAgentId.mockResolvedValue(2); + + const result = await service.countSessions(agentId); + + expect(result).toBe(2); + expect(mockChatSessionsRepository.countByAgentId).toHaveBeenCalledWith(agentId); + }); + }); + + describe('mapToResponseDto', () => { + it('maps entity to response DTO', () => { + const dto = service.mapToResponseDto(mockUserSession); + + expect(dto).toEqual({ + id: mockUserSession.id, + agentId: mockUserSession.agentId, + title: mockUserSession.title, + kind: mockUserSession.kind, + lastMessageAt: undefined, + createdAt: mockUserSession.createdAt, + updatedAt: mockUserSession.updatedAt, + }); + }); + }); + + describe('mapToSummaryDto', () => { + it('maps entity to summary DTO', () => { + const dto = service.mapToSummaryDto(mockPrimarySession); + + expect(dto).toEqual({ + id: mockPrimarySession.id, + title: mockPrimarySession.title, + kind: mockPrimarySession.kind, + lastMessageAt: undefined, + createdAt: mockPrimarySession.createdAt, + }); + }); + }); +}); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts new file mode 100644 index 000000000..df49b6ba6 --- /dev/null +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-chat-sessions.service.ts @@ -0,0 +1,207 @@ +import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common'; +import { randomUUID } from 'crypto'; +import { QueryFailedError } from 'typeorm'; + +import { + PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + buildUserChatResumeSessionSuffix, +} from '../constants/chat-session.constants'; +import { AgentChatSessionSummaryDto, ChatSessionResponseDto } from '../dto/chat-session-response.dto'; +import { AgentChatSessionEntity } from '../entities/agent-chat-session.entity'; +import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentChatSessionsRepository } from '../repositories/agent-chat-sessions.repository'; +import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; + +/** + * Service for user-visible chat sessions nested under an agent (environment). + * Hidden ACP suffixes are not managed here. + */ +@Injectable() +export class AgentChatSessionsService { + private readonly logger = new Logger(AgentChatSessionsService.name); + + constructor( + private readonly agentChatSessionsRepository: AgentChatSessionsRepository, + private readonly agentsRepository: AgentsRepository, + private readonly agentMessagesRepository: AgentMessagesRepository, + ) {} + + /** + * Ensure a primary chat session exists for the agent (idempotent). + */ + async ensurePrimarySession(agentId: string): Promise { + const existing = await this.agentChatSessionsRepository.findPrimaryByAgentId(agentId); + + if (existing) { + return existing; + } + + await this.agentsRepository.findByIdOrThrow(agentId); + + try { + const primary = await this.agentChatSessionsRepository.create({ + agentId, + title: 'Chat', + kind: 'primary', + resumeSessionSuffix: PRIMARY_CHAT_RESUME_SESSION_SUFFIX, + }); + + this.logger.log(`Created primary chat session ${primary.id} for agent ${agentId}`); + + return primary; + } catch (error) { + // Concurrent ensurePrimarySession callers can race on the unique primary index. + if (this.isUniqueViolation(error)) { + const raced = await this.agentChatSessionsRepository.findPrimaryByAgentId(agentId); + + if (raced) { + return raced; + } + } + + throw error; + } + } + + private isUniqueViolation(error: unknown): boolean { + if (!(error instanceof QueryFailedError)) { + return false; + } + + const driverError = error.driverError as { code?: string } | undefined; + + return driverError?.code === '23505'; + } + + async createUserSession(agentId: string, title?: string): Promise { + await this.agentsRepository.findByIdOrThrow(agentId); + await this.ensurePrimarySession(agentId); + + const chatId = randomUUID(); + const session = await this.agentChatSessionsRepository.create({ + id: chatId, + agentId, + title: title?.trim() || 'Chat', + kind: 'user', + resumeSessionSuffix: buildUserChatResumeSessionSuffix(chatId), + }); + + this.logger.log(`Created user chat session ${session.id} for agent ${agentId}`); + + return session; + } + + async getSession(agentId: string, chatId: string): Promise { + return await this.agentChatSessionsRepository.findByAgentIdAndIdOrThrow(agentId, chatId); + } + + async resolveSessionForChat(agentId: string, chatId?: string): Promise { + if (chatId) { + return await this.getSession(agentId, chatId); + } + + return await this.ensurePrimarySession(agentId); + } + + async listSessions(agentId: string, limit = 50, offset = 0): Promise { + await this.agentsRepository.findByIdOrThrow(agentId); + await this.ensurePrimarySession(agentId); + + return await this.agentChatSessionsRepository.findByAgentId(agentId, limit, offset); + } + + async countSessions(agentId: string): Promise { + await this.agentsRepository.findByIdOrThrow(agentId); + await this.ensurePrimarySession(agentId); + + return await this.agentChatSessionsRepository.countByAgentId(agentId); + } + + async updateSessionTitle(agentId: string, chatId: string, title: string): Promise { + const session = await this.getSession(agentId, chatId); + const trimmed = title.trim(); + + if (!trimmed) { + throw new BadRequestException('Title is required'); + } + + return await this.agentChatSessionsRepository.update(session.id, { title: trimmed }); + } + + async deleteSession(agentId: string, chatId: string): Promise { + const session = await this.getSession(agentId, chatId); + + if (session.kind === 'primary') { + throw new BadRequestException('The primary chat session cannot be deleted'); + } + + await this.agentChatSessionsRepository.delete(session.id); + await this.agentsRepository.clearAcpSession(agentId, session.resumeSessionSuffix); + + this.logger.log(`Deleted chat session ${chatId} for agent ${agentId}`); + } + + async touchLastMessageAt(chatSessionId: string, at: Date = new Date()): Promise { + await this.agentChatSessionsRepository.touchLastMessageAt(chatSessionId, at); + } + + async getSummariesByAgentIds(agentIds: string[]): Promise> { + const sessions = await this.agentChatSessionsRepository.findAllByAgentIds(agentIds); + const byAgent = new Map(); + + for (const agentId of agentIds) { + byAgent.set(agentId, []); + } + + for (const session of sessions) { + const list = byAgent.get(session.agentId); + + if (list) { + list.push(session); + } else { + byAgent.set(session.agentId, [session]); + } + } + + return byAgent; + } + + mapToResponseDto(entity: AgentChatSessionEntity): ChatSessionResponseDto { + return { + id: entity.id, + agentId: entity.agentId, + title: entity.title ?? undefined, + kind: entity.kind, + lastMessageAt: entity.lastMessageAt ?? undefined, + createdAt: entity.createdAt, + updatedAt: entity.updatedAt, + }; + } + + mapToSummaryDto(entity: AgentChatSessionEntity): AgentChatSessionSummaryDto { + return { + id: entity.id, + title: entity.title ?? undefined, + kind: entity.kind, + lastMessageAt: entity.lastMessageAt ?? undefined, + createdAt: entity.createdAt, + }; + } + + async getMessagesForSession( + agentId: string, + chatId: string, + limit = 50, + offset = 0, + ): Promise<{ id: string; actor: string; message: string; filtered: boolean; createdAt: Date; updatedAt: Date }[]> { + await this.getSession(agentId, chatId); + + return await this.agentMessagesRepository.findByAgentIdAndChatSessionId(agentId, chatId, limit, offset); + } + + async countMessagesForSession(agentId: string, chatId: string): Promise { + await this.getSession(agentId, chatId); + + return await this.agentMessagesRepository.countByAgentIdAndChatSessionId(agentId, chatId); + } +} diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts index b21bb5859..f1fb26f94 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-file-system.service.spec.ts @@ -28,6 +28,15 @@ describe('AgentFileSystemService', () => { description: 'Test Description', agentType: 'cursor', containerType: ContainerType.GENERIC, + chats: [ + { + id: 'primary-chat-id', + title: 'Chat', + kind: 'primary', + createdAt: new Date('2024-01-01'), + }, + ], + primaryChatId: 'primary-chat-id', createdAt: new Date('2024-01-01'), updatedAt: new Date('2024-01-01'), }; diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts index 7094d16db..03b499b93 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-message-events.service.ts @@ -2,22 +2,29 @@ import { Injectable, Logger } from '@nestjs/common'; import { AgentEventEnvelope } from '../providers/agent-events.types'; import { AgentMessageEventsRepository } from '../repositories/agent-message-events.repository'; +import { AgentChatSessionsService } from './agent-chat-sessions.service'; @Injectable() export class AgentMessageEventsService { private readonly logger = new Logger(AgentMessageEventsService.name); - constructor(private readonly repository: AgentMessageEventsRepository) {} + constructor( + private readonly repository: AgentMessageEventsRepository, + private readonly agentChatSessionsService: AgentChatSessionsService, + ) {} - async persistEvent(agentId: string, event: AgentEventEnvelope): Promise { + async persistEvent(agentId: string, event: AgentEventEnvelope, chatSessionId?: string): Promise { // Avoid storing high-volume deltas by default; transcript + key events remain. if (event.kind === 'assistantDelta') { return; } try { + const session = await this.agentChatSessionsService.resolveSessionForChat(agentId, chatSessionId); + await this.repository.create({ agentId, + chatSessionId: session.id, correlationId: event.correlationId, sequence: event.sequence, kind: event.kind, @@ -35,9 +42,11 @@ export class AgentMessageEventsService { async listRecentEvents( agentId: string, limit = 200, - opts?: { kinds?: string[]; since?: Date }, + opts?: { kinds?: string[]; since?: Date; chatSessionId?: string }, ): Promise { - const rows = await this.repository.listRecent(agentId, limit, opts); + const chatSessionId = + opts?.chatSessionId ?? (await this.agentChatSessionsService.resolveSessionForChat(agentId)).id; + const rows = await this.repository.listRecent(agentId, limit, { ...opts, chatSessionId }); return rows.map( (row) => @@ -49,6 +58,7 @@ export class AgentMessageEventsService { sequence: row.sequence, timestamp: row.eventTimestamp.toISOString(), payload: row.payload as AgentEventEnvelope['payload'], + chatId: row.chatSessionId, }) as AgentEventEnvelope, ); } diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts index 70a9396ef..426634539 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.spec.ts @@ -3,10 +3,22 @@ import { Test, TestingModule } from '@nestjs/testing'; import { AgentMessageEntity } from '../entities/agent-message.entity'; import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; +import { AgentChatSessionsService } from './agent-chat-sessions.service'; import { AgentMessagesService } from './agent-messages.service'; describe('AgentMessagesService', () => { let service: AgentMessagesService; + const primaryChatSessionId = 'primary-chat-id'; + const mockPrimarySession = { + id: primaryChatSessionId, + agentId: 'agent-uuid-123', + title: 'Chat', + kind: 'primary' as const, + resumeSessionSuffix: '', + lastMessageAt: null, + createdAt: new Date(), + updatedAt: new Date(), + }; const mockAgent = { id: 'agent-uuid-123', name: 'Test Agent', @@ -20,6 +32,8 @@ describe('AgentMessagesService', () => { const mockMessage: AgentMessageEntity = { id: 'message-uuid-123', agentId: 'agent-uuid-123', + chatSessionId: primaryChatSessionId, + chatSession: mockPrimarySession as any, agent: mockAgent as any, actor: 'user', message: 'Test message content', @@ -30,12 +44,21 @@ describe('AgentMessagesService', () => { const mockRepository = { create: jest.fn(), findByAgentId: jest.fn(), + findByAgentIdAndChatSessionId: jest.fn(), findLatestAgentMessage: jest.fn(), countByAgentId: jest.fn(), + countByAgentIdAndChatSessionId: jest.fn(), deleteByAgentId: jest.fn(), }; + const mockAgentChatSessionsService = { + resolveSessionForChat: jest.fn(), + touchLastMessageAt: jest.fn(), + }; beforeEach(async () => { + mockAgentChatSessionsService.resolveSessionForChat.mockResolvedValue(mockPrimarySession); + mockAgentChatSessionsService.touchLastMessageAt.mockResolvedValue(undefined); + const module: TestingModule = await Test.createTestingModule({ providers: [ AgentMessagesService, @@ -43,6 +66,10 @@ describe('AgentMessagesService', () => { provide: AgentMessagesRepository, useValue: mockRepository, }, + { + provide: AgentChatSessionsService, + useValue: mockAgentChatSessionsService, + }, ], }).compile(); @@ -69,12 +96,18 @@ describe('AgentMessagesService', () => { const result = await service.createUserMessage(agentId, messageText); expect(result).toEqual(expectedMessage); + expect(mockAgentChatSessionsService.resolveSessionForChat).toHaveBeenCalledWith(agentId, undefined); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'user', message: messageText, filtered: false, }); + expect(mockAgentChatSessionsService.touchLastMessageAt).toHaveBeenCalledWith( + primaryChatSessionId, + expectedMessage.createdAt, + ); }); it('should trim the message content', async () => { @@ -93,6 +126,7 @@ describe('AgentMessagesService', () => { expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'user', message: 'Hello, agent!', filtered: false, @@ -118,10 +152,15 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: response, filtered: false, }); + expect(mockAgentChatSessionsService.touchLastMessageAt).toHaveBeenCalledWith( + primaryChatSessionId, + expectedMessage.createdAt, + ); }); it('should create and persist a filtered agent message', async () => { @@ -142,6 +181,7 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: response, filtered: true, @@ -169,6 +209,7 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: JSON.stringify(response), filtered: false, @@ -192,6 +233,7 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: 'null', filtered: false, @@ -215,6 +257,7 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: '42', filtered: false, @@ -248,6 +291,7 @@ describe('AgentMessagesService', () => { expect(result).toEqual(expectedMessage); expect(mockRepository.create).toHaveBeenCalledWith({ agentId, + chatSessionId: primaryChatSessionId, actor: 'agent', message: expect.any(String), filtered: false, @@ -259,40 +303,42 @@ describe('AgentMessagesService', () => { }); describe('getChatHistory', () => { - it('should return chat history for an agent', async () => { + it('should return chat history for an agent primary session', async () => { const agentId = 'agent-uuid-123'; const messages = [mockMessage]; - mockRepository.findByAgentId.mockResolvedValue(messages); + mockRepository.findByAgentIdAndChatSessionId.mockResolvedValue(messages); const result = await service.getChatHistory(agentId); expect(result).toEqual(messages); - expect(mockRepository.findByAgentId).toHaveBeenCalledWith(agentId, 50, 0); + expect(mockAgentChatSessionsService.resolveSessionForChat).toHaveBeenCalledWith(agentId); + expect(mockRepository.findByAgentIdAndChatSessionId).toHaveBeenCalledWith(agentId, primaryChatSessionId, 50, 0); }); it('should use custom pagination parameters', async () => { const agentId = 'agent-uuid-123'; const messages = [mockMessage]; - mockRepository.findByAgentId.mockResolvedValue(messages); + mockRepository.findByAgentIdAndChatSessionId.mockResolvedValue(messages); await service.getChatHistory(agentId, 100, 10); - expect(mockRepository.findByAgentId).toHaveBeenCalledWith(agentId, 100, 10); + expect(mockRepository.findByAgentIdAndChatSessionId).toHaveBeenCalledWith(agentId, primaryChatSessionId, 100, 10); }); }); describe('countMessages', () => { - it('should return count of messages for an agent', async () => { + it('should return count of messages for an agent primary session', async () => { const agentId = 'agent-uuid-123'; - mockRepository.countByAgentId.mockResolvedValue(5); + mockRepository.countByAgentIdAndChatSessionId.mockResolvedValue(5); const result = await service.countMessages(agentId); expect(result).toBe(5); - expect(mockRepository.countByAgentId).toHaveBeenCalledWith(agentId); + expect(mockAgentChatSessionsService.resolveSessionForChat).toHaveBeenCalledWith(agentId); + expect(mockRepository.countByAgentIdAndChatSessionId).toHaveBeenCalledWith(agentId, primaryChatSessionId); }); }); @@ -304,6 +350,7 @@ describe('AgentMessagesService', () => { id: 'msg-1', createdAt, agentId: 'agent-uuid-123', + chatSessionId: primaryChatSessionId, actor: 'agent', message: 'hi', filtered: false, diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts index 72112e633..64a6826c6 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agent-messages.service.ts @@ -2,6 +2,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { AgentMessageEntity } from '../entities/agent-message.entity'; import { AgentMessagesRepository } from '../repositories/agent-messages.repository'; +import { AgentChatSessionsService } from './agent-chat-sessions.service'; /** * Service for agent message business logic operations. @@ -11,24 +12,36 @@ import { AgentMessagesRepository } from '../repositories/agent-messages.reposito export class AgentMessagesService { private readonly logger = new Logger(AgentMessagesService.name); - constructor(private readonly agentMessagesRepository: AgentMessagesRepository) {} + constructor( + private readonly agentMessagesRepository: AgentMessagesRepository, + private readonly agentChatSessionsService: AgentChatSessionsService, + ) {} /** * Persist a user message. * @param agentId - The UUID of the agent * @param message - The message text from the user * @param filtered - Whether the message was filtered (default: false) + * @param chatSessionId - Optional chat session; defaults to primary * @returns The created message entity */ - async createUserMessage(agentId: string, message: string, filtered = false): Promise { + async createUserMessage( + agentId: string, + message: string, + filtered = false, + chatSessionId?: string, + ): Promise { + const session = await this.agentChatSessionsService.resolveSessionForChat(agentId, chatSessionId); const messageEntity = await this.agentMessagesRepository.create({ agentId, + chatSessionId: session.id, actor: 'user', message: message.trim(), filtered, }); - this.logger.debug(`Persisted user message for agent ${agentId}${filtered ? ' (filtered)' : ''}`); + await this.agentChatSessionsService.touchLastMessageAt(session.id, messageEntity.createdAt); + this.logger.debug(`Persisted user message for agent ${agentId} chat ${session.id}${filtered ? ' (filtered)' : ''}`); return messageEntity; } @@ -38,9 +51,15 @@ export class AgentMessagesService { * @param agentId - The UUID of the agent * @param response - The agent response (can be JSON object or string) * @param filtered - Whether the message was filtered (default: false) + * @param chatSessionId - Optional chat session; defaults to primary * @returns The created message entity */ - async createAgentMessage(agentId: string, response: unknown, filtered = false): Promise { + async createAgentMessage( + agentId: string, + response: unknown, + filtered = false, + chatSessionId?: string, + ): Promise { // Convert response to string representation let messageContent: string; @@ -59,36 +78,47 @@ export class AgentMessagesService { messageContent = String(response); } + const session = await this.agentChatSessionsService.resolveSessionForChat(agentId, chatSessionId); const messageEntity = await this.agentMessagesRepository.create({ agentId, + chatSessionId: session.id, actor: 'agent', message: messageContent, filtered, }); - this.logger.debug(`Persisted agent message for agent ${agentId}${filtered ? ' (filtered)' : ''}`); + await this.agentChatSessionsService.touchLastMessageAt(session.id, messageEntity.createdAt); + this.logger.debug( + `Persisted agent message for agent ${agentId} chat ${session.id}${filtered ? ' (filtered)' : ''}`, + ); return messageEntity; } /** - * Get chat history for a specific agent. - * @param agentId - The UUID of the agent - * @param limit - Maximum number of messages to return - * @param offset - Number of messages to skip - * @returns Array of message entities ordered chronologically + * Get chat history for a specific agent (optionally scoped to a chat session). */ - async getChatHistory(agentId: string, limit = 50, offset = 0): Promise { - return await this.agentMessagesRepository.findByAgentId(agentId, limit, offset); + async getChatHistory(agentId: string, limit = 50, offset = 0, chatSessionId?: string): Promise { + if (chatSessionId) { + return await this.agentMessagesRepository.findByAgentIdAndChatSessionId(agentId, chatSessionId, limit, offset); + } + + const primary = await this.agentChatSessionsService.resolveSessionForChat(agentId); + + return await this.agentMessagesRepository.findByAgentIdAndChatSessionId(agentId, primary.id, limit, offset); } /** - * Count messages for a specific agent. - * @param agentId - The UUID of the agent - * @returns Total count of messages for the agent + * Count messages for a specific agent (optionally scoped to a chat session). */ - async countMessages(agentId: string): Promise { - return await this.agentMessagesRepository.countByAgentId(agentId); + async countMessages(agentId: string, chatSessionId?: string): Promise { + if (chatSessionId) { + return await this.agentMessagesRepository.countByAgentIdAndChatSessionId(agentId, chatSessionId); + } + + const primary = await this.agentChatSessionsService.resolveSessionForChat(agentId); + + return await this.agentMessagesRepository.countByAgentIdAndChatSessionId(agentId, primary.id); } /** diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts index ccbe5b1f4..09ad04dc1 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.spec.ts @@ -11,6 +11,7 @@ import { AgentProviderFactory } from '../providers/agent-provider.factory'; import { AgentProvider, AgentProviderModels } from '../providers/agent-provider.interface'; import { AgentsRepository } from '../repositories/agents.repository'; +import { AgentChatSessionsService } from './agent-chat-sessions.service'; import { AgentsService } from './agents.service'; import { DeploymentsService } from './deployments.service'; import { DockerService } from './docker.service'; @@ -22,6 +23,23 @@ describe('AgentsService', () => { let dockerService: jest.Mocked; let agentProviderFactory: jest.Mocked; let deploymentsService: jest.Mocked; + const mockPrimaryChatSession = { + id: 'primary-chat-id', + agentId: 'test-uuid', + title: 'Chat', + kind: 'primary' as const, + resumeSessionSuffix: '', + lastMessageAt: null, + createdAt: new Date('2024-01-01'), + updatedAt: new Date('2024-01-01'), + }; + const mockPrimaryChatSummary = { + id: 'primary-chat-id', + title: 'Chat', + kind: 'primary' as const, + lastMessageAt: undefined, + createdAt: new Date('2024-01-01'), + }; const mockAgent: AgentEntity = { id: 'test-uuid', name: 'Test Agent', @@ -92,8 +110,31 @@ describe('AgentsService', () => { deleteConfiguration: jest.fn(), getConfiguration: jest.fn(), }; + const mockAgentChatSessionsService = { + ensurePrimarySession: jest.fn(), + getSummariesByAgentIds: jest.fn(), + mapToSummaryDto: jest.fn(), + }; beforeEach(async () => { + mockAgentChatSessionsService.ensurePrimarySession.mockResolvedValue(mockPrimaryChatSession); + mockAgentChatSessionsService.getSummariesByAgentIds.mockImplementation(async (agentIds: string[]) => { + const byAgent = new Map(); + + for (const agentId of agentIds) { + byAgent.set(agentId, [{ ...mockPrimaryChatSession, agentId }]); + } + + return byAgent; + }); + mockAgentChatSessionsService.mapToSummaryDto.mockImplementation((entity: typeof mockPrimaryChatSession) => ({ + id: entity.id, + title: entity.title ?? undefined, + kind: entity.kind, + lastMessageAt: entity.lastMessageAt ?? undefined, + createdAt: entity.createdAt, + })); + const module: TestingModule = await Test.createTestingModule({ providers: [ AgentsService, @@ -113,6 +154,10 @@ describe('AgentsService', () => { provide: AgentProviderFactory, useValue: mockAgentProviderFactory, }, + { + provide: AgentChatSessionsService, + useValue: mockAgentChatSessionsService, + }, { provide: DeploymentsService, useValue: mockDeploymentsService, @@ -191,6 +236,8 @@ describe('AgentsService', () => { expect(result.password).toBeDefined(); expect(result.password.length).toBeGreaterThan(0); expect(typeof result.password).toBe('string'); + expect(result.chats).toEqual([mockPrimaryChatSummary]); + expect(result.primaryChatId).toBe('primary-chat-id'); expect(repository.findByName).toHaveBeenCalledWith(createDto.name); expect(passwordService.hashPassword).toHaveBeenCalled(); expect(agentProviderFactory.getProvider).toHaveBeenCalledWith('cursor'); @@ -1675,6 +1722,10 @@ describe('AgentsService', () => { provide: AgentProviderFactory, useValue: mockAgentProviderFactory, }, + { + provide: AgentChatSessionsService, + useValue: mockAgentChatSessionsService, + }, { provide: DeploymentsService, useValue: undefined, @@ -1801,8 +1852,11 @@ describe('AgentsService', () => { expect(result).toHaveLength(1); expect(result[0].id).toBe(mockAgent.id); + expect(result[0].chats).toEqual([mockPrimaryChatSummary]); + expect(result[0].primaryChatId).toBe('primary-chat-id'); expect(result[0]).not.toHaveProperty('hashedPassword'); expect(repository.findAll).toHaveBeenCalledWith(10, 0); + expect(mockAgentChatSessionsService.getSummariesByAgentIds).toHaveBeenCalledWith([mockAgent.id]); }); it('should use default pagination values', async () => { @@ -1823,8 +1877,11 @@ describe('AgentsService', () => { const result = await service.findOne('test-uuid'); expect(result.id).toBe(mockAgent.id); + expect(result.chats).toEqual([mockPrimaryChatSummary]); + expect(result.primaryChatId).toBe('primary-chat-id'); expect(result).not.toHaveProperty('hashedPassword'); expect(repository.findByIdOrThrow).toHaveBeenCalledWith('test-uuid'); + expect(mockAgentChatSessionsService.ensurePrimarySession).toHaveBeenCalledWith('test-uuid'); }); }); diff --git a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts index be6eba5d2..075346c72 100644 --- a/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts +++ b/libs/domains/agenstra/backend/feature-agent-manager/src/lib/services/agents.service.ts @@ -17,6 +17,7 @@ import { AgentProviderModels } from '../providers/agent-provider.interface'; import { AgentsRepository } from '../repositories/agents.repository'; import { expandProviderPathTildeInContainer } from '../utils/provider-container-path.utils'; +import { AgentChatSessionsService } from './agent-chat-sessions.service'; import { DeploymentsService } from './deployments.service'; import { DockerService } from './docker.service'; @@ -38,6 +39,7 @@ export class AgentsService implements OnApplicationBootstrap { private readonly dockerService: DockerService, private readonly passwordService: PasswordService, private readonly agentProviderFactory: AgentProviderFactory, + private readonly agentChatSessionsService: AgentChatSessionsService, @Inject(forwardRef(() => DeploymentsService)) private readonly deploymentsService?: DeploymentsService, ) {} @@ -580,6 +582,8 @@ export class AgentsService implements OnApplicationBootstrap { : createAgentDto.gitRepositorySetupMode, }); + await this.agentChatSessionsService.ensurePrimarySession(agent.id); + // Create deployment configuration if provided if (createAgentDto.deploymentConfiguration && this.deploymentsService) { try { @@ -600,7 +604,7 @@ export class AgentsService implements OnApplicationBootstrap { } return { - ...this.mapToResponseDto(agent), + ...(await this.mapToResponseDto(agent)), password: generatedPassword, }; } catch (error) { @@ -655,8 +659,9 @@ export class AgentsService implements OnApplicationBootstrap { */ async findAll(limit = 10, offset = 0): Promise { const agents = await this.agentsRepository.findAll(limit, offset); + const chatsByAgent = await this.agentChatSessionsService.getSummariesByAgentIds(agents.map((agent) => agent.id)); - return agents.map((agent) => this.mapToResponseDto(agent)); + return Promise.all(agents.map((agent) => this.mapToResponseDto(agent, chatsByAgent.get(agent.id)))); } /** @@ -668,7 +673,7 @@ export class AgentsService implements OnApplicationBootstrap { async findOne(id: string): Promise { const agent = await this.agentsRepository.findByIdOrThrow(id); - return this.mapToResponseDto(agent); + return await this.mapToResponseDto(agent); } /** @@ -722,7 +727,7 @@ export class AgentsService implements OnApplicationBootstrap { } } - return this.mapToResponseDto(agent); + return await this.mapToResponseDto(agent); } /** @@ -815,7 +820,7 @@ export class AgentsService implements OnApplicationBootstrap { } } - return this.mapToResponseDto(agent); + return await this.mapToResponseDto(agent); } /** @@ -865,7 +870,7 @@ export class AgentsService implements OnApplicationBootstrap { } } - return this.mapToResponseDto(agent); + return await this.mapToResponseDto(agent); } /** @@ -915,7 +920,7 @@ export class AgentsService implements OnApplicationBootstrap { } } - return this.mapToResponseDto(agent); + return await this.mapToResponseDto(agent); } /** @@ -958,7 +963,10 @@ export class AgentsService implements OnApplicationBootstrap { * @param agent - The agent entity to map * @returns The agent response DTO */ - private mapToResponseDto(agent: AgentEntity): AgentResponseDto { + private async mapToResponseDto( + agent: AgentEntity, + preloadedSessions?: import('../entities/agent-chat-session.entity').AgentChatSessionEntity[], + ): Promise { let capabilities: AgentResponseDto['capabilities']; try { @@ -976,6 +984,22 @@ export class AgentsService implements OnApplicationBootstrap { capabilities = undefined; } + let sessions = preloadedSessions; + + if (!sessions?.some((session) => session.kind === 'primary')) { + await this.agentChatSessionsService.ensurePrimarySession(agent.id); + const byAgent = await this.agentChatSessionsService.getSummariesByAgentIds([agent.id]); + + sessions = byAgent.get(agent.id) ?? []; + } + + const primary = sessions.find((session) => session.kind === 'primary') ?? sessions[0]; + const chats = sessions.map((session) => this.agentChatSessionsService.mapToSummaryDto(session)); + + if (!primary?.id) { + throw new Error(`Primary chat session missing for agent ${agent.id}`); + } + return { id: agent.id, name: agent.name, @@ -996,6 +1020,8 @@ export class AgentsService implements OnApplicationBootstrap { } : undefined, git: this.mapAgentGit(agent), + chats, + primaryChatId: primary.id, createdAt: agent.createdAt, updatedAt: agent.updatedAt, }; 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 07f381ffd..bc8a11645 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 @@ -108,6 +108,7 @@ export * from './lib/services/agents.service'; export * from './lib/services/clients.service'; export * from './lib/services/deployments.service'; export * from './lib/services/env.service'; +export * from './lib/services/chat-sessions.service'; export * from './lib/services/files.service'; export * from './lib/services/statistics.service'; export * from './lib/services/tickets.service'; @@ -119,6 +120,12 @@ export * from './lib/state/agents/agents.facade'; export * from './lib/state/agents/agents.reducer'; export * from './lib/state/agents/agents.selectors'; export * from './lib/state/agents/agents.types'; +export * from './lib/state/chat-sessions/chat-sessions.actions'; +export * from './lib/state/chat-sessions/chat-sessions.effects'; +export * from './lib/state/chat-sessions/chat-sessions.facade'; +export * from './lib/state/chat-sessions/chat-sessions.reducer'; +export * from './lib/state/chat-sessions/chat-sessions.selectors'; +export * from './lib/state/chat-sessions/chat-sessions.types'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.actions'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.effects'; export * from './lib/state/client-agent-autonomy/client-agent-autonomy.facade'; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.spec.ts index ffc7f4f14..e7b22c568 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/agents.service.spec.ts @@ -23,6 +23,8 @@ describe('AgentsService', () => { description: 'Test Description', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.spec.ts new file mode 100644 index 000000000..ed58767b4 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.spec.ts @@ -0,0 +1,188 @@ +import { HttpClientTestingModule, HttpTestingController } from '@angular/common/http/testing'; +import { TestBed } from '@angular/core/testing'; +import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; + +import type { + ChatSessionResponseDto, + CreateChatSessionDto, + UpdateChatSessionDto, +} from '../state/chat-sessions/chat-sessions.types'; + +import { ChatSessionsService } from './chat-sessions.service'; + +describe('ChatSessionsService', () => { + let service: ChatSessionsService; + let httpMock: HttpTestingController; + const apiUrl = 'http://localhost:3100/api'; + const clientId = 'client-1'; + const agentId = 'agent-1'; + const chatId = 'chat-1'; + const mockSession: ChatSessionResponseDto = { + id: chatId, + agentId, + title: 'Primary', + kind: 'primary', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + TestBed.configureTestingModule({ + imports: [HttpClientTestingModule], + providers: [ + { + provide: ENVIRONMENT, + useValue: { + controller: { + restApiUrl: apiUrl, + }, + }, + }, + ], + }); + + service = TestBed.inject(ChatSessionsService); + httpMock = TestBed.inject(HttpTestingController); + }); + + afterEach(() => { + httpMock.verify(); + }); + + it('should be created', () => { + expect(service).toBeTruthy(); + }); + + describe('listChatSessions', () => { + it('should return chat sessions list', (done) => { + service.listChatSessions(clientId, agentId).subscribe((sessions) => { + expect(sessions).toEqual([mockSession]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats`); + + expect(req.request.method).toBe('GET'); + req.flush([mockSession]); + }); + + it('should include pagination parameters when provided', (done) => { + service.listChatSessions(clientId, agentId, { limit: 10, offset: 20 }).subscribe((sessions) => { + expect(sessions).toEqual([mockSession]); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats?limit=10&offset=20`); + + expect(req.request.method).toBe('GET'); + expect(req.request.params.get('limit')).toBe('10'); + expect(req.request.params.get('offset')).toBe('20'); + req.flush([mockSession]); + }); + }); + + describe('countChatSessions', () => { + it('should return count of chat sessions', (done) => { + const mockCount = { count: 3 }; + + service.countChatSessions(clientId, agentId).subscribe((count) => { + expect(count).toEqual(mockCount); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats/count`); + + expect(req.request.method).toBe('GET'); + req.flush(mockCount); + }); + }); + + describe('createChatSession', () => { + it('should create a chat session', (done) => { + const createDto: CreateChatSessionDto = { title: 'New chat' }; + const created: ChatSessionResponseDto = { ...mockSession, id: 'chat-2', kind: 'user', title: 'New chat' }; + + service.createChatSession(clientId, agentId, createDto).subscribe((session) => { + expect(session).toEqual(created); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats`); + + expect(req.request.method).toBe('POST'); + expect(req.request.body).toEqual(createDto); + req.flush(created); + }); + }); + + describe('getChatSession', () => { + it('should return a chat session', (done) => { + service.getChatSession(clientId, agentId, chatId).subscribe((session) => { + expect(session).toEqual(mockSession); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`); + + expect(req.request.method).toBe('GET'); + req.flush(mockSession); + }); + }); + + describe('updateChatSession', () => { + it('should update a chat session title', (done) => { + const updateDto: UpdateChatSessionDto = { title: 'Renamed' }; + const updated = { ...mockSession, title: 'Renamed' }; + + service.updateChatSession(clientId, agentId, chatId, updateDto).subscribe((session) => { + expect(session).toEqual(updated); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`); + + expect(req.request.method).toBe('PUT'); + expect(req.request.body).toEqual(updateDto); + req.flush(updated); + }); + }); + + describe('deleteChatSession', () => { + it('should delete a chat session', (done) => { + service.deleteChatSession(clientId, agentId, chatId).subscribe((result) => { + expect(result).toBeNull(); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`); + + expect(req.request.method).toBe('DELETE'); + req.flush(null); + }); + }); + + describe('listChatSessionMessages', () => { + it('should return messages for a chat session', (done) => { + const messages = [ + { + id: 'msg-1', + actor: 'user', + message: 'hello', + filtered: false, + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }, + ]; + + service.listChatSessionMessages(clientId, agentId, chatId).subscribe((result) => { + expect(result).toEqual(messages); + done(); + }); + + const req = httpMock.expectOne(`${apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}/messages`); + + expect(req.request.method).toBe('GET'); + req.flush(messages); + }); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts new file mode 100644 index 000000000..114357723 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/services/chat-sessions.service.ts @@ -0,0 +1,104 @@ +import { HttpClient, HttpParams } from '@angular/common/http'; +import { Injectable, inject } from '@angular/core'; +import type { Environment } from '@forepath/shared/frontend/util-configuration'; +import { ENVIRONMENT } from '@forepath/shared/frontend/util-configuration'; +import { Observable } from 'rxjs'; + +import type { + ChatSessionMessageResponseDto, + ChatSessionResponseDto, + CreateChatSessionDto, + ListChatSessionsParams, + UpdateChatSessionDto, +} from '../state/chat-sessions/chat-sessions.types'; + +@Injectable({ + providedIn: 'root', +}) +export class ChatSessionsService { + private readonly http = inject(HttpClient); + private readonly environment = inject(ENVIRONMENT); + + private get apiUrl(): string { + return this.environment.controller.restApiUrl; + } + + listChatSessions( + clientId: string, + agentId: string, + params?: ListChatSessionsParams, + ): Observable { + let httpParams = new HttpParams(); + + if (params?.limit !== undefined) { + httpParams = httpParams.set('limit', params.limit.toString()); + } + + if (params?.offset !== undefined) { + httpParams = httpParams.set('offset', params.offset.toString()); + } + + return this.http.get(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats`, { + params: httpParams, + }); + } + + countChatSessions(clientId: string, agentId: string): Observable<{ count: number }> { + return this.http.get<{ count: number }>(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats/count`); + } + + createChatSession( + clientId: string, + agentId: string, + createDto: CreateChatSessionDto = {}, + ): Observable { + return this.http.post( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats`, + createDto, + ); + } + + getChatSession(clientId: string, agentId: string, chatId: string): Observable { + return this.http.get( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`, + ); + } + + updateChatSession( + clientId: string, + agentId: string, + chatId: string, + updateDto: UpdateChatSessionDto, + ): Observable { + return this.http.put( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`, + updateDto, + ); + } + + deleteChatSession(clientId: string, agentId: string, chatId: string): Observable { + return this.http.delete(`${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}`); + } + + listChatSessionMessages( + clientId: string, + agentId: string, + chatId: string, + params?: ListChatSessionsParams, + ): Observable { + let httpParams = new HttpParams(); + + if (params?.limit !== undefined) { + httpParams = httpParams.set('limit', params.limit.toString()); + } + + if (params?.offset !== undefined) { + httpParams = httpParams.set('offset', params.offset.toString()); + } + + return this.http.get( + `${this.apiUrl}/clients/${clientId}/agents/${agentId}/chats/${chatId}/messages`, + { params: httpParams }, + ); + } +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.effects.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.effects.spec.ts index c20af44a1..e387637d0 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.effects.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.effects.spec.ts @@ -77,6 +77,8 @@ describe('AgentsEffects', () => { description: 'Test Description', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.spec.ts index 748d86e0c..e7a34f81a 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.facade.spec.ts @@ -35,6 +35,8 @@ describe('AgentsFacade', () => { description: 'Test Description', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -43,6 +45,8 @@ describe('AgentsFacade', () => { name: 'Test Agent 2', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-2', createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts index 14a3e089e..891e9a03d 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.reducer.spec.ts @@ -46,6 +46,8 @@ describe('agentsReducer', () => { description: 'Test Description', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -54,6 +56,8 @@ describe('agentsReducer', () => { name: 'Test Agent 2', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-2', createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.selectors.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.selectors.spec.ts index 587d1518a..ac2a9544c 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.selectors.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.selectors.spec.ts @@ -46,6 +46,8 @@ describe('Agents Selectors', () => { description: 'Test Description', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-1', createdAt: '2024-01-01T00:00:00Z', updatedAt: '2024-01-01T00:00:00Z', }; @@ -54,6 +56,8 @@ describe('Agents Selectors', () => { name: 'Test Agent 2', agentType: 'cursor', containerType: 'generic' as ContainerType, + chats: [], + primaryChatId: 'primary-chat-2', createdAt: '2024-01-02T00:00:00Z', updatedAt: '2024-01-02T00:00:00Z', }; diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts index 3a9da4276..6e94682df 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/agents/agents.types.ts @@ -1,3 +1,5 @@ +import type { AgentChatSessionSummaryDto } from '../chat-sessions/chat-sessions.types'; + // Types based on OpenAPI spec export interface AgentTypeCapabilities { transport?: 'acp'; @@ -26,6 +28,10 @@ export interface AgentResponseDto { repositoryUrl?: string; setupMode: 'clone' | 'empty'; }; + /** User-visible chat sessions for this agent (primary + user; never hidden/background). */ + chats: AgentChatSessionSummaryDto[]; + /** Id of the primary chat session for this agent. */ + primaryChatId: string; createdAt: string; updatedAt: string; } diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.actions.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.actions.ts new file mode 100644 index 000000000..ea44c81b4 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.actions.ts @@ -0,0 +1,94 @@ +import { createAction, props } from '@ngrx/store'; + +import type { + AgentChatSessionSummaryDto, + ChatSessionResponseDto, + CreateChatSessionDto, + ListChatSessionsParams, + UpdateChatSessionDto, +} from './chat-sessions.types'; + +export const loadChatSessions = createAction( + '[ChatSessions] Load Chat Sessions', + props<{ clientId: string; agentId: string; params?: ListChatSessionsParams }>(), +); + +export const loadChatSessionsSuccess = createAction( + '[ChatSessions] Load Chat Sessions Success', + props<{ + clientId: string; + agentId: string; + sessions: ChatSessionResponseDto[]; + primaryChatId?: string; + }>(), +); + +export const loadChatSessionsFailure = createAction( + '[ChatSessions] Load Chat Sessions Failure', + props<{ clientId: string; agentId: string; error: string }>(), +); + +export const hydrateChatSessions = createAction( + '[ChatSessions] Hydrate Chat Sessions', + props<{ + clientId: string; + agentId: string; + chats: AgentChatSessionSummaryDto[]; + primaryChatId: string; + }>(), +); + +export const createChatSession = createAction( + '[ChatSessions] Create Chat Session', + props<{ clientId: string; agentId: string; createDto?: CreateChatSessionDto }>(), +); + +export const createChatSessionSuccess = createAction( + '[ChatSessions] Create Chat Session Success', + props<{ clientId: string; agentId: string; session: ChatSessionResponseDto }>(), +); + +export const createChatSessionFailure = createAction( + '[ChatSessions] Create Chat Session Failure', + props<{ clientId: string; agentId: string; error: string }>(), +); + +export const updateChatSession = createAction( + '[ChatSessions] Update Chat Session', + props<{ clientId: string; agentId: string; chatId: string; updateDto: UpdateChatSessionDto }>(), +); + +export const updateChatSessionSuccess = createAction( + '[ChatSessions] Update Chat Session Success', + props<{ clientId: string; agentId: string; session: ChatSessionResponseDto }>(), +); + +export const updateChatSessionFailure = createAction( + '[ChatSessions] Update Chat Session Failure', + props<{ clientId: string; agentId: string; chatId: string; error: string }>(), +); + +export const deleteChatSession = createAction( + '[ChatSessions] Delete Chat Session', + props<{ clientId: string; agentId: string; chatId: string }>(), +); + +export const deleteChatSessionSuccess = createAction( + '[ChatSessions] Delete Chat Session Success', + props<{ clientId: string; agentId: string; chatId: string }>(), +); + +export const deleteChatSessionFailure = createAction( + '[ChatSessions] Delete Chat Session Failure', + props<{ clientId: string; agentId: string; chatId: string; error: string }>(), +); + +export const selectChatSession = createAction( + '[ChatSessions] Select Chat Session', + props<{ clientId: string; agentId: string; chatId: string; restore?: boolean }>(), +); + +export const clearChatSessions = createAction( + '[ChatSessions] Clear Chat Sessions', + props<{ clientId: string; agentId: string }>(), +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.spec.ts new file mode 100644 index 000000000..8687df887 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.spec.ts @@ -0,0 +1,180 @@ +import { TestBed } from '@angular/core/testing'; +import { Actions } from '@ngrx/effects'; +import { provideMockActions } from '@ngrx/effects/testing'; +import { of, throwError } from 'rxjs'; + +import { ChatSessionsService } from '../../services/chat-sessions.service'; + +import { + createChatSession, + createChatSessionFailure, + createChatSessionSuccess, + deleteChatSession, + deleteChatSessionFailure, + deleteChatSessionSuccess, + loadChatSessions, + loadChatSessionsFailure, + loadChatSessionsSuccess, + updateChatSession, + updateChatSessionFailure, + updateChatSessionSuccess, +} from './chat-sessions.actions'; +import { createChatSession$, deleteChatSession$, loadChatSessions$, updateChatSession$ } from './chat-sessions.effects'; +import type { ChatSessionResponseDto, CreateChatSessionDto, UpdateChatSessionDto } from './chat-sessions.types'; + +describe('ChatSessionsEffects', () => { + let actions$: Actions; + let chatSessionsService: jest.Mocked; + const clientId = 'client-1'; + const agentId = 'agent-1'; + const chatId = 'chat-1'; + const mockSession: ChatSessionResponseDto = { + id: chatId, + agentId, + title: 'Primary', + kind: 'primary', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + chatSessionsService = { + listChatSessions: jest.fn(), + createChatSession: jest.fn(), + updateChatSession: jest.fn(), + deleteChatSession: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [ + provideMockActions(() => actions$), + { + provide: ChatSessionsService, + useValue: chatSessionsService, + }, + ], + }); + + actions$ = TestBed.inject(Actions); + }); + + describe('loadChatSessions$', () => { + it('should return loadChatSessionsSuccess on success', (done) => { + const action = loadChatSessions({ clientId, agentId }); + const outcome = loadChatSessionsSuccess({ + clientId, + agentId, + sessions: [mockSession], + primaryChatId: mockSession.id, + }); + + actions$ = of(action); + chatSessionsService.listChatSessions.mockReturnValue(of([mockSession])); + + loadChatSessions$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(outcome); + done(); + }); + }); + + it('should return loadChatSessionsFailure on error', (done) => { + const action = loadChatSessions({ clientId, agentId }); + const error = new Error('Load failed'); + + actions$ = of(action); + chatSessionsService.listChatSessions.mockReturnValue(throwError(() => error)); + + loadChatSessions$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(loadChatSessionsFailure({ clientId, agentId, error: 'Load failed' })); + done(); + }); + }); + }); + + describe('createChatSession$', () => { + it('should return createChatSessionSuccess on success', (done) => { + const createDto: CreateChatSessionDto = { title: 'New' }; + const created = { ...mockSession, id: 'chat-2', kind: 'user' as const, title: 'New' }; + const action = createChatSession({ clientId, agentId, createDto }); + + actions$ = of(action); + chatSessionsService.createChatSession.mockReturnValue(of(created)); + + createChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(createChatSessionSuccess({ clientId, agentId, session: created })); + done(); + }); + }); + + it('should return createChatSessionFailure on error', (done) => { + const action = createChatSession({ clientId, agentId }); + + actions$ = of(action); + chatSessionsService.createChatSession.mockReturnValue(throwError(() => new Error('Create failed'))); + + createChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(createChatSessionFailure({ clientId, agentId, error: 'Create failed' })); + done(); + }); + }); + }); + + describe('updateChatSession$', () => { + it('should return updateChatSessionSuccess on success', (done) => { + const updateDto: UpdateChatSessionDto = { title: 'Renamed' }; + const updated = { ...mockSession, title: 'Renamed' }; + const action = updateChatSession({ clientId, agentId, chatId, updateDto }); + + actions$ = of(action); + chatSessionsService.updateChatSession.mockReturnValue(of(updated)); + + updateChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(updateChatSessionSuccess({ clientId, agentId, session: updated })); + done(); + }); + }); + + it('should return updateChatSessionFailure on error', (done) => { + const action = updateChatSession({ + clientId, + agentId, + chatId, + updateDto: { title: 'x' }, + }); + + actions$ = of(action); + chatSessionsService.updateChatSession.mockReturnValue(throwError(() => new Error('Update failed'))); + + updateChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(updateChatSessionFailure({ clientId, agentId, chatId, error: 'Update failed' })); + done(); + }); + }); + }); + + describe('deleteChatSession$', () => { + it('should return deleteChatSessionSuccess on success', (done) => { + const action = deleteChatSession({ clientId, agentId, chatId }); + + actions$ = of(action); + chatSessionsService.deleteChatSession.mockReturnValue(of(undefined)); + + deleteChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(deleteChatSessionSuccess({ clientId, agentId, chatId })); + done(); + }); + }); + + it('should return deleteChatSessionFailure on error', (done) => { + const action = deleteChatSession({ clientId, agentId, chatId }); + + actions$ = of(action); + chatSessionsService.deleteChatSession.mockReturnValue(throwError(() => new Error('Delete failed'))); + + deleteChatSession$(actions$, chatSessionsService).subscribe((result) => { + expect(result).toEqual(deleteChatSessionFailure({ clientId, agentId, chatId, error: 'Delete failed' })); + done(); + }); + }); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.ts new file mode 100644 index 000000000..85be88c4a --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.effects.ts @@ -0,0 +1,104 @@ +import { inject } from '@angular/core'; +import { Actions, createEffect, ofType } from '@ngrx/effects'; +import { catchError, exhaustMap, map, of, switchMap } from 'rxjs'; + +import { ChatSessionsService } from '../../services/chat-sessions.service'; + +import { + createChatSession, + createChatSessionFailure, + createChatSessionSuccess, + deleteChatSession, + deleteChatSessionFailure, + deleteChatSessionSuccess, + loadChatSessions, + loadChatSessionsFailure, + loadChatSessionsSuccess, + updateChatSession, + updateChatSessionFailure, + updateChatSessionSuccess, +} from './chat-sessions.actions'; + +function normalizeError(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + + if (typeof error === 'string') { + return error; + } + + if (error && typeof error === 'object' && 'message' in error) { + return String(error.message); + } + + return 'An unexpected error occurred'; +} + +export const loadChatSessions$ = createEffect( + (actions$ = inject(Actions), chatSessionsService = inject(ChatSessionsService)) => { + return actions$.pipe( + ofType(loadChatSessions), + switchMap(({ clientId, agentId, params }) => + chatSessionsService.listChatSessions(clientId, agentId, params).pipe( + map((sessions) => { + const primaryChatId = sessions.find((session) => session.kind === 'primary')?.id; + + return loadChatSessionsSuccess({ clientId, agentId, sessions, primaryChatId }); + }), + catchError((error) => of(loadChatSessionsFailure({ clientId, agentId, error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const createChatSession$ = createEffect( + (actions$ = inject(Actions), chatSessionsService = inject(ChatSessionsService)) => { + return actions$.pipe( + ofType(createChatSession), + exhaustMap(({ clientId, agentId, createDto }) => + chatSessionsService.createChatSession(clientId, agentId, createDto ?? {}).pipe( + map((session) => createChatSessionSuccess({ clientId, agentId, session })), + catchError((error) => of(createChatSessionFailure({ clientId, agentId, error: normalizeError(error) }))), + ), + ), + ); + }, + { functional: true }, +); + +export const updateChatSession$ = createEffect( + (actions$ = inject(Actions), chatSessionsService = inject(ChatSessionsService)) => { + return actions$.pipe( + ofType(updateChatSession), + exhaustMap(({ clientId, agentId, chatId, updateDto }) => + chatSessionsService.updateChatSession(clientId, agentId, chatId, updateDto).pipe( + map((session) => updateChatSessionSuccess({ clientId, agentId, session })), + catchError((error) => + of(updateChatSessionFailure({ clientId, agentId, chatId, error: normalizeError(error) })), + ), + ), + ), + ); + }, + { functional: true }, +); + +export const deleteChatSession$ = createEffect( + (actions$ = inject(Actions), chatSessionsService = inject(ChatSessionsService)) => { + return actions$.pipe( + ofType(deleteChatSession), + exhaustMap(({ clientId, agentId, chatId }) => + chatSessionsService.deleteChatSession(clientId, agentId, chatId).pipe( + map(() => deleteChatSessionSuccess({ clientId, agentId, chatId })), + catchError((error) => + of(deleteChatSessionFailure({ clientId, agentId, chatId, error: normalizeError(error) })), + ), + ), + ), + ); + }, + { functional: true }, +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.spec.ts new file mode 100644 index 000000000..743b5d084 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.spec.ts @@ -0,0 +1,96 @@ +import { TestBed } from '@angular/core/testing'; +import { Store } from '@ngrx/store'; +import { of } from 'rxjs'; + +import { + clearChatSessions, + createChatSession, + deleteChatSession, + hydrateChatSessions, + loadChatSessions, + selectChatSession, + updateChatSession, +} from './chat-sessions.actions'; +import { ChatSessionsFacade } from './chat-sessions.facade'; +import type { ChatSessionResponseDto } from './chat-sessions.types'; + +describe('ChatSessionsFacade', () => { + let facade: ChatSessionsFacade; + let store: jest.Mocked; + const clientId = 'client-1'; + const agentId = 'agent-1'; + const mockSession: ChatSessionResponseDto = { + id: 'chat-1', + agentId, + title: 'Primary', + kind: 'primary', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + + beforeEach(() => { + store = { + select: jest.fn(), + dispatch: jest.fn(), + } as unknown as jest.Mocked; + + TestBed.configureTestingModule({ + providers: [ + ChatSessionsFacade, + { + provide: Store, + useValue: store, + }, + ], + }); + + facade = TestBed.inject(ChatSessionsFacade); + }); + + it('should select chat sessions', (done) => { + store.select.mockReturnValue(of([mockSession])); + + facade.getChatSessions$(clientId, agentId).subscribe((result) => { + expect(result).toEqual([mockSession]); + done(); + }); + }); + + it('should dispatch loadChatSessions', () => { + facade.loadChatSessions(clientId, agentId); + expect(store.dispatch).toHaveBeenCalledWith(loadChatSessions({ clientId, agentId, params: undefined })); + }); + + it('should dispatch hydrateChatSessions', () => { + facade.hydrateChatSessions(clientId, agentId, [{ id: 'chat-1', kind: 'primary', createdAt: 'x' }], 'chat-1'); + expect(store.dispatch).toHaveBeenCalledWith( + hydrateChatSessions({ + clientId, + agentId, + chats: [{ id: 'chat-1', kind: 'primary', createdAt: 'x' }], + primaryChatId: 'chat-1', + }), + ); + }); + + it('should dispatch create/update/delete/select/clear', () => { + facade.createChatSession(clientId, agentId, { title: 'New' }); + expect(store.dispatch).toHaveBeenCalledWith(createChatSession({ clientId, agentId, createDto: { title: 'New' } })); + + facade.updateChatSession(clientId, agentId, 'chat-1', { title: 'Renamed' }); + expect(store.dispatch).toHaveBeenCalledWith( + updateChatSession({ clientId, agentId, chatId: 'chat-1', updateDto: { title: 'Renamed' } }), + ); + + facade.deleteChatSession(clientId, agentId, 'chat-1'); + expect(store.dispatch).toHaveBeenCalledWith(deleteChatSession({ clientId, agentId, chatId: 'chat-1' })); + + facade.selectChatSession(clientId, agentId, 'chat-1'); + expect(store.dispatch).toHaveBeenCalledWith( + selectChatSession({ clientId, agentId, chatId: 'chat-1', restore: true }), + ); + + facade.clearChatSessions(clientId, agentId); + expect(store.dispatch).toHaveBeenCalledWith(clearChatSessions({ clientId, agentId })); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.ts new file mode 100644 index 000000000..c1d1f1f5f --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.facade.ts @@ -0,0 +1,109 @@ +import { Injectable, inject } from '@angular/core'; +import { Store } from '@ngrx/store'; +import { Observable } from 'rxjs'; + +import { + clearChatSessions, + createChatSession, + deleteChatSession, + hydrateChatSessions, + loadChatSessions, + selectChatSession, + updateChatSession, +} from './chat-sessions.actions'; +import { + selectChatSessionsError, + selectChatSessionsForAgent, + selectIsCreatingChatSession, + selectIsDeletingChatSession, + selectIsLoadingChatSessions, + selectIsUpdatingChatSession, + selectSelectedChatId, + selectSelectedChatSession, +} from './chat-sessions.selectors'; +import type { + AgentChatSessionSummaryDto, + ChatSessionResponseDto, + CreateChatSessionDto, + ListChatSessionsParams, + UpdateChatSessionDto, +} from './chat-sessions.types'; + +/** + * Facade for chat sessions state management. + * Sessions are scoped per client + agent (environment). + */ +@Injectable({ + providedIn: 'root', +}) +export class ChatSessionsFacade { + private readonly store = inject(Store); + + getChatSessions$(clientId: string, agentId: string): Observable { + return this.store.select(selectChatSessionsForAgent(clientId, agentId)); + } + + getSelectedChatId$(clientId: string, agentId: string): Observable { + return this.store.select(selectSelectedChatId(clientId, agentId)); + } + + getSelectedChatSession$(clientId: string, agentId: string): Observable { + return this.store.select(selectSelectedChatSession(clientId, agentId)); + } + + isLoadingChatSessions$(clientId: string, agentId: string): Observable { + return this.store.select(selectIsLoadingChatSessions(clientId, agentId)); + } + + isCreatingChatSession$(clientId: string, agentId: string): Observable { + return this.store.select(selectIsCreatingChatSession(clientId, agentId)); + } + + isUpdatingChatSession$(clientId: string, agentId: string, chatId: string): Observable { + return this.store.select(selectIsUpdatingChatSession(clientId, agentId, chatId)); + } + + isDeletingChatSession$(clientId: string, agentId: string, chatId: string): Observable { + return this.store.select(selectIsDeletingChatSession(clientId, agentId, chatId)); + } + + getChatSessionsError$(clientId: string, agentId: string): Observable { + return this.store.select(selectChatSessionsError(clientId, agentId)); + } + + loadChatSessions(clientId: string, agentId: string, params?: ListChatSessionsParams): void { + this.store.dispatch(loadChatSessions({ clientId, agentId, params })); + } + + hydrateChatSessions( + clientId: string, + agentId: string, + chats: AgentChatSessionSummaryDto[], + primaryChatId: string, + ): void { + this.store.dispatch(hydrateChatSessions({ clientId, agentId, chats, primaryChatId })); + } + + createChatSession(clientId: string, agentId: string, createDto?: CreateChatSessionDto): void { + this.store.dispatch(createChatSession({ clientId, agentId, createDto })); + } + + updateChatSession(clientId: string, agentId: string, chatId: string, updateDto: UpdateChatSessionDto): void { + this.store.dispatch(updateChatSession({ clientId, agentId, chatId, updateDto })); + } + + deleteChatSession(clientId: string, agentId: string, chatId: string): void { + this.store.dispatch(deleteChatSession({ clientId, agentId, chatId })); + } + + /** + * Select a chat session. When `restore` is true (default), UI/effects should clear timeline and forward restoreChat. + */ + selectChatSession(clientId: string, agentId: string, chatId: string, restore = true): void { + this.store.dispatch(selectChatSession({ clientId, agentId, chatId, restore })); + } + + clearChatSessions(clientId: string, agentId: string): void { + this.store.dispatch(clearChatSessions({ clientId, agentId })); + } +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.spec.ts new file mode 100644 index 000000000..0e204158d --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.spec.ts @@ -0,0 +1,188 @@ +import { + clearChatSessions, + createChatSession, + createChatSessionFailure, + createChatSessionSuccess, + deleteChatSession, + deleteChatSessionFailure, + deleteChatSessionSuccess, + hydrateChatSessions, + loadChatSessions, + loadChatSessionsFailure, + loadChatSessionsSuccess, + selectChatSession, + updateChatSession, + updateChatSessionFailure, + updateChatSessionSuccess, +} from './chat-sessions.actions'; +import { chatSessionsReducer, initialChatSessionsState, type ChatSessionsState } from './chat-sessions.reducer'; +import type { ChatSessionResponseDto } from './chat-sessions.types'; + +describe('chatSessionsReducer', () => { + const clientId = 'client-1'; + const agentId = 'agent-1'; + const key = `${clientId}:${agentId}`; + const primarySession: ChatSessionResponseDto = { + id: 'chat-primary', + agentId, + title: 'Primary', + kind: 'primary', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + const userSession: ChatSessionResponseDto = { + id: 'chat-user', + agentId, + title: 'Side chat', + kind: 'user', + createdAt: '2024-01-02T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }; + + it('should return the initial state', () => { + const state = chatSessionsReducer(undefined, { type: 'UNKNOWN' } as never); + + expect(state).toEqual(initialChatSessionsState); + }); + + it('should set loading on loadChatSessions', () => { + const state = chatSessionsReducer(initialChatSessionsState, loadChatSessions({ clientId, agentId })); + + expect(state.loading[key]).toBe(true); + expect(state.errors[key]).toBeNull(); + }); + + it('should store sessions and select primary on load success', () => { + const state = chatSessionsReducer( + { ...initialChatSessionsState, loading: { [key]: true } }, + loadChatSessionsSuccess({ + clientId, + agentId, + sessions: [primarySession, userSession], + primaryChatId: primarySession.id, + }), + ); + + expect(state.sessions[key]).toEqual([primarySession, userSession]); + expect(state.selectedChatIds[key]).toBe(primarySession.id); + expect(state.loading[key]).toBe(false); + }); + + it('should set error on load failure', () => { + const state = chatSessionsReducer( + { ...initialChatSessionsState, loading: { [key]: true } }, + loadChatSessionsFailure({ clientId, agentId, error: 'Load failed' }), + ); + + expect(state.loading[key]).toBe(false); + expect(state.errors[key]).toBe('Load failed'); + }); + + it('should hydrate from agent chat summaries', () => { + const state = chatSessionsReducer( + initialChatSessionsState, + hydrateChatSessions({ + clientId, + agentId, + chats: [ + { id: primarySession.id, kind: 'primary', createdAt: primarySession.createdAt, title: 'Primary' }, + { id: userSession.id, kind: 'user', createdAt: userSession.createdAt, title: 'Side chat' }, + ], + primaryChatId: primarySession.id, + }), + ); + + expect(state.sessions[key]?.map((s) => s.id)).toEqual([primarySession.id, userSession.id]); + expect(state.selectedChatIds[key]).toBe(primarySession.id); + }); + + it('should append and select session on create success', () => { + const prev: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession] }, + selectedChatIds: { [key]: primarySession.id }, + creating: { [key]: true }, + }; + const state = chatSessionsReducer(prev, createChatSessionSuccess({ clientId, agentId, session: userSession })); + + expect(state.sessions[key]).toEqual([primarySession, userSession]); + expect(state.selectedChatIds[key]).toBe(userSession.id); + expect(state.creating[key]).toBe(false); + }); + + it('should update session title', () => { + const prev: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession, userSession] }, + }; + const updated = { ...userSession, title: 'Renamed' }; + const state = chatSessionsReducer(prev, updateChatSessionSuccess({ clientId, agentId, session: updated })); + + expect(state.sessions[key]?.find((s) => s.id === userSession.id)?.title).toBe('Renamed'); + }); + + it('should remove session and fall back to primary on delete', () => { + const prev: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession, userSession] }, + selectedChatIds: { [key]: userSession.id }, + }; + const state = chatSessionsReducer(prev, deleteChatSessionSuccess({ clientId, agentId, chatId: userSession.id })); + + expect(state.sessions[key]).toEqual([primarySession]); + expect(state.selectedChatIds[key]).toBe(primarySession.id); + }); + + it('should select a chat session', () => { + const prev: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession, userSession] }, + selectedChatIds: { [key]: primarySession.id }, + }; + const state = chatSessionsReducer(prev, selectChatSession({ clientId, agentId, chatId: userSession.id })); + + expect(state.selectedChatIds[key]).toBe(userSession.id); + }); + + it('should clear sessions for a client/agent', () => { + const prev: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession] }, + selectedChatIds: { [key]: primarySession.id }, + }; + const state = chatSessionsReducer(prev, clearChatSessions({ clientId, agentId })); + + expect(state.sessions[key]).toBeUndefined(); + expect(state.selectedChatIds[key]).toBeUndefined(); + }); + + it('should track creating/updating/deleting flags', () => { + let state = chatSessionsReducer(initialChatSessionsState, createChatSession({ clientId, agentId })); + + expect(state.creating[key]).toBe(true); + + state = chatSessionsReducer(state, createChatSessionFailure({ clientId, agentId, error: 'x' })); + expect(state.creating[key]).toBe(false); + + state = chatSessionsReducer( + state, + updateChatSession({ clientId, agentId, chatId: userSession.id, updateDto: { title: 't' } }), + ); + expect(state.updating[`${key}:${userSession.id}`]).toBe(true); + + state = chatSessionsReducer( + state, + updateChatSessionFailure({ clientId, agentId, chatId: userSession.id, error: 'y' }), + ); + expect(state.updating[`${key}:${userSession.id}`]).toBe(false); + + state = chatSessionsReducer(state, deleteChatSession({ clientId, agentId, chatId: userSession.id })); + expect(state.deleting[`${key}:${userSession.id}`]).toBe(true); + + state = chatSessionsReducer( + state, + deleteChatSessionFailure({ clientId, agentId, chatId: userSession.id, error: 'z' }), + ); + expect(state.deleting[`${key}:${userSession.id}`]).toBe(false); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.ts new file mode 100644 index 000000000..a6f5ce8f3 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.reducer.ts @@ -0,0 +1,242 @@ +import { createReducer, on } from '@ngrx/store'; + +import { + clearChatSessions, + createChatSession, + createChatSessionFailure, + createChatSessionSuccess, + deleteChatSession, + deleteChatSessionFailure, + deleteChatSessionSuccess, + hydrateChatSessions, + loadChatSessions, + loadChatSessionsFailure, + loadChatSessionsSuccess, + selectChatSession, + updateChatSession, + updateChatSessionFailure, + updateChatSessionSuccess, +} from './chat-sessions.actions'; +import type { AgentChatSessionSummaryDto, ChatSessionResponseDto } from './chat-sessions.types'; + +export interface ChatSessionsState { + /** Sessions keyed by clientId:agentId */ + sessions: Record; + /** Selected chat id keyed by clientId:agentId */ + selectedChatIds: Record; + loading: Record; + creating: Record; + updating: Record; + deleting: Record; + errors: Record; +} + +export const initialChatSessionsState: ChatSessionsState = { + sessions: {}, + selectedChatIds: {}, + loading: {}, + creating: {}, + updating: {}, + deleting: {}, + errors: {}, +}; + +export function getClientAgentKey(clientId: string, agentId: string): string { + return `${clientId}:${agentId}`; +} + +function getChatKey(clientId: string, agentId: string, chatId: string): string { + return `${clientId}:${agentId}:${chatId}`; +} + +function mapSummariesToSessions(agentId: string, chats: AgentChatSessionSummaryDto[]): ChatSessionResponseDto[] { + return chats.map((chat) => ({ + id: chat.id, + agentId, + title: chat.title, + kind: chat.kind, + lastMessageAt: chat.lastMessageAt, + createdAt: chat.createdAt, + updatedAt: chat.createdAt, + })); +} + +function resolvePrimaryChatId(sessions: ChatSessionResponseDto[], preferred?: string): string | null { + if (preferred && sessions.some((session) => session.id === preferred)) { + return preferred; + } + + const primary = sessions.find((session) => session.kind === 'primary'); + + return primary?.id ?? sessions[0]?.id ?? null; +} + +export const chatSessionsReducer = createReducer( + initialChatSessionsState, + on(loadChatSessions, (state, { clientId, agentId }) => { + const key = getClientAgentKey(clientId, agentId); + + return { + ...state, + loading: { ...state.loading, [key]: true }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(loadChatSessionsSuccess, (state, { clientId, agentId, sessions, primaryChatId }) => { + const key = getClientAgentKey(clientId, agentId); + const nextSelected = + state.selectedChatIds[key] && sessions.some((session) => session.id === state.selectedChatIds[key]) + ? state.selectedChatIds[key] + : resolvePrimaryChatId(sessions, primaryChatId); + + return { + ...state, + sessions: { ...state.sessions, [key]: sessions }, + selectedChatIds: { ...state.selectedChatIds, [key]: nextSelected }, + loading: { ...state.loading, [key]: false }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(loadChatSessionsFailure, (state, { clientId, agentId, error }) => { + const key = getClientAgentKey(clientId, agentId); + + return { + ...state, + loading: { ...state.loading, [key]: false }, + errors: { ...state.errors, [key]: error }, + }; + }), + on(hydrateChatSessions, (state, { clientId, agentId, chats, primaryChatId }) => { + const key = getClientAgentKey(clientId, agentId); + const sessions = mapSummariesToSessions(agentId, chats); + const nextSelected = + state.selectedChatIds[key] && sessions.some((session) => session.id === state.selectedChatIds[key]) + ? state.selectedChatIds[key] + : resolvePrimaryChatId(sessions, primaryChatId); + + return { + ...state, + sessions: { ...state.sessions, [key]: sessions }, + selectedChatIds: { ...state.selectedChatIds, [key]: nextSelected }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(createChatSession, (state, { clientId, agentId }) => { + const key = getClientAgentKey(clientId, agentId); + + return { + ...state, + creating: { ...state.creating, [key]: true }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(createChatSessionSuccess, (state, { clientId, agentId, session }) => { + const key = getClientAgentKey(clientId, agentId); + const current = state.sessions[key] ?? []; + + return { + ...state, + sessions: { ...state.sessions, [key]: [...current, session] }, + selectedChatIds: { ...state.selectedChatIds, [key]: session.id }, + creating: { ...state.creating, [key]: false }, + errors: { ...state.errors, [key]: null }, + }; + }), + on(createChatSessionFailure, (state, { clientId, agentId, error }) => { + const key = getClientAgentKey(clientId, agentId); + + return { + ...state, + creating: { ...state.creating, [key]: false }, + errors: { ...state.errors, [key]: error }, + }; + }), + on(updateChatSession, (state, { clientId, agentId, chatId }) => { + const chatKey = getChatKey(clientId, agentId, chatId); + + return { + ...state, + updating: { ...state.updating, [chatKey]: true }, + errors: { ...state.errors, [chatKey]: null }, + }; + }), + on(updateChatSessionSuccess, (state, { clientId, agentId, session }) => { + const key = getClientAgentKey(clientId, agentId); + const chatKey = getChatKey(clientId, agentId, session.id); + const current = state.sessions[key] ?? []; + + return { + ...state, + sessions: { + ...state.sessions, + [key]: current.map((item) => (item.id === session.id ? session : item)), + }, + updating: { ...state.updating, [chatKey]: false }, + errors: { ...state.errors, [chatKey]: null }, + }; + }), + on(updateChatSessionFailure, (state, { clientId, agentId, chatId, error }) => { + const chatKey = getChatKey(clientId, agentId, chatId); + + return { + ...state, + updating: { ...state.updating, [chatKey]: false }, + errors: { ...state.errors, [chatKey]: error }, + }; + }), + on(deleteChatSession, (state, { clientId, agentId, chatId }) => { + const chatKey = getChatKey(clientId, agentId, chatId); + + return { + ...state, + deleting: { ...state.deleting, [chatKey]: true }, + errors: { ...state.errors, [chatKey]: null }, + }; + }), + on(deleteChatSessionSuccess, (state, { clientId, agentId, chatId }) => { + const key = getClientAgentKey(clientId, agentId); + const chatKey = getChatKey(clientId, agentId, chatId); + const current = state.sessions[key] ?? []; + const nextSessions = current.filter((session) => session.id !== chatId); + const wasSelected = state.selectedChatIds[key] === chatId; + + return { + ...state, + sessions: { ...state.sessions, [key]: nextSessions }, + selectedChatIds: { + ...state.selectedChatIds, + [key]: wasSelected ? resolvePrimaryChatId(nextSessions) : state.selectedChatIds[key], + }, + deleting: { ...state.deleting, [chatKey]: false }, + errors: { ...state.errors, [chatKey]: null }, + }; + }), + on(deleteChatSessionFailure, (state, { clientId, agentId, chatId, error }) => { + const chatKey = getChatKey(clientId, agentId, chatId); + + return { + ...state, + deleting: { ...state.deleting, [chatKey]: false }, + errors: { ...state.errors, [chatKey]: error }, + }; + }), + on(selectChatSession, (state, { clientId, agentId, chatId }) => { + const key = getClientAgentKey(clientId, agentId); + + return { + ...state, + selectedChatIds: { ...state.selectedChatIds, [key]: chatId }, + }; + }), + on(clearChatSessions, (state, { clientId, agentId }) => { + const key = getClientAgentKey(clientId, agentId); + const { [key]: _sessions, ...sessions } = state.sessions; + const { [key]: _selected, ...selectedChatIds } = state.selectedChatIds; + + return { + ...state, + sessions, + selectedChatIds, + }; + }), +); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.spec.ts new file mode 100644 index 000000000..3448a2e9d --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.spec.ts @@ -0,0 +1,77 @@ +import type { ChatSessionsState } from './chat-sessions.reducer'; +import { initialChatSessionsState } from './chat-sessions.reducer'; +import { + selectChatSessionsError, + selectChatSessionsForAgent, + selectIsCreatingChatSession, + selectIsLoadingChatSessions, + selectSelectedChatId, + selectSelectedChatSession, +} from './chat-sessions.selectors'; +import type { ChatSessionResponseDto } from './chat-sessions.types'; + +describe('ChatSessions Selectors', () => { + const clientId = 'client-1'; + const agentId = 'agent-1'; + const key = `${clientId}:${agentId}`; + const primarySession: ChatSessionResponseDto = { + id: 'chat-primary', + agentId, + title: 'Primary', + kind: 'primary', + createdAt: '2024-01-01T00:00:00Z', + updatedAt: '2024-01-01T00:00:00Z', + }; + const userSession: ChatSessionResponseDto = { + id: 'chat-user', + agentId, + title: 'Side chat', + kind: 'user', + createdAt: '2024-01-02T00:00:00Z', + updatedAt: '2024-01-02T00:00:00Z', + }; + const state: ChatSessionsState = { + ...initialChatSessionsState, + sessions: { [key]: [primarySession, userSession] }, + selectedChatIds: { [key]: userSession.id }, + loading: { [key]: true }, + creating: { [key]: false }, + errors: { [key]: 'boom' }, + }; + + it('should select sessions for agent', () => { + const result = selectChatSessionsForAgent(clientId, agentId).projector(state.sessions); + + expect(result).toEqual([primarySession, userSession]); + }); + + it('should return null when sessions are missing', () => { + const result = selectChatSessionsForAgent(clientId, agentId).projector({}); + + expect(result).toBeNull(); + }); + + it('should select selected chat id', () => { + const result = selectSelectedChatId(clientId, agentId).projector(state.selectedChatIds); + + expect(result).toBe(userSession.id); + }); + + it('should select selected chat session', () => { + const result = selectSelectedChatSession(clientId, agentId).projector( + [primarySession, userSession], + userSession.id, + ); + + expect(result).toEqual(userSession); + }); + + it('should select loading and creating flags', () => { + expect(selectIsLoadingChatSessions(clientId, agentId).projector(state.loading)).toBe(true); + expect(selectIsCreatingChatSession(clientId, agentId).projector(state.creating)).toBe(false); + }); + + it('should select error', () => { + expect(selectChatSessionsError(clientId, agentId).projector(state.errors)).toBe('boom'); + }); +}); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.ts new file mode 100644 index 000000000..e6c2c61c5 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.selectors.ts @@ -0,0 +1,75 @@ +import { createFeatureSelector, createSelector } from '@ngrx/store'; + +import { getClientAgentKey, type ChatSessionsState } from './chat-sessions.reducer'; + +export const selectChatSessionsState = createFeatureSelector('chatSessions'); + +export const selectChatSessionsMap = createSelector(selectChatSessionsState, (state) => state.sessions); +export const selectSelectedChatIdsMap = createSelector(selectChatSessionsState, (state) => state.selectedChatIds); +export const selectChatSessionsLoadingMap = createSelector(selectChatSessionsState, (state) => state.loading); +export const selectChatSessionsCreatingMap = createSelector(selectChatSessionsState, (state) => state.creating); +export const selectChatSessionsUpdatingMap = createSelector(selectChatSessionsState, (state) => state.updating); +export const selectChatSessionsDeletingMap = createSelector(selectChatSessionsState, (state) => state.deleting); +export const selectChatSessionsErrorsMap = createSelector(selectChatSessionsState, (state) => state.errors); + +function getChatKey(clientId: string, agentId: string, chatId: string): string { + return `${clientId}:${agentId}:${chatId}`; +} + +export const selectChatSessionsForAgent = (clientId: string, agentId: string) => + createSelector(selectChatSessionsMap, (sessions) => { + const key = getClientAgentKey(clientId, agentId); + + return sessions[key] ?? null; + }); + +export const selectSelectedChatId = (clientId: string, agentId: string) => + createSelector(selectSelectedChatIdsMap, (selectedChatIds) => { + const key = getClientAgentKey(clientId, agentId); + + return selectedChatIds[key] ?? null; + }); + +export const selectSelectedChatSession = (clientId: string, agentId: string) => + createSelector( + selectChatSessionsForAgent(clientId, agentId), + selectSelectedChatId(clientId, agentId), + (sessions, selectedChatId) => { + if (!sessions || !selectedChatId) { + return null; + } + + return sessions.find((session) => session.id === selectedChatId) ?? null; + }, + ); + +export const selectIsLoadingChatSessions = (clientId: string, agentId: string) => + createSelector(selectChatSessionsLoadingMap, (loading) => { + const key = getClientAgentKey(clientId, agentId); + + return loading[key] ?? false; + }); + +export const selectIsCreatingChatSession = (clientId: string, agentId: string) => + createSelector(selectChatSessionsCreatingMap, (creating) => { + const key = getClientAgentKey(clientId, agentId); + + return creating[key] ?? false; + }); + +export const selectIsUpdatingChatSession = (clientId: string, agentId: string, chatId: string) => + createSelector(selectChatSessionsUpdatingMap, (updating) => { + return updating[getChatKey(clientId, agentId, chatId)] ?? false; + }); + +export const selectIsDeletingChatSession = (clientId: string, agentId: string, chatId: string) => + createSelector(selectChatSessionsDeletingMap, (deleting) => { + return deleting[getChatKey(clientId, agentId, chatId)] ?? false; + }); + +export const selectChatSessionsError = (clientId: string, agentId: string) => + createSelector(selectChatSessionsErrorsMap, (errors) => { + const key = getClientAgentKey(clientId, agentId); + + return errors[key] ?? null; + }); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.types.ts new file mode 100644 index 000000000..ed174dc60 --- /dev/null +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/chat-sessions/chat-sessions.types.ts @@ -0,0 +1,44 @@ +/** User-visible chat session kinds (hidden/background ACP chats are never listed). */ +export type ChatSessionKind = 'primary' | 'user'; + +/** Compact chat summary embedded on AgentResponseDto. */ +export interface AgentChatSessionSummaryDto { + id: string; + title?: string; + kind: ChatSessionKind; + lastMessageAt?: string; + createdAt: string; +} + +/** Full chat session API response. */ +export interface ChatSessionResponseDto { + id: string; + agentId: string; + title?: string; + kind: ChatSessionKind; + lastMessageAt?: string; + createdAt: string; + updatedAt: string; +} + +export interface CreateChatSessionDto { + title?: string; +} + +export interface UpdateChatSessionDto { + title: string; +} + +export interface ListChatSessionsParams { + limit?: number; + offset?: number; +} + +export interface ChatSessionMessageResponseDto { + id: string; + actor: string; + message: string; + filtered: boolean; + createdAt: string; + updatedAt: string; +} diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.spec.ts index bf1417607..000b4e594 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.spec.ts @@ -5,6 +5,7 @@ import { of } from 'rxjs'; import { chatEnhancementStarted, ticketBodyGenerationStarted, + clearChatHistory, connectSocket, disconnectSocket, forwardEvent, @@ -466,6 +467,43 @@ describe('SocketsFacade', () => { }); }); + it('should forward login event with optional chatId', () => { + const agentId = 'agent-1'; + const chatId = 'chat-1'; + + facade.forwardLogin(agentId, chatId); + + expect(store.dispatch).toHaveBeenCalledWith( + forwardEvent({ + event: ForwardableEvent.LOGIN, + payload: { agentId, password: '', chatId }, + agentId, + }), + ); + }); + + it('should forward restoreChat event', () => { + const agentId = 'agent-1'; + const chatId = 'chat-1'; + + facade.forwardRestoreChat(chatId, agentId); + + expect(store.dispatch).toHaveBeenCalledWith( + forwardEvent({ event: ForwardableEvent.RESTORE_CHAT, payload: { chatId }, agentId }), + ); + expect(mockSocket.emit).toHaveBeenCalledWith('forward', { + event: ForwardableEvent.RESTORE_CHAT, + payload: { chatId }, + agentId, + }); + }); + + it('should dispatch clearChatHistory', () => { + facade.clearChatHistory(); + + expect(store.dispatch).toHaveBeenCalledWith(clearChatHistory()); + }); + it('should forward logout event', () => { facade.forwardLogout(); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts index 59fdc59c7..12790592f 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.facade.ts @@ -6,6 +6,7 @@ import { distinctUntilChanged, Observable, take } from 'rxjs'; import { chatEnhancementStarted, ticketBodyGenerationStarted, + clearChatHistory, connectSocket, disconnectSocket, forwardEvent, @@ -194,24 +195,43 @@ export class SocketsFacade { * Forward a chat event with typed payload * @param message - The chat message text * @param agentId - Agent UUID (required for routing the event to the correct agent) + * @param model - Optional model override + * @param contextInjection - Optional context injection payload + * @param chatId - Optional chat session id (defaults to primary on the agent) */ forwardChat( message: string, agentId: string, model?: string | null, contextInjection?: ContextInjectionPayload, + chatId?: string | null, ): void { const effectiveModel = model ?? this.currentChatModel ?? undefined; const responseMode = this.currentChatResponseMode; const contextPart = contextInjection ? { contextInjection } : {}; + const chatIdPart = chatId ? { chatId } : {}; const payload = effectiveModel !== undefined && effectiveModel !== null - ? { message, model: effectiveModel, responseMode, ...contextPart } - : { message, responseMode, ...contextPart }; + ? { message, model: effectiveModel, responseMode, ...contextPart, ...chatIdPart } + : { message, responseMode, ...contextPart, ...chatIdPart }; this.forwardEvent(ForwardableEvent.CHAT, payload, agentId); } + /** + * Clear the local chat timeline (messages + filter results). + */ + clearChatHistory(): void { + this.store.dispatch(clearChatHistory()); + } + + /** + * Ask the agent gateway to restore a chat session into the viewer timeline. + */ + forwardRestoreChat(chatId: string, agentId: string): void { + this.forwardEvent(ForwardableEvent.RESTORE_CHAT, { chatId }, agentId); + } + /** * Request prompt enhancement (unicast chatEnhanceResult; not added to main chat transcript). */ @@ -281,12 +301,15 @@ export class SocketsFacade { /** * Forward a login event - * Note: When agentId is provided, the payload is automatically overridden with credentials from database + * Note: When agentId is provided, credentials are loaded from the database. + * Optional chatId requests restore of that session after login (when supported by the controller). * @param agentId - Agent UUID for auto-login (credentials loaded from database) + * @param chatId - Optional chat session to restore on login */ - forwardLogin(agentId: string): void { - // Payload is optional and ignored when agentId is provided (credentials loaded from DB) - this.forwardEvent(ForwardableEvent.LOGIN, undefined, agentId); + forwardLogin(agentId: string, chatId?: string | null): void { + const payload = chatId ? ({ agentId, password: '', chatId } as const) : undefined; + + this.forwardEvent(ForwardableEvent.LOGIN, payload, agentId); } /** diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts index b1c00c071..a06902bf3 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.reducer.ts @@ -36,6 +36,27 @@ import { } from './sockets.actions'; import { ForwardableEvent, type AgentResponseMode } from './sockets.types'; +function extractForwardedEventChatId( + event: string, + payload: import('./sockets.types').ForwardedEventPayload, +): string | undefined { + if (!payload || typeof payload !== 'object' || !('success' in payload) || !payload.success) { + return undefined; + } + + const data = (payload as { data?: { chatId?: unknown } }).data; + + if (!data || typeof data !== 'object' || typeof data.chatId !== 'string' || data.chatId.trim() === '') { + return undefined; + } + + if (event === 'chatMessage' || event === 'chatEvent') { + return data.chatId; + } + + return undefined; +} + export interface RemoteConnectionState { clientId: string; connected: boolean; @@ -63,6 +84,8 @@ export interface SocketsState { event: string; payload: import('./sockets.types').ForwardedEventPayload; timestamp: number; + /** Chat session id when present on chatMessage/chatEvent payloads. */ + chatId?: string; }>; // Track message filter results keyed by direction and timestamp (for matching to messages) messageFilterResults: Array<{ @@ -488,9 +511,18 @@ export const socketsReducer = createReducer( } } + const eventChatId = skipForwardedAppend ? undefined : extractForwardedEventChatId(event, payload); const updatedForwardedEvents = skipForwardedAppend ? state.forwardedEvents - : [...state.forwardedEvents, { event, payload, timestamp: eventRowTimestamp }]; + : [ + ...state.forwardedEvents, + { + event, + payload, + timestamp: eventRowTimestamp, + ...(eventChatId ? { chatId: eventChatId } : {}), + }, + ]; const trimmedForwardedEvents = updatedForwardedEvents.length > state.maxForwardedEvents ? updatedForwardedEvents.slice(-state.maxForwardedEvents) diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.chat-timeline.spec.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.chat-timeline.spec.ts index eb56a826e..66e309b96 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.chat-timeline.spec.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.chat-timeline.spec.ts @@ -49,7 +49,9 @@ describe('selectChatTimelineOrdered', () => { ], selectedAgentId: 'a1', } as never; - const out = selectChatTimelineOrdered.projector(state.forwardedEvents as never, state.selectedAgentId); + const out = selectChatTimelineOrdered.projector(state.forwardedEvents as never, state.selectedAgentId, 'c1', { + 'c1:a1': null, + }); expect(out.map((r) => r.event)).toEqual([CLIENT_CHAT_AUTOMATION_SOCKET_EVENT, 'chatMessage']); expect((out[0]?.payload as typeof autoPayload2).run.status).toBe('succeeded'); @@ -88,6 +90,49 @@ describe('selectChatTimelineOrdered', () => { { event: CLIENT_CHAT_AUTOMATION_SOCKET_EVENT, payload: autoPayload, timestamp: 1, semanticTimestamp: 1 }, ] as never, 'a1', + 'c1', + {}, + ); + + expect(out).toHaveLength(0); + }); + + it('filters chat messages by selected chatId when present', () => { + const matching: ForwardedEventPayload = { + success: true, + data: { from: 'user', text: 'keep', timestamp: new Date(1000).toISOString(), chatId: 'chat-a' }, + timestamp: new Date(1000).toISOString(), + } as ForwardedEventPayload; + const other: ForwardedEventPayload = { + success: true, + data: { from: 'user', text: 'drop', timestamp: new Date(2000).toISOString(), chatId: 'chat-b' }, + timestamp: new Date(2000).toISOString(), + } as ForwardedEventPayload; + const out = selectChatTimelineOrdered.projector( + [ + { event: 'chatMessage', payload: matching, timestamp: 1, chatId: 'chat-a' }, + { event: 'chatMessage', payload: other, timestamp: 2, chatId: 'chat-b' }, + ] as never, + 'a1', + 'c1', + { 'c1:a1': 'chat-a' }, + ); + + expect(out).toHaveLength(1); + expect((out[0]?.payload as typeof matching).data).toMatchObject({ text: 'keep' }); + }); + + it('hides chat messages without chatId when a session is selected', () => { + const legacy: ForwardedEventPayload = { + success: true, + data: { from: 'user', text: 'legacy', timestamp: new Date(1000).toISOString() }, + timestamp: new Date(1000).toISOString(), + } as ForwardedEventPayload; + const out = selectChatTimelineOrdered.projector( + [{ event: 'chatMessage', payload: legacy, timestamp: 1 }] as never, + 'a1', + 'c1', + { 'c1:a1': 'chat-a' }, ); expect(out).toHaveLength(0); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.ts index 0943b5bee..cff31e1c7 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.selectors.ts @@ -1,5 +1,8 @@ import { createFeatureSelector, createSelector } from '@ngrx/store'; +import { selectSelectedChatIdsMap } from '../chat-sessions/chat-sessions.selectors'; +import { getClientAgentKey } from '../chat-sessions/chat-sessions.reducer'; + import { CLIENT_CHAT_AUTOMATION_SOCKET_EVENT } from './client-chat-automation.constants'; import type { SocketsState } from './sockets.reducer'; import type { ChatMessageData, TicketAutomationRunChatEventPayload } from './sockets.types'; @@ -154,6 +157,7 @@ export type ChatTimelineOrderedRow = { payload: import('./sockets.types').ForwardedEventPayload; timestamp: number; semanticTimestamp: number; + chatId?: string; }; function semanticSortKey(row: { event: string; payload: unknown; timestamp: number }): number { @@ -187,12 +191,38 @@ function semanticSortKey(row: { event: string; payload: unknown; timestamp: numb /** * Chat messages merged with ticket automation chat events, ordered by semantic time. * Automation rows are deduped by `run.id` (latest `timelineAt` wins). Filtered to `run.agentId === selectedAgentId` when an agent is selected. + * Chat messages are filtered to the selected chat session when a chatId is selected and present on the message. */ export const selectChatTimelineOrdered = createSelector( selectForwardedEvents, selectSelectedAgentId, - (events, selectedAgentId): ChatTimelineOrderedRow[] => { - const chatMsgs = events.filter((e) => e.event === 'chatMessage'); + selectSelectedClientId, + selectSelectedChatIdsMap, + (events, selectedAgentId, selectedClientId, selectedChatIds): ChatTimelineOrderedRow[] => { + const selectedChatId = + selectedClientId && selectedAgentId + ? (selectedChatIds[getClientAgentKey(selectedClientId, selectedAgentId)] ?? null) + : null; + const chatMsgs = events.filter((e) => { + if (e.event !== 'chatMessage') { + return false; + } + + if (!selectedChatId) { + return true; + } + + const rowChatId = + e.chatId ?? + (e.payload && + typeof e.payload === 'object' && + 'success' in e.payload && + e.payload.success && + (e.payload as { data?: ChatMessageData }).data?.chatId); + + // Strict session isolation: only show messages tagged for the selected chat. + return rowChatId === selectedChatId; + }); const rawAuto = events.filter((e) => e.event === CLIENT_CHAT_AUTOMATION_SOCKET_EVENT); const byRun = new Map(); diff --git a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts index d7eac2eb2..58fc68a60 100644 --- a/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts +++ b/libs/domains/agenstra/frontend/data-access-agent-console/src/lib/state/sockets/sockets.types.ts @@ -27,6 +27,7 @@ export enum ForwardableEvent { CREATE_TERMINAL = 'createTerminal', TERMINAL_INPUT = 'terminalInput', CLOSE_TERMINAL = 'closeTerminal', + RESTORE_CHAT = 'restoreChat', } /** @@ -51,7 +52,8 @@ export type ForwardableEventPayload = | FileUpdatePayload | CreateTerminalPayload | TerminalInputPayload - | CloseTerminalPayload; + | CloseTerminalPayload + | RestoreChatPayload; /** * Chat event payload (from agents.gateway.ts ChatPayload) @@ -62,6 +64,15 @@ export interface ChatPayload { correlationId?: string; responseMode?: AgentResponseMode; contextInjection?: ContextInjectionPayload; + /** User-visible chat session id; defaults to primary when omitted. */ + chatId?: string; +} + +/** + * Restore a specific chat session history into the viewer timeline. + */ +export interface RestoreChatPayload { + chatId: string; } export type AgentResponseMode = 'single' | 'stream'; @@ -86,6 +97,7 @@ export interface AgentEventEnvelopeBase { timestamp: string; kind: AgentEventKind; payload: unknown; + chatId?: string; } export type AgentEventEnvelope = AgentEventEnvelopeBase; @@ -146,11 +158,13 @@ export type TicketBodyResultPayload = ChatEnhanceResultPayload; /** * Login event payload (from agents.gateway.ts LoginPayload) - * Note: When forwarding with agentId, the payload is overridden with credentials from database + * Note: When forwarding with agentId, credentials are loaded from the database; + * optional chatId requests restore of that session on login. */ export interface LoginPayload { agentId: string; password: string; + chatId?: string; } /** @@ -255,6 +269,7 @@ export interface UserChatMessageData { from: ChatActor.USER; text: string; timestamp: string; + chatId?: string; } /** @@ -264,6 +279,7 @@ export interface AgentChatMessageData { from: ChatActor.AGENT; response: AgentResponseObject | string; // Parsed JSON object or raw string if parsing fails timestamp: string; + chatId?: string; } /** diff --git a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts index ab8cca2f0..96ad422b5 100644 --- a/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts +++ b/libs/domains/agenstra/frontend/feature-agent-console/src/lib/agent-console.routes.ts @@ -25,6 +25,7 @@ import { createClientAgent$, createDeploymentConfiguration$, createEnvironmentVariable$, + createChatSession$, createExternalImportConfig$, createFileOrDirectory$, createFilterRule$, @@ -36,6 +37,7 @@ import { deleteBranch$, deleteClient$, deleteClientAgent$, + deleteChatSession$, deleteDeploymentConfiguration$, deleteEnvironmentVariable$, deleteExternalImportConfig$, @@ -55,6 +57,8 @@ import { duplicateKnowledgeNode$, EnvFacade, envReducer, + ChatSessionsFacade, + chatSessionsReducer, fetch$, FilesFacade, filesReducer, @@ -90,6 +94,7 @@ import { loadEnvironmentVariables$, loadEnvironmentVariablesBatch$, loadEnvironmentVariablesCount$, + loadChatSessions$, loadFilterRules$, loadMoreFilterRules$, loadGitBranches$, @@ -175,6 +180,7 @@ import { updateAtlassianConnection$, updateClient$, updateClientAgent$, + updateChatSession$, updateDeploymentConfiguration$, updateEnvironmentVariable$, updateExternalImportConfig$, @@ -323,6 +329,7 @@ export const agentConsoleRoutes: Route[] = [ KnowledgeBoardSocketFacade, FilesFacade, EnvFacade, + ChatSessionsFacade, VcsFacade, StatsFacade, StatisticsFacade, @@ -343,6 +350,7 @@ export const agentConsoleRoutes: Route[] = [ provideState('knowledgeBoardSocket', knowledgeBoardSocketReducer), provideState('files', filesReducer), provideState('env', envReducer), + provideState('chatSessions', chatSessionsReducer), provideState('vcs', vcsReducer), provideState('stats', statsReducer), provideState('statistics', statisticsReducer), @@ -418,6 +426,10 @@ export const agentConsoleRoutes: Route[] = [ updateEnvironmentVariable$, deleteEnvironmentVariable$, deleteAllEnvironmentVariables$, + loadChatSessions$, + createChatSession$, + updateChatSession$, + deleteChatSession$, loadGitStatus$, loadGitBranches$, loadGitDiff$, 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 771796f38..3c9a34769 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 @@ -587,7 +587,7 @@
Environments
>
-
+
+ +
+ } @else { +
{{ getChatSessionDisplayTitle(selectedChatSession$ | async) }}
+ }
@if (activeClient$ | async; as activeClient) { @@ -1640,6 +1723,116 @@
+ + + +