Skip to content

Commit 247932a

Browse files
MaxLinCodeclaude
andcommitted
feat: populate parentTargetRef on clarification entities
Derive from resolvedOperation.targetRef when creating clarifications. Close prior open clarifications (one-open-per-workflow invariant). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 093a03d commit 247932a

2 files changed

Lines changed: 150 additions & 0 deletions

File tree

apps/web/src/lib/server/conversation-state.test.ts

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,7 @@ describe("deriveConversationReplyState", () => {
154154
prompt: "What time should I schedule it?",
155155
reason: "time",
156156
open: true,
157+
parentTargetRef: null,
157158
},
158159
},
159160
];
@@ -276,6 +277,7 @@ describe("deriveConversationReplyState", () => {
276277
prompt: "What time should I schedule it?",
277278
reason: "time",
278279
open: true,
280+
parentTargetRef: null,
279281
},
280282
},
281283
];
@@ -331,6 +333,134 @@ describe("deriveConversationReplyState", () => {
331333
]),
332334
);
333335
});
336+
337+
it("sets parentTargetRef on clarification entity from resolvedOperation targetRef", () => {
338+
const op = buildPendingWriteOperation({
339+
targetRef: { entityId: "task-1" },
340+
resolvedFields: { scheduleFields: { day: "tomorrow" } },
341+
missingFields: ["scheduleFields.time"],
342+
});
343+
344+
const result = deriveConversationReplyState({
345+
snapshot: buildSnapshot(),
346+
policy: {
347+
action: "ask_clarification",
348+
clarificationSlots: ["scheduleFields.time"],
349+
resolvedOperation: op,
350+
},
351+
interpretation: {
352+
turnType: "planning_request",
353+
confidence: 0.58,
354+
resolvedEntityIds: [],
355+
ambiguity: "high",
356+
missingFields: ["scheduleFields.time"],
357+
},
358+
reply: "What time should I schedule it?",
359+
userTurnText: "schedule gym tomorrow",
360+
summaryText: null,
361+
occurredAt: "2026-03-22T16:05:00.000Z",
362+
});
363+
364+
const clarEntity = result.entityRegistry.find((e) => e.kind === "clarification");
365+
expect(clarEntity).toBeDefined();
366+
expect(clarEntity!.data.parentTargetRef).toEqual({ entityId: "task-1" });
367+
});
368+
369+
it("sets parentTargetRef to null on clarification entity for new plans", () => {
370+
const op = buildPendingWriteOperation({
371+
targetRef: null,
372+
resolvedFields: { scheduleFields: { day: "tomorrow" } },
373+
missingFields: ["scheduleFields.time"],
374+
});
375+
376+
const result = deriveConversationReplyState({
377+
snapshot: buildSnapshot(),
378+
policy: {
379+
action: "ask_clarification",
380+
clarificationSlots: ["scheduleFields.time"],
381+
resolvedOperation: op,
382+
},
383+
interpretation: {
384+
turnType: "planning_request",
385+
confidence: 0.58,
386+
resolvedEntityIds: [],
387+
ambiguity: "high",
388+
missingFields: ["scheduleFields.time"],
389+
},
390+
reply: "What time should I schedule it?",
391+
userTurnText: "schedule gym tomorrow",
392+
summaryText: null,
393+
occurredAt: "2026-03-22T16:05:00.000Z",
394+
});
395+
396+
const clarEntity = result.entityRegistry.find((e) => e.kind === "clarification");
397+
expect(clarEntity).toBeDefined();
398+
expect(clarEntity!.data.parentTargetRef).toBeNull();
399+
});
400+
401+
it("closes prior open clarification when a new one is created", () => {
402+
const snapshot = buildSnapshot();
403+
snapshot.entityRegistry = [
404+
{
405+
id: "clar-old",
406+
conversationId: "conversation-1",
407+
kind: "clarification",
408+
label: "Need a time",
409+
status: "active",
410+
createdAt: "2026-03-22T16:01:00.000Z",
411+
updatedAt: "2026-03-22T16:01:00.000Z",
412+
data: { prompt: "What time?", reason: "scheduleFields.time", open: true, parentTargetRef: null },
413+
},
414+
];
415+
snapshot.discourseState = {
416+
focus_entity_id: "clar-old",
417+
currently_editable_entity_id: null,
418+
last_user_mentioned_entity_ids: [],
419+
last_presented_items: [],
420+
pending_clarifications: [
421+
{ id: "clar-old", slot: "scheduleFields.time", question: "What time?", status: "pending", createdAt: "2026-03-22T16:01:00.000Z", createdTurnId: "assistant:1", parentTargetRef: null },
422+
],
423+
mode: "clarifying",
424+
};
425+
426+
const op = buildPendingWriteOperation({
427+
targetRef: null,
428+
resolvedFields: { scheduleFields: { day: "tomorrow" } },
429+
missingFields: ["scheduleFields.duration"],
430+
});
431+
432+
const result = deriveConversationReplyState({
433+
snapshot,
434+
policy: {
435+
action: "ask_clarification",
436+
clarificationSlots: ["scheduleFields.duration"],
437+
resolvedOperation: op,
438+
},
439+
interpretation: {
440+
turnType: "clarification_answer",
441+
confidence: 0.8,
442+
resolvedEntityIds: [],
443+
ambiguity: "high",
444+
missingFields: ["scheduleFields.duration"],
445+
},
446+
reply: "Got it, 5pm. How long should it be?",
447+
userTurnText: "5pm",
448+
summaryText: null,
449+
occurredAt: "2026-03-22T16:06:00.000Z",
450+
});
451+
452+
const oldClar = result.entityRegistry.find((e) => e.id === "clar-old");
453+
expect(oldClar).toMatchObject({
454+
status: "resolved",
455+
data: expect.objectContaining({ open: false }),
456+
});
457+
458+
const newClars = result.entityRegistry.filter(
459+
(e) => e.kind === "clarification" && e.data.open === true,
460+
);
461+
expect(newClars).toHaveLength(1);
462+
expect(newClars[0]!.data).toMatchObject({ reason: "scheduleFields.duration" });
463+
});
334464
});
335465

336466
describe("deriveMutationState", () => {
@@ -349,6 +479,7 @@ describe("deriveMutationState", () => {
349479
prompt: "What time should I schedule it?",
350480
reason: "time",
351481
open: true,
482+
parentTargetRef: null,
352483
},
353484
},
354485
];

apps/web/src/lib/server/conversation-state.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,23 @@ export function deriveConversationReplyState(
129129
);
130130
}
131131

132+
const parentTargetRef =
133+
input.policy.resolvedOperation?.targetRef ?? null;
134+
135+
// Close any prior open clarifications (one-open-per-workflow)
136+
for (let i = 0; i < entityRegistry.length; i++) {
137+
const entity = entityRegistry[i]!;
138+
if (entity.kind === "clarification" && entity.data.open) {
139+
entityRegistry[i] = {
140+
...entity,
141+
status: "resolved" as const,
142+
updatedAt: occurredAt,
143+
data: { ...entity.data, open: false },
144+
};
145+
resolvedClarificationIds.push(entity.id);
146+
}
147+
}
148+
132149
const clarificationEntity = buildConversationEntity(
133150
input.snapshot.conversation.id,
134151
{
@@ -141,6 +158,7 @@ export function deriveConversationReplyState(
141158
prompt: input.reply,
142159
reason: clarificationSlot,
143160
open: true,
161+
parentTargetRef,
144162
},
145163
},
146164
);
@@ -277,6 +295,7 @@ export function deriveMutationState(input: DeriveMutationStateInput) {
277295
prompt: input.processing.followUpMessage,
278296
reason: input.processing.reason,
279297
open: true,
298+
parentTargetRef: null,
280299
},
281300
},
282301
);

0 commit comments

Comments
 (0)