From d8038ed956697c68e870319879fd2955ce62deb7 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:34:38 +0800 Subject: [PATCH 1/9] docs: add draft_task entity design spec for multi-turn clarification fix Introduces the design for a draft_task conversation entity kind that anchors new-task write workflows before a real task is persisted. This fixes the bug where clarification answers lose accumulated fields because no entity exists for parentTargetRef to point at. Co-Authored-By: Claude Opus 4.6 --- .../2026-04-16-draft-task-entity-design.md | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-16-draft-task-entity-design.md diff --git a/docs/superpowers/specs/2026-04-16-draft-task-entity-design.md b/docs/superpowers/specs/2026-04-16-draft-task-entity-design.md new file mode 100644 index 0000000..c606563 --- /dev/null +++ b/docs/superpowers/specs/2026-04-16-draft-task-entity-design.md @@ -0,0 +1,123 @@ +# Draft Task Entity for Multi-Turn Write Workflows + +**Date:** 2026-04-16 +**Status:** Approved +**Relates to:** [Bug: Clarification answer resets write workflow](../../bugs/2026-04-08-clarification-workflow-reset.md) + +## Problem + +When a user starts a new task via clarification ("Schedule gym tomorrow" -> "What time?" -> "10am"), the write pipeline loses accumulated fields and treats the answer as a new workflow. The root cause: no task entity exists in the entity registry until after mutation, so the clarification entity's `parentTargetRef` is `null`, and `resolveWriteTarget` returns the clarification entity ID as the write target. `applyWriteCommit` sees a target mismatch and wipes prior fields. + +PR #85 added generic `parentTargetRef` resolution, which fixes the bug for existing-task edits. But for new tasks, there is nothing for `parentTargetRef` to point at. + +## Solution + +Introduce a `draft_task` entity kind in the conversation entity registry. Any time a user initiates a new planning operation (`operationKind === "plan"`, `targetRef === null`), a `draft_task` entity is created to represent the intent before the task is persisted to the database. Clarification entities point their `parentTargetRef` at the draft, and the persisted `pending_write_operation.targetRef` is patched to reference it. This allows `applyWriteCommit` to see matching targets across turns and accumulate fields correctly. + +## Design + +### Schema + +Add `draft_task` to the entity discriminated union in `packages/core/src/index.ts`: + +```typescript +export const conversationDraftTaskEntitySchema = conversationEntityBaseSchema.extend({ + kind: z.literal("draft_task"), + data: z.object({ + operationKind: operationKindSchema, + taskName: z.string().nullable(), + resolvedFields: resolvedFieldsSchema, + originatingText: z.string().min(1), + }), +}); +``` + +Add to `conversationEntitySchema` discriminated union and `ConversationEntity` type. + +Supporting changes: +- `getParentEntityId` in `turn-router.ts`: add `"draft_task"` case returning `undefined` (drafts are targets, not references) +- `entityKey` in `conversation-state.ts`: add `"draft_task"` case keyed by entity ID (no DB `taskId` to key on) + +### Creation trigger + +In `deriveConversationReplyState`, after existing entity sync and before the clarification block: + +**Condition:** `policy.resolvedOperation` exists, `operationKind === "plan"`, and `targetRef` is `null`. + +**Behavior:** +1. If a `draft_task` entity already exists in the registry (prior turn in same workflow), update its `resolvedFields` and `taskName` from the latest `resolvedOperation`. +2. Otherwise, create a new `draft_task` via `buildConversationEntity`. +3. Store the draft entity ID for use by the clarification block. + +### parentTargetRef wiring + +In the clarification block (~line 131 of `conversation-state.ts`), change: +```typescript +const parentTargetRef = input.policy.resolvedOperation?.targetRef ?? null; +``` +to: +```typescript +const parentTargetRef = draftEntityId + ? { entityId: draftEntityId } + : (input.policy.resolvedOperation?.targetRef ?? null); +``` + +New-task clarifications always point to the draft. Existing-task clarifications still point to the real task entity. + +### pending_write_operation targetRef patch + +After creating or finding the draft, patch `resolvedOperation.targetRef` to `{ entityId: draftEntityId }` before it is stored as `pending_write_operation` in discourse state (line ~194 of `conversation-state.ts`). + +This ensures that on Turn 2, `applyWriteCommit` sees `priorPendingWriteOperation.targetRef = { entityId: draft-id }` matching the resolved target, so `targetChanged = false` and fields accumulate. + +### Resolution flow (Turn 2) + +No changes needed to `resolveWriteTarget`. Existing generic parent ref resolution handles the path: +1. Read `focus_entity_id` -> clarification entity +2. `getParentEntityId(clarification)` -> draft entity ID via `parentTargetRef` +3. Return draft entity ID as `targetEntityId` + +### Promotion after mutation + +In `deriveMutationState`, when a real `task` entity is created from processing results, find and supersede (`status: "superseded"`) any active `draft_task` entity in the same conversation. + +### Entity context + +Add `"draft_task"` case to `buildEntityContext` in `entity-context.ts`. Render with `expectedType: "draft_task"` and `taskName`/`originatingText` as label, so the LLM knows a task is being planned. + +### Focus tracking + +No changes. `nextFocusEntityId` is set to the clarification entity when one is created. The draft is reachable through `parentTargetRef`. + +## Edge cases + +1. **User pivots mid-workflow** — Different `taskName`/fields cause `applyWriteCommit` to detect `operationChanged = true`. Old draft superseded, new draft created. +2. **All fields provided on Turn 1** — Draft created then immediately superseded by real task entity in `deriveMutationState`. +3. **Edit to existing task** — `operationKind` is `"edit"` or `targetRef` is non-null. Draft creation trigger does not fire. +4. **Multiple clarification rounds** — Each turn finds the existing draft, updates `resolvedFields`. New clarifications point to the same draft. + +## Testing + +- Unit: `draft_task` entity created when `operationKind === "plan"` and `targetRef === null` +- Unit: `parentTargetRef` on clarification points to draft entity +- Unit: `pending_write_operation.targetRef` patched to draft entity ID +- Unit: `resolveWriteTarget` follows clarification -> draft via `parentTargetRef` +- Unit: `applyWriteCommit` accumulates fields across turns (no wipe) +- Unit: draft superseded after mutation creates real task +- Integration: full 2-turn "schedule gym tomorrow" -> "10am 30min" -> successful execution + +## Files affected + +| File | Change | +|------|--------| +| `packages/core/src/index.ts` | `conversationDraftTaskEntitySchema`, add to discriminated union | +| `packages/core/src/entity-context.ts` | `buildEntityContext` draft_task case | +| `apps/web/src/lib/server/conversation-state.ts` | Draft creation trigger, parentTargetRef wiring, targetRef patch, entityKey case, draft superseding in `deriveMutationState` | +| `apps/web/src/lib/server/turn-router.ts` | `getParentEntityId` draft_task case | +| `apps/web/src/lib/server/turn-router.test.ts` | New resolution tests | +| `apps/web/src/lib/server/conversation-state.test.ts` | Draft creation, parentTargetRef, targetRef patch tests | +| `packages/core/src/entity-context.test.ts` | Draft entity in LLM context | + +## Secondary fix: misleading ambiguity reason + +`deriveAmbiguityReason` in `turn-router.ts` falls through to "Classification confidence is too low for reliable routing" when the actual ambiguity source is missing fields. Add a missing-fields case to the reason derivation. This is cosmetic but improves debugging. From 1f3ec393663ee533fdf063d58832b7a99871ce3a Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:41:30 +0800 Subject: [PATCH 2/9] chore: add .worktrees to gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 45bc43c..8eb3a0f 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ coverage .env.local .env.*.local *.tsbuildinfo +.worktrees packages/integrations/*.manual-eval-report.json packages/integrations/manual-eval-report.json packages/integrations/*.prompt-improvement.md From dad62f95039ed0001eef887e311e7a97d94a2a39 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:43:54 +0800 Subject: [PATCH 3/9] feat: add draft_task entity schema to conversation entity union --- packages/core/src/entity-context.test.ts | 46 ++++++++++++++++++++++++ packages/core/src/index.ts | 12 +++++++ 2 files changed, 58 insertions(+) diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts index 5ed66ed..57abb32 100644 --- a/packages/core/src/entity-context.test.ts +++ b/packages/core/src/entity-context.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { buildEntityContext, + conversationEntitySchema, renderEntityContext, type ConversationEntity, type Task, @@ -235,3 +236,48 @@ describe("entity context", () => { ); }); }); + +describe("draft_task entity schema", () => { + it("parses a valid draft_task entity", () => { + const result = conversationEntitySchema.safeParse({ + id: "draft-1", + conversationId: "conversation-1", + kind: "draft_task", + label: "Schedule gym tomorrow", + status: "active", + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:00:00.000Z", + data: { + operationKind: "plan", + taskName: "gym", + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + originatingText: "schedule gym tomorrow", + }, + }); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.kind).toBe("draft_task"); + } + }); + + it("rejects draft_task with empty originatingText", () => { + const result = conversationEntitySchema.safeParse({ + id: "draft-1", + conversationId: "conversation-1", + kind: "draft_task", + label: "Draft", + status: "active", + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:00:00.000Z", + data: { + operationKind: "plan", + taskName: null, + resolvedFields: {}, + originatingText: "", + }, + }); + + expect(result.success).toBe(false); + }); +}); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb377c3..b9ef67d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -715,12 +715,24 @@ export const conversationReminderEntitySchema = }), }); +export const conversationDraftTaskEntitySchema = + conversationEntityBaseSchema.extend({ + kind: z.literal("draft_task"), + data: z.object({ + operationKind: operationKindSchema, + taskName: z.string().nullable(), + resolvedFields: resolvedFieldsSchema, + originatingText: z.string().min(1), + }), + }); + export const conversationEntitySchema = z.discriminatedUnion("kind", [ conversationTaskEntitySchema, conversationProposalOptionEntitySchema, conversationScheduledBlockEntitySchema, conversationClarificationEntitySchema, conversationReminderEntitySchema, + conversationDraftTaskEntitySchema, ]); export const conversationRecordSchema = z.object({ From 266f12a0b881d2a34567cbc8a275b31fe02a700b Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:45:34 +0800 Subject: [PATCH 4/9] feat: include draft_task entities in LLM entity context --- packages/core/src/entity-context.test.ts | 60 ++++++++++++++++++++++++ packages/core/src/entity-context.ts | 16 ++++++- 2 files changed, 75 insertions(+), 1 deletion(-) diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts index 57abb32..b10d0f7 100644 --- a/packages/core/src/entity-context.test.ts +++ b/packages/core/src/entity-context.test.ts @@ -223,6 +223,66 @@ describe("entity context", () => { ); }); + it("includes active draft_task entities in known entities", () => { + const context = buildEntityContext({ + entityRegistry: [ + buildEntity({ + id: "draft-1", + label: "Schedule gym tomorrow", + kind: "draft_task", + status: "active", + data: { + operationKind: "plan", + taskName: "gym", + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + originatingText: "schedule gym tomorrow", + }, + }), + ], + tasks: [], + discourseState: { + focus_entity_id: null, + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "planning", + }, + }); + + expect(context.knownEntities).toEqual([ + { + id: "draft-1", + label: "gym — schedule gym tomorrow", + expectedType: "draft_task", + state: "planning", + }, + ]); + }); + + it("excludes superseded draft_task entities from known entities", () => { + const context = buildEntityContext({ + entityRegistry: [ + buildEntity({ + id: "draft-1", + label: "Schedule gym", + kind: "draft_task", + status: "superseded", + data: { + operationKind: "plan", + taskName: "gym", + resolvedFields: {}, + originatingText: "schedule gym", + }, + }), + ], + tasks: [], + discourseState: null, + }); + + expect(context.knownEntities).toEqual([]); + }); + it("renders an explicit no-known-entities line when the context is empty", () => { expect( renderEntityContext({ diff --git a/packages/core/src/entity-context.ts b/packages/core/src/entity-context.ts index 397be50..847dbe3 100644 --- a/packages/core/src/entity-context.ts +++ b/packages/core/src/entity-context.ts @@ -9,7 +9,8 @@ export type EntityContextEntry = { | "proposal" | "clarification" | "scheduled_block" - | "reminder"; + | "reminder" + | "draft_task"; state: string; }; @@ -94,6 +95,19 @@ export function buildEntityContext( state: entity.status, }); break; + case "draft_task": + if (entity.status === "active") { + const draftLabel = entity.data.taskName + ? `${entity.data.taskName} — ${entity.data.originatingText}` + : entity.data.originatingText; + knownEntities.push({ + id: entity.id, + label: draftLabel, + expectedType: "draft_task", + state: "planning", + }); + } + break; } } From c66a65276a9880c103460b22a0b6fc1d0dc258a9 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:52:16 +0800 Subject: [PATCH 5/9] feat: add draft_task to getParentEntityId, improve ambiguity reason for missing fields --- apps/web/src/lib/server/turn-router.test.ts | 63 +++++++++++++++++++++ apps/web/src/lib/server/turn-router.ts | 8 +++ 2 files changed, 71 insertions(+) diff --git a/apps/web/src/lib/server/turn-router.test.ts b/apps/web/src/lib/server/turn-router.test.ts index c466ce8..4df9ffc 100644 --- a/apps/web/src/lib/server/turn-router.test.ts +++ b/apps/web/src/lib/server/turn-router.test.ts @@ -582,6 +582,36 @@ describe("turn router", () => { result.policy.resolvedOperation?.resolvedFields.scheduleFields?.day, ).toBeUndefined(); }); + + it("derives ambiguity reason for missing fields instead of generic confidence message", async () => { + mockClassification({ + turnType: "planning_request", + confidence: 0.95, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: "gym", + fields: { scheduleFields: { day: "tomorrow" } }, + sourceText: "schedule gym tomorrow", + confidence: { "scheduleFields.day": 0.95 }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "schedule gym tomorrow", + normalizedText: "schedule gym tomorrow", + recentTurns: [], + }); + + // Should have missing time field, ambiguity should mention fields + if (result.interpretation.ambiguityReason) { + expect(result.interpretation.ambiguityReason).not.toBe( + "Classification confidence is too low for reliable routing.", + ); + } + }); }); describe("resolveWriteTarget", () => { @@ -835,6 +865,39 @@ describe("resolveWriteTarget", () => { resolvedProposalId: "proposal-1", }); }); + + it("does not follow parentTargetRef for draft_task entities (drafts are targets, not references)", () => { + const result = resolveWriteTarget( + { + focus_entity_id: "draft-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "planning", + }, + [ + { + id: "draft-1", + conversationId: "c-1", + kind: "draft_task", + label: "Schedule gym", + status: "active", + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:00:00.000Z", + data: { + operationKind: "plan", + taskName: "gym", + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + originatingText: "schedule gym tomorrow", + }, + } as any, + ], + "clarification_answer", + ); + + expect(result.targetEntityId).toBe("draft-1"); + }); }); describe("containsModificationPayload", () => { diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index ec6503a..6867e21 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -39,6 +39,8 @@ function getParentEntityId(entity: ConversationEntity): string | undefined { switch (entity.kind) { case "clarification": return entity.data.parentTargetRef?.entityId; + case "draft_task": + return undefined; default: return undefined; } @@ -286,6 +288,7 @@ function buildInterpretation( ambiguityReason: deriveAmbiguityReason( classification.turnType, ambiguity, + allMissingFields, ), } : {}), @@ -296,7 +299,12 @@ function buildInterpretation( function deriveAmbiguityReason( turnType: TurnInterpretation["turnType"], ambiguity: TurnAmbiguity, + missingFields?: string[], ): string { + if (missingFields && missingFields.length > 0) { + return `Required fields are still missing: ${missingFields.join(", ")}.`; + } + if (ambiguity === "high") { switch (turnType) { case "unknown": From aeaef8b8f43035b495bc5b67321388511b5b6a17 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 16 Apr 2026 19:52:57 +0800 Subject: [PATCH 6/9] feat: add explicit entityKey case for draft_task --- apps/web/src/lib/server/conversation-state.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/web/src/lib/server/conversation-state.ts b/apps/web/src/lib/server/conversation-state.ts index c3862b3..0533c41 100644 --- a/apps/web/src/lib/server/conversation-state.ts +++ b/apps/web/src/lib/server/conversation-state.ts @@ -518,6 +518,8 @@ function entityKey(entity: ConversationEntity) { return `scheduled_block:${entity.data.blockId}`; case "reminder": return `reminder:${entity.data.taskId}:${entity.data.reminderKind}`; + case "draft_task": + return `draft_task:${entity.id}`; default: return `${entity.kind}:${entity.id}`; } From e89f78eec88ebfca1d3c24c039e65649618bbcb4 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Fri, 17 Apr 2026 07:28:26 +0800 Subject: [PATCH 7/9] feat: create draft_task entity for new plan workflows, wire parentTargetRef and targetRef --- .../src/lib/server/conversation-state.test.ts | 259 +++++++++++++++++- apps/web/src/lib/server/conversation-state.ts | 68 ++++- 2 files changed, 319 insertions(+), 8 deletions(-) diff --git a/apps/web/src/lib/server/conversation-state.test.ts b/apps/web/src/lib/server/conversation-state.test.ts index 9c36382..14532e9 100644 --- a/apps/web/src/lib/server/conversation-state.test.ts +++ b/apps/web/src/lib/server/conversation-state.test.ts @@ -212,7 +212,7 @@ describe("deriveConversationReplyState", () => { expect(result.discourseState?.mode).toBe("confirming"); }); - it("persists pending_write_operation when resolvedOperation is provided", () => { + it("persists pending_write_operation with patched targetRef when draft is created", () => { const op = buildPendingWriteOperation({ resolvedFields: { scheduleFields: { day: "tomorrow" } }, missingFields: ["scheduleFields.time"], @@ -238,7 +238,12 @@ describe("deriveConversationReplyState", () => { occurredAt: "2026-03-22T16:05:00.000Z", }); - expect(result.discourseState?.pending_write_operation).toEqual(op); + const draftEntity = result.entityRegistry.find((e) => e.kind === "draft_task"); + expect(draftEntity).toBeDefined(); + expect(result.discourseState?.pending_write_operation).toEqual({ + ...op, + targetRef: { entityId: draftEntity!.id }, + }); }); it("does not set pending_write_operation when resolvedOperation is absent", () => { @@ -366,7 +371,7 @@ describe("deriveConversationReplyState", () => { expect(clarEntity!.data.parentTargetRef).toEqual({ entityId: "task-1" }); }); - it("sets parentTargetRef to null on clarification entity for new plans", () => { + it("sets parentTargetRef to draft entity on clarification for new plans", () => { const op = buildPendingWriteOperation({ targetRef: null, resolvedFields: { scheduleFields: { day: "tomorrow" } }, @@ -393,9 +398,11 @@ describe("deriveConversationReplyState", () => { occurredAt: "2026-03-22T16:05:00.000Z", }); + const draftEntity = result.entityRegistry.find((e) => e.kind === "draft_task"); const clarEntity = result.entityRegistry.find((e) => e.kind === "clarification"); + expect(draftEntity).toBeDefined(); expect(clarEntity).toBeDefined(); - expect(clarEntity!.data.parentTargetRef).toBeNull(); + expect(clarEntity!.data.parentTargetRef).toEqual({ entityId: draftEntity!.id }); }); it("closes prior open clarification when a new one is created", () => { @@ -461,6 +468,250 @@ describe("deriveConversationReplyState", () => { expect(newClars).toHaveLength(1); expect(newClars[0]!.data).toMatchObject({ reason: "scheduleFields.duration" }); }); + + it("creates a draft_task entity when operationKind is plan and targetRef is null", () => { + const op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + missingFields: ["scheduleFields.time"], + }); + + const result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.time"], + resolvedOperation: op, + }, + interpretation: { + turnType: "planning_request", + confidence: 0.58, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.time"], + }, + reply: "What time should I schedule gym?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + const draftEntity = result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(draftEntity).toBeDefined(); + expect(draftEntity!.status).toBe("active"); + expect(draftEntity!.data).toMatchObject({ + operationKind: "plan", + taskName: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + originatingText: "schedule gym tomorrow", + }); + }); + + it("sets parentTargetRef on clarification to draft entity for new plans", () => { + const op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + missingFields: ["scheduleFields.time"], + }); + + const result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.time"], + resolvedOperation: op, + }, + interpretation: { + turnType: "planning_request", + confidence: 0.58, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.time"], + }, + reply: "What time should I schedule gym?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + const draftEntity = result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + const clarEntity = result.entityRegistry.find( + (e) => e.kind === "clarification", + ); + + expect(draftEntity).toBeDefined(); + expect(clarEntity).toBeDefined(); + expect(clarEntity!.data.parentTargetRef).toEqual({ + entityId: draftEntity!.id, + }); + }); + + it("patches pending_write_operation targetRef to draft entity ID for new plans", () => { + const op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + missingFields: ["scheduleFields.time"], + }); + + const result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.time"], + resolvedOperation: op, + }, + interpretation: { + turnType: "planning_request", + confidence: 0.58, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.time"], + }, + reply: "What time should I schedule gym?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + const draftEntity = result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(draftEntity).toBeDefined(); + expect(result.discourseState?.pending_write_operation?.targetRef).toEqual({ + entityId: draftEntity!.id, + }); + }); + + it("updates existing draft_task instead of creating a new one on subsequent turns", () => { + const snapshot = buildSnapshot(); + const existingDraft = { + id: "draft-1", + conversationId: "conversation-1", + kind: "draft_task" as const, + label: "schedule gym tomorrow", + status: "active" as const, + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:00:00.000Z", + data: { + operationKind: "plan" as const, + taskName: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + originatingText: "schedule gym tomorrow", + }, + }; + snapshot.entityRegistry = [existingDraft as any]; + + const op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: null, + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(17, 0) }, + }, + missingFields: ["scheduleFields.duration"], + }); + + const result = deriveConversationReplyState({ + snapshot, + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.duration"], + resolvedOperation: op, + }, + interpretation: { + turnType: "clarification_answer", + confidence: 0.8, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.duration"], + }, + reply: "Got it, 5pm. How long should it be?", + userTurnText: "5pm", + summaryText: null, + occurredAt: "2026-04-16T10:05:00.000Z", + }); + + const drafts = result.entityRegistry.filter((e) => e.kind === "draft_task"); + expect(drafts).toHaveLength(1); + expect(drafts[0]!.id).toBe("draft-1"); + expect(drafts[0]!.data).toMatchObject({ + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(17, 0) }, + }, + }); + }); + + it("does not create a draft_task for edit operations", () => { + const op = buildPendingWriteOperation({ + operationKind: "edit", + targetRef: { entityId: "task-1" }, + resolvedFields: { scheduleFields: { time: t(17, 0) } }, + missingFields: [], + }); + + const result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "present_proposal", + resolvedOperation: op, + }, + interpretation: { + turnType: "edit_request", + confidence: 0.95, + resolvedEntityIds: ["task-1"], + ambiguity: "none", + }, + reply: "I'll move it to 5pm. Sound good?", + userTurnText: "move it to 5pm", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + const draftEntity = result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(draftEntity).toBeUndefined(); + }); + + it("does not create a draft_task when targetRef is non-null", () => { + const op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: { entityId: "task-1" }, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + missingFields: ["scheduleFields.time"], + }); + + const result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.time"], + resolvedOperation: op, + }, + interpretation: { + turnType: "planning_request", + confidence: 0.58, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.time"], + }, + reply: "What time should I schedule it?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + const draftEntity = result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(draftEntity).toBeUndefined(); + }); }); describe("deriveMutationState", () => { diff --git a/apps/web/src/lib/server/conversation-state.ts b/apps/web/src/lib/server/conversation-state.ts index 0533c41..4eeda47 100644 --- a/apps/web/src/lib/server/conversation-state.ts +++ b/apps/web/src/lib/server/conversation-state.ts @@ -85,6 +85,57 @@ export function deriveConversationReplyState( } } + // --- Draft task creation for new plan workflows --- + let draftEntityId: string | null = null; + + if ( + input.policy.resolvedOperation && + input.policy.resolvedOperation.operationKind === "plan" && + input.policy.resolvedOperation.targetRef === null + ) { + const existingDraftIdx = entityRegistry.findIndex( + (e) => e.kind === "draft_task" && e.status === "active", + ); + const existingDraft = + existingDraftIdx >= 0 ? entityRegistry[existingDraftIdx] : undefined; + + if (existingDraft && existingDraft.kind === "draft_task") { + entityRegistry[existingDraftIdx] = { + ...existingDraft, + updatedAt: occurredAt, + data: { + ...existingDraft.data, + taskName: + input.policy.resolvedOperation.resolvedFields.taskFields?.label ?? + existingDraft.data.taskName, + resolvedFields: input.policy.resolvedOperation.resolvedFields, + }, + }; + draftEntityId = existingDraft.id; + } else { + const draftEntity = buildConversationEntity( + input.snapshot.conversation.id, + { + kind: "draft_task", + label: summarizeLabel(input.userTurnText), + status: "active", + createdAt: occurredAt, + updatedAt: occurredAt, + data: { + operationKind: input.policy.resolvedOperation.operationKind, + taskName: + input.policy.resolvedOperation.resolvedFields.taskFields?.label ?? + null, + resolvedFields: input.policy.resolvedOperation.resolvedFields, + originatingText: input.userTurnText, + }, + }, + ); + entityRegistry.push(draftEntity); + draftEntityId = draftEntity.id; + } + } + if (input.policy.action === "present_proposal") { const proposalEntity = upsertActiveProposalEntity( entityRegistry, @@ -128,8 +179,9 @@ export function deriveConversationReplyState( ); } - const parentTargetRef = - input.policy.resolvedOperation?.targetRef ?? null; + const parentTargetRef = draftEntityId + ? { entityId: draftEntityId } + : (input.policy.resolvedOperation?.targetRef ?? null); // Close any prior open clarifications (one-open-per-workflow) for (let i = 0; i < entityRegistry.length; i++) { @@ -189,10 +241,18 @@ export function deriveConversationReplyState( }, ).state; + const patchedOperation = + input.policy.resolvedOperation && draftEntityId + ? { + ...input.policy.resolvedOperation, + targetRef: { entityId: draftEntityId }, + } + : input.policy.resolvedOperation; + const nextDiscourseState = { ...updatedDiscourseState, - ...(input.policy.resolvedOperation - ? { pending_write_operation: input.policy.resolvedOperation } + ...(patchedOperation + ? { pending_write_operation: patchedOperation } : {}), }; From 24beb2c377aeb324c84567010c57d3433b927df9 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Fri, 17 Apr 2026 07:29:38 +0800 Subject: [PATCH 8/9] feat: supersede active draft_task entities after mutation creates real task --- .../src/lib/server/conversation-state.test.ts | 78 +++++++++++++++++++ apps/web/src/lib/server/conversation-state.ts | 8 ++ 2 files changed, 86 insertions(+) diff --git a/apps/web/src/lib/server/conversation-state.test.ts b/apps/web/src/lib/server/conversation-state.test.ts index 14532e9..c1d1b04 100644 --- a/apps/web/src/lib/server/conversation-state.test.ts +++ b/apps/web/src/lib/server/conversation-state.test.ts @@ -888,4 +888,82 @@ describe("deriveMutationState", () => { expect(result.discourseState.pending_write_operation).toEqual(op); }); + + it("supersedes active draft_task after mutation creates a real task", () => { + const snapshot = buildSnapshot(); + snapshot.entityRegistry = [ + { + id: "draft-1", + conversationId: "conversation-1", + kind: "draft_task", + label: "schedule gym tomorrow", + status: "active", + createdAt: "2026-04-16T10:00:00.000Z", + updatedAt: "2026-04-16T10:05:00.000Z", + data: { + operationKind: "plan", + taskName: null, + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(17, 0) }, + }, + originatingText: "schedule gym tomorrow", + }, + } as any, + ]; + + const processing: MutationResult = { + outcome: "created", + tasks: [ + { + id: "task-1", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Gym", + lifecycleState: "scheduled", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: "2026-04-17T17:00:00.000Z", + scheduledEndAt: "2026-04-17T18:00:00.000Z", + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + }, + ], + scheduleBlocks: [ + { + id: "block-1", + userId: "user-1", + taskId: "task-1", + startAt: "2026-04-17T17:00:00.000Z", + endAt: "2026-04-17T18:00:00.000Z", + confidence: 0.92, + reason: "User requested 5 PM.", + rescheduleCount: 0, + externalCalendarId: null, + }, + ], + followUpMessage: "Scheduled gym for 5 PM tomorrow.", + }; + + const result = deriveMutationState({ + snapshot, + processing, + occurredAt: "2026-04-16T10:10:00.000Z", + }); + + const draft = result.entityRegistry.find((e) => e.kind === "draft_task"); + expect(draft).toBeDefined(); + expect(draft!.status).toBe("superseded"); + + const task = result.entityRegistry.find((e) => e.kind === "task"); + expect(task).toBeDefined(); + expect(task!.data.taskId).toBe("task-1"); + }); }); diff --git a/apps/web/src/lib/server/conversation-state.ts b/apps/web/src/lib/server/conversation-state.ts index 4eeda47..8a4c83d 100644 --- a/apps/web/src/lib/server/conversation-state.ts +++ b/apps/web/src/lib/server/conversation-state.ts @@ -287,6 +287,14 @@ export function deriveMutationState(input: DeriveMutationStateInput) { }; } + if (entity.kind === "draft_task" && entity.status === "active") { + return { + ...entity, + status: "superseded", + updatedAt: occurredAt, + }; + } + return entity; }); let lastConcreteEntityId: string | null = null; From 5dd4d9689a78d659fa5aff2fe7d99c426511895d Mon Sep 17 00:00:00 2001 From: Max Lin Date: Fri, 17 Apr 2026 07:30:23 +0800 Subject: [PATCH 9/9] test: add 2-turn draft_task integration test for field accumulation --- .../src/lib/server/conversation-state.test.ts | 162 ++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/apps/web/src/lib/server/conversation-state.test.ts b/apps/web/src/lib/server/conversation-state.test.ts index c1d1b04..af4671d 100644 --- a/apps/web/src/lib/server/conversation-state.test.ts +++ b/apps/web/src/lib/server/conversation-state.test.ts @@ -967,3 +967,165 @@ describe("deriveMutationState", () => { expect(task!.data.taskId).toBe("task-1"); }); }); + +describe("draft_task integration: 2-turn new-task clarification", () => { + it("accumulates fields across turns without resetting", () => { + // --- Turn 1: "schedule gym tomorrow" → creates draft + clarification --- + const turn1Op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + missingFields: ["scheduleFields.time"], + }); + + const turn1Result = deriveConversationReplyState({ + snapshot: buildSnapshot(), + policy: { + action: "ask_clarification", + clarificationSlots: ["scheduleFields.time"], + resolvedOperation: turn1Op, + }, + interpretation: { + turnType: "planning_request", + confidence: 0.58, + resolvedEntityIds: [], + ambiguity: "high", + missingFields: ["scheduleFields.time"], + }, + reply: "What time should I schedule gym?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-04-16T10:00:00.000Z", + }); + + // Verify Turn 1 state + const draftEntity = turn1Result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + const clarEntity = turn1Result.entityRegistry.find( + (e) => e.kind === "clarification", + ); + expect(draftEntity).toBeDefined(); + expect(clarEntity).toBeDefined(); + expect(clarEntity!.data.parentTargetRef).toEqual({ + entityId: draftEntity!.id, + }); + expect( + turn1Result.discourseState?.pending_write_operation?.targetRef, + ).toEqual({ entityId: draftEntity!.id }); + + // --- Turn 2: "10am 30min" → resolves clarification, accumulates fields --- + const turn2Snapshot = buildSnapshot(); + turn2Snapshot.entityRegistry = turn1Result.entityRegistry; + turn2Snapshot.discourseState = turn1Result.discourseState!; + + const turn2Op = buildPendingWriteOperation({ + operationKind: "plan", + targetRef: { entityId: draftEntity!.id }, + resolvedFields: { + scheduleFields: { day: "tomorrow", time: t(10, 0), duration: 30 }, + }, + missingFields: [], + }); + + const turn2Result = deriveConversationReplyState({ + snapshot: turn2Snapshot, + policy: { + action: "present_proposal", + resolvedOperation: turn2Op, + }, + interpretation: { + turnType: "clarification_answer", + confidence: 0.93, + resolvedEntityIds: [], + ambiguity: "none", + }, + reply: "I'll schedule gym for 10 AM, 30 minutes. Sound good?", + userTurnText: "10am 30min", + summaryText: null, + occurredAt: "2026-04-16T10:05:00.000Z", + }); + + // Verify Turn 2: fields accumulated, not wiped + expect( + turn2Result.discourseState?.pending_write_operation?.resolvedFields, + ).toMatchObject({ + scheduleFields: { day: "tomorrow", time: t(10, 0), duration: 30 }, + }); + + // Verify the draft is still in the registry (not yet superseded — no mutation happened) + const turn2Draft = turn2Result.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(turn2Draft).toBeDefined(); + expect(turn2Draft!.status).toBe("active"); + + // --- Mutation: creates real task → supersedes draft --- + const mutationSnapshot = buildSnapshot(); + mutationSnapshot.entityRegistry = turn2Result.entityRegistry; + mutationSnapshot.discourseState = turn2Result.discourseState!; + + const mutationResult = deriveMutationState({ + snapshot: mutationSnapshot, + processing: { + outcome: "created", + tasks: [ + { + id: "task-1", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Gym", + lifecycleState: "scheduled", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: "2026-04-17T10:00:00.000Z", + scheduledEndAt: "2026-04-17T10:30:00.000Z", + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + }, + ], + scheduleBlocks: [ + { + id: "block-1", + userId: "user-1", + taskId: "task-1", + startAt: "2026-04-17T10:00:00.000Z", + endAt: "2026-04-17T10:30:00.000Z", + confidence: 0.92, + reason: "User requested 10 AM.", + rescheduleCount: 0, + externalCalendarId: null, + }, + ], + followUpMessage: "Scheduled gym for 10 AM tomorrow.", + }, + occurredAt: "2026-04-16T10:10:00.000Z", + }); + + // Draft superseded, real task created + const finalDraft = mutationResult.entityRegistry.find( + (e) => e.kind === "draft_task", + ); + expect(finalDraft).toBeDefined(); + expect(finalDraft!.status).toBe("superseded"); + + const realTask = mutationResult.entityRegistry.find( + (e) => e.kind === "task", + ); + expect(realTask).toBeDefined(); + expect(realTask!.data.taskId).toBe("task-1"); + + // pending_write_operation cleared after successful mutation + expect( + mutationResult.discourseState.pending_write_operation, + ).toBeUndefined(); + }); +});