From 56ba35a7d5392ae6793eef3888872c55a2db45e9 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 9 Apr 2026 11:10:25 -0700 Subject: [PATCH 1/3] feat: add parentTargetRef to clarification entity and pending clarification schemas Clarification entities and pending clarifications now carry a parentTargetRef field that back-references the write workflow's target. Uses .optional().default(null) for backward compatibility with existing records. Co-Authored-By: Claude Opus 4.6 --- .../core/src/clarification-schema.test.ts | 88 +++++++++++++++++++ packages/core/src/discourse-state.test.ts | 1 + packages/core/src/discourse-state.ts | 21 ++--- packages/core/src/entity-context.test.ts | 2 + packages/core/src/index.ts | 1 + 5 files changed, 103 insertions(+), 10 deletions(-) create mode 100644 packages/core/src/clarification-schema.test.ts diff --git a/packages/core/src/clarification-schema.test.ts b/packages/core/src/clarification-schema.test.ts new file mode 100644 index 0000000..182667f --- /dev/null +++ b/packages/core/src/clarification-schema.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + conversationClarificationEntitySchema, + pendingClarificationSchema, +} from "./index"; + +describe("clarification parentTargetRef schema", () => { + it("accepts parentTargetRef: null on clarification entity", () => { + const entity = conversationClarificationEntitySchema.parse({ + id: "clar-1", + conversationId: "conv-1", + kind: "clarification", + label: "Need a time", + status: "active", + createdAt: "2026-04-09T10:00:00.000Z", + updatedAt: "2026-04-09T10:00:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + parentTargetRef: null, + }, + }); + expect(entity.data.parentTargetRef).toBeNull(); + }); + + it("accepts parentTargetRef with entityId on clarification entity", () => { + const entity = conversationClarificationEntitySchema.parse({ + id: "clar-1", + conversationId: "conv-1", + kind: "clarification", + label: "Need a time", + status: "active", + createdAt: "2026-04-09T10:00:00.000Z", + updatedAt: "2026-04-09T10:00:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + parentTargetRef: { entityId: "task-1" }, + }, + }); + expect(entity.data.parentTargetRef).toEqual({ entityId: "task-1" }); + }); + + it("defaults parentTargetRef to null when omitted (backward compat)", () => { + const entity = conversationClarificationEntitySchema.parse({ + id: "clar-1", + conversationId: "conv-1", + kind: "clarification", + label: "Need a time", + status: "active", + createdAt: "2026-04-09T10:00:00.000Z", + updatedAt: "2026-04-09T10:00:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + }, + }); + expect(entity.data.parentTargetRef).toBeNull(); + }); + + it("accepts parentTargetRef on pending clarification", () => { + const pc = pendingClarificationSchema.parse({ + id: "clar-1", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-04-09T10:00:00.000Z", + createdTurnId: "assistant:1", + parentTargetRef: null, + }); + expect(pc.parentTargetRef).toBeNull(); + }); + + it("defaults parentTargetRef to null on pending clarification when omitted", () => { + const pc = pendingClarificationSchema.parse({ + id: "clar-1", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-04-09T10:00:00.000Z", + createdTurnId: "assistant:1", + }); + expect(pc.parentTargetRef).toBeNull(); + }); +}); diff --git a/packages/core/src/discourse-state.test.ts b/packages/core/src/discourse-state.test.ts index 439f905..e8e985e 100644 --- a/packages/core/src/discourse-state.test.ts +++ b/packages/core/src/discourse-state.test.ts @@ -21,6 +21,7 @@ function buildClarification( status: "pending", createdAt: "2026-03-22T10:00:00.000Z", createdTurnId: "assistant:turn-1", + parentTargetRef: null, ...input, }; } diff --git a/packages/core/src/discourse-state.ts b/packages/core/src/discourse-state.ts index 1e79ca3..a07dc05 100644 --- a/packages/core/src/discourse-state.ts +++ b/packages/core/src/discourse-state.ts @@ -32,6 +32,16 @@ export const presentedItemSchema = z.discriminatedUnion("type", [ presentedOptionSchema, ]); +export const targetRefSchema = z + .object({ + entityId: z.string().optional(), + description: z.string().optional(), + entityKind: z.string().optional(), + }) + .nullable(); + +export type TargetRef = z.infer; + export const pendingClarificationSchema = z.object({ id: z.string().min(1), entityId: z.string().min(1).optional(), @@ -41,6 +51,7 @@ export const pendingClarificationSchema = z.object({ createdAt: z.string().datetime(), createdTurnId: z.string().min(1), priority: z.number().int().optional(), + parentTargetRef: targetRefSchema.optional().default(null), }); export const absoluteTimeSpecSchema = z.object({ @@ -91,16 +102,6 @@ export const operationKindSchema = z.enum([ export type OperationKind = z.infer; -export const targetRefSchema = z - .object({ - entityId: z.string().optional(), - description: z.string().optional(), - entityKind: z.string().optional(), - }) - .nullable(); - -export type TargetRef = z.infer; - export const resolvedFieldsSchema = z.object({ scheduleFields: z .object({ diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts index 9b6ea4a..8b1b96b 100644 --- a/packages/core/src/entity-context.test.ts +++ b/packages/core/src/entity-context.test.ts @@ -94,6 +94,7 @@ describe("entity context", () => { prompt: "What time should I schedule it?", reason: null, open: true, + parentTargetRef: null, }, }), buildEntity({ @@ -156,6 +157,7 @@ describe("entity context", () => { prompt: "Closed clarification", reason: null, open: false, + parentTargetRef: null, }, }), ], diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index eb237d4..963b77d 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -852,6 +852,7 @@ export const conversationClarificationEntitySchema = prompt: z.string().min(1), reason: z.string().min(1).nullable(), open: z.boolean(), + parentTargetRef: targetRefSchema.optional().default(null), }), }); From b374db9c86852c99faaccb114452be0800dc9155 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 9 Apr 2026 11:13:15 -0700 Subject: [PATCH 2/3] feat: populate parentTargetRef on clarification creation, enforce one-open-per-workflow When creating a clarification entity, copy the targetRef from the current pending_write_operation into parentTargetRef. Close any prior open clarifications before creating a new one to enforce a single active clarification per workflow. Co-Authored-By: Claude Opus 4.6 --- .../src/lib/server/conversation-state.test.ts | 153 ++++++++++++++++++ apps/web/src/lib/server/conversation-state.ts | 21 +++ 2 files changed, 174 insertions(+) diff --git a/apps/web/src/lib/server/conversation-state.test.ts b/apps/web/src/lib/server/conversation-state.test.ts index b6a61fb..e00b019 100644 --- a/apps/web/src/lib/server/conversation-state.test.ts +++ b/apps/web/src/lib/server/conversation-state.test.ts @@ -154,6 +154,7 @@ describe("deriveConversationReplyState", () => { prompt: "What time should I schedule it?", reason: "time", open: true, + parentTargetRef: null, }, }, ]; @@ -171,6 +172,7 @@ describe("deriveConversationReplyState", () => { createdAt: "2026-03-22T16:01:00.000Z", createdTurnId: "assistant:1", + parentTargetRef: null, }, ], mode: "clarifying", @@ -276,6 +278,7 @@ describe("deriveConversationReplyState", () => { prompt: "What time should I schedule it?", reason: "time", open: true, + parentTargetRef: null, }, }, ]; @@ -292,6 +295,7 @@ describe("deriveConversationReplyState", () => { status: "pending", createdAt: "2026-03-22T16:01:00.000Z", createdTurnId: "assistant:1", + parentTargetRef: null, }, ], mode: "clarifying", @@ -331,6 +335,153 @@ describe("deriveConversationReplyState", () => { ]), ); }); + + it("sets parentTargetRef on clarification entity from resolvedOperation targetRef", () => { + const op = buildPendingWriteOperation({ + 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-03-22T16:05:00.000Z", + }); + + const clarEntity = result.entityRegistry.find( + (e) => e.kind === "clarification", + ); + expect(clarEntity).toBeDefined(); + expect(clarEntity!.data.parentTargetRef).toEqual({ + entityId: "task-1", + }); + }); + + it("sets parentTargetRef to null on clarification entity for new plans", () => { + const op = buildPendingWriteOperation({ + 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 it?", + userTurnText: "schedule gym tomorrow", + summaryText: null, + occurredAt: "2026-03-22T16:05:00.000Z", + }); + + const clarEntity = result.entityRegistry.find( + (e) => e.kind === "clarification", + ); + expect(clarEntity).toBeDefined(); + expect(clarEntity!.data.parentTargetRef).toBeNull(); + }); + + it("closes prior open clarification when a new one is created", () => { + const snapshot = buildSnapshot(); + snapshot.entityRegistry = [ + { + id: "clar-old", + conversationId: "conversation-1", + kind: "clarification", + label: "Need a time", + status: "active", + createdAt: "2026-03-22T16:01:00.000Z", + updatedAt: "2026-03-22T16:01:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + parentTargetRef: null, + }, + }, + ]; + snapshot.discourseState = { + focus_entity_id: "clar-old", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [ + { + id: "clar-old", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-03-22T16:01:00.000Z", + createdTurnId: "assistant:1", + parentTargetRef: null, + }, + ], + mode: "clarifying", + }; + + const op = buildPendingWriteOperation({ + targetRef: null, + resolvedFields: { scheduleFields: { day: "tomorrow" } }, + 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-03-22T16:06:00.000Z", + }); + + const oldClar = result.entityRegistry.find((e) => e.id === "clar-old"); + expect(oldClar).toMatchObject({ + status: "resolved", + data: expect.objectContaining({ open: false }), + }); + + const newClars = result.entityRegistry.filter( + (e) => e.kind === "clarification" && e.data.open === true, + ); + expect(newClars).toHaveLength(1); + expect(newClars[0]!.data).toMatchObject({ reason: "scheduleFields.duration" }); + }); }); describe("deriveMutationState", () => { @@ -349,6 +500,7 @@ describe("deriveMutationState", () => { prompt: "What time should I schedule it?", reason: "time", open: true, + parentTargetRef: null, }, }, ]; @@ -366,6 +518,7 @@ describe("deriveMutationState", () => { createdAt: "2026-03-22T16:01:00.000Z", createdTurnId: "assistant:1", + parentTargetRef: null, }, ], mode: "clarifying", diff --git a/apps/web/src/lib/server/conversation-state.ts b/apps/web/src/lib/server/conversation-state.ts index 8a0336e..1871f09 100644 --- a/apps/web/src/lib/server/conversation-state.ts +++ b/apps/web/src/lib/server/conversation-state.ts @@ -129,6 +129,23 @@ export function deriveConversationReplyState( ); } + const parentTargetRef = + input.policy.resolvedOperation?.targetRef ?? null; + + // Close any prior open clarifications (one-open-per-workflow) + for (let i = 0; i < entityRegistry.length; i++) { + const entity = entityRegistry[i]!; + if (entity.kind === "clarification" && entity.data.open) { + entityRegistry[i] = { + ...entity, + status: "resolved" as const, + updatedAt: occurredAt, + data: { ...entity.data, open: false }, + }; + resolvedClarificationIds.push(entity.id); + } + } + const clarificationEntity = buildConversationEntity( input.snapshot.conversation.id, { @@ -141,6 +158,7 @@ export function deriveConversationReplyState( prompt: input.reply, reason: clarificationSlot, open: true, + parentTargetRef, }, }, ); @@ -153,6 +171,7 @@ export function deriveConversationReplyState( status: "pending", createdAt: occurredAt, createdTurnId: `assistant:${occurredAt}`, + parentTargetRef, }); nextFocusEntityId ??= clarificationEntity.id; } @@ -277,6 +296,7 @@ export function deriveMutationState(input: DeriveMutationStateInput) { prompt: input.processing.followUpMessage, reason: input.processing.reason, open: true, + parentTargetRef: null, }, }, ); @@ -290,6 +310,7 @@ export function deriveMutationState(input: DeriveMutationStateInput) { status: "pending", createdAt: occurredAt, createdTurnId: `assistant:${occurredAt}`, + parentTargetRef: null, }); } From ce5cd6d1505d58adb2b44b993513fa2a4feb66a3 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 9 Apr 2026 15:50:26 -0700 Subject: [PATCH 3/3] fix: resolveWriteTarget uses clarification parentTargetRef instead of focus_entity_id For clarification_answer turns, resolve the write target from the open clarification's parentTargetRef instead of focus_entity_id (which may point at the clarification entity itself). Also parse entity registries at the turn-router and decide-turn-policy boundaries to apply Zod defaults for the new parentTargetRef field. Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/server/decide-turn-policy.ts | 16 ++- apps/web/src/lib/server/turn-router.test.ts | 114 ++++++++++++++++++ apps/web/src/lib/server/turn-router.ts | 36 +++++- 3 files changed, 160 insertions(+), 6 deletions(-) diff --git a/apps/web/src/lib/server/decide-turn-policy.ts b/apps/web/src/lib/server/decide-turn-policy.ts index ef5c277..a2dbaaf 100644 --- a/apps/web/src/lib/server/decide-turn-policy.ts +++ b/apps/web/src/lib/server/decide-turn-policy.ts @@ -2,6 +2,7 @@ import { type CommitPolicyOutput, containsWriteVerb, type ConversationEntity, + conversationEntitySchema, deriveAmbiguity, deriveConsentRequirement, type TurnAmbiguity, @@ -39,6 +40,9 @@ export function decideTurnPolicy( ): TurnPolicyDecision { const { classification, commitResult } = input; const targetEntityId = input.targetEntityId; + const entityRegistry = (input.routingContext.entityRegistry ?? []).map((e) => + conversationEntitySchema.parse(e), + ); const ambiguity = deriveAmbiguity({ classifierConfidence: classification.confidence, missingFields: commitResult.missingFields, @@ -69,7 +73,7 @@ export function decideTurnPolicy( const proposalId = input.resolvedProposalId ?? resolveSingleActiveProposalId( - input.routingContext.entityRegistry ?? [], + entityRegistry, ); if (proposalId) { @@ -165,8 +169,10 @@ function deriveStructuredWriteReadiness( } if (classification.turnType === "clarification_answer") { - const entityRegistry = input.routingContext.entityRegistry ?? []; - const alreadyConfirmed = entityRegistry.some( + const parsedRegistry = (input.routingContext.entityRegistry ?? []).map((e) => + conversationEntitySchema.parse(e), + ); + const alreadyConfirmed = parsedRegistry.some( (e) => e.kind === "proposal_option" && e.id === input.resolvedProposalId && @@ -187,7 +193,9 @@ function deriveStructuredWriteReadiness( ...(input.resolvedProposalId ? { resolvedProposalId: input.resolvedProposalId } : {}), - entityRegistry: input.routingContext.entityRegistry ?? [], + entityRegistry: (input.routingContext.entityRegistry ?? []).map((e) => + conversationEntitySchema.parse(e), + ), resolvedFields: commitResult.resolvedFields, turnType: classification.turnType, }); diff --git a/apps/web/src/lib/server/turn-router.test.ts b/apps/web/src/lib/server/turn-router.test.ts index 41ce277..ad4f59c 100644 --- a/apps/web/src/lib/server/turn-router.test.ts +++ b/apps/web/src/lib/server/turn-router.test.ts @@ -678,6 +678,120 @@ describe("resolveWriteTarget", () => { expect(result).toEqual({}); }); + it("uses parentTargetRef from open clarification instead of focus_entity_id for clarification_answer", () => { + const result = resolveWriteTarget( + { + focus_entity_id: "clar-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [ + { + id: "clar-1", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-04-09T10:00:00.000Z", + createdTurnId: "assistant:1", + parentTargetRef: { entityId: "task-1" }, + }, + ], + mode: "clarifying", + }, + [ + { + id: "clar-1", + conversationId: "c-1", + kind: "clarification", + label: "What time?", + status: "active", + createdAt: "2026-04-09T10:00:00.000Z", + updatedAt: "2026-04-09T10:00:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + parentTargetRef: { entityId: "task-1" }, + }, + }, + ], + "clarification_answer", + ); + + expect(result.targetEntityId).toBe("task-1"); + }); + + it("returns no targetEntityId when clarification parentTargetRef is null (new plan)", () => { + const result = resolveWriteTarget( + { + focus_entity_id: "clar-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [ + { + id: "clar-1", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-04-09T10:00:00.000Z", + createdTurnId: "assistant:1", + parentTargetRef: null, + }, + ], + mode: "clarifying", + }, + [ + { + id: "clar-1", + conversationId: "c-1", + kind: "clarification", + label: "What time?", + status: "active", + createdAt: "2026-04-09T10:00:00.000Z", + updatedAt: "2026-04-09T10:00:00.000Z", + data: { + prompt: "What time?", + reason: "scheduleFields.time", + open: true, + parentTargetRef: null, + }, + }, + ], + "clarification_answer", + ); + + expect(result.targetEntityId).toBeUndefined(); + }); + + it("does not use clarification parentTargetRef for non-clarification_answer turns", () => { + const result = resolveWriteTarget( + { + focus_entity_id: "task-2", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [ + { + id: "clar-1", + slot: "scheduleFields.time", + question: "What time?", + status: "pending", + createdAt: "2026-04-09T10:00:00.000Z", + createdTurnId: "assistant:1", + parentTargetRef: { entityId: "task-1" }, + }, + ], + mode: "clarifying", + }, + [], + "edit_request", + ); + + // For non-clarification_answer, should use normal focus_entity_id path + expect(result.targetEntityId).toBe("task-2"); + }); + it("attaches resolvedProposalId for non-confirmation turns when single proposal exists", () => { const result = resolveWriteTarget( null, diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index 61d3805..8c37a1c 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -2,7 +2,9 @@ import { applyWriteCommit, buildEntityContext, type ConversationDiscourseState, + conversationDiscourseStateSchema, type ConversationEntity, + conversationEntitySchema, type ConversationTurn, createEmptyDiscourseState, deriveAmbiguity, @@ -39,6 +41,32 @@ export function resolveWriteTarget( entityRegistry: ConversationEntity[], turnType: TurnInterpretationType, ): WriteTarget { + // For clarification answers, resolve target from the open clarification's parentTargetRef + // instead of focus_entity_id (which may point at the clarification entity itself). + if (turnType === "clarification_answer" && discourseState) { + const openClarification = discourseState.pending_clarifications.find( + (c) => c.status === "pending", + ); + if (openClarification) { + const parentEntityId = + openClarification.parentTargetRef?.entityId ?? undefined; + const activeProposals = entityRegistry.filter( + (e): e is Extract => + e.kind === "proposal_option" && + (e.status === "active" || e.status === "presented"), + ); + const singleProposal = + activeProposals.length === 1 ? activeProposals[0] : null; + + return { + ...(parentEntityId ? { targetEntityId: parentEntityId } : {}), + ...(singleProposal + ? { resolvedProposalId: singleProposal.id } + : {}), + }; + } + } + const resolvedEntityIds = compactResolvedEntityIds([ discourseState?.currently_editable_entity_id ?? null, discourseState?.focus_entity_id ?? null, @@ -76,8 +104,12 @@ function compactResolvedEntityIds(entityIds: Array) { export async function routeMessageTurn( input: TurnRouterInput, ): Promise { - const discourseState = input.discourseState ?? createEmptyDiscourseState(); - const entityRegistry = input.entityRegistry ?? []; + const discourseState = input.discourseState + ? conversationDiscourseStateSchema.parse(input.discourseState) + : createEmptyDiscourseState(); + const entityRegistry = (input.entityRegistry ?? []).map((e) => + conversationEntitySchema.parse(e), + ); const tasks = (input.tasks ?? []).map((task) => taskSchema.parse(task)); // Pipeline A: classify intent