Skip to content

Commit 7bbc40a

Browse files
authored
Merge pull request #56 from MaxLinCode/claude/fix-stale-clarification
Fix stale/dupe clarifications
2 parents 204e16c + 7ec23d1 commit 7bbc40a

6 files changed

Lines changed: 102 additions & 27 deletions

File tree

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

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ describe("deriveConversationReplyState", () => {
3434
snapshot: buildSnapshot(),
3535
policy: {
3636
action: "ask_clarification",
37+
clarificationSlots: ["time"],
3738
committedSlots: {}
3839
},
3940
interpretation: {
@@ -201,6 +202,7 @@ describe("deriveConversationReplyState", () => {
201202
snapshot: buildSnapshot(),
202203
policy: {
203204
action: "ask_clarification",
205+
clarificationSlots: ["time"],
204206
committedSlots: { day: "tomorrow" },
205207
resolvedContract: contract
206208
},
@@ -241,6 +243,78 @@ describe("deriveConversationReplyState", () => {
241243

242244
expect(result.discourseState?.pending_write_contract).toBeUndefined();
243245
});
246+
247+
it("clears active clarifications when a clarification_answer resolves all slots", () => {
248+
const snapshot = buildSnapshot();
249+
snapshot.entityRegistry = [
250+
{
251+
id: "clar-1",
252+
conversationId: "conversation-1",
253+
kind: "clarification",
254+
label: "Need a time",
255+
status: "active",
256+
createdAt: "2026-03-22T16:01:00.000Z",
257+
updatedAt: "2026-03-22T16:01:00.000Z",
258+
data: {
259+
prompt: "What time should I schedule it?",
260+
reason: "time",
261+
open: true
262+
}
263+
}
264+
];
265+
snapshot.discourseState = {
266+
focus_entity_id: "clar-1",
267+
currently_editable_entity_id: null,
268+
last_user_mentioned_entity_ids: [],
269+
last_presented_items: [],
270+
pending_clarifications: [
271+
{
272+
id: "clar-1",
273+
slot: "time",
274+
question: "What time should I schedule it?",
275+
status: "pending",
276+
blocking: true,
277+
createdAt: "2026-03-22T16:01:00.000Z",
278+
createdTurnId: "assistant:1"
279+
}
280+
],
281+
mode: "clarifying"
282+
};
283+
284+
const result = deriveConversationReplyState({
285+
snapshot,
286+
policy: {
287+
action: "present_proposal",
288+
committedSlots: { day: "tomorrow", time: "17:00" }
289+
},
290+
interpretation: {
291+
turnType: "clarification_answer",
292+
confidence: 0.93,
293+
resolvedEntityIds: [],
294+
ambiguity: "none"
295+
},
296+
reply: "I'll schedule gym at 5 PM tomorrow. Sound good?",
297+
userTurnText: "5pm",
298+
summaryText: null,
299+
occurredAt: "2026-03-22T16:05:00.000Z"
300+
});
301+
302+
expect(result.discourseState?.pending_clarifications).toEqual([
303+
expect.objectContaining({
304+
id: "clar-1",
305+
status: "resolved"
306+
})
307+
]);
308+
expect(result.entityRegistry).toEqual(
309+
expect.arrayContaining([
310+
expect.objectContaining({
311+
id: "clar-1",
312+
kind: "clarification",
313+
status: "resolved"
314+
})
315+
])
316+
);
317+
});
244318
});
245319

246320
describe("deriveMutationState", () => {

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

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,13 +43,14 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
4343
const newClarifications: PendingClarification[] = [];
4444
const resolvedClarificationIds: string[] = [];
4545
const activePendingClarifications = getActivePendingClarifications(discourseState);
46-
const persistableClarificationSlots = derivePersistableClarificationSlots(input.interpretation.missingSlots);
46+
const persistableClarificationSlots = derivePersistableClarificationSlots(input.policy.clarificationSlots);
4747
const shouldPersistClarification =
4848
input.policy.action === "ask_clarification" && persistableClarificationSlots.length > 0;
4949
const shouldClearActiveClarifications =
5050
shouldPersistClarification ||
5151
input.policy.action === "present_proposal" ||
52-
input.interpretation.turnType === "confirmation";
52+
input.interpretation.turnType === "confirmation" ||
53+
(input.interpretation.turnType === "clarification_answer" && !shouldPersistClarification);
5354
let nextFocusEntityId: string | null | undefined;
5455

5556
if (shouldClearActiveClarifications) {
@@ -87,7 +88,7 @@ export function deriveConversationReplyState(input: DeriveConversationReplyState
8788
mutationInputSource: null,
8889
confirmationRequired: true,
8990
originatingTurnText: input.userTurnText,
90-
missingSlots: input.interpretation.missingSlots
91+
missingSlots: input.policy.clarificationSlots
9192
}
9293
})
9394
);
@@ -409,14 +410,16 @@ function upsertActiveProposalEntity(
409410
return nextProposal;
410411
}
411412

413+
const PERSISTABLE_SLOT_KEYS = new Set(["day", "time", "duration", "target"]);
414+
412415
function derivePersistableClarificationSlots(slots: string[] | undefined) {
413416
if (!slots) {
414417
return [];
415418
}
416419

417420
return Array.from(
418421
new Set(
419-
slots.map((slot) => slot.trim()).filter((slot) => slot.length > 0 && slot !== "unknown")
422+
slots.map((slot) => slot.trim()).filter((slot) => PERSISTABLE_SLOT_KEYS.has(slot))
420423
)
421424
);
422425
}

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

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,7 @@ export function decideTurnPolicy(input: DecideTurnPolicyInput): TurnPolicyDecisi
3737
const ambiguity = deriveAmbiguity({
3838
classifierConfidence: classification.confidence,
3939
missingSlots: commitResult.missingSlots,
40-
needsClarification: commitResult.needsClarification,
41-
blockingSlots: []
40+
needsClarification: commitResult.needsClarification
4241
});
4342

4443
switch (classification.turnType) {

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

Lines changed: 6 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,8 @@
11
import {
22
applyCommitPolicy,
33
createEmptyDiscourseState,
4-
getActivePendingClarifications,
54
routedTurnSchema,
65
type ConversationTurn,
7-
type DiscourseState,
86
type RoutedTurn,
97
type SlotKey,
108
deriveAmbiguity,
@@ -75,7 +73,7 @@ export async function routeMessageTurn(input: TurnRouterInput): Promise<TurnRout
7573
});
7674

7775
// Build interpretation for backward compatibility
78-
const interpretation = buildInterpretation(classification, commitResult, discourseState);
76+
const interpretation = buildInterpretation(classification, commitResult);
7977

8078
return routedTurnSchema.parse({
8179
interpretation,
@@ -96,22 +94,16 @@ function deriveConversationContext(recentTurns: ConversationTurn[]): string {
9694

9795
function buildInterpretation(
9896
classification: TurnClassifierOutput,
99-
commitResult: CommitPolicyOutput,
100-
discourseState: DiscourseState
97+
commitResult: CommitPolicyOutput
10198
): TurnInterpretation {
102-
const blockingSlots = getActivePendingClarifications(discourseState)
103-
.filter((c) => c.blocking)
104-
.map((c) => c.slot);
10599
const allMissingSlots = unique([
106100
...commitResult.missingSlots,
107-
...commitResult.needsClarification,
108-
...blockingSlots
101+
...commitResult.needsClarification
109102
]);
110103
const ambiguity = deriveAmbiguity({
111104
classifierConfidence: classification.confidence,
112105
missingSlots: commitResult.missingSlots,
113-
needsClarification: commitResult.needsClarification,
114-
blockingSlots
106+
needsClarification: commitResult.needsClarification
115107
});
116108

117109
return {
@@ -121,21 +113,16 @@ function buildInterpretation(
121113
...(classification.resolvedProposalId ? { resolvedProposalId: classification.resolvedProposalId } : {}),
122114
ambiguity,
123115
...(ambiguity !== "none"
124-
? { ambiguityReason: deriveAmbiguityReason(classification.turnType, ambiguity, blockingSlots) }
116+
? { ambiguityReason: deriveAmbiguityReason(classification.turnType, ambiguity) }
125117
: {}),
126118
...(allMissingSlots.length > 0 ? { missingSlots: allMissingSlots } : {})
127119
};
128120
}
129121

130122
function deriveAmbiguityReason(
131123
turnType: TurnInterpretation["turnType"],
132-
ambiguity: TurnAmbiguity,
133-
blockingSlots: string[]
124+
ambiguity: TurnAmbiguity
134125
): string {
135-
if (blockingSlots.length > 0) {
136-
return "Blocking clarification slots are still open.";
137-
}
138-
139126
if (ambiguity === "high") {
140127
switch (turnType) {
141128
case "unknown":

docs/current-work.md

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -299,6 +299,20 @@ PR #53 (`codex/turn-routing-refactor`) implements the 3-layer turn routing pipel
299299
3. Merge PR #53
300300
4. Open follow-up issue for items 6–10
301301

302+
### Follow-up — clarification state cleanup (separate PR)
303+
304+
The stale-slot-readiness fix revealed further simplification opportunities in the clarification persistence model:
305+
306+
1. **`blocking` field on `PendingClarification` is dead weight.** It is hardcoded to `true` everywhere — never set to `false`. The only reader is `deriveMode()`, which checks `hasBlockingClarification` to return `"clarifying"` mode. The field can be removed and `deriveMode` can derive "clarifying" from `pending_write_contract` + `resolved_slots` (contract gaps) instead.
307+
308+
2. **Resolved/cancelled clarifications accumulate as noise.** `getActivePendingClarifications()` filters to `status === "pending"`, so resolved entries just grow the array. No code inspects resolved entries for decisions. They could be pruned on state update or moved to a separate audit trail.
309+
310+
3. **Full `pending_clarifications` array (including resolved entries) is passed to the LLM classifier.** The system prompt says "active clarifications the assistant is waiting on" but the data includes stale resolved entries. This could mislead the model into thinking there are still open questions. Fix: filter to `status === "pending"` before passing to the classifier, or pass only `getActivePendingClarifications()`.
311+
312+
4. **`deriveMode` could derive "clarifying" from canonical slot state.** Instead of checking `hasBlockingClarification` (which depends on persisted clarification entities), check whether `pending_write_contract` exists and has unresolved required slots in `resolved_slots`. This aligns mode derivation with the same canonical source of truth used for routing.
313+
314+
5. **`getActivePendingClarifications` remains needed** for reference resolution (disambiguating pronouns when there's a single active clarification for an entity) and state transitions (marking clarifications as resolved). It is no longer needed for routing decisions after the stale-slot-readiness fix.
315+
302316
### After merge — next priorities
303317

304318
- Stabilize the planner contract and prompt iteration loop using `pnpm eval:planner` and `pnpm eval:all` as the gating loop

packages/core/src/ambiguity.ts

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,9 @@ export type DeriveAmbiguityInput = {
44
classifierConfidence: number;
55
missingSlots: string[];
66
needsClarification: string[];
7-
blockingSlots: string[];
87
};
98

109
export function deriveAmbiguity(input: DeriveAmbiguityInput): TurnAmbiguity {
11-
if (input.blockingSlots.length > 0) return "high";
1210
if (input.classifierConfidence < 0.6) return "high";
1311
if (input.missingSlots.length > 0) return "high";
1412
if (input.needsClarification.length > 0) return "high";

0 commit comments

Comments
 (0)