Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/core/src/discourse-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export const timeSpecSchema = z.discriminatedUnion("kind", [

export type TimeSpec = z.infer<typeof timeSpecSchema>;

// Kept for entity slotSnapshot fields (proposal_option, task_draft) — not used in discourse state.
// Kept for slot-extractor schemas — not used in discourse state.
export const resolvedSlotsSchema = z.object({
day: z.string().optional(),
time: timeSpecSchema.optional(),
Expand Down
2 changes: 1 addition & 1 deletion packages/core/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1146,7 +1146,7 @@ describe("core package", () => {
route: "conversation_then_mutation",
replyText:
"It sounds like you want to move the dentist reminder after lunch.",
slotSnapshot: {},
fieldSnapshot: {},
},
},
],
Expand Down
5 changes: 3 additions & 2 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { z } from "zod";
import {
conversationDiscourseStateSchema,
pendingWriteOperationSchema,
resolvedFieldsSchema,
resolvedSlotsSchema,
} from "./discourse-state";

Expand Down Expand Up @@ -821,8 +822,8 @@ export const conversationProposalOptionEntitySchema =
.optional(),
confirmationRequired: z.boolean().optional(),
originatingTurnText: z.string().min(1).nullable().optional(),
missingSlots: z.array(z.string().min(1)).optional(),
slotSnapshot: resolvedSlotsSchema,
missingFields: z.array(z.string().min(1)).optional(),
fieldSnapshot: resolvedFieldsSchema,
}),
});

Expand Down
41 changes: 20 additions & 21 deletions packages/core/src/proposal-rules.test.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,23 @@
import { describe, expect, it } from "vitest";
import type {
ConversationEntity,
ResolvedFields,
ResolvedSlots,
TimeSpec,
} from "./index";
import type { ConversationEntity, ResolvedFields, TimeSpec } from "./index";
import { deriveProposalCompatibility } from "./proposal-rules";

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

function sf(slots: ResolvedSlots): ResolvedFields {
return { scheduleFields: slots };
type ScheduleFields = NonNullable<ResolvedFields["scheduleFields"]>;

function sf(fields: ScheduleFields): ResolvedFields {
return { scheduleFields: fields };
}

type ProposalOption = Extract<ConversationEntity, { kind: "proposal_option" }>;

function makeProposal(
overrides: Partial<ProposalOption["data"]> & { slotSnapshot: ResolvedSlots },
overrides: Partial<ProposalOption["data"]> & {
fieldSnapshot: ResolvedFields;
},
): ProposalOption {
return {
id: "proposal-1",
Expand All @@ -38,10 +37,10 @@ function makeProposal(
}

describe("deriveProposalCompatibility", () => {
describe("slot-based compatibility", () => {
it("is compatible when committed slots match the snapshot", () => {
describe("field-based compatibility", () => {
it("is compatible when committed fields match the snapshot", () => {
const proposal = makeProposal({
slotSnapshot: { time: t(15, 0), day: "friday" },
fieldSnapshot: sf({ time: t(15, 0), day: "friday" }),
});

const result = deriveProposalCompatibility(
Expand All @@ -53,9 +52,9 @@ describe("deriveProposalCompatibility", () => {
expect(result.compatible).toBe(true);
});

it("is incompatible when a committed slot differs from the snapshot", () => {
it("is incompatible when a committed field differs from the snapshot", () => {
const proposal = makeProposal({
slotSnapshot: { time: t(15, 0) },
fieldSnapshot: sf({ time: t(15, 0) }),
});

const result = deriveProposalCompatibility(
Expand All @@ -68,9 +67,9 @@ describe("deriveProposalCompatibility", () => {
expect(result.reason).toMatch(/differs from proposal snapshot/);
});

it("is compatible when committed slot is new (not in snapshot)", () => {
it("is compatible when committed field is new (not in snapshot)", () => {
const proposal = makeProposal({
slotSnapshot: { day: "friday" },
fieldSnapshot: sf({ day: "friday" }),
});

const result = deriveProposalCompatibility(
Expand All @@ -82,9 +81,9 @@ describe("deriveProposalCompatibility", () => {
expect(result.compatible).toBe(true);
});

it("is compatible when committed slots are empty", () => {
it("is compatible when committed fields are empty", () => {
const proposal = makeProposal({
slotSnapshot: { time: t(15, 0), day: "friday" },
fieldSnapshot: sf({ time: t(15, 0), day: "friday" }),
});

const result = deriveProposalCompatibility(
Expand All @@ -98,7 +97,7 @@ describe("deriveProposalCompatibility", () => {

it("detects duration change as incompatible", () => {
const proposal = makeProposal({
slotSnapshot: { duration: 30 },
fieldSnapshot: sf({ duration: 30 }),
});

const result = deriveProposalCompatibility(
Expand All @@ -116,7 +115,7 @@ describe("deriveProposalCompatibility", () => {
it("is incompatible when action kind changes from plan to edit", () => {
const proposal = makeProposal({
originatingTurnText: "schedule a meeting",
slotSnapshot: { time: t(15, 0) },
fieldSnapshot: sf({ time: t(15, 0) }),
});

const result = deriveProposalCompatibility(
Expand All @@ -132,7 +131,7 @@ describe("deriveProposalCompatibility", () => {
it("skips action kind check for clarification answers", () => {
const proposal = makeProposal({
originatingTurnText: "move the meeting",
slotSnapshot: { time: t(15, 0) },
fieldSnapshot: sf({ time: t(15, 0) }),
});

const result = deriveProposalCompatibility(
Expand Down
38 changes: 17 additions & 21 deletions packages/core/src/proposal-rules.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type {
ConversationEntity,
ResolvedFields,
ResolvedSlots,
TurnClassifierOutput,
TurnInterpretationType,
} from "./index";
Expand Down Expand Up @@ -101,15 +100,18 @@ export function matchesProposalTarget(
return resolvedEntityIds.includes(targetEntityId);
}

type ScheduleFields = NonNullable<ResolvedFields["scheduleFields"]>;

export function deriveProposalCompatibility(
turnType: TurnInterpretationType,
resolvedFields: ResolvedFields,
proposal: ProposalOption,
) {
const committedSlots = flattenScheduleFields(resolvedFields);
const committedSchedule = resolvedFields.scheduleFields ?? {};
const snapshotSchedule = proposal.data.fieldSnapshot.scheduleFields ?? {};

if (turnType === "clarification_answer") {
return deriveSlotsCompatibility(committedSlots, proposal.data.slotSnapshot);
return deriveFieldsCompatibility(committedSchedule, snapshotSchedule);
}

const currentActionKind = turnType === "edit_request" ? "edit" : "plan";
Expand All @@ -124,24 +126,18 @@ export function deriveProposalCompatibility(
};
}

return deriveSlotsCompatibility(committedSlots, proposal.data.slotSnapshot);
}

// Adapter: flatten grouped schedule fields back to ResolvedSlots for
// comparison against proposal.data.slotSnapshot (entity shape not yet migrated).
function flattenScheduleFields(fields: ResolvedFields): ResolvedSlots {
return fields.scheduleFields ?? {};
return deriveFieldsCompatibility(committedSchedule, snapshotSchedule);
}

function deriveSlotsCompatibility(
committedSlots: ResolvedSlots,
snapshotSlots: ResolvedSlots,
function deriveFieldsCompatibility(
committedFields: ScheduleFields,
snapshotFields: ScheduleFields,
) {
const scalarKeys = ["day", "duration", "target"] as const;
const scalarKeys = ["day", "duration"] as const;

for (const key of scalarKeys) {
const committed = committedSlots[key];
const snapshot = snapshotSlots[key];
const committed = committedFields[key];
const snapshot = snapshotFields[key];

if (
committed !== undefined &&
Expand All @@ -150,19 +146,19 @@ function deriveSlotsCompatibility(
) {
return {
compatible: false,
reason: `Committed slot "${key}" differs from proposal snapshot, so it needs fresh consent.`,
reason: `Committed field "${key}" differs from proposal snapshot, so it needs fresh consent.`,
};
}
}

if (
committedSlots.time !== undefined &&
snapshotSlots.time !== undefined &&
!timeSpecsEqual(committedSlots.time, snapshotSlots.time)
committedFields.time !== undefined &&
snapshotFields.time !== undefined &&
!timeSpecsEqual(committedFields.time, snapshotFields.time)
) {
return {
compatible: false,
reason: `Committed slot "time" differs from proposal snapshot, so it needs fresh consent.`,
reason: `Committed field "time" differs from proposal snapshot, so it needs fresh consent.`,
};
}

Expand Down
Loading