Task Breakdown: AI Conversation
Feature ID: ai-conversation
Plan: ./plan.md
Status: Done (retrospective — surface already shipped)
Last updated: 2026-05-08
How to use
This document is a retrospective task list — every shipped task below
links to the merged PR or the source path. Outstanding follow-ups
(open questions in spec.md §8 plus deferred coverage gaps) are
listed at the bottom of the file as T30+.
Phase 1 — Data model & contracts (shipped)
- T1.
Conversationentity atpackages/agent/src/entities/conversation.entity.ts(userId, updatedAt)composite index,userIdstandalone index,ON DELETE CASCADEfromusers.
- T2.
ConversationMessageentity atpackages/agent/src/entities/conversation-message.entity.ts(conversationId, createdAt)composite +conversationIdstandalone index,ON DELETE CASCADEfromconversations.partsandusagecolumns aresimple-json.
- T3. Forward-only TypeORM migration under
apps/api/src/database/migrations/adding both tables. - T4. OpenAI-compat DTOs at
apps/api/src/ai-conversation/dto/openai-compat.dto.ts- Includes
OpenAiFunctionDto,OpenAiToolDefinitionDto,OpenAiMessageDto,OpenAiChatCompletionRequestDto, plus the response interfacesOpenAiToolCallResponse,OpenAiChatCompletionResponse,OpenAiChatCompletionChunkResponse. - Permissive:
@Allow()on free-form fields (tool_calls,tool_choice,response_format,parameters,stream_options) so the controller'sValidationPipe({whitelist:true})can strip without rejecting.
- Includes
Phase 2 — Repository layer (shipped)
- T5.
ConversationRepositoryatpackages/agent/src/database/repositories/conversation.repository.tscreate({userId, title?, providerId?, model?})findById(id, userId?)— relations:messages, orderedASCbycreatedAt.findByUser(userId, {limit, offset})— selects onlyid, title, providerId, model, createdAt, updatedAt, defaultslimit=50,offset=0, orderedupdatedAt DESC, returns{conversations, total}.appendMessage(input)— creates+saves one message, touches conversationupdatedAt.appendMessages(inputs)— sequential save with explicitcreatedAt = new Date(baseTime + i), then touches conversationupdatedAt. Empty input →[]no-op.updateTitle(id, userId, title, metadata?)— composite-key update so cross-user updates can't happen.delete(id, userId)—(id, userId)composite delete, returnsaffected > 0.deleteAllByUser(userId)— returnsaffected ?? 0.
- T6. Wire
ConversationRepositoryinto@ever-works/agent/database(already present in theDatabaseModuleexports via the entities + repos barrel).
Phase 3 — Title-generation service (shipped)
- T7.
ConversationTitleServiceatapps/api/src/ai-conversation/conversation-title.service.tsmaybeGenerateTitle(conversationId, userId)— gated onmessageCount >= 4 && !metadata.aiTitle.- Summarises last 4 user/assistant messages, each truncated to
200 chars; system prompt "Generate a short title (max 50
chars)…";
temperature: 0.3,maxTokens: 30. - Persists the trimmed AI response truncated to 100 chars with
metadata: {aiTitle: true}. - Catches every failure path with
logger.debug. extractMessageTextfalls back toparts[].textwhencontentis empty.resolveFacadeOptions(userId)—findByUserfirst work fallback, swallows repo errors.
Phase 4 — Conversation REST surface (shipped)
- T8.
ConversationControlleratapps/api/src/ai-conversation/conversation.controller.tsGET /api/conversations— list withlimit?,offset?parseInt(_, 10).POST /api/conversations— create with optionaltitle,providerId.GET /api/conversations/:id— read, 404 on missing / cross-user.PATCH /api/conversations/:id— rename, 204, 404 on missing.POST /api/conversations/:id/messages— append messages, derive first-message title (/\s+/g, ' 'collapse + trim + 60-char cap with...suffix), fire-and-forget AI title.DELETE /api/conversations/:id— delete one (204), 404 on missing.DELETE /api/conversations— delete all ({deleted: count}).- Swagger annotations:
@ApiTags('Conversations'),@ApiBearerAuth('JWT-auth'),@ApiOperationper endpoint.
Phase 5 — OpenAI-compat surface (shipped)
- T9.
OpenAiCompatServiceatapps/api/src/ai-conversation/openai-compat.service.tshandleCompletion(dto, facadeOptions)— resolveWorkContext → mapToInternalOptions →aiFacade.createChatCompletion→mapToOpenAiResponse.handleStreamingCompletion(dto, facadeOptions, res)— iterates the facade async iterator, writesdata: …\n\nSSE frames, ends withdata: [DONE]\n\n, maps pre-headers errors to 502 JSON, post-headers errors tores.destroy(error).mapToInternalOptions—model:'auto'→undefined, passes throughtemperature,max_tokens → maxTokens,top_p → topP,frequency_penalty → frequencyPenalty,presence_penalty → presencePenalty,stop,stream,tools,tool_choice → toolChoice,response_format → responseFormat,user.mapToInternalMessages— three-shape handler (assistant-with-tool-calls, tool-result, default).content === null→''.name?only when truthy.mapToOpenAiResponse—created: floor(ms/1000),content: nullwhen upstream is non-string,usageonly when present.mapToOpenAiStreamChunk—role:'assistant'whenever upstream delta carries one; omitcontentwhen undefined; emit tool-callid/type/nameonly on first chunk (whenchunk.idis set).resolveWorkContext— picks firstfindByUserwork whenworkIdis omitted; passes through unchanged whenworkIdis supplied.sanitizeErrorMessage— regex redact + 300-char cap.
- T10.
OpenAiCompatControlleratapps/api/src/ai-conversation/openai-compat.controller.tsPOST /api/v1/chat/completionswith@HttpCode(200)+ permissiveValidationPipe.- Forwards headers
x-provider-overrideandx-work-idtoFacadeOptions. - Branches on
body.streamfor SSE vs JSON.
Phase 6 — Module wiring (shipped)
- T11.
AiConversationModuleatapps/api/src/ai-conversation/ai-conversation.module.tsimports: [FacadesModule, DatabaseModule]controllers: [OpenAiCompatController, ConversationController]providers: [OpenAiCompatService, ConversationTitleService]
- T12. Imported into
ApiModuleso the controllers register under/api/conversationsand/api/v1/chat/completions.
Phase 7 — Tests (shipped via PR #484)
- T13.
conversation.controller.spec.ts— 16 tests.list— limit/offsetparseInt+ undefined passthrough.create— body forwarded to repo;auth.userIdinjected.get— 404 on null repo result.update— 204 +updateTitleinvocation; 404 on missing.appendMessages— happy path; 404 on missing; first-message title derivation ('\s+'collapse, 60-char cap,...suffix); existing-title short-circuit; fire-and-forget AI title invocation; AI-title-rejection swallowing; tool-message passthrough.delete— 204 + 404.deleteAll—{deleted: count}envelope.
- T14.
conversation-title.service.spec.ts— 15 tests.- Gating (
<4messages,aiTitle:truemetadata, missing conversation). - Summary windowing (last 4 user/assistant only, 200-char per
message,
parts[]fallback for empty content). - AI request shape (
temperature: 0.3,maxTokens: 30, system prompt verbatim). - Trim + 100-char cap on AI response.
- Empty / whitespace / non-string AI response → no update.
- AI request failure →
logger.debug+ no update. resolveFacadeOptions— first work wins; throw → fallback to{userId}only.
- Gating (
- T15.
openai-compat.controller.spec.ts— 4 tests.- Streaming branch sets the four SSE headers and calls
handleStreamingCompletion. - Non-streaming branch sets
Content-Type: application/jsonand callshandleCompletion. x-provider-overrideandx-work-idheaders forwarded toFacadeOptions.- Permissive validation — extra fields stripped, not rejected.
- Streaming branch sets the four SSE headers and calls
- T16.
openai-compat.service.spec.ts— 21 tests.- DTO → internal options mapping for every supported field.
model === 'auto'→undefined.- Tool-call
namepassthrough. - Three-shape message mapping (assistant-with-tools, tool-result, default).
content === null→''.- Internal → OpenAI response mapping
(
created: floor(ms/1000), non-stringcontent→null,usagegating). - Streaming chunk mapping (
role:'assistant'normalisation,contentundefined-omit, tool-call delta first-vs-continuationid/type/namerules). - Pre-headers error → 502 JSON envelope.
- Post-headers error →
res.destroy(error)with the originalError(or wrapped non-Error). sanitizeErrorMessage—sk-…/Bearer …redaction + 300-char truncation + non-Error fallback.resolveWorkContext— first-work pick / passthrough on explicitworkId/ no-work fallback.
Phase 8 — Docs & retrospective
- T17. Spec, plan, and tasks authored under
docs/specs/features/ai-conversation/(this PR). - T18. Cross-link from
docs/specs/features/index.md(or the auto-generated index, if used) — no separate index file indocs/specs/features/; cross-references are surfaced via theCOVERAGE-TRACKER.md"Pending — Medium Priority" checkbox flip. - T19.
COVERAGE-TRACKER.mdrow moved to the "Done" table with this PR's link.
Outstanding follow-ups
These map 1:1 to the open questions in spec.md §8 and to coverage
gaps not yet shipped. Each is its own future PR.
- T30. Decide on / implement OQ-1: optional persistence of
the user/assistant turn into a
ConversationfromOpenAiCompatService.handleStreamingCompletionwhenx-conversation-idis supplied (the comments in the source hint at this but it isn't wired). Owner: TBD. - T31. OQ-2: tighten the title-derivation regex to also
strip zero-width / RTL marks, OR document why the loose
/\s+/gis sufficient. - T32. OQ-3: make
resolveWorkContext/resolveFacadeOptionsdeterministic — add adefaultWorkIdcolumn onUser(or pickoldest-work/most-recently-activeconsistently) and document the choice inplan.md§11. - T33. OQ-4: title-generation should resolve provider from
conversation.providerIdfirst, falling back to the user's first work only whenproviderIdis unset. UpdateConversationTitleService.resolveFacadeOptionsaccordingly. - T34. OQ-5: switch
appendMessagesto a single batch save with explicitcreatedAtoverrides — required for long-paste conversations to scale. Add cross-driver tests (Postgres / MySQL / SQLite) before flipping the implementation. - T35. OQ-6: short-circuit
OpenAiCompatService.handleCompletion/handleStreamingCompletionwithBadRequestExceptionifuserRepository.findById(userId)returns null — protects against hard-deleted-user races where the auth session survives the user row. - T36. OQ-7 (deferred from §4): add a typed
AppendMessagesDtoforPOST /api/conversations/:id/messagesto match the rest of the codebase's class-validator pattern (today the body is a plain inline shape). - T37. e2e test against
/api/v1/chat/completionsusing a mocked AI provider plugin to pin the streaming wire format end-to-end (current coverage is unit-level only). - T38. Postgres-container integration test for
ConversationRepositorycovering: cascade delete fromusers; cascade delete toconversation_messages; the(userId, updatedAt)and(conversationId, createdAt)indexes;appendMessagesordering after a 100-row insert. - T39. Document the
chat/completionssurface indocs/api/— the canonical OpenAI-compat endpoint reference is not yet written. Cross-link fromapps/docs/sidebarsPlatform.ts.
Definition of Done
- All checkboxes T1–T19 ticked. ✅ (this is a retrospective spec)
- 56 unit tests in PR #484 passing.
pnpm format:check,pnpm lint, andpnpm --filter ever-works-api testgreen at PR-merge time.- Outstanding follow-ups T30–T39 captured above; none are blocking.