From bf0242555d424f917ee4428bc5149dd68c3320a7 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 21:54:27 -0700 Subject: [PATCH 1/7] feat: add entity resolution instructions to interpretWriteTurn system prompt --- .../integrations/src/prompts/interpret-write-turn.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/integrations/src/prompts/interpret-write-turn.ts b/packages/integrations/src/prompts/interpret-write-turn.ts index 5dce5fb..8b4a6b5 100644 --- a/packages/integrations/src/prompts/interpret-write-turn.ts +++ b/packages/integrations/src/prompts/interpret-write-turn.ts @@ -14,6 +14,7 @@ export const interpretWriteTurnSystemPrompt = buildPromptSpec([ "Infer the operation kind expressed by the user, extract any concrete write fields, and report uncertainty per field path.", "Describe what the user said in this turn. Do not decide whether Atlas should execute, clarify, or ask for consent.", "Use priorPendingWriteOperation only for continuity when the turn is a follow-up clarification or continuation.", + "Use entityContext to resolve references to known entities when the user points at existing work, proposals, reminders, or schedule blocks.", ], }, { @@ -43,6 +44,16 @@ export const interpretWriteTurnSystemPrompt = buildPromptSpec([ "When the user is vague like 'whenever' or 'you pick', do not guess. Put the field path in unresolvedFields.", ], }, + { + title: "Entity Resolution", + lines: [ + "Resolve entity references only against the provided entityContext.", + "If you identify a known entity, copy its exact [id: ...] value into targetRef.entityId.", + "Never invent an entity ID that is not present in entityContext.", + "When the user is introducing new work rather than referring to a known entity, return targetRef: null and populate taskName if they named the work.", + "If the turn continues a prior write without naming a new entity, you may leave targetRef null.", + ], + }, { title: "Important Constraints", lines: [ From 0da87effd4d81e7db2bdfc85551354a43a6fa2aa Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 21:54:55 -0700 Subject: [PATCH 2/7] feat: add buildEntityContext for slim entity projection Co-Authored-By: Claude Opus 4.6 --- packages/core/src/entity-context.test.ts | 203 +++++++++++++++++++++++ packages/core/src/entity-context.ts | 172 +++++++++++++++++++ packages/core/src/index.ts | 3 + 3 files changed, 378 insertions(+) create mode 100644 packages/core/src/entity-context.test.ts create mode 100644 packages/core/src/entity-context.ts diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts new file mode 100644 index 0000000..fe11b29 --- /dev/null +++ b/packages/core/src/entity-context.test.ts @@ -0,0 +1,203 @@ +import { describe, expect, it } from "vitest"; + +import { + buildEntityContext, + type ConversationEntity, + type Task, +} from "./index"; + +function buildTask(overrides: Partial = {}): Task { + return { + id: "task-1", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Gym session", + lifecycleState: "pending_schedule", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: null, + scheduledEndAt: null, + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + ...overrides, + }; +} + +function buildEntity( + overrides: Partial, +): ConversationEntity { + return { + id: "entity-1", + conversationId: "conversation-1", + label: "Entity label", + status: "active", + createdAt: "2026-04-01T15:00:00.000Z", + updatedAt: "2026-04-01T15:00:00.000Z", + kind: "task", + data: { + taskId: "task-1", + title: "Gym session", + lifecycleState: "pending_schedule", + scheduledStartAt: null, + scheduledEndAt: null, + }, + ...overrides, + } as ConversationEntity; +} + +describe("entity context", () => { + it("builds filtered known entities and derives focus, proposal, and clarification helpers", () => { + const context = buildEntityContext({ + entityRegistry: [ + buildEntity({ + id: "task-entity-1", + label: "Gym session", + kind: "task", + data: { + taskId: "task-1", + title: "Gym session", + lifecycleState: "scheduled", + scheduledStartAt: "2026-04-02T01:00:00.000Z", + scheduledEndAt: "2026-04-02T02:00:00.000Z", + }, + }), + buildEntity({ + id: "proposal-1", + label: "Schedule gym tomorrow at 6pm", + kind: "proposal_option", + status: "presented", + data: { + route: "conversation_then_mutation", + replyText: "Would you like me to schedule it at 6pm?", + confirmationRequired: true, + targetEntityId: "task-entity-1", + mutationInputSource: null, + originatingTurnText: "Schedule gym tomorrow at 6pm", + missingFields: ["scheduleFields.time"], + fieldSnapshot: {}, + }, + }), + buildEntity({ + id: "clarification-1", + label: "What time?", + kind: "clarification", + data: { + prompt: "What time should I schedule it?", + reason: null, + open: true, + }, + }), + buildEntity({ + id: "block-1", + label: "Write blog post", + kind: "scheduled_block", + data: { + blockId: "block-db-1", + taskId: "task-2", + title: "Write blog post", + startAt: "2026-04-02T03:00:00.000Z", + endAt: "2026-04-02T04:00:00.000Z", + externalCalendarId: "primary", + }, + }), + buildEntity({ + id: "reminder-1", + label: "Review taxes", + kind: "reminder", + status: "active", + data: { + taskId: "task-3", + title: "Review taxes", + reminderKind: "reminder", + number: 1, + }, + }), + // These should be filtered out: + buildEntity({ + id: "done-task", + label: "Completed task", + kind: "task", + data: { + taskId: "task-done", + title: "Completed task", + lifecycleState: "done", + scheduledStartAt: null, + scheduledEndAt: null, + }, + }), + buildEntity({ + id: "resolved-proposal", + label: "Old proposal", + kind: "proposal_option", + status: "resolved", + data: { + route: "conversation_then_mutation", + replyText: "Old proposal", + confirmationRequired: true, + targetEntityId: null, + mutationInputSource: null, + fieldSnapshot: {}, + }, + }), + buildEntity({ + id: "closed-clarification", + label: "Closed clarification", + kind: "clarification", + data: { + prompt: "Closed clarification", + reason: null, + open: false, + }, + }), + ], + tasks: [ + buildTask({ id: "task-1", title: "Gym session duplicate" }), // matches registry by taskId, should be deduplicated + buildTask({ + id: "task-2", + title: "Weekly review", + lifecycleState: "awaiting_followup", + externalCalendarEventId: "event-1", + externalCalendarId: "primary", + scheduledStartAt: "2026-04-02T05:00:00.000Z", + scheduledEndAt: "2026-04-02T06:00:00.000Z", + }), + ], + discourseState: { + focus_entity_id: "task-entity-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "planning", + }, + }); + + // Sorted by expectedType then label then id + expect(context.knownEntities).toEqual([ + { id: "clarification-1", label: "What time should I schedule it?", expectedType: "clarification", state: "open" }, + { id: "proposal-1", label: "Schedule gym tomorrow at 6pm", expectedType: "proposal", state: "presented" }, + { id: "reminder-1", label: "Review taxes", expectedType: "reminder", state: "active" }, + { id: "block-1", label: "Write blog post", expectedType: "scheduled_block", state: "scheduled" }, + { id: "task-entity-1", label: "Gym session", expectedType: "task", state: "scheduled" }, + { id: "task-2", label: "Weekly review", expectedType: "task", state: "awaiting_followup" }, + ]); + expect(context.focusedEntityId).toBe("task-entity-1"); + expect(context.activeProposal).toEqual({ + id: "proposal-1", + summary: "Schedule gym tomorrow at 6pm", + missingFields: ["scheduleFields.time"], + }); + expect(context.openClarification).toEqual({ + id: "clarification-1", + prompt: "What time should I schedule it?", + }); + }); +}); diff --git a/packages/core/src/entity-context.ts b/packages/core/src/entity-context.ts new file mode 100644 index 0000000..bf65955 --- /dev/null +++ b/packages/core/src/entity-context.ts @@ -0,0 +1,172 @@ +import type { ConversationDiscourseState } from "./discourse-state"; +import type { ConversationEntity, Task } from "./index"; + +export type EntityContextEntry = { + id: string; + label: string; + expectedType: + | "task" + | "proposal" + | "clarification" + | "scheduled_block" + | "reminder"; + state: string; +}; + +export type EntityContext = { + knownEntities: EntityContextEntry[]; + focusedEntityId: string | null; + activeProposal: { id: string; summary: string; missingFields?: string[] } | null; + openClarification: { id: string; prompt: string } | null; +}; + +type BuildEntityContextInput = { + entityRegistry: ConversationEntity[]; + tasks: Task[]; + discourseState: ConversationDiscourseState | null; +}; + +const INACTIVE_TASK_STATES = new Set(["done", "archived"]); +const INACTIVE_PROPOSAL_STATES = new Set(["resolved", "superseded"]); + +export function buildEntityContext( + input: BuildEntityContextInput, +): EntityContext { + const registryTaskIds = new Set( + input.entityRegistry + .filter( + (entity): entity is Extract => + entity.kind === "task", + ) + .map((entity) => entity.data.taskId), + ); + + const knownEntities: EntityContextEntry[] = []; + + for (const entity of input.entityRegistry) { + switch (entity.kind) { + case "task": + if (!INACTIVE_TASK_STATES.has(entity.data.lifecycleState)) { + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "task", + state: entity.data.lifecycleState, + }); + } + break; + case "proposal_option": + if (!INACTIVE_PROPOSAL_STATES.has(entity.status)) { + knownEntities.push({ + id: entity.id, + label: + entity.data.originatingTurnText ?? + entity.data.replyText ?? + entity.label, + expectedType: "proposal", + state: entity.status, + }); + } + break; + case "clarification": + if (entity.data.open) { + knownEntities.push({ + id: entity.id, + label: entity.data.prompt, + expectedType: "clarification", + state: "open", + }); + } + break; + case "scheduled_block": + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "scheduled_block", + state: "scheduled", + }); + break; + case "reminder": + knownEntities.push({ + id: entity.id, + label: entity.data.title, + expectedType: "reminder", + state: entity.status, + }); + break; + } + } + + for (const task of input.tasks) { + if ( + !registryTaskIds.has(task.id) && + !INACTIVE_TASK_STATES.has(task.lifecycleState) + ) { + knownEntities.push({ + id: task.id, + label: task.title, + expectedType: "task", + state: task.lifecycleState, + }); + } + } + + knownEntities.sort( + (left, right) => + left.expectedType.localeCompare(right.expectedType) || + left.label.localeCompare(right.label) || + left.id.localeCompare(right.id), + ); + + const knownEntityIds = new Set(knownEntities.map((entity) => entity.id)); + const focusedEntityId = + input.discourseState?.focus_entity_id && + knownEntityIds.has(input.discourseState.focus_entity_id) + ? input.discourseState.focus_entity_id + : null; + + const activeProposals = input.entityRegistry.filter( + ( + entity, + ): entity is Extract => + entity.kind === "proposal_option" && + !INACTIVE_PROPOSAL_STATES.has(entity.status), + ); + const singleActiveProposal = + activeProposals.length === 1 ? activeProposals[0] : null; + const activeProposal = + singleActiveProposal + ? { + id: singleActiveProposal.id, + summary: + singleActiveProposal.data.originatingTurnText ?? + singleActiveProposal.data.replyText, + ...(singleActiveProposal.data.missingFields?.length + ? { missingFields: singleActiveProposal.data.missingFields } + : {}), + } + : null; + + const openClarifications = input.entityRegistry.filter( + ( + entity, + ): entity is Extract => + entity.kind === "clarification" && entity.data.open, + ); + const singleOpenClarification = + openClarifications.length === 1 ? openClarifications[0] : null; + const openClarification = + singleOpenClarification + ? { + id: singleOpenClarification.id, + prompt: singleOpenClarification.data.prompt, + } + : null; + + return { + knownEntities, + focusedEntityId, + activeProposal, + openClarification, + }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index adf8435..eb237d4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -13,6 +13,7 @@ import { export * from "./ambiguity"; export * from "./commit-policy"; export * from "./discourse-state"; +export * from "./entity-context"; export * from "./proposal-rules"; export * from "./slot-normalizer"; export * from "./synthesize-mutation-text"; @@ -904,6 +905,7 @@ export const turnRoutingInputSchema = z.object({ summaryText: z.string().nullable().optional(), entityRegistry: z.array(conversationEntitySchema).optional().default([]), discourseState: conversationDiscourseStateSchema.nullable().optional(), + tasks: z.array(taskSchema).optional().default([]), }); export const turnRoutingOutputSchema = z.object({ @@ -1052,6 +1054,7 @@ export const writeInterpretationInputSchema = z.object({ turnType: turnInterpretationTypeSchema, priorPendingWriteOperation: pendingWriteOperationSchema.optional(), conversationContext: z.string().optional(), + entityContext: z.string().optional(), }); export const rawWriteInterpretationSchema = z.object({ From 8f151eb050e15f7c24f68c776d1b279796d07f70 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 21:59:14 -0700 Subject: [PATCH 3/7] feat: add renderEntityContext for semi-structured LLM prompt text --- packages/core/src/entity-context.test.ts | 34 ++++++++++++++ packages/core/src/entity-context.ts | 56 ++++++++++++++++++++++++ 2 files changed, 90 insertions(+) diff --git a/packages/core/src/entity-context.test.ts b/packages/core/src/entity-context.test.ts index fe11b29..9b6ea4a 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, + renderEntityContext, type ConversationEntity, type Task, } from "./index"; @@ -200,4 +201,37 @@ describe("entity context", () => { prompt: "What time should I schedule it?", }); }); + + it("renders deterministic prompt text with explicit empty sections", () => { + const rendered = renderEntityContext({ + knownEntities: [ + { + id: "task-1", + label: "Gym session", + expectedType: "task", + state: "scheduled", + }, + ], + focusedEntityId: "task-1", + activeProposal: null, + openClarification: null, + }); + + expect(rendered).toBe( + 'Known entities:\n- "Gym session" (task, scheduled) [id: task-1]\n\nCurrently focused: "Gym session" [id: task-1]\n\nNo active proposal.\n\nNo open clarification.', + ); + }); + + it("renders an explicit no-known-entities line when the context is empty", () => { + expect( + renderEntityContext({ + knownEntities: [], + focusedEntityId: null, + activeProposal: null, + openClarification: null, + }), + ).toBe( + "Known entities:\nNo known entities.\n\nNo focused entity.\n\nNo active proposal.\n\nNo open clarification.", + ); + }); }); diff --git a/packages/core/src/entity-context.ts b/packages/core/src/entity-context.ts index bf65955..397be50 100644 --- a/packages/core/src/entity-context.ts +++ b/packages/core/src/entity-context.ts @@ -170,3 +170,59 @@ export function buildEntityContext( openClarification, }; } + +export function renderEntityContext(context: EntityContext): string { + const lines = ["Known entities:"]; + + if (context.knownEntities.length === 0) { + lines.push("No known entities."); + } else { + for (const entity of context.knownEntities) { + lines.push( + `- "${entity.label}" (${entity.expectedType}, ${entity.state}) [id: ${entity.id}]`, + ); + } + } + + lines.push(""); + + if (context.focusedEntityId) { + const focusedEntity = context.knownEntities.find( + (entity) => entity.id === context.focusedEntityId, + ); + lines.push( + focusedEntity + ? `Currently focused: "${focusedEntity.label}" [id: ${focusedEntity.id}]` + : "No focused entity.", + ); + } else { + lines.push("No focused entity."); + } + + lines.push(""); + + if (context.activeProposal) { + const missingFields = + context.activeProposal.missingFields && + context.activeProposal.missingFields.length > 0 + ? ` - still needs: ${context.activeProposal.missingFields.join(", ")}` + : ""; + lines.push( + `Active proposal: "${context.activeProposal.summary}"${missingFields} [id: ${context.activeProposal.id}]`, + ); + } else { + lines.push("No active proposal."); + } + + lines.push(""); + + if (context.openClarification) { + lines.push( + `Open clarification: "${context.openClarification.prompt}" [id: ${context.openClarification.id}]`, + ); + } else { + lines.push("No open clarification."); + } + + return lines.join("\n"); +} From 4985ad155774a4e9bd810d1c83f3c35c9be35fc3 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 22:05:19 -0700 Subject: [PATCH 4/7] feat: include entityContext in interpretWriteTurn prompt payload Co-Authored-By: Claude Opus 4.6 --- packages/integrations/src/index.test.ts | 64 +++++++++++++++++++++++++ packages/integrations/src/openai.ts | 26 +++++++--- 2 files changed, 83 insertions(+), 7 deletions(-) diff --git a/packages/integrations/src/index.test.ts b/packages/integrations/src/index.test.ts index fcd3c76..64805fd 100644 --- a/packages/integrations/src/index.test.ts +++ b/packages/integrations/src/index.test.ts @@ -4,6 +4,7 @@ import { } from "@atlas/core"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { interpretWriteTurnSystemPrompt } from "./prompts/interpret-write-turn"; import { buildGoogleCalendarOAuthUrl, createGoogleCalendarAdapter, @@ -1153,4 +1154,67 @@ describe("integrations", () => { "Telegram editMessageText failed with status 400: Bad Request: message can't be edited.", ); }); + + it("includes entityContext in the write interpretation prompt payload and prompt instructions", async () => { + const parse = vi.fn(async () => ({ + output_parsed: { + operationKind: "edit", + actionDomain: "task", + targetRef: { + entityId: "task-1", + description: null, + entityKind: null, + }, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: {}, + unresolvedFields: [], + }, + })); + + await interpretWriteTurnWithResponses( + { + currentTurnText: "Move gym to 5", + turnType: "edit_request", + entityContext: + 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }, + { + responses: { + parse, + }, + }, + ); + + expect(parse).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.arrayContaining([ + expect.objectContaining({ + role: "system", + content: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining( + "Resolve entity references only against the provided entityContext.", + ), + }), + ]), + }), + expect.objectContaining({ + role: "user", + content: expect.arrayContaining([ + expect.objectContaining({ + text: expect.stringContaining("Entity context:\nKnown entities:"), + }), + ]), + }), + ]), + }), + ); + expect(interpretWriteTurnSystemPrompt).toContain( + "Never invent an entity ID", + ); + }); }); diff --git a/packages/integrations/src/openai.ts b/packages/integrations/src/openai.ts index 3821f96..ffab0be 100644 --- a/packages/integrations/src/openai.ts +++ b/packages/integrations/src/openai.ts @@ -330,7 +330,7 @@ export async function interpretWriteTurnWithResponses( content: [ { type: "input_text", - text: JSON.stringify(buildWriteInterpretationPromptContext(context)), + text: buildWriteInterpretationPromptContext(context), }, ], }, @@ -413,12 +413,24 @@ function buildSlotExtractorPromptContext(context: SlotExtractorInput) { function buildWriteInterpretationPromptContext( context: WriteInterpretationInput, ) { - return { - currentTurnText: context.currentTurnText, - turnType: context.turnType, - priorPendingWriteOperation: context.priorPendingWriteOperation ?? null, - conversationContext: context.conversationContext ?? null, - }; + return [ + "Current turn text:", + context.currentTurnText, + "", + "Turn type:", + context.turnType, + "", + "Prior pending write operation:", + context.priorPendingWriteOperation + ? JSON.stringify(context.priorPendingWriteOperation, null, 2) + : "None", + "", + "Conversation context:", + context.conversationContext ?? "None", + "", + "Entity context:", + context.entityContext ?? "None", + ].join("\n"); } function buildTurnRoutingPromptContext(context: TurnRoutingInput) { From c0f161ded3c6297974163128b28a3a259afac753 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 22:07:47 -0700 Subject: [PATCH 5/7] test: add interpret-write-turn wrapper tests for entityContext forwarding Co-Authored-By: Claude Opus 4.6 --- .../lib/server/interpret-write-turn.test.ts | 76 +++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 apps/web/src/lib/server/interpret-write-turn.test.ts diff --git a/apps/web/src/lib/server/interpret-write-turn.test.ts b/apps/web/src/lib/server/interpret-write-turn.test.ts new file mode 100644 index 0000000..da35e85 --- /dev/null +++ b/apps/web/src/lib/server/interpret-write-turn.test.ts @@ -0,0 +1,76 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("@atlas/integrations", () => ({ + interpretWriteTurnWithResponses: vi.fn(), +})); + +import { interpretWriteTurnWithResponses } from "@atlas/integrations"; + +import { interpretWriteTurn } from "./interpret-write-turn"; + +const mockInterpretWriteTurnWithResponses = vi.mocked( + interpretWriteTurnWithResponses, +); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("interpretWriteTurn", () => { + it("forwards entityContext to the integrations layer", async () => { + mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: { entityId: "task-1", description: null, entityKind: null }, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: {}, + unresolvedFields: [], + }); + + await interpretWriteTurn({ + currentTurnText: "move gym", + turnType: "edit_request", + entityContext: 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }); + + expect(mockInterpretWriteTurnWithResponses).toHaveBeenCalledWith( + expect.objectContaining({ + entityContext: + 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]', + }), + undefined, + ); + }); + + it("falls back cleanly when the integrations layer returns malformed output", async () => { + mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({ + operationKind: "plan", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { + scheduleFields: null, + taskFields: null, + }, + confidence: { + bad: 2, + }, + unresolvedFields: [], + } as never); + + await expect( + interpretWriteTurn({ + currentTurnText: "schedule gym", + turnType: "planning_request", + }), + ).resolves.toMatchObject({ + operationKind: "plan", + targetRef: null, + sourceText: "schedule gym", + }); + }); +}); From c9e4d8f6baafad888c9a5f5a5af58b3077f7f1f7 Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 22:07:49 -0700 Subject: [PATCH 6/7] feat: build and inject entityContext into interpretWriteTurn pipeline Co-Authored-By: Claude Opus 4.6 --- apps/web/src/lib/server/turn-router.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index 42ac65d..d0ecd40 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -1,13 +1,16 @@ import { applyWriteCommit, + buildEntityContext, type ConversationDiscourseState, type ConversationEntity, type ConversationTurn, createEmptyDiscourseState, deriveAmbiguity, type PendingWriteOperation, + renderEntityContext, type RoutedTurn, routedTurnSchema, + taskSchema, type TurnAmbiguity, type TurnClassifierOutput, type TurnInterpretation, @@ -75,6 +78,7 @@ export async function routeMessageTurn( ): Promise { const discourseState = input.discourseState ?? createEmptyDiscourseState(); const entityRegistry = input.entityRegistry ?? []; + const tasks = (input.tasks ?? []).map((task) => taskSchema.parse(task)); // Pipeline A: classify intent let classification = await classifyTurn({ @@ -122,6 +126,13 @@ export async function routeMessageTurn( turnType: classification.turnType, priorPendingWriteOperation: priorOperation, conversationContext: deriveConversationContext(input.recentTurns), + entityContext: renderEntityContext( + buildEntityContext({ + entityRegistry, + tasks, + discourseState, + }), + ), }) : { operationKind: priorOperation?.operationKind ?? "plan", From cfbaf4f6f143abe49bb17a8515079940cc8ea70d Mon Sep 17 00:00:00 2001 From: Max Lin Date: Thu, 2 Apr 2026 22:09:54 -0700 Subject: [PATCH 7/7] 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 --- apps/web/src/lib/server/turn-router.test.ts | 101 ++++++++++++++++++++ apps/web/src/lib/server/turn-router.ts | 18 +++- 2 files changed, 115 insertions(+), 4 deletions(-) diff --git a/apps/web/src/lib/server/turn-router.test.ts b/apps/web/src/lib/server/turn-router.test.ts index 1351434..41ce277 100644 --- a/apps/web/src/lib/server/turn-router.test.ts +++ b/apps/web/src/lib/server/turn-router.test.ts @@ -436,6 +436,107 @@ describe("turn router", () => { expect(mockInterpretWriteTurn).not.toHaveBeenCalled(); }); + it("prefers the interpreter targetRef entity over discourse focus for write turns", async () => { + mockClassification({ + turnType: "edit_request", + confidence: 0.96, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: { entityId: "task-2" }, + taskName: null, + fields: { scheduleFields: { time: t(11, 0) } }, + sourceText: "Move weekly review to 11", + confidence: { + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "Move weekly review to 11", + normalizedText: "Move weekly review to 11", + recentTurns: [], + tasks: [ + { + id: "task-2", + userId: "user-1", + sourceInboxItemId: "inbox-1", + lastInboxItemId: "inbox-1", + title: "Weekly review", + lifecycleState: "pending_schedule", + externalCalendarEventId: null, + externalCalendarId: null, + scheduledStartAt: null, + scheduledEndAt: null, + calendarSyncStatus: "in_sync", + calendarSyncUpdatedAt: null, + rescheduleCount: 0, + lastFollowupAt: null, + followupReminderSentAt: null, + completedAt: null, + archivedAt: null, + priority: "medium", + urgency: "medium", + }, + ], + discourseState: { + focus_entity_id: "task-1", + currently_editable_entity_id: "task-1", + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "editing", + }, + }); + + expect(result.interpretation.resolvedEntityIds).toEqual(["task-2"]); + expect(result.policy.targetEntityId).toBe("task-2"); + expect(result.policy.resolvedOperation?.targetRef).toEqual({ + entityId: "task-2", + }); + }); + + it("falls back to resolveWriteTarget when the interpreter does not resolve an entity", async () => { + mockClassification({ + turnType: "edit_request", + confidence: 0.96, + }); + mockInterpretWriteTurn.mockResolvedValueOnce({ + operationKind: "edit", + actionDomain: "task", + targetRef: null, + taskName: null, + fields: { scheduleFields: { time: t(11, 0) } }, + sourceText: "Move it to 11", + confidence: { + "scheduleFields.time": 0.95, + }, + unresolvedFields: [], + }); + + const result = await routeMessageTurn({ + rawText: "Move it to 11", + normalizedText: "Move it to 11", + recentTurns: [], + discourseState: { + focus_entity_id: "task-1", + currently_editable_entity_id: null, + last_user_mentioned_entity_ids: [], + last_presented_items: [], + pending_clarifications: [], + mode: "editing", + }, + }); + + expect(result.interpretation.resolvedEntityIds).toEqual(["task-1"]); + expect(result.policy.targetEntityId).toBe("task-1"); + expect(result.policy.resolvedOperation?.targetRef).toEqual({ + entityId: "task-1", + }); + }); + it("clears prior committed fields when the interpreted workflow changes", async () => { mockClassification({ turnType: "planning_request", diff --git a/apps/web/src/lib/server/turn-router.ts b/apps/web/src/lib/server/turn-router.ts index d0ecd40..61d3805 100644 --- a/apps/web/src/lib/server/turn-router.ts +++ b/apps/web/src/lib/server/turn-router.ts @@ -145,13 +145,23 @@ export async function routeMessageTurn( unresolvedFields: [], }; + // LLM-resolved targetRef takes priority over discourse-state entity lookup + const effectiveTargetEntityId = + writeInterpretation.targetRef?.entityId ?? writeTarget.targetEntityId; + const effectiveWriteTarget: WriteTarget = { + ...writeTarget, + ...(effectiveTargetEntityId + ? { targetEntityId: effectiveTargetEntityId } + : {}), + }; + // Policy layer: commit + route const commitResult = applyWriteCommit({ turnType: classification.turnType, interpretation: writeInterpretation, priorPendingWriteOperation: priorOperation, - ...(writeTarget.targetEntityId !== undefined - ? { currentTargetEntityId: writeTarget.targetEntityId } + ...(effectiveTargetEntityId !== undefined + ? { currentTargetEntityId: effectiveTargetEntityId } : {}), }); @@ -159,7 +169,7 @@ export async function routeMessageTurn( classification, commitResult, routingContext: input, - ...writeTarget, + ...effectiveWriteTarget, }); // Assemble the resolved PendingWriteOperation for any turn that advances or maintains @@ -179,7 +189,7 @@ export async function routeMessageTurn( const interpretation = buildInterpretation( classification, commitResult, - writeTarget, + effectiveWriteTarget, ); return routedTurnSchema.parse({