Skip to content

Commit 202de8d

Browse files
authored
Merge pull request #50 from MaxLinCode/codex/fix-clarification-loop
Fix consent-only write-ready turns routing to proposal instead of clarification
2 parents 1d9d74d + bad1511 commit 202de8d

4 files changed

Lines changed: 566 additions & 99 deletions

File tree

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import { deriveConversationReplyState } from "./conversation-state";
4+
5+
function buildSnapshot() {
6+
return {
7+
conversation: {
8+
id: "conversation-1",
9+
userId: "user-1",
10+
title: null,
11+
mode: "conversation" as const,
12+
summaryText: null,
13+
createdAt: "2026-03-22T16:00:00.000Z",
14+
updatedAt: "2026-03-22T16:00:00.000Z"
15+
},
16+
transcript: [],
17+
entityRegistry: [],
18+
discourseState: {
19+
focus_entity_id: null,
20+
currently_editable_entity_id: null,
21+
last_user_mentioned_entity_ids: [],
22+
last_presented_items: [],
23+
pending_clarifications: [],
24+
mode: "clarifying" as const
25+
}
26+
};
27+
}
28+
29+
describe("deriveConversationReplyState", () => {
30+
it("keeps clarifications as clarifications when required slots are still missing", () => {
31+
const result = deriveConversationReplyState({
32+
snapshot: buildSnapshot(),
33+
policyAction: "ask_clarification",
34+
interpretation: {
35+
turnType: "planning_request",
36+
confidence: 0.58,
37+
resolvedEntityIds: [],
38+
ambiguity: "high",
39+
missingSlots: ["time"]
40+
},
41+
reply: "It sounds like you want me to block out time for planning your Malaysia trip. I can proceed with that now once I know the time.",
42+
userTurnText: "schedule malaysia trip planning tomorrow",
43+
summaryText: null,
44+
occurredAt: "2026-03-22T16:05:00.000Z"
45+
});
46+
47+
expect(result.mode).toBe("conversation_then_mutation");
48+
expect(result.entityRegistry).toHaveLength(1);
49+
expect(result.entityRegistry[0]).toMatchObject({
50+
kind: "clarification",
51+
status: "active"
52+
});
53+
expect(result.discourseState?.mode).toBe("clarifying");
54+
});
55+
56+
it("does not upgrade to proposal based on wording alone", () => {
57+
const result = deriveConversationReplyState({
58+
snapshot: buildSnapshot(),
59+
policyAction: "ask_clarification",
60+
interpretation: {
61+
turnType: "planning_request",
62+
confidence: 0.91,
63+
resolvedEntityIds: [],
64+
ambiguity: "none"
65+
},
66+
reply: "It sounds like you want me to block out time for planning your Malaysia trip at 3:15 PM for 15 minutes. I can proceed with that now.",
67+
userTurnText: "schedule malaysia trip planning at 3:15pm for 15 minutes",
68+
summaryText: null,
69+
occurredAt: "2026-03-22T16:05:00.000Z"
70+
});
71+
72+
expect(result.entityRegistry[0]).toMatchObject({
73+
kind: "clarification"
74+
});
75+
expect(result.discourseState?.mode).toBe("clarifying");
76+
});
77+
78+
it("persists proposal state only when policy already says present_proposal", () => {
79+
const result = deriveConversationReplyState({
80+
snapshot: buildSnapshot(),
81+
policyAction: "present_proposal",
82+
interpretation: {
83+
turnType: "edit_request",
84+
confidence: 0.94,
85+
resolvedEntityIds: ["task-1"],
86+
ambiguity: "none"
87+
},
88+
reply: "I can move the planning block to 3:15 PM for 15 minutes. Would you like me to do that now?",
89+
userTurnText: "move it to 3:15pm for 15 minutes",
90+
summaryText: null,
91+
occurredAt: "2026-03-22T16:05:00.000Z"
92+
});
93+
94+
expect(result.entityRegistry).toHaveLength(1);
95+
expect(result.entityRegistry[0]).toMatchObject({
96+
kind: "proposal_option",
97+
status: "active",
98+
data: {
99+
targetEntityId: "task-1",
100+
policyAction: "present_proposal",
101+
confirmationRequired: true
102+
}
103+
});
104+
expect(result.discourseState?.mode).toBe("confirming");
105+
});
106+
});

apps/web/src/lib/server/decide-turn-policy.test.ts

Lines changed: 182 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,28 @@ describe("decideTurnPolicy", () => {
4646
});
4747
});
4848

49+
it("keeps clarification answers in clarification when required slots remain", () => {
50+
expect(
51+
decideTurnPolicy({
52+
interpretation: {
53+
turnType: "clarification_answer",
54+
confidence: 0.84,
55+
resolvedEntityIds: [],
56+
ambiguity: "high",
57+
missingSlots: ["time"]
58+
},
59+
routingContext: {
60+
rawText: "Tomorrow",
61+
normalizedText: "Tomorrow",
62+
recentTurns: []
63+
}
64+
})
65+
).toMatchObject({
66+
action: "ask_clarification",
67+
clarificationSlots: ["time"]
68+
});
69+
});
70+
4971
it("does not force proposal mode for low-confidence writes alone", () => {
5072
expect(
5173
decideTurnPolicy({
@@ -126,6 +148,46 @@ describe("decideTurnPolicy", () => {
126148
});
127149
});
128150

151+
it("treats affirmative consent on a pending proposal as execution", () => {
152+
expect(
153+
decideTurnPolicy({
154+
interpretation: {
155+
turnType: "confirmation",
156+
confidence: 0.97,
157+
resolvedEntityIds: ["task-1"],
158+
resolvedProposalId: "proposal-1",
159+
ambiguity: "none"
160+
},
161+
routingContext: {
162+
rawText: "yes",
163+
normalizedText: "yes",
164+
recentTurns: [],
165+
entityRegistry: [
166+
{
167+
id: "proposal-1",
168+
conversationId: "conversation-1",
169+
kind: "proposal_option",
170+
label: "Move it to 3pm",
171+
status: "active",
172+
createdAt: "2026-03-20T16:00:00.000Z",
173+
updatedAt: "2026-03-20T16:00:00.000Z",
174+
data: {
175+
route: "conversation_then_mutation",
176+
replyText: "Would you like me to move it to 3pm?",
177+
confirmationRequired: true,
178+
targetEntityId: "task-1"
179+
}
180+
}
181+
]
182+
}
183+
})
184+
).toMatchObject({
185+
action: "recover_and_execute",
186+
targetProposalId: "proposal-1",
187+
mutationInputSource: "recovered_proposal"
188+
});
189+
});
190+
129191
it("asks for clarification on ambiguous write-like turns", () => {
130192
expect(
131193
decideTurnPolicy({
@@ -185,4 +247,124 @@ describe("decideTurnPolicy", () => {
185247
requiresConfirmation: true
186248
});
187249
});
250+
251+
it("routes ready clarification answers to present_proposal when deterministic consent is still required", () => {
252+
expect(
253+
decideTurnPolicy({
254+
interpretation: {
255+
turnType: "clarification_answer",
256+
confidence: 0.92,
257+
resolvedEntityIds: ["task-1"],
258+
resolvedProposalId: "proposal-1",
259+
ambiguity: "none"
260+
},
261+
routingContext: {
262+
rawText: "3:15pm",
263+
normalizedText: "3:15pm",
264+
recentTurns: [],
265+
entityRegistry: [
266+
{
267+
id: "proposal-1",
268+
conversationId: "conversation-1",
269+
kind: "proposal_option",
270+
label: "Move it to 3:15pm",
271+
status: "active",
272+
createdAt: "2026-03-20T16:00:00.000Z",
273+
updatedAt: "2026-03-20T16:00:00.000Z",
274+
data: {
275+
route: "conversation_then_mutation",
276+
replyText: "Would you like me to move it to 3:15pm?",
277+
confirmationRequired: true,
278+
targetEntityId: "task-1"
279+
}
280+
}
281+
]
282+
}
283+
})
284+
).toMatchObject({
285+
action: "present_proposal",
286+
requiresConfirmation: true,
287+
targetProposalId: "proposal-1"
288+
});
289+
});
290+
291+
it("recomputes a parameter edit on an active proposal instead of executing it", () => {
292+
const result = decideTurnPolicy({
293+
interpretation: {
294+
turnType: "edit_request",
295+
confidence: 0.9,
296+
resolvedEntityIds: ["task-1"],
297+
resolvedProposalId: "proposal-1",
298+
ambiguity: "none"
299+
},
300+
routingContext: {
301+
rawText: "make it 3 instead",
302+
normalizedText: "make it 3 instead",
303+
recentTurns: [],
304+
entityRegistry: [
305+
{
306+
id: "proposal-1",
307+
conversationId: "conversation-1",
308+
kind: "proposal_option",
309+
label: "Move it to tomorrow 2pm",
310+
status: "active",
311+
createdAt: "2026-03-20T16:00:00.000Z",
312+
updatedAt: "2026-03-20T16:00:00.000Z",
313+
data: {
314+
route: "conversation_then_mutation",
315+
replyText: "Would you like me to move it to tomorrow 2pm?",
316+
confirmationRequired: true,
317+
targetEntityId: "task-1",
318+
originatingTurnText: "move it to tomorrow 2pm"
319+
}
320+
}
321+
]
322+
}
323+
});
324+
325+
expect(result).toMatchObject({
326+
action: "present_proposal",
327+
requiresConfirmation: true
328+
});
329+
expect(result.targetProposalId).toBeUndefined();
330+
});
331+
332+
it("does not bind consent to a stale proposal with a different target", () => {
333+
expect(
334+
decideTurnPolicy({
335+
interpretation: {
336+
turnType: "edit_request",
337+
confidence: 0.91,
338+
resolvedEntityIds: ["task-2"],
339+
resolvedProposalId: "proposal-1",
340+
ambiguity: "none"
341+
},
342+
routingContext: {
343+
rawText: "move that to 3pm",
344+
normalizedText: "move that to 3pm",
345+
recentTurns: [],
346+
entityRegistry: [
347+
{
348+
id: "proposal-1",
349+
conversationId: "conversation-1",
350+
kind: "proposal_option",
351+
label: "Move task one to 2pm",
352+
status: "active",
353+
createdAt: "2026-03-20T16:00:00.000Z",
354+
updatedAt: "2026-03-20T16:00:00.000Z",
355+
data: {
356+
route: "conversation_then_mutation",
357+
replyText: "Would you like me to move task one to 2pm?",
358+
confirmationRequired: true,
359+
targetEntityId: "task-1"
360+
}
361+
}
362+
]
363+
}
364+
})
365+
).toMatchObject({
366+
action: "execute_mutation",
367+
mutationInputSource: "direct_user_turn"
368+
});
369+
});
188370
});

0 commit comments

Comments
 (0)