Skip to content

Commit 0da87ef

Browse files
MaxLinCodeclaude
andcommitted
feat: add buildEntityContext for slim entity projection
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent bf02425 commit 0da87ef

3 files changed

Lines changed: 378 additions & 0 deletions

File tree

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import {
4+
buildEntityContext,
5+
type ConversationEntity,
6+
type Task,
7+
} from "./index";
8+
9+
function buildTask(overrides: Partial<Task> = {}): Task {
10+
return {
11+
id: "task-1",
12+
userId: "user-1",
13+
sourceInboxItemId: "inbox-1",
14+
lastInboxItemId: "inbox-1",
15+
title: "Gym session",
16+
lifecycleState: "pending_schedule",
17+
externalCalendarEventId: null,
18+
externalCalendarId: null,
19+
scheduledStartAt: null,
20+
scheduledEndAt: null,
21+
calendarSyncStatus: "in_sync",
22+
calendarSyncUpdatedAt: null,
23+
rescheduleCount: 0,
24+
lastFollowupAt: null,
25+
followupReminderSentAt: null,
26+
completedAt: null,
27+
archivedAt: null,
28+
priority: "medium",
29+
urgency: "medium",
30+
...overrides,
31+
};
32+
}
33+
34+
function buildEntity(
35+
overrides: Partial<ConversationEntity>,
36+
): ConversationEntity {
37+
return {
38+
id: "entity-1",
39+
conversationId: "conversation-1",
40+
label: "Entity label",
41+
status: "active",
42+
createdAt: "2026-04-01T15:00:00.000Z",
43+
updatedAt: "2026-04-01T15:00:00.000Z",
44+
kind: "task",
45+
data: {
46+
taskId: "task-1",
47+
title: "Gym session",
48+
lifecycleState: "pending_schedule",
49+
scheduledStartAt: null,
50+
scheduledEndAt: null,
51+
},
52+
...overrides,
53+
} as ConversationEntity;
54+
}
55+
56+
describe("entity context", () => {
57+
it("builds filtered known entities and derives focus, proposal, and clarification helpers", () => {
58+
const context = buildEntityContext({
59+
entityRegistry: [
60+
buildEntity({
61+
id: "task-entity-1",
62+
label: "Gym session",
63+
kind: "task",
64+
data: {
65+
taskId: "task-1",
66+
title: "Gym session",
67+
lifecycleState: "scheduled",
68+
scheduledStartAt: "2026-04-02T01:00:00.000Z",
69+
scheduledEndAt: "2026-04-02T02:00:00.000Z",
70+
},
71+
}),
72+
buildEntity({
73+
id: "proposal-1",
74+
label: "Schedule gym tomorrow at 6pm",
75+
kind: "proposal_option",
76+
status: "presented",
77+
data: {
78+
route: "conversation_then_mutation",
79+
replyText: "Would you like me to schedule it at 6pm?",
80+
confirmationRequired: true,
81+
targetEntityId: "task-entity-1",
82+
mutationInputSource: null,
83+
originatingTurnText: "Schedule gym tomorrow at 6pm",
84+
missingFields: ["scheduleFields.time"],
85+
fieldSnapshot: {},
86+
},
87+
}),
88+
buildEntity({
89+
id: "clarification-1",
90+
label: "What time?",
91+
kind: "clarification",
92+
data: {
93+
prompt: "What time should I schedule it?",
94+
reason: null,
95+
open: true,
96+
},
97+
}),
98+
buildEntity({
99+
id: "block-1",
100+
label: "Write blog post",
101+
kind: "scheduled_block",
102+
data: {
103+
blockId: "block-db-1",
104+
taskId: "task-2",
105+
title: "Write blog post",
106+
startAt: "2026-04-02T03:00:00.000Z",
107+
endAt: "2026-04-02T04:00:00.000Z",
108+
externalCalendarId: "primary",
109+
},
110+
}),
111+
buildEntity({
112+
id: "reminder-1",
113+
label: "Review taxes",
114+
kind: "reminder",
115+
status: "active",
116+
data: {
117+
taskId: "task-3",
118+
title: "Review taxes",
119+
reminderKind: "reminder",
120+
number: 1,
121+
},
122+
}),
123+
// These should be filtered out:
124+
buildEntity({
125+
id: "done-task",
126+
label: "Completed task",
127+
kind: "task",
128+
data: {
129+
taskId: "task-done",
130+
title: "Completed task",
131+
lifecycleState: "done",
132+
scheduledStartAt: null,
133+
scheduledEndAt: null,
134+
},
135+
}),
136+
buildEntity({
137+
id: "resolved-proposal",
138+
label: "Old proposal",
139+
kind: "proposal_option",
140+
status: "resolved",
141+
data: {
142+
route: "conversation_then_mutation",
143+
replyText: "Old proposal",
144+
confirmationRequired: true,
145+
targetEntityId: null,
146+
mutationInputSource: null,
147+
fieldSnapshot: {},
148+
},
149+
}),
150+
buildEntity({
151+
id: "closed-clarification",
152+
label: "Closed clarification",
153+
kind: "clarification",
154+
data: {
155+
prompt: "Closed clarification",
156+
reason: null,
157+
open: false,
158+
},
159+
}),
160+
],
161+
tasks: [
162+
buildTask({ id: "task-1", title: "Gym session duplicate" }), // matches registry by taskId, should be deduplicated
163+
buildTask({
164+
id: "task-2",
165+
title: "Weekly review",
166+
lifecycleState: "awaiting_followup",
167+
externalCalendarEventId: "event-1",
168+
externalCalendarId: "primary",
169+
scheduledStartAt: "2026-04-02T05:00:00.000Z",
170+
scheduledEndAt: "2026-04-02T06:00:00.000Z",
171+
}),
172+
],
173+
discourseState: {
174+
focus_entity_id: "task-entity-1",
175+
currently_editable_entity_id: null,
176+
last_user_mentioned_entity_ids: [],
177+
last_presented_items: [],
178+
pending_clarifications: [],
179+
mode: "planning",
180+
},
181+
});
182+
183+
// Sorted by expectedType then label then id
184+
expect(context.knownEntities).toEqual([
185+
{ id: "clarification-1", label: "What time should I schedule it?", expectedType: "clarification", state: "open" },
186+
{ id: "proposal-1", label: "Schedule gym tomorrow at 6pm", expectedType: "proposal", state: "presented" },
187+
{ id: "reminder-1", label: "Review taxes", expectedType: "reminder", state: "active" },
188+
{ id: "block-1", label: "Write blog post", expectedType: "scheduled_block", state: "scheduled" },
189+
{ id: "task-entity-1", label: "Gym session", expectedType: "task", state: "scheduled" },
190+
{ id: "task-2", label: "Weekly review", expectedType: "task", state: "awaiting_followup" },
191+
]);
192+
expect(context.focusedEntityId).toBe("task-entity-1");
193+
expect(context.activeProposal).toEqual({
194+
id: "proposal-1",
195+
summary: "Schedule gym tomorrow at 6pm",
196+
missingFields: ["scheduleFields.time"],
197+
});
198+
expect(context.openClarification).toEqual({
199+
id: "clarification-1",
200+
prompt: "What time should I schedule it?",
201+
});
202+
});
203+
});
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import type { ConversationDiscourseState } from "./discourse-state";
2+
import type { ConversationEntity, Task } from "./index";
3+
4+
export type EntityContextEntry = {
5+
id: string;
6+
label: string;
7+
expectedType:
8+
| "task"
9+
| "proposal"
10+
| "clarification"
11+
| "scheduled_block"
12+
| "reminder";
13+
state: string;
14+
};
15+
16+
export type EntityContext = {
17+
knownEntities: EntityContextEntry[];
18+
focusedEntityId: string | null;
19+
activeProposal: { id: string; summary: string; missingFields?: string[] } | null;
20+
openClarification: { id: string; prompt: string } | null;
21+
};
22+
23+
type BuildEntityContextInput = {
24+
entityRegistry: ConversationEntity[];
25+
tasks: Task[];
26+
discourseState: ConversationDiscourseState | null;
27+
};
28+
29+
const INACTIVE_TASK_STATES = new Set(["done", "archived"]);
30+
const INACTIVE_PROPOSAL_STATES = new Set(["resolved", "superseded"]);
31+
32+
export function buildEntityContext(
33+
input: BuildEntityContextInput,
34+
): EntityContext {
35+
const registryTaskIds = new Set(
36+
input.entityRegistry
37+
.filter(
38+
(entity): entity is Extract<ConversationEntity, { kind: "task" }> =>
39+
entity.kind === "task",
40+
)
41+
.map((entity) => entity.data.taskId),
42+
);
43+
44+
const knownEntities: EntityContextEntry[] = [];
45+
46+
for (const entity of input.entityRegistry) {
47+
switch (entity.kind) {
48+
case "task":
49+
if (!INACTIVE_TASK_STATES.has(entity.data.lifecycleState)) {
50+
knownEntities.push({
51+
id: entity.id,
52+
label: entity.data.title,
53+
expectedType: "task",
54+
state: entity.data.lifecycleState,
55+
});
56+
}
57+
break;
58+
case "proposal_option":
59+
if (!INACTIVE_PROPOSAL_STATES.has(entity.status)) {
60+
knownEntities.push({
61+
id: entity.id,
62+
label:
63+
entity.data.originatingTurnText ??
64+
entity.data.replyText ??
65+
entity.label,
66+
expectedType: "proposal",
67+
state: entity.status,
68+
});
69+
}
70+
break;
71+
case "clarification":
72+
if (entity.data.open) {
73+
knownEntities.push({
74+
id: entity.id,
75+
label: entity.data.prompt,
76+
expectedType: "clarification",
77+
state: "open",
78+
});
79+
}
80+
break;
81+
case "scheduled_block":
82+
knownEntities.push({
83+
id: entity.id,
84+
label: entity.data.title,
85+
expectedType: "scheduled_block",
86+
state: "scheduled",
87+
});
88+
break;
89+
case "reminder":
90+
knownEntities.push({
91+
id: entity.id,
92+
label: entity.data.title,
93+
expectedType: "reminder",
94+
state: entity.status,
95+
});
96+
break;
97+
}
98+
}
99+
100+
for (const task of input.tasks) {
101+
if (
102+
!registryTaskIds.has(task.id) &&
103+
!INACTIVE_TASK_STATES.has(task.lifecycleState)
104+
) {
105+
knownEntities.push({
106+
id: task.id,
107+
label: task.title,
108+
expectedType: "task",
109+
state: task.lifecycleState,
110+
});
111+
}
112+
}
113+
114+
knownEntities.sort(
115+
(left, right) =>
116+
left.expectedType.localeCompare(right.expectedType) ||
117+
left.label.localeCompare(right.label) ||
118+
left.id.localeCompare(right.id),
119+
);
120+
121+
const knownEntityIds = new Set(knownEntities.map((entity) => entity.id));
122+
const focusedEntityId =
123+
input.discourseState?.focus_entity_id &&
124+
knownEntityIds.has(input.discourseState.focus_entity_id)
125+
? input.discourseState.focus_entity_id
126+
: null;
127+
128+
const activeProposals = input.entityRegistry.filter(
129+
(
130+
entity,
131+
): entity is Extract<ConversationEntity, { kind: "proposal_option" }> =>
132+
entity.kind === "proposal_option" &&
133+
!INACTIVE_PROPOSAL_STATES.has(entity.status),
134+
);
135+
const singleActiveProposal =
136+
activeProposals.length === 1 ? activeProposals[0] : null;
137+
const activeProposal =
138+
singleActiveProposal
139+
? {
140+
id: singleActiveProposal.id,
141+
summary:
142+
singleActiveProposal.data.originatingTurnText ??
143+
singleActiveProposal.data.replyText,
144+
...(singleActiveProposal.data.missingFields?.length
145+
? { missingFields: singleActiveProposal.data.missingFields }
146+
: {}),
147+
}
148+
: null;
149+
150+
const openClarifications = input.entityRegistry.filter(
151+
(
152+
entity,
153+
): entity is Extract<ConversationEntity, { kind: "clarification" }> =>
154+
entity.kind === "clarification" && entity.data.open,
155+
);
156+
const singleOpenClarification =
157+
openClarifications.length === 1 ? openClarifications[0] : null;
158+
const openClarification =
159+
singleOpenClarification
160+
? {
161+
id: singleOpenClarification.id,
162+
prompt: singleOpenClarification.data.prompt,
163+
}
164+
: null;
165+
166+
return {
167+
knownEntities,
168+
focusedEntityId,
169+
activeProposal,
170+
openClarification,
171+
};
172+
}

0 commit comments

Comments
 (0)