Skip to content

Commit c6cb566

Browse files
authored
Merge pull request #71 from MaxLinCode/claude/phase1-pending-write-operation
Replace committedSlots/resolvedContract with PendingWriteOperation
2 parents b18c2aa + 89fc2b3 commit c6cb566

22 files changed

Lines changed: 710 additions & 491 deletions

apps/web/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66
"build": "next build",
77
"lint": "biome check src next.config.ts vitest.config.mts",
88
"typecheck": "tsc --noEmit",
9-
"test": "vitest run"
9+
"test": "vitest run",
10+
"vitest": "vitest"
1011
},
1112
"dependencies": {
1213
"@atlas/core": "workspace:*",

apps/web/src/app/api/telegram/webhook/route.test.ts

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,12 @@
11
import type {
22
ConversationEntity,
33
InboxPlanningOutput,
4+
PendingWriteOperation,
45
RoutedTurn,
5-
TimeSpec,
66
TurnInterpretationType,
77
TurnPolicyAction,
88
} from "@atlas/core";
99

10-
function t(hour: number, minute: number): TimeSpec {
11-
return { kind: "absolute", hour, minute };
12-
}
13-
1410
import {
1511
getDefaultFollowUpRuntimeStore,
1612
getDefaultGoogleCalendarConnectionStore,
@@ -155,8 +151,8 @@ function buildRoutedTurn(input: {
155151
confidence?: number;
156152
ambiguity?: "none" | "low" | "high";
157153
resolvedProposalId?: string;
158-
committedSlots?: Record<string, string | number | TimeSpec>;
159154
clarificationSlots?: string[];
155+
resolvedOperation?: PendingWriteOperation;
160156
}): RoutedTurn {
161157
return {
162158
interpretation: {
@@ -186,10 +182,12 @@ function buildRoutedTurn(input: {
186182
: input.action === "execute_mutation"
187183
? { mutationInputSource: "direct_user_turn" as const }
188184
: {}),
189-
committedSlots: input.committedSlots ?? {},
190185
...(input.clarificationSlots
191186
? { clarificationSlots: input.clarificationSlots }
192187
: {}),
188+
...(input.resolvedOperation
189+
? { resolvedOperation: input.resolvedOperation }
190+
: {}),
193191
},
194192
};
195193
}
@@ -1192,7 +1190,18 @@ describe("telegram webhook route", () => {
11921190
turnType: "confirmation",
11931191
action: "recover_and_execute",
11941192
resolvedProposalId: "proposal-1",
1195-
committedSlots: { time: t(15, 0) },
1193+
resolvedOperation: {
1194+
operationKind: "plan",
1195+
targetRef: null,
1196+
resolvedFields: {
1197+
scheduleFields: {
1198+
time: { kind: "absolute", hour: 15, minute: 0 },
1199+
},
1200+
},
1201+
missingFields: [],
1202+
originatingText: "Schedule the dentist reminder",
1203+
startedAt: new Date().toISOString(),
1204+
},
11961205
}),
11971206
},
11981207
);

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

Lines changed: 53 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,29 @@
1-
import type { ConversationStateSnapshot, TimeSpec } from "@atlas/core";
1+
import type {
2+
ConversationStateSnapshot,
3+
PendingWriteOperation,
4+
TimeSpec,
5+
} from "@atlas/core";
26
import type { ProcessedInboxResult } from "@atlas/db";
37
import { describe, expect, it } from "vitest";
48

59
function t(hour: number, minute: number): TimeSpec {
610
return { kind: "absolute", hour, minute };
711
}
812

13+
function buildPendingWriteOperation(
14+
overrides?: Partial<PendingWriteOperation>,
15+
): PendingWriteOperation {
16+
return {
17+
operationKind: "plan",
18+
targetRef: null,
19+
resolvedFields: {},
20+
missingFields: [],
21+
originatingText: "schedule gym tomorrow",
22+
startedAt: "2026-03-22T16:00:00.000Z",
23+
...overrides,
24+
};
25+
}
26+
927
import {
1028
deriveConversationReplyState,
1129
deriveMutationState,
@@ -41,15 +59,14 @@ describe("deriveConversationReplyState", () => {
4159
snapshot: buildSnapshot(),
4260
policy: {
4361
action: "ask_clarification",
44-
clarificationSlots: ["time"],
45-
committedSlots: {},
62+
clarificationSlots: ["scheduleFields.time"],
4663
},
4764
interpretation: {
4865
turnType: "planning_request",
4966
confidence: 0.58,
5067
resolvedEntityIds: [],
5168
ambiguity: "high",
52-
missingSlots: ["time"],
69+
missingFields: ["scheduleFields.time"],
5370
},
5471
reply: "What time should I schedule the Malaysia trip planning?",
5572
userTurnText: "schedule malaysia trip planning tomorrow",
@@ -62,12 +79,12 @@ describe("deriveConversationReplyState", () => {
6279
kind: "clarification",
6380
status: "active",
6481
data: {
65-
reason: "time",
82+
reason: "scheduleFields.time",
6683
},
6784
});
6885
expect(result.discourseState?.pending_clarifications).toEqual([
6986
expect.objectContaining({
70-
slot: "time",
87+
slot: "scheduleFields.time",
7188
status: "pending",
7289
}),
7390
]);
@@ -80,7 +97,6 @@ describe("deriveConversationReplyState", () => {
8097
policy: {
8198
action: "ask_clarification",
8299
clarificationSlots: ["proposal"],
83-
committedSlots: {},
84100
},
85101
interpretation: {
86102
turnType: "confirmation",
@@ -104,14 +120,13 @@ describe("deriveConversationReplyState", () => {
104120
snapshot: buildSnapshot(),
105121
policy: {
106122
action: "ask_clarification",
107-
committedSlots: {},
108123
},
109124
interpretation: {
110125
turnType: "unknown",
111126
confidence: 0.42,
112127
resolvedEntityIds: [],
113128
ambiguity: "high",
114-
missingSlots: ["unknown"],
129+
missingFields: ["unknown"],
115130
},
116131
reply: "Can you clarify what you want me to change?",
117132
userTurnText: "do it",
@@ -165,7 +180,6 @@ describe("deriveConversationReplyState", () => {
165180
snapshot,
166181
policy: {
167182
action: "present_proposal",
168-
committedSlots: {},
169183
},
170184
interpretation: {
171185
turnType: "clarification_answer",
@@ -197,47 +211,40 @@ describe("deriveConversationReplyState", () => {
197211
expect(result.discourseState?.mode).toBe("confirming");
198212
});
199213

200-
it("persists pending_write_contract when resolvedContract is provided", () => {
201-
const contract = {
202-
requiredSlots: ["day", "time"] as (
203-
| "day"
204-
| "time"
205-
| "duration"
206-
| "target"
207-
)[],
208-
intentKind: "plan" as const,
209-
};
214+
it("persists pending_write_operation when resolvedOperation is provided", () => {
215+
const op = buildPendingWriteOperation({
216+
resolvedFields: { scheduleFields: { day: "tomorrow" } },
217+
missingFields: ["scheduleFields.time"],
218+
});
210219

211220
const result = deriveConversationReplyState({
212221
snapshot: buildSnapshot(),
213222
policy: {
214223
action: "ask_clarification",
215-
clarificationSlots: ["time"],
216-
committedSlots: { day: "tomorrow" },
217-
resolvedContract: contract,
224+
clarificationSlots: ["scheduleFields.time"],
225+
resolvedOperation: op,
218226
},
219227
interpretation: {
220228
turnType: "planning_request",
221229
confidence: 0.58,
222230
resolvedEntityIds: [],
223231
ambiguity: "high",
224-
missingSlots: ["time"],
232+
missingFields: ["scheduleFields.time"],
225233
},
226234
reply: "What time should I schedule it?",
227235
userTurnText: "schedule gym tomorrow",
228236
summaryText: null,
229237
occurredAt: "2026-03-22T16:05:00.000Z",
230238
});
231239

232-
expect(result.discourseState?.pending_write_contract).toEqual(contract);
240+
expect(result.discourseState?.pending_write_operation).toEqual(op);
233241
});
234242

235-
it("does not set pending_write_contract when resolvedContract is absent", () => {
243+
it("does not set pending_write_operation when resolvedOperation is absent", () => {
236244
const result = deriveConversationReplyState({
237245
snapshot: buildSnapshot(),
238246
policy: {
239247
action: "reply_only",
240-
committedSlots: {},
241248
},
242249
interpretation: {
243250
turnType: "informational",
@@ -251,7 +258,7 @@ describe("deriveConversationReplyState", () => {
251258
occurredAt: "2026-03-22T16:05:00.000Z",
252259
});
253260

254-
expect(result.discourseState?.pending_write_contract).toBeUndefined();
261+
expect(result.discourseState?.pending_write_operation).toBeUndefined();
255262
});
256263

257264
it("clears active clarifications when a clarification_answer resolves all slots", () => {
@@ -294,7 +301,12 @@ describe("deriveConversationReplyState", () => {
294301
snapshot,
295302
policy: {
296303
action: "present_proposal",
297-
committedSlots: { day: "tomorrow", time: t(17, 0) },
304+
resolvedOperation: buildPendingWriteOperation({
305+
resolvedFields: {
306+
scheduleFields: { day: "tomorrow", time: t(17, 0) },
307+
},
308+
missingFields: [],
309+
}),
298310
},
299311
interpretation: {
300312
turnType: "clarification_answer",
@@ -428,15 +440,13 @@ describe("deriveMutationState", () => {
428440
expect(result.discourseState.mode).toBe("editing");
429441
});
430442

431-
it("clears resolved_slots and pending_write_contract on successful mutation", () => {
443+
it("clears pending_write_operation on successful mutation", () => {
432444
const snapshot = buildSnapshot();
433445
snapshot.discourseState = {
434446
...snapshot.discourseState!,
435-
resolved_slots: { day: "tomorrow", time: t(17, 0) },
436-
pending_write_contract: {
437-
requiredSlots: ["day", "time"],
438-
intentKind: "plan",
439-
},
447+
pending_write_operation: buildPendingWriteOperation({
448+
resolvedFields: { scheduleFields: { day: "tomorrow", time: t(17, 0) } },
449+
}),
440450
};
441451

442452
const processing: ProcessedInboxResult = {
@@ -504,19 +514,18 @@ describe("deriveMutationState", () => {
504514
occurredAt: "2026-03-22T16:10:00.000Z",
505515
});
506516

507-
expect(result.discourseState.resolved_slots).toEqual({});
508-
expect(result.discourseState.pending_write_contract).toBeUndefined();
517+
expect(result.discourseState.pending_write_operation).toBeUndefined();
509518
});
510519

511-
it("preserves resolved_slots and pending_write_contract on needs_clarification", () => {
520+
it("preserves pending_write_operation on needs_clarification", () => {
521+
const op = buildPendingWriteOperation({
522+
resolvedFields: { scheduleFields: { day: "tomorrow" } },
523+
missingFields: ["scheduleFields.time"],
524+
});
512525
const snapshot = buildSnapshot();
513526
snapshot.discourseState = {
514527
...snapshot.discourseState!,
515-
resolved_slots: { day: "tomorrow" },
516-
pending_write_contract: {
517-
requiredSlots: ["day", "time"],
518-
intentKind: "plan",
519-
},
528+
pending_write_operation: op,
520529
};
521530

522531
const processing: ProcessedInboxResult = {
@@ -549,10 +558,6 @@ describe("deriveMutationState", () => {
549558
occurredAt: "2026-03-22T16:10:00.000Z",
550559
});
551560

552-
expect(result.discourseState.resolved_slots).toEqual({ day: "tomorrow" });
553-
expect(result.discourseState.pending_write_contract).toEqual({
554-
requiredSlots: ["day", "time"],
555-
intentKind: "plan",
556-
});
561+
expect(result.discourseState.pending_write_operation).toEqual(op);
557562
});
558563
});

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

Lines changed: 11 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ type DeriveConversationReplyStateInput = {
2121
snapshot: ConversationStateSnapshot;
2222
policy: Pick<
2323
TurnPolicyDecision,
24-
| "action"
25-
| "clarificationSlots"
26-
| "targetProposalId"
27-
| "committedSlots"
28-
| "resolvedContract"
24+
"action" | "clarificationSlots" | "targetProposalId" | "resolvedOperation"
2925
> & {
3026
action: Extract<
3127
TurnPolicyAction,
@@ -109,7 +105,8 @@ export function deriveConversationReplyState(
109105
confirmationRequired: true,
110106
originatingTurnText: input.userTurnText,
111107
missingSlots: input.policy.clarificationSlots,
112-
slotSnapshot: input.policy.committedSlots ?? {},
108+
slotSnapshot:
109+
input.policy.resolvedOperation?.resolvedFields.scheduleFields ?? {},
113110
},
114111
}),
115112
);
@@ -176,14 +173,10 @@ export function deriveConversationReplyState(
176173
},
177174
).state;
178175

179-
const committedSlots = input.policy.committedSlots;
180176
const nextDiscourseState = {
181177
...updatedDiscourseState,
182-
...(committedSlots && Object.keys(committedSlots).length > 0
183-
? { resolved_slots: committedSlots }
184-
: {}),
185-
...(input.policy.resolvedContract
186-
? { pending_write_contract: input.policy.resolvedContract }
178+
...(input.policy.resolvedOperation
179+
? { pending_write_operation: input.policy.resolvedOperation }
187180
: {}),
188181
};
189182

@@ -319,8 +312,7 @@ export function deriveMutationState(input: DeriveMutationStateInput) {
319312
const finalDiscourseState = isFlowComplete
320313
? {
321314
...discourseState,
322-
resolved_slots: {},
323-
pending_write_contract: undefined,
315+
pending_write_operation: undefined,
324316
}
325317
: discourseState;
326318

@@ -470,7 +462,11 @@ function upsertActiveProposalEntity(
470462
return nextProposal;
471463
}
472464

473-
const PERSISTABLE_SLOT_KEYS = new Set(["day", "time", "duration", "target"]);
465+
const PERSISTABLE_SLOT_KEYS = new Set([
466+
"scheduleFields.day",
467+
"scheduleFields.time",
468+
"scheduleFields.duration",
469+
]);
474470

475471
function derivePersistableClarificationSlots(slots: string[] | undefined) {
476472
if (!slots) {

0 commit comments

Comments
 (0)