AW-12 — Chat, group conversations and the organization channel · Task breakdown
Program: Agent Workspace · Epic ID: AW-12-chat-channels
Spec: spec.md · Plan: plan.md
Status: Draft · Last updated: 2026-09-06
How to use
- Tasks run top to bottom. A task marked
(parallel)may run alongside the one before it. - Every task names the files to create or modify and states what done means.
- Every task belongs to a phase: P1 conversations, participants and the panel; P2 groups and
Agent-to-Agent; P3 the organization channel. Each phase leaves
developgreen on its own. - Add new tasks at the bottom rather than renumbering.
- Commands run from the repo root unless stated:
pnpm lint,pnpm type-check,pnpm test,pnpm build. Migration authoring runs fromapps/api/. - Hard rule for every task: nothing existing is removed, renamed or retyped. The dead i18n keys
dashboard.aiChat.editMessage/saveEdit/cancelEdit/editedstay exactly where they are.
Phase P1 — Conversations, participants and the panel
P1.1 — Data model
-
T1 · Shared types Create
packages/agent/src/conversations/conversation.types.tswithConversationKind(direct/group/organization_channel/agent_pair),ConversationMessageStatus(sending/sent/failed),ConversationAuthorType(user/agent/system),ConversationReachOutcome(delivered/queued/skipped/refused),ConversationReach, and the numeric constants from the spec:MAX_CONVERSATION_BODY_BYTES = 16 * 1024,MAX_MENTIONS_PER_MESSAGE = 10,MAX_ATTACHMENTS_PER_MESSAGE = 10,MAX_GROUP_AGENTS = 8,MAX_DISPATCH_PER_MESSAGE = 8,MAX_BROADCAST_AGENTS = 200,PROMOTION_CARRY_MESSAGES = 20,PROMOTION_CARRY_DAYS = 7,AGENT_PAIR_STREAK_CEILING = 20,AGENT_PAIR_DAILY_MESSAGE_CEILING = 200,CONVERSATION_NAME_MAX = 200. Done: compiles; every constant carries a one-line comment naming the FR it implements. -
T2 · Participant entity Create
packages/agent/src/entities/conversation-participant.entity.tswith@Entity('conversation_participants')and the columns in plan §3.4:id,conversationId(FKconversations.id,ON DELETE CASCADE),participantType,participantId,role,joinedAt,leftAt,lastReadMessageId,lastReadAt,mutedAt,tenantId,organizationId,createdAt,updatedAt. Raw uuid columns forparticipantId— no@ManyToOnetoUserorAgent(the entity-cycle rule documented inconversation.entity.ts). Declare@Index('uq_conversation_participants', [...], { unique: true })and@Index('idx_conversation_participants_target', [...]). Done: compiles; the class doc comment states that the unique index is the mechanism that makes concurrent group promotion safe (spec FR-56). -
T3 · Register the participant entity in all three registries Modify
packages/agent/src/entities/index.ts(add theexport *),packages/agent/src/database/_entity-names.ts(insert'ConversationParticipant'alphabetically, beside the existing'Conversation'/'ConversationMessage'at line ~74), andpackages/agent/src/database/_entities-inventory.ts(import at ~line 44, add toENTITIESat ~line 195). Done:packages/agent/src/database/database.module.spec.tspasses. -
T4 · Columns on
conversationsModifypackages/agent/src/entities/conversation.entity.ts: addkind(varchar 24, not null, default'direct'),agentId(uuid, nullable),titleSource(varchar 8, nullable),contextType(varchar 16, nullable),contextId(uuid, nullable),lastMessageAt(nullable). Add the P1 indexesidx_conversations_user_kind_activityandidx_conversations_agent_activity. Done: compiles;titleSource's comment explains that'user'permanently disables the automatic titling run byconversation-title.service.ts(spec FR-6), and no existing column is touched. -
T5 · Columns on
conversation_messagesModifypackages/agent/src/entities/conversation-message.entity.ts: addauthorType(varchar 8, not null, default'user'),authorId(uuid, nullable),mentions(simple-json, nullable — reuse theTaskChatMentionshape frompackages/agent/src/entities/task-chat-message.entity.ts),attachments(simple-json, nullable),status(varchar 8, not null, default'sent'),failureCode(varchar 40, nullable),clientMessageId(varchar 64, nullable),replyToMessageId(uuid, nullable). Addidx_conversation_messages_statusand the partial uniqueuq_conversation_messages_client_id. Done: compiles; the existingrolecolumn keeps its meaning and its comment now saysroledescribes what the model sees whileauthorTypedescribes who wrote it. -
T6 · Column on
agent_runs+ trigger kind Modifypackages/agent/src/entities/agent-run.entity.ts: addconversationMessageId(uuid, nullable, FKconversation_messages.id,ON DELETE SET NULL) with indexidx_agent_runs_conversation_message, and widen theAgentRunTriggerKindunion with'conversation'. Done: compiles; the column comment states it is populated only whentriggerKind = 'conversation'and explains whychatMessageId(an FK totask_chat_messages) could not be reused. -
T7 · Migration — SAME PR as T2–T6 (Constitution V) Create
apps/api/src/migrations/1791120000000-AddConversationKindAndParticipants.ts.up():ADD COLUMNfor every field in T4/T5/T6,CREATE TABLE conversation_participants, every P1 index, then the backfill — oneownerparticipant row per existing conversation from itsuserId;lastMessageAt = (SELECT MAX("createdAt") FROM conversation_messages …);titleSource = 'auto'wheremetadatarecords an AI title.down()reverses in reverse order. Done: generated withpnpm typeorm migration:generate -d typeorm.config.tsfromapps/api/, reviewed to contain noDROP COLUMN, no rename and no retype inup(), and the API boots clean against a database seeded fromdevelop. -
T8 · Participant repository Create
packages/agent/src/database/repositories/conversation-participant.repository.ts—listForConversation,listConversationsFor(participantType, participantId),addIfAbsent(catching the unique-constraint violation and returning the existing row),markLeft,markRead,countActiveAgents. Export frompackages/agent/src/database/index.tsand add topackages/agent/src/database/_repository-inventory.ts. Done: the repository-inventory drift spec passes. -
T9 · Extend the conversation repository Modify
packages/agent/src/database/repositories/conversation.repository.ts: addkind,agentId,archived,contextType/contextIdfilters tofindByUser; addfindByIdForParticipant,touchLastMessageAt,setName(id, name | null),unreadCountsFor(userId, conversationIds),findMessagesPaged(conversationId, limit, before)andfindByClientMessageId. Do not change any existing signature. Done: existing callers compile unchanged; new methods have unit coverage in T35.
P1.2 — Domain services
-
T10 · Domain module skeleton Create
packages/agent/src/conversations/index.tsandpackages/agent/src/conversations/conversations.module.ts, modelled onpackages/agent/src/tasks-domain/tasks.module.ts. Add a./conversationssubpath topackages/agent/package.jsonexports, beside the existing./tasks-domainentry. Done:turbo build --filter=@ever-works/agentemitsdist/conversations/index.jsand.d.ts. -
T11 · Mention service Create
packages/agent/src/conversations/conversation-mention.service.ts. Port the parser shape frompackages/agent/src/tasks-domain/task-chat.service.ts(MENTION_RE, theMentionLookupscontract, the resolve-then-strip rule) and extend it: full display-name matching including multi-word names, case-insensitive, never prefix; per-kind candidate sets; the 10-mention cap; andresolveCandidates(query, conversationId, viewerId)returning at most 8 ranked candidates for the picker. Done: unresolved tokens are absent from the agent-visible body and present verbatim in the stored body; an Agent the viewer cannot see returns exactly the same result as a name that does not exist (spec FR-96). -
T12 · Conversation service Create
packages/agent/src/conversations/conversation.service.ts—create(kind rules, participant seeding, context validation),rename(id, name | null)with the 200-character cap andtitleSourcehandling,get/listscoped throughownershipWherefrompackages/agent/src/database/ownership-scope.ts,markRead, andassertParticipantthat throwsNotFoundException(neverForbidden) for a non-participant (spec FR-95). Done: unit-covered by T35; a cross-user read returns 404-shaped behaviour. -
T13 · Message service Create
packages/agent/src/conversations/conversation-message.service.ts—post()running the pipeline in plan §2.3:assertNoSecrets(frompackages/agent/src/utils/secret-scan.ts) → 16 KB cap → mention resolve → persist withclientMessageId→ dispatch. Plusretry(messageId)(only fromfailed),discard(messageId)(only fromfailed), andappendAgentMessage()used by the reply job. Done: posting the sameclientMessageIdtwice returns the first message and creates no second row; a body over 16 KB and a body containing a credential are both rejected before insert. -
T14 · Dispatch service Create
packages/agent/src/conversations/conversation-dispatch.service.tsimplementing the reply contract (spec FR-87–FR-93): direct → the addressed Agent; group/channel with mentions → exactly those; group without mentions → all addressable, each deciding; duplicate mentions → one dispatch; live-run steering throughRUN_STEERING_PORT; admission throughRunDispatchGateService; the 8-dispatch ceiling; and the reach classification returned to the caller. Done: every branch is unit-covered in T36, and no code path in this service imports a job-runtime SDK. -
T15 · Dispatcher ports Create
packages/agent/src/conversations/conversation-dispatcher.ts— a leaf file with no service imports, mirroringpackages/agent/src/tasks-domain/task-dispatcher.ts. ExportAGENT_CONVERSATION_REPLY_DISPATCHERandCONVERSATION_BROADCAST_DISPATCHERplus their payload interfaces. Done: the file imports nothing frompackages/agent/src/conversations/*.service.ts, and its doc comment states whyAGENT_CHAT_REPLY_DISPATCHERwas not widened.
P1.3 — Job runtime (Constitution IV)
-
T16 · Reply job Create
packages/tasks/src/tasks/trigger/agent-conversation-reply.task.ts, modelled onagent-chat-reply.task.tsin the same folder: resolve the Conversation and the triggering message, build the Agent's context (recent messages, resolved document references, the attached context object), execute throughAgentRunService, and append the reply throughappendAgentMessagewithauthorType='agent'andreplyToMessageIdset. Register it inpackages/tasks/src/tasks/trigger/index.ts. Done: a reply produces anAgentRunwithtriggerKind='conversation'andconversationMessageIdpopulated. -
T17 · Trigger adapter Create
packages/tasks/src/dispatchers/conversation-dispatchers.tsexportingagentConversationReplyTriggerAdapter(and, in P3,conversationBroadcastTriggerAdapter), mirroringpackages/tasks/src/dispatchers/agent-task-dispatchers.ts, including its throw-not-swallow behaviour when the runtime is unconfigured. Done: a companion specconversation-dispatchers.spec.tsasserts the unconfigured case rejects rather than resolving. -
T18 · Bind the port Modify
apps/api/src/tasks/tasks.module.ts: add{ provide: AGENT_CONVERSATION_REPLY_DISPATCHER, useValue: agentConversationReplyTriggerAdapter }toprovidersbeside the existingAGENT_CHAT_REPLY_DISPATCHERbinding (~line 162), and add the symbol toexports(~line 189). Done:apps/api/src/tasks/tasks.module.di-contract.spec.tsis extended and passes.
P1.4 — API
-
T19 · DTOs Create
apps/api/src/ai-conversation/dto/conversation.dto.tswithListConversationsQueryDto,CreateConversationDto(moved out of the controller and extended),UpdateConversationDto(title: string | null;providerIdstill absent from the whitelist),PostConversationMessageDto,MarkReadDto,MentionCandidatesQueryDto. Every string field carries an explicit@MaxLength. Done:apps/api/src/ai-conversation/update-conversation.dto.spec.tsis extended and passes, including a case asserting that sendingproviderIdis still a 400. -
T20 · Contracts package Create
packages/contracts/src/conversations/conversation.types.tsandindex.ts, exported frompackages/contracts/src/index.ts, alongside the existinginbox/andhitl/folders. Done:turbo build --filter=@ever-works/contractsis clean and the web app can import the response types. -
T21 · Extend the conversation controller Modify
apps/api/src/ai-conversation/conversation.controller.tsper plan §4.1 and §4.2: new optional query params onGET, new optional body fields onPOST,title: nullonPATCH, the new response fields, and the new routesGET /:id/messages,POST /:id/messages/:messageId/retry,DELETE /:id/messages/:messageId,POST /:id/read,GET /mention-candidates. KeepMAX_CONVERSATIONS_PAGE_SIZE = 200; put@Throttle({ long: { limit: 30, ttl: 60_000 } })on every write. Done: every existing e2e spec underapps/web/e2e/matchingconversations*andchat*passes unmodified. -
T22 · Participants controller (read side) Create
apps/api/src/ai-conversation/conversation-participants.controller.tswithGET /api/conversations/:id/participantsonly. The write routes land in P2. Done: registered inapps/api/src/ai-conversation/ai-conversation.module.ts; a non-participant caller gets 404. -
T23 · SSE controller Create
apps/api/src/ai-conversation/conversation-stream.controller.tsimplementingGET /api/conversations/stream?conversationId=, copied in shape fromapps/api/src/email/email.controller.tslines ~167–250:text/event-streamheaders, prime- then-diff so the backlog is not announced, 5 s poll, 15 s heartbeat comment, 10-minute forced lifetime with full timer cleanup, every error swallowed. Done: the route is declared before any:idroute so it is not captured, and closing the client clears both timers. -
T24 · Wire the module Modify
apps/api/src/ai-conversation/ai-conversation.module.ts: import the new agent-packageConversationsModule, register the three controllers, keepOpenAiCompatServiceexported unchanged. Done:apps/api/src/api.module.tsneeds no change (the module is already registered at line ~166) and the API boots.
P1.5 — Web
-
T25 · API client + server actions Modify
apps/web/src/lib/api/conversations.tsto add a method per new endpoint, leaving every existing signature untouched. Createapps/web/src/app/actions/conversations.tswrapping them, matching the existingapps/web/src/app/actions/*pattern. Done:pnpm type-checkclean; no existing caller changed. -
T26 · Panel router and header Create
apps/web/src/components/ai/conversations/ConversationPanelRouter.tsxandConversationHeader.tsx. Modifyapps/web/src/components/ai/ChatPanel.tsxto render the router, andapps/web/src/components/ai/ChatInterface.tsxto accept aconversationIdandkind. Extendapps/web/src/components/ai/ChatProvider.tsxwith the view stack and the active participant. Done: navigating five dashboard routes never unmounts the panel, and only the close control closes it (spec FR-13). -
T27 · Conversation list and switcher Create
apps/web/src/components/ai/conversations/ConversationListPanel.tsxandParticipantSwitcher.tsx, with the loading, empty and error states from spec §6.2. Leaveapps/web/src/components/ai/ChatHistory.tsxuntouched — it stays as the flat all-conversation list. Done: a named Conversation renders bold-name-over-preview; an unnamed one renders the preview alone; no row reads "Untitled". -
T28 · Name control Create
apps/web/src/components/ai/conversations/ConversationNameDialog.tsxwith the 200-character counter and the clear-to-revert hint. Done: setting a name survives reload and stops automatic re-titling; clearing it restores the preview. -
T29 · Mention picker and highlight layer Create
apps/web/src/components/ai/conversations/MentionPicker.tsx(keyboard model copied fromapps/web/src/components/skills/SlashCommandAutocomplete.tsx) andComposerHighlightLayer.tsx(thearia-hidden, pointer-events-none overlay described in plan §5.1 D6). Createapps/web/src/lib/hooks/use-mention-candidates.ts. Modifyapps/web/src/components/ai/ChatInput.tsxto mount both and add the 16 KB pre-send guard. Done: the textarea is still uncontrolled; only server-confirmed tokens are highlighted; a non-matching@word never highlights. -
T30 · Outbox, failure and retry Create
apps/web/src/lib/hooks/use-conversation-outbox.ts(client id generation, optimistic insert, failed-message persistence underlocalStorage['chat-outbox'], Retry / Discard) andapps/web/src/components/ai/conversations/MessageRetryBar.tsxwith the reason copy from spec §6.10. Done: a failed message survives reload; two fast Retries produce exactly one delivered message; the composer text is never lost on a refusal. -
T31 · Live delivery Create
apps/web/src/lib/hooks/use-conversation-stream.ts, modelled onapps/web/src/lib/hooks/use-inbox-stream.ts:EventSourcewith a 30-second poll fallback and no user-visible error on the downgrade. Done: a message posted in a second browser context appears within 5 seconds; blocking the stream degrades silently to polling. -
T32 · Panel resize affordances Modify
apps/web/src/lib/hooks/use-chat-panel.tsxandapps/web/src/app/[locale]/(dashboard)/layout-client.tsx: add double-click-to-reset (420 px) on the drag handle and keyboard resize (←/→by 16 px,Homeresets) when the handle has focus. Do not touch the existing clampMath.max(350, Math.min(maxWidth, pointerWidth))(~line 346) or thechat-width/chat-panel-openpersistence. Done: width still restores with no visible reflow on first paint, and the handle is reachable byTabwith an accessible name. -
T33 · Entry points Wire
Chat about iton Mission cards (the menu item and its keydashboard.missionsPage.menu.chatare owned by AW-02) to open the docked panel withcontextType='mission', and add aMessage <agent>action onapps/web/src/app/[locale]/(dashboard)/agents/[id]/page.tsx. Done: both open the panel in place without navigating, and the context chip renders in the header.
P1.6 — i18n
- T34 · Message keys
Add the
conversations,panel,mentionsandsendFailureblocks from plan §8 todashboard.aiChatinapps/web/messages/en.json, then mirror the same keys with English values into the 20 sibling locale files inapps/web/messages/. Every leaf name is camelCase and contains no literal dot. Done:pnpm testpasses including the i18n key-shape checks, and no rendered surface shows a raw key.
P1.7 — Tests
-
T35 · Agent-package unit specs Create
packages/agent/src/conversations/__tests__/conversation-mention.service.spec.ts,conversation.service.spec.ts,conversation-message.service.spec.ts, andpackages/agent/src/database/repositories/conversation.repository.spec.ts+conversation-participant.repository.spec.ts. Done: covers spec FR-1..FR-12, FR-25..FR-48 and FR-94..FR-97 as listed in plan §10.1. -
T36 · Dispatch unit spec Create
packages/agent/src/conversations/__tests__/conversation-dispatch.spec.ts. Done: asserts mention → dispatch, duplicate mention → one dispatch, live-run steering, the 8-dispatch ceiling, and every reach outcome includingqueuedwith its reason. -
T37 · Controller specs Extend
apps/api/src/ai-conversation/conversation.controller.spec.ts; createconversation-stream.controller.spec.tsandconversation.controller.scope.spec.ts(the latter modelled onapps/api/src/tasks/task-chat.controller.scope.spec.ts). Done: cross-scope reads return 404; the SSE handler sets the right headers and clears both timers on close. -
T38 · Web unit specs Create
apps/web/src/components/ai/conversations/MentionPicker.unit.spec.tsx,ComposerHighlightLayer.unit.spec.tsx,MessageRetryBar.unit.spec.tsx; extendapps/web/src/components/ai/ChatProvider.unit.spec.ts. Done: all pass under the web app's Vitest runner. -
T39 · P1 e2e Create
apps/web/e2e/flow-conversation-naming.spec.ts,flow-conversation-panel-navigation.spec.ts,flow-conversation-mentions.spec.ts,flow-conversation-send-retry.spec.ts. Done: green, and every pre-existing spec matchingchat*/conversations*/flow-chat*/flow-conversation*still passes unmodified.
Phase P2 — Groups and Agent-to-Agent
P2.1 — Data model
-
T40 · Columns for groups and pairs Modify
packages/agent/src/entities/conversation.entity.ts: addarchivedAt(nullable),linkedConversationId(uuid, nullable, FKconversations.id,ON DELETE SET NULL),pausedReason(varchar 32, nullable),agentMessageStreak(int, not null, default 0). Done: compiles;agentMessageStreak's comment states it is reset by any message withauthorType='user'. -
T41 · Migration — SAME PR as T40 Create
apps/api/src/migrations/1791120100000-AddConversationGroupsAndPeers.ts:ADD COLUMNfor the four fields, the self-FK, and the index refresh onidx_conversations_user_kind_activityto includearchivedAt. Done: additive only;down()drops in reverse order; the API boots against a P1 database.