Skip to content

Commit b7a3bf3

Browse files
MaxLinCodeclaude
andcommitted
Complete phase 1 target handling and slot key cleanup
- Add workflowChanged: boolean to CommitPolicyOutput; set from operationChanged so commit-policy is the sole authority on workflow identity decisions - Thin down buildResolvedOperation: use commitResult.resolvedTargetRef for targetRef; reset originatingText/startedAt when workflowChanged or no prior op - Fix PERSISTABLE_SLOT_KEYS to dot-path format (scheduleFields.*) matching the clarificationSlots format from commit-policy; drop "target" (now in targetRef) - Remove "target" from slotKeySchema in index.ts — target is resolved via classification.resolvedEntityIds, not slot extraction - Add target-change tests: switch clears prior schedule fields, workflowChanged is true, resolvedTargetRef carries the new entity ID - Rename "Committed slots" reason string in proposal-rules to "Resolved fields" Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 9750801 commit b7a3bf3

8 files changed

Lines changed: 106 additions & 20 deletions

File tree

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

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -55,14 +55,14 @@ describe("deriveConversationReplyState", () => {
5555
snapshot: buildSnapshot(),
5656
policy: {
5757
action: "ask_clarification",
58-
clarificationSlots: ["time"],
58+
clarificationSlots: ["scheduleFields.time"],
5959
},
6060
interpretation: {
6161
turnType: "planning_request",
6262
confidence: 0.58,
6363
resolvedEntityIds: [],
6464
ambiguity: "high",
65-
missingFields: ["time"],
65+
missingFields: ["scheduleFields.time"],
6666
},
6767
reply: "What time should I schedule the Malaysia trip planning?",
6868
userTurnText: "schedule malaysia trip planning tomorrow",
@@ -75,12 +75,12 @@ describe("deriveConversationReplyState", () => {
7575
kind: "clarification",
7676
status: "active",
7777
data: {
78-
reason: "time",
78+
reason: "scheduleFields.time",
7979
},
8080
});
8181
expect(result.discourseState?.pending_clarifications).toEqual([
8282
expect.objectContaining({
83-
slot: "time",
83+
slot: "scheduleFields.time",
8484
status: "pending",
8585
}),
8686
]);
@@ -217,15 +217,15 @@ describe("deriveConversationReplyState", () => {
217217
snapshot: buildSnapshot(),
218218
policy: {
219219
action: "ask_clarification",
220-
clarificationSlots: ["time"],
220+
clarificationSlots: ["scheduleFields.time"],
221221
resolvedOperation: op,
222222
},
223223
interpretation: {
224224
turnType: "planning_request",
225225
confidence: 0.58,
226226
resolvedEntityIds: [],
227227
ambiguity: "high",
228-
missingFields: ["time"],
228+
missingFields: ["scheduleFields.time"],
229229
},
230230
reply: "What time should I schedule it?",
231231
userTurnText: "schedule gym tomorrow",

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

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,11 @@ function upsertActiveProposalEntity(
464464
return nextProposal;
465465
}
466466

467-
const PERSISTABLE_SLOT_KEYS = new Set(["day", "time", "duration", "target"]);
467+
const PERSISTABLE_SLOT_KEYS = new Set([
468+
"scheduleFields.day",
469+
"scheduleFields.time",
470+
"scheduleFields.duration",
471+
]);
468472

469473
function derivePersistableClarificationSlots(slots: string[] | undefined) {
470474
if (!slots) {

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,10 @@ import {
1616

1717
const emptyCommit: CommitPolicyOutput = {
1818
resolvedFields: {},
19+
resolvedTargetRef: null,
1920
needsClarification: [],
2021
missingFields: [],
22+
workflowChanged: false,
2123
};
2224

2325
function input(

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

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ export type TurnRouterResult = RoutedTurn;
2929
// Required schedule fields per operation kind — mirrors commit-policy internals.
3030
function requiredSlotsForOperation(
3131
operationKind: OperationKind,
32-
): ("day" | "time" | "duration" | "target")[] {
32+
): ("day" | "time" | "duration")[] {
3333
switch (operationKind) {
3434
case "plan":
3535
return ["day", "time"];
@@ -109,6 +109,9 @@ export async function routeMessageTurn(
109109
unresolvable: slotExtraction?.unresolvable ?? [],
110110
operationKind,
111111
priorPendingWriteOperation: priorOperation,
112+
...(classification.resolvedEntityIds[0] !== undefined
113+
? { currentTargetEntityId: classification.resolvedEntityIds[0] }
114+
: {}),
112115
});
113116

114117
const policy = decideTurnPolicy({
@@ -145,13 +148,14 @@ function buildResolvedOperation(
145148
priorOperation: PendingWriteOperation | undefined,
146149
currentTurnText: string,
147150
): PendingWriteOperation {
151+
const isNewWorkflow = commitResult.workflowChanged || !priorOperation;
148152
return {
149153
operationKind,
150-
targetRef: priorOperation?.targetRef ?? null,
154+
targetRef: commitResult.resolvedTargetRef,
151155
resolvedFields: commitResult.resolvedFields,
152156
missingFields: commitResult.missingFields,
153-
originatingText: priorOperation?.originatingText ?? currentTurnText,
154-
startedAt: priorOperation?.startedAt ?? new Date().toISOString(),
157+
originatingText: isNewWorkflow ? currentTurnText : priorOperation.originatingText,
158+
startedAt: isNewWorkflow ? new Date().toISOString() : priorOperation.startedAt,
155159
};
156160
}
157161

@@ -220,11 +224,11 @@ function unique(values: string[]) {
220224

221225
function compactConfidence(
222226
confidence: Record<string, number | null | undefined>,
223-
): Partial<Record<"day" | "time" | "duration" | "target", number>> {
224-
const result: Partial<Record<"day" | "time" | "duration" | "target", number>> = {};
227+
): Partial<Record<"day" | "time" | "duration", number>> {
228+
const result: Partial<Record<"day" | "time" | "duration", number>> = {};
225229
for (const [key, value] of Object.entries(confidence)) {
226230
if (typeof value === "number") {
227-
result[key as "day" | "time" | "duration" | "target"] = value;
231+
result[key as "day" | "time" | "duration"] = value;
228232
}
229233
}
230234
return result;

packages/core/src/commit-policy.test.ts

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@ function t(hour: number, minute: number): TimeSpec {
1111
function priorOp(
1212
operationKind: PendingWriteOperation["operationKind"],
1313
scheduleFields: PendingWriteOperation["resolvedFields"]["scheduleFields"],
14+
targetEntityId?: string,
1415
): PendingWriteOperation {
1516
return {
1617
operationKind,
17-
targetRef: null,
18+
targetRef: targetEntityId ? { entityId: targetEntityId } : null,
1819
resolvedFields: { scheduleFields },
1920
missingFields: [],
2021
originatingText: "prior turn",
@@ -256,4 +257,62 @@ describe("applyCommitPolicy", () => {
256257
expect(result.resolvedFields.scheduleFields?.day).toBe("friday");
257258
expect(result.needsClarification).toContain("scheduleFields.time");
258259
});
260+
261+
it("sets resolvedTargetRef from currentTargetEntityId when no prior operation", () => {
262+
const result = applyCommitPolicy(
263+
buildInput({
264+
turnType: "planning_request",
265+
currentTargetEntityId: "task-abc",
266+
}),
267+
);
268+
269+
expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" });
270+
expect(result.workflowChanged).toBe(false);
271+
});
272+
273+
it("carries forward prior targetRef when no new entity is resolved", () => {
274+
const result = applyCommitPolicy(
275+
buildInput({
276+
turnType: "clarification_answer",
277+
priorPendingWriteOperation: priorOp("plan", { day: "tomorrow" }, "task-abc"),
278+
}),
279+
);
280+
281+
expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" });
282+
expect(result.workflowChanged).toBe(false);
283+
});
284+
285+
it("clears prior schedule fields and sets workflowChanged when target changes", () => {
286+
const result = applyCommitPolicy(
287+
buildInput({
288+
turnType: "planning_request",
289+
extractedValues: { day: "friday" },
290+
confidence: { day: 0.9 },
291+
currentTargetEntityId: "task-xyz",
292+
priorPendingWriteOperation: priorOp("plan", { time: t(14, 0) }, "task-abc"),
293+
}),
294+
);
295+
296+
expect(result.resolvedTargetRef).toEqual({ entityId: "task-xyz" });
297+
expect(result.workflowChanged).toBe(true);
298+
expect(result.resolvedFields.scheduleFields?.time).toBeUndefined();
299+
expect(result.resolvedFields.scheduleFields?.day).toBe("friday");
300+
});
301+
302+
it("does not set workflowChanged when target is the same entity", () => {
303+
const result = applyCommitPolicy(
304+
buildInput({
305+
turnType: "clarification_answer",
306+
extractedValues: { time: t(17, 0) },
307+
confidence: { time: 0.9 },
308+
currentTargetEntityId: "task-abc",
309+
priorPendingWriteOperation: priorOp("plan", { day: "tomorrow" }, "task-abc"),
310+
}),
311+
);
312+
313+
expect(result.resolvedTargetRef).toEqual({ entityId: "task-abc" });
314+
expect(result.workflowChanged).toBe(false);
315+
expect(result.resolvedFields.scheduleFields?.day).toBe("tomorrow");
316+
expect(result.resolvedFields.scheduleFields?.time).toEqual(t(17, 0));
317+
});
259318
});

packages/core/src/commit-policy.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,13 @@ import type {
22
OperationKind,
33
PendingWriteOperation,
44
ResolvedFields,
5+
TargetRef,
56
TimeSpec,
67
TurnInterpretation,
78
} from "./index";
89
import { timeSpecsEqual } from "./time-spec";
910

10-
type ScheduleSlot = "day" | "time" | "duration" | "target";
11+
type ScheduleSlot = "day" | "time" | "duration";
1112

1213
export type CommitPolicyInput = {
1314
turnType: TurnInterpretation["turnType"];
@@ -16,12 +17,15 @@ export type CommitPolicyInput = {
1617
unresolvable: ScheduleSlot[];
1718
operationKind: OperationKind;
1819
priorPendingWriteOperation?: PendingWriteOperation | undefined;
20+
currentTargetEntityId?: string;
1921
};
2022

2123
export type CommitPolicyOutput = {
2224
resolvedFields: ResolvedFields;
25+
resolvedTargetRef: TargetRef;
2326
needsClarification: string[];
2427
missingFields: string[];
28+
workflowChanged: boolean;
2529
};
2630

2731
export const SLOT_COMMITTING_TURN_TYPES = new Set<
@@ -56,11 +60,19 @@ export function applyCommitPolicy(
5660
unresolvable,
5761
operationKind,
5862
priorPendingWriteOperation,
63+
currentTargetEntityId,
5964
} = input;
6065

66+
// Target change: a new entity ID that differs from the prior workflow's target
67+
// means the user switched subjects. Treat it the same as an operation change —
68+
// prior committed schedule fields belong to a different task and must be cleared.
69+
const targetChanged =
70+
currentTargetEntityId !== undefined &&
71+
currentTargetEntityId !== priorPendingWriteOperation?.targetRef?.entityId;
72+
6173
const operationChanged =
6274
priorPendingWriteOperation != null &&
63-
operationKind !== priorPendingWriteOperation.operationKind;
75+
(operationKind !== priorPendingWriteOperation.operationKind || targetChanged);
6476

6577
const priorScheduleFields: Partial<Record<ScheduleSlot, unknown>> =
6678
operationChanged
@@ -125,7 +137,12 @@ export function applyCommitPolicy(
125137
.filter((slot) => committedSchedule[slot] === undefined)
126138
.map((slot) => `scheduleFields.${slot}`);
127139

128-
return { resolvedFields, needsClarification, missingFields };
140+
// Carry forward the prior target unless this turn introduced a new one.
141+
const resolvedTargetRef: TargetRef = currentTargetEntityId
142+
? { entityId: currentTargetEntityId }
143+
: (priorPendingWriteOperation?.targetRef ?? null);
144+
145+
return { resolvedFields, resolvedTargetRef, needsClarification, missingFields, workflowChanged: operationChanged };
129146
}
130147

131148
function slotValuesEqual(slot: ScheduleSlot, a: unknown, b: unknown): boolean {

packages/core/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -957,7 +957,7 @@ export const routedTurnSchema = z.object({
957957
policy: turnPolicyDecisionSchema,
958958
});
959959

960-
const slotKeySchema = z.enum(["day", "time", "duration", "target"]);
960+
const slotKeySchema = z.enum(["day", "time", "duration"]);
961961

962962
const slotConfidenceSchema = z.object({
963963
day: z.number().nullable().optional(),

packages/core/src/proposal-rules.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,7 +168,7 @@ function deriveSlotsCompatibility(
168168

169169
return {
170170
compatible: true,
171-
reason: "Committed slots are compatible with the proposal snapshot.",
171+
reason: "Resolved fields are compatible with the proposal snapshot.",
172172
};
173173
}
174174

0 commit comments

Comments
 (0)