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.