Skip to content

Commit 9ede728

Browse files
authored
Merge pull request #76 from MaxLinCode/claude/entity-ctx
feat: inject entity context into interpretWriteTurn pipeline
2 parents a482353 + cfbaf4f commit 9ede728

9 files changed

Lines changed: 764 additions & 11 deletions

File tree

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
vi.mock("@atlas/integrations", () => ({
4+
interpretWriteTurnWithResponses: vi.fn(),
5+
}));
6+
7+
import { interpretWriteTurnWithResponses } from "@atlas/integrations";
8+
9+
import { interpretWriteTurn } from "./interpret-write-turn";
10+
11+
const mockInterpretWriteTurnWithResponses = vi.mocked(
12+
interpretWriteTurnWithResponses,
13+
);
14+
15+
beforeEach(() => {
16+
vi.clearAllMocks();
17+
});
18+
19+
describe("interpretWriteTurn", () => {
20+
it("forwards entityContext to the integrations layer", async () => {
21+
mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({
22+
operationKind: "plan",
23+
actionDomain: "task",
24+
targetRef: { entityId: "task-1", description: null, entityKind: null },
25+
taskName: null,
26+
fields: {
27+
scheduleFields: null,
28+
taskFields: null,
29+
},
30+
confidence: {},
31+
unresolvedFields: [],
32+
});
33+
34+
await interpretWriteTurn({
35+
currentTurnText: "move gym",
36+
turnType: "edit_request",
37+
entityContext: 'Known entities:\n- "Gym" (task, scheduled) [id: task-1]',
38+
});
39+
40+
expect(mockInterpretWriteTurnWithResponses).toHaveBeenCalledWith(
41+
expect.objectContaining({
42+
entityContext:
43+
'Known entities:\n- "Gym" (task, scheduled) [id: task-1]',
44+
}),
45+
undefined,
46+
);
47+
});
48+
49+
it("falls back cleanly when the integrations layer returns malformed output", async () => {
50+
mockInterpretWriteTurnWithResponses.mockResolvedValueOnce({
51+
operationKind: "plan",
52+
actionDomain: "task",
53+
targetRef: null,
54+
taskName: null,
55+
fields: {
56+
scheduleFields: null,
57+
taskFields: null,
58+
},
59+
confidence: {
60+
bad: 2,
61+
},
62+
unresolvedFields: [],
63+
} as never);
64+
65+
await expect(
66+
interpretWriteTurn({
67+
currentTurnText: "schedule gym",
68+
turnType: "planning_request",
69+
}),
70+
).resolves.toMatchObject({
71+
operationKind: "plan",
72+
targetRef: null,
73+
sourceText: "schedule gym",
74+
});
75+
});
76+
});

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: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import {
22
applyWriteCommit,
3+
buildEntityContext,
34
type ConversationDiscourseState,
45
type ConversationEntity,
56
type ConversationTurn,
67
createEmptyDiscourseState,
78
deriveAmbiguity,
89
type PendingWriteOperation,
10+
renderEntityContext,
911
type RoutedTurn,
1012
routedTurnSchema,
13+
taskSchema,
1114
type TurnAmbiguity,
1215
type TurnClassifierOutput,
1316
type TurnInterpretation,
@@ -75,6 +78,7 @@ export async function routeMessageTurn(
7578
): Promise<TurnRouterResult> {
7679
const discourseState = input.discourseState ?? createEmptyDiscourseState();
7780
const entityRegistry = input.entityRegistry ?? [];
81+
const tasks = (input.tasks ?? []).map((task) => taskSchema.parse(task));
7882

7983
// Pipeline A: classify intent
8084
let classification = await classifyTurn({
@@ -122,6 +126,13 @@ export async function routeMessageTurn(
122126
turnType: classification.turnType,
123127
priorPendingWriteOperation: priorOperation,
124128
conversationContext: deriveConversationContext(input.recentTurns),
129+
entityContext: renderEntityContext(
130+
buildEntityContext({
131+
entityRegistry,
132+
tasks,
133+
discourseState,
134+
}),
135+
),
125136
})
126137
: {
127138
operationKind: priorOperation?.operationKind ?? "plan",
@@ -134,21 +145,31 @@ export async function routeMessageTurn(
134145
unresolvedFields: [],
135146
};
136147

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+
137158
// Policy layer: commit + route
138159
const commitResult = applyWriteCommit({
139160
turnType: classification.turnType,
140161
interpretation: writeInterpretation,
141162
priorPendingWriteOperation: priorOperation,
142-
...(writeTarget.targetEntityId !== undefined
143-
? { currentTargetEntityId: writeTarget.targetEntityId }
163+
...(effectiveTargetEntityId !== undefined
164+
? { currentTargetEntityId: effectiveTargetEntityId }
144165
: {}),
145166
});
146167

147168
const policy = decideTurnPolicy({
148169
classification,
149170
commitResult,
150171
routingContext: input,
151-
...writeTarget,
172+
...effectiveWriteTarget,
152173
});
153174

154175
// Assemble the resolved PendingWriteOperation for any turn that advances or maintains
@@ -168,7 +189,7 @@ export async function routeMessageTurn(
168189
const interpretation = buildInterpretation(
169190
classification,
170191
commitResult,
171-
writeTarget,
192+
effectiveWriteTarget,
172193
);
173194

174195
return routedTurnSchema.parse({

0 commit comments

Comments
 (0)