Skip to content

Commit cfbaf4f

Browse files
MaxLinCodeclaude
andcommitted
feat: LLM-resolved targetRef takes priority over discourse-state entity lookup
When the write interpreter resolves an entity via targetRef (e.g. by matching a task name), that entityId now overrides the discourse-state focus entity. Falls back to resolveWriteTarget when the interpreter does not resolve an entity. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent c9e4d8f commit cfbaf4f

2 files changed

Lines changed: 115 additions & 4 deletions

File tree

apps/web/src/lib/server/turn-router.test.ts

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -436,6 +436,107 @@ describe("turn router", () => {
436436
expect(mockInterpretWriteTurn).not.toHaveBeenCalled();
437437
});
438438

439+
it("prefers the interpreter targetRef entity over discourse focus for write turns", async () => {
440+
mockClassification({
441+
turnType: "edit_request",
442+
confidence: 0.96,
443+
});
444+
mockInterpretWriteTurn.mockResolvedValueOnce({
445+
operationKind: "edit",
446+
actionDomain: "task",
447+
targetRef: { entityId: "task-2" },
448+
taskName: null,
449+
fields: { scheduleFields: { time: t(11, 0) } },
450+
sourceText: "Move weekly review to 11",
451+
confidence: {
452+
"scheduleFields.time": 0.95,
453+
},
454+
unresolvedFields: [],
455+
});
456+
457+
const result = await routeMessageTurn({
458+
rawText: "Move weekly review to 11",
459+
normalizedText: "Move weekly review to 11",
460+
recentTurns: [],
461+
tasks: [
462+
{
463+
id: "task-2",
464+
userId: "user-1",
465+
sourceInboxItemId: "inbox-1",
466+
lastInboxItemId: "inbox-1",
467+
title: "Weekly review",
468+
lifecycleState: "pending_schedule",
469+
externalCalendarEventId: null,
470+
externalCalendarId: null,
471+
scheduledStartAt: null,
472+
scheduledEndAt: null,
473+
calendarSyncStatus: "in_sync",
474+
calendarSyncUpdatedAt: null,
475+
rescheduleCount: 0,
476+
lastFollowupAt: null,
477+
followupReminderSentAt: null,
478+
completedAt: null,
479+
archivedAt: null,
480+
priority: "medium",
481+
urgency: "medium",
482+
},
483+
],
484+
discourseState: {
485+
focus_entity_id: "task-1",
486+
currently_editable_entity_id: "task-1",
487+
last_user_mentioned_entity_ids: [],
488+
last_presented_items: [],
489+
pending_clarifications: [],
490+
mode: "editing",
491+
},
492+
});
493+
494+
expect(result.interpretation.resolvedEntityIds).toEqual(["task-2"]);
495+
expect(result.policy.targetEntityId).toBe("task-2");
496+
expect(result.policy.resolvedOperation?.targetRef).toEqual({
497+
entityId: "task-2",
498+
});
499+
});
500+
501+
it("falls back to resolveWriteTarget when the interpreter does not resolve an entity", async () => {
502+
mockClassification({
503+
turnType: "edit_request",
504+
confidence: 0.96,
505+
});
506+
mockInterpretWriteTurn.mockResolvedValueOnce({
507+
operationKind: "edit",
508+
actionDomain: "task",
509+
targetRef: null,
510+
taskName: null,
511+
fields: { scheduleFields: { time: t(11, 0) } },
512+
sourceText: "Move it to 11",
513+
confidence: {
514+
"scheduleFields.time": 0.95,
515+
},
516+
unresolvedFields: [],
517+
});
518+
519+
const result = await routeMessageTurn({
520+
rawText: "Move it to 11",
521+
normalizedText: "Move it to 11",
522+
recentTurns: [],
523+
discourseState: {
524+
focus_entity_id: "task-1",
525+
currently_editable_entity_id: null,
526+
last_user_mentioned_entity_ids: [],
527+
last_presented_items: [],
528+
pending_clarifications: [],
529+
mode: "editing",
530+
},
531+
});
532+
533+
expect(result.interpretation.resolvedEntityIds).toEqual(["task-1"]);
534+
expect(result.policy.targetEntityId).toBe("task-1");
535+
expect(result.policy.resolvedOperation?.targetRef).toEqual({
536+
entityId: "task-1",
537+
});
538+
});
539+
439540
it("clears prior committed fields when the interpreted workflow changes", async () => {
440541
mockClassification({
441542
turnType: "planning_request",

apps/web/src/lib/server/turn-router.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -145,21 +145,31 @@ export async function routeMessageTurn(
145145
unresolvedFields: [],
146146
};
147147

148+
// LLM-resolved targetRef takes priority over discourse-state entity lookup
149+
const effectiveTargetEntityId =
150+
writeInterpretation.targetRef?.entityId ?? writeTarget.targetEntityId;
151+
const effectiveWriteTarget: WriteTarget = {
152+
...writeTarget,
153+
...(effectiveTargetEntityId
154+
? { targetEntityId: effectiveTargetEntityId }
155+
: {}),
156+
};
157+
148158
// Policy layer: commit + route
149159
const commitResult = applyWriteCommit({
150160
turnType: classification.turnType,
151161
interpretation: writeInterpretation,
152162
priorPendingWriteOperation: priorOperation,
153-
...(writeTarget.targetEntityId !== undefined
154-
? { currentTargetEntityId: writeTarget.targetEntityId }
163+
...(effectiveTargetEntityId !== undefined
164+
? { currentTargetEntityId: effectiveTargetEntityId }
155165
: {}),
156166
});
157167

158168
const policy = decideTurnPolicy({
159169
classification,
160170
commitResult,
161171
routingContext: input,
162-
...writeTarget,
172+
...effectiveWriteTarget,
163173
});
164174

165175
// Assemble the resolved PendingWriteOperation for any turn that advances or maintains
@@ -179,7 +189,7 @@ export async function routeMessageTurn(
179189
const interpretation = buildInterpretation(
180190
classification,
181191
commitResult,
182-
writeTarget,
192+
effectiveWriteTarget,
183193
);
184194

185195
return routedTurnSchema.parse({

0 commit comments

Comments
 (0)