Skip to content

Commit 204e16c

Browse files
authored
Merge pull request #55 from MaxLinCode/claude/fix-contract-handling
Fix contract handling
2 parents dd76f5d + b7a4cdf commit 204e16c

10 files changed

Lines changed: 384 additions & 19 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,3 +11,4 @@ coverage
1111
packages/integrations/*.manual-eval-report.json
1212
packages/integrations/manual-eval-report.json
1313
packages/integrations/*.prompt-improvement.md
14+
apps/web/src/lib/server/.DS_Store

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

Lines changed: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,54 @@ describe("deriveConversationReplyState", () => {
193193
]);
194194
expect(result.discourseState?.mode).toBe("confirming");
195195
});
196+
197+
it("persists pending_write_contract when resolvedContract is provided", () => {
198+
const contract = { requiredSlots: ["day", "time"] as ("day" | "time" | "duration" | "target")[], intentKind: "plan" as const };
199+
200+
const result = deriveConversationReplyState({
201+
snapshot: buildSnapshot(),
202+
policy: {
203+
action: "ask_clarification",
204+
committedSlots: { day: "tomorrow" },
205+
resolvedContract: contract
206+
},
207+
interpretation: {
208+
turnType: "planning_request",
209+
confidence: 0.58,
210+
resolvedEntityIds: [],
211+
ambiguity: "high",
212+
missingSlots: ["time"]
213+
},
214+
reply: "What time should I schedule it?",
215+
userTurnText: "schedule gym tomorrow",
216+
summaryText: null,
217+
occurredAt: "2026-03-22T16:05:00.000Z"
218+
});
219+
220+
expect(result.discourseState?.pending_write_contract).toEqual(contract);
221+
});
222+
223+
it("does not set pending_write_contract when resolvedContract is absent", () => {
224+
const result = deriveConversationReplyState({
225+
snapshot: buildSnapshot(),
226+
policy: {
227+
action: "reply_only",
228+
committedSlots: {}
229+
},
230+
interpretation: {
231+
turnType: "informational",
232+
confidence: 0.93,
233+
resolvedEntityIds: [],
234+
ambiguity: "none"
235+
},
236+
reply: "Your schedule is empty.",
237+
userTurnText: "what's on my schedule?",
238+
summaryText: null,
239+
occurredAt: "2026-03-22T16:05:00.000Z"
240+
});
241+
242+
expect(result.discourseState?.pending_write_contract).toBeUndefined();
243+
});
196244
});
197245

198246
describe("deriveMutationState", () => {
@@ -306,4 +354,126 @@ describe("deriveMutationState", () => {
306354
]);
307355
expect(result.discourseState.mode).toBe("editing");
308356
});
357+
358+
it("clears resolved_slots and pending_write_contract on successful mutation", () => {
359+
const snapshot = buildSnapshot();
360+
snapshot.discourseState = {
361+
...snapshot.discourseState!,
362+
resolved_slots: { day: "tomorrow", time: "17:00" },
363+
pending_write_contract: { requiredSlots: ["day", "time"], intentKind: "plan" }
364+
};
365+
366+
const processing: ProcessedInboxResult = {
367+
outcome: "planned",
368+
inboxItem: {
369+
id: "inbox-1",
370+
userId: "user-1",
371+
rawText: "schedule gym",
372+
normalizedText: "schedule gym",
373+
processingStatus: "planned",
374+
linkedTaskIds: ["task-1"],
375+
createdAt: "2026-03-22T16:00:00.000Z"
376+
},
377+
plannerRun: {
378+
id: "run-1",
379+
userId: "user-1",
380+
inboxItemId: "inbox-1",
381+
version: "test",
382+
modelInput: {},
383+
modelOutput: {},
384+
confidence: 0.92
385+
},
386+
createdTasks: [
387+
{
388+
id: "task-1",
389+
userId: "user-1",
390+
sourceInboxItemId: "inbox-1",
391+
lastInboxItemId: "inbox-1",
392+
title: "Gym",
393+
lifecycleState: "scheduled",
394+
externalCalendarEventId: null,
395+
externalCalendarId: null,
396+
scheduledStartAt: "2026-03-23T17:00:00.000Z",
397+
scheduledEndAt: "2026-03-23T18:00:00.000Z",
398+
calendarSyncStatus: "in_sync",
399+
calendarSyncUpdatedAt: null,
400+
rescheduleCount: 0,
401+
lastFollowupAt: null,
402+
followupReminderSentAt: null,
403+
completedAt: null,
404+
archivedAt: null,
405+
priority: "medium",
406+
urgency: "medium"
407+
}
408+
],
409+
scheduleBlocks: [
410+
{
411+
id: "block-1",
412+
userId: "user-1",
413+
taskId: "task-1",
414+
startAt: "2026-03-23T17:00:00.000Z",
415+
endAt: "2026-03-23T18:00:00.000Z",
416+
confidence: 0.92,
417+
reason: "User requested 5 PM.",
418+
rescheduleCount: 0,
419+
externalCalendarId: null
420+
}
421+
],
422+
followUpMessage: "Scheduled gym for 5 PM."
423+
};
424+
425+
const result = deriveMutationState({
426+
snapshot,
427+
processing,
428+
occurredAt: "2026-03-22T16:10:00.000Z"
429+
});
430+
431+
expect(result.discourseState.resolved_slots).toEqual({});
432+
expect(result.discourseState.pending_write_contract).toBeUndefined();
433+
});
434+
435+
it("preserves resolved_slots and pending_write_contract on needs_clarification", () => {
436+
const snapshot = buildSnapshot();
437+
snapshot.discourseState = {
438+
...snapshot.discourseState!,
439+
resolved_slots: { day: "tomorrow" },
440+
pending_write_contract: { requiredSlots: ["day", "time"], intentKind: "plan" }
441+
};
442+
443+
const processing: ProcessedInboxResult = {
444+
outcome: "needs_clarification",
445+
inboxItem: {
446+
id: "inbox-1",
447+
userId: "user-1",
448+
rawText: "schedule gym tomorrow",
449+
normalizedText: "schedule gym tomorrow",
450+
processingStatus: "needs_clarification",
451+
linkedTaskIds: [],
452+
createdAt: "2026-03-22T16:00:00.000Z"
453+
},
454+
plannerRun: {
455+
id: "run-1",
456+
userId: "user-1",
457+
inboxItemId: "inbox-1",
458+
version: "test",
459+
modelInput: {},
460+
modelOutput: {},
461+
confidence: 0.5
462+
},
463+
reason: "time",
464+
followUpMessage: "What time should I schedule the gym?"
465+
};
466+
467+
const result = deriveMutationState({
468+
snapshot,
469+
processing,
470+
occurredAt: "2026-03-22T16:10:00.000Z"
471+
});
472+
473+
expect(result.discourseState.resolved_slots).toEqual({ day: "tomorrow" });
474+
expect(result.discourseState.pending_write_contract).toEqual({
475+
requiredSlots: ["day", "time"],
476+
intentKind: "plan"
477+
});
478+
});
309479
});

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

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import { type ProcessedInboxResult } from "@atlas/db";
1919

2020
type DeriveConversationReplyStateInput = {
2121
snapshot: ConversationStateSnapshot;
22-
policy: Pick<TurnPolicyDecision, "action" | "clarificationSlots" | "targetProposalId" | "committedSlots"> & {
22+
policy: Pick<TurnPolicyDecision, "action" | "clarificationSlots" | "targetProposalId" | "committedSlots" | "resolvedContract"> & {
2323
action: Extract<TurnPolicyAction, "reply_only" | "ask_clarification" | "present_proposal">;
2424
};
2525
interpretation: TurnInterpretation;
@@ -144,10 +144,15 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
144144
}).state;
145145

146146
const committedSlots = input.policy.committedSlots;
147-
const nextDiscourseState =
148-
committedSlots && Object.keys(committedSlots).length > 0
149-
? { ...updatedDiscourseState, resolved_slots: committedSlots }
150-
: updatedDiscourseState;
147+
const nextDiscourseState = {
148+
...updatedDiscourseState,
149+
...(committedSlots && Object.keys(committedSlots).length > 0
150+
? { resolved_slots: committedSlots }
151+
: {}),
152+
...(input.policy.resolvedContract
153+
? { pending_write_contract: input.policy.resolvedContract }
154+
: {})
155+
};
151156

152157
return {
153158
summaryText: input.summaryText,
@@ -268,11 +273,16 @@ export function deriveMutationState(input: DeriveMutationStateInput) {
268273
}
269274
).state;
270275

276+
const isFlowComplete = input.processing.outcome !== "needs_clarification";
277+
const finalDiscourseState = isFlowComplete
278+
? { ...discourseState, resolved_slots: {}, pending_write_contract: undefined }
279+
: discourseState;
280+
271281
return {
272282
mode: (input.processing.outcome === "needs_clarification" ? "conversation_then_mutation" : "mutation") as
273283
ConversationRecordMode,
274284
entityRegistry: trimEntityRegistry(entityRegistry),
275-
discourseState
285+
discourseState: finalDiscourseState
276286
};
277287
}
278288

apps/web/src/lib/server/telegram-webhook.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -370,7 +370,8 @@ export async function handleTelegramWebhook(
370370
action: routedWithContext.policy.action,
371371
clarificationSlots: routedWithContext.policy.clarificationSlots,
372372
targetProposalId: routedWithContext.policy.targetProposalId,
373-
committedSlots: routedWithContext.policy.committedSlots
373+
committedSlots: routedWithContext.policy.committedSlots,
374+
resolvedContract: routedWithContext.policy.resolvedContract
374375
},
375376
interpretation: routedWithContext.interpretation,
376377
reply: conversationResponse.reply,
@@ -438,7 +439,8 @@ export async function handleTelegramWebhook(
438439
snapshot: conversationState,
439440
policy: {
440441
action: "ask_clarification",
441-
committedSlots: routedWithContext.policy.committedSlots
442+
committedSlots: routedWithContext.policy.committedSlots,
443+
resolvedContract: routedWithContext.policy.resolvedContract
442444
},
443445
interpretation: routedWithContext.interpretation,
444446
reply: recoveredMutation.userReplyMessage,

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

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -177,4 +177,81 @@ describe("turn router", () => {
177177

178178
expect(result.policy.committedSlots).toEqual({});
179179
});
180+
181+
it("includes resolvedContract on policy output for planning_request", async () => {
182+
mockClassification({
183+
turnType: "planning_request",
184+
confidence: 0.95
185+
});
186+
mockExtractSlots.mockResolvedValueOnce({
187+
extractedValues: { day: "tomorrow", time: "18:00" },
188+
confidence: { day: 0.95, time: 0.95 },
189+
unresolvable: []
190+
});
191+
192+
const result = await routeMessageTurn({
193+
rawText: "Schedule gym tomorrow at 6pm",
194+
normalizedText: "Schedule gym tomorrow at 6pm",
195+
recentTurns: []
196+
});
197+
198+
expect(result.policy.resolvedContract).toEqual({
199+
requiredSlots: ["day", "time"],
200+
intentKind: "plan"
201+
});
202+
});
203+
204+
it("carries forward priorContract from discourse state for clarification_answer", async () => {
205+
const priorContract = { requiredSlots: ["time"] as ("day" | "time" | "duration" | "target")[], intentKind: "edit" as const };
206+
207+
mockClassification({
208+
turnType: "clarification_answer",
209+
confidence: 0.9
210+
});
211+
mockExtractSlots.mockResolvedValueOnce({
212+
extractedValues: { time: "17:00" },
213+
confidence: { time: 0.92 },
214+
unresolvable: []
215+
});
216+
217+
const result = await routeMessageTurn({
218+
rawText: "5pm",
219+
normalizedText: "5pm",
220+
recentTurns: [],
221+
discourseState: {
222+
focus_entity_id: null,
223+
currently_editable_entity_id: null,
224+
last_user_mentioned_entity_ids: [],
225+
last_presented_items: [],
226+
pending_clarifications: [],
227+
pending_write_contract: priorContract,
228+
mode: "clarifying"
229+
}
230+
});
231+
232+
expect(result.policy.resolvedContract).toEqual(priorContract);
233+
});
234+
235+
it("falls back to default contract when no prior contract exists", async () => {
236+
mockClassification({
237+
turnType: "clarification_answer",
238+
confidence: 0.9
239+
});
240+
mockExtractSlots.mockResolvedValueOnce({
241+
extractedValues: { time: "17:00" },
242+
confidence: { time: 0.92 },
243+
unresolvable: []
244+
});
245+
246+
const result = await routeMessageTurn({
247+
rawText: "5pm",
248+
normalizedText: "5pm",
249+
recentTurns: []
250+
});
251+
252+
expect(result.policy.resolvedContract).toEqual({
253+
requiredSlots: ["day", "time"],
254+
intentKind: "plan"
255+
});
256+
});
180257
});

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

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,9 @@ import {
1616
type TurnRoutingInput,
1717
type WriteContract,
1818
type CommitPolicyOutput,
19-
SLOT_COMMITTING_TURN_TYPES
19+
SLOT_COMMITTING_TURN_TYPES,
20+
DEFAULT_WRITE_CONTRACT,
21+
resolveWriteContract
2022
} from "@atlas/core";
2123

2224
import { decideTurnPolicy } from "./decide-turn-policy";
@@ -26,10 +28,6 @@ import { extractSlots } from "./slot-extractor";
2628
export type TurnRouterInput = TurnRoutingInput;
2729
export type TurnRouterResult = RoutedTurn;
2830

29-
const DEFAULT_CONTRACT: WriteContract = {
30-
requiredSlots: ["day", "time"],
31-
intentKind: "plan"
32-
};
3331

3432
export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRouterResult> {
3533
const discourseState = input.discourseState ?? createEmptyDiscourseState();
@@ -43,7 +41,11 @@ export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRout
4341
});
4442

4543
// Pipeline B: extract slots (conditional)
46-
const activeContract = discourseState.pending_write_contract ?? DEFAULT_CONTRACT;
44+
const priorContract = discourseState.pending_write_contract;
45+
const activeContract = resolveWriteContract({
46+
turnType: classification.turnType,
47+
priorContract
48+
}) ?? DEFAULT_WRITE_CONTRACT;
4749
let slotExtraction = null;
4850

4951
if (SLOT_COMMITTING_TURN_TYPES.has(classification.turnType)) {
@@ -62,7 +64,8 @@ export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRout
6264
confidence: compactConfidence(slotExtraction?.confidence ?? {}),
6365
unresolvable: slotExtraction?.unresolvable ?? [],
6466
priorResolvedSlots: discourseState.resolved_slots ?? {},
65-
activeContract
67+
activeContract,
68+
priorContract
6669
});
6770

6871
const policy = decideTurnPolicy({
@@ -76,7 +79,7 @@ export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRout
7679

7780
return routedTurnSchema.parse({
7881
interpretation,
79-
policy
82+
policy: { ...policy, resolvedContract: activeContract }
8083
});
8184
}
8285

packages/core/src/commit-policy.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export type CommitPolicyInput = {
77
unresolvable: SlotKey[];
88
priorResolvedSlots: ResolvedSlots;
99
activeContract: WriteContract;
10-
priorContract?: WriteContract;
10+
priorContract?: WriteContract | undefined;
1111
};
1212

1313
export type CommitPolicyOutput = {

0 commit comments

Comments
 (0)