fix(consult): mark rectification opened only after the case opens (BUG-1306)
This commit is contained in:
@@ -4,7 +4,10 @@ import { readFileSync, readdirSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
import { createElement } from "react";
|
||||
import { renderToString } from "react-dom/server";
|
||||
|
||||
import { RectificationOfferCard } from "../src/components/rectification-in-chat-cards.tsx";
|
||||
import { consultationAgentPublicEventSchema, createNdjsonParser, type AgentExecutionReceipt, type ConsultationAgentPublicEvent } from "../src/lib/consultation-agent-events.ts";
|
||||
import { RECTIFICATION_OFFER_RULE } from "../src/lib/consultation-thinking-plan.ts";
|
||||
import {
|
||||
@@ -13,10 +16,14 @@ import {
|
||||
RECTIFICATION_DIRECT_RESUME_COPY,
|
||||
RECTIFICATION_DIRECT_RESUME_ELSEWHERE_COPY,
|
||||
RECTIFICATION_DIRECT_UNAVAILABLE_COPY,
|
||||
RECTIFICATION_OFFER_OPENED_COPY,
|
||||
RECTIFICATION_OFFER_START_LABEL,
|
||||
RECTIFICATION_OFFER_TOOL_ID,
|
||||
attachOpenedOfferToFreeMessage,
|
||||
bindOfferToCase,
|
||||
offerForCase,
|
||||
parseRectificationOfferRecord,
|
||||
rectificationOfferCardCopy,
|
||||
sourceFromSessionRows,
|
||||
type RectificationOfferRecord,
|
||||
} from "../src/lib/rectification-in-chat.ts";
|
||||
@@ -179,13 +186,15 @@ test("the free reply can carry the opened offer once, without clobbering a case
|
||||
assert.equal(withCase, "present");
|
||||
});
|
||||
|
||||
test("explicit pass opens once, records an opened offer, and does not open again", async () => {
|
||||
test("explicit pass opens once, records an offered auto-open offer, and does not open again", async () => {
|
||||
const fresh = offerTool();
|
||||
const result = await fresh.tool.execute({ explicitRequest: true, userAsked: true });
|
||||
assert.equal(result.offered, true);
|
||||
assert.equal(result.directEntry, "new");
|
||||
assert.match(String(result.note), /好,我们开始核对出生时间/);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
assert.equal(fresh.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(fresh.state.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(fresh.state.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(fresh.state.rectificationDirectEntry, {
|
||||
kind: "new",
|
||||
@@ -199,14 +208,15 @@ test("explicit pass opens once, records an opened offer, and does not open again
|
||||
assert.match(String(again.note), /已经在打开/);
|
||||
assert.match(String(again.note), /不要再写解读/);
|
||||
assert.equal(fresh.decisions(), 1);
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("opened"));
|
||||
assert.deepEqual(fresh.state.rectificationOffer, offer("offered", { autoOpen: true }));
|
||||
});
|
||||
|
||||
test("explicitRequest bypasses an earlier offer, and userAsked alone still only shows a card", async () => {
|
||||
const direct = offerTool({ alreadyOffered: true });
|
||||
const opened = await direct.tool.execute({ explicitRequest: true });
|
||||
assert.equal(opened.directEntry, "new");
|
||||
assert.equal(direct.state.rectificationOffer?.status, "opened");
|
||||
assert.equal(direct.state.rectificationOffer?.status, "offered");
|
||||
assert.equal(direct.state.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(direct.state.rectificationDirectEntry?.kind, "new");
|
||||
|
||||
const card = offerTool({ alreadyOffered: true });
|
||||
@@ -375,7 +385,7 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
...(kind === "resume" ? { resumeSessionId: RESUME_SESSION } : {}),
|
||||
};
|
||||
if (kind === "new") state.rectificationOffer = offer("opened");
|
||||
if (kind === "new") state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
yield { type: "text-delta", payload: { text: "然后写一段解读。" } };
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
@@ -405,7 +415,8 @@ test("natal and window direct entry or explicit refusal skips the runtime-contra
|
||||
assert.equal(done.type === "run.completed" && done.responseKind, "smalltalk");
|
||||
assert.equal(done.type === "run.completed" && done.receipt, undefined);
|
||||
if (kind === "new") {
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "opened");
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.status, "offered");
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.autoOpen, true);
|
||||
assert.equal(done.type === "run.completed" && done.rectificationOffer?.caseId, undefined);
|
||||
assert.deepEqual(done.type === "run.completed" ? done.rectificationDirectOpen : undefined, {
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
@@ -450,7 +461,7 @@ test("a reading that already streamed is replaced by the direct-entry sentence",
|
||||
sentence: RECTIFICATION_DIRECT_NEW_COPY,
|
||||
sourceSessionId: SOURCE_SESSION,
|
||||
};
|
||||
state.rectificationOffer = offer("opened");
|
||||
state.rectificationOffer = offer("offered", { autoOpen: true });
|
||||
yield STOP_FINISH;
|
||||
})(),
|
||||
retry: async () => {
|
||||
@@ -568,6 +579,9 @@ test("the offer rule is defined once, and direct entry settles with the free com
|
||||
assert.match(hook, /openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/);
|
||||
assert.match(hook, /openRectificationSession\(rectificationDirectOpen\.resumeSessionId\)/);
|
||||
assert.equal(hook.match(/openRectificationFromChat\(rectificationDirectOpen\.sourceSessionId\)/g)?.length, 1);
|
||||
const directOpen = hook.slice(hook.indexOf("rectificationDirectOpen?.mode"), hook.indexOf("await persistSession(completedSession)"));
|
||||
assert.match(directOpen, /if \(opened\) return/);
|
||||
assert.doesNotMatch(directOpen, /status:\s*"opened"/);
|
||||
assert.match(hook, /responseKind === "smalltalk" \? \{[\s\S]*?rectificationOffer/);
|
||||
|
||||
const page = src("../src/app/(app)/page.tsx");
|
||||
@@ -586,4 +600,85 @@ test("the offer rule is defined once, and direct entry settles with the free com
|
||||
rectificationOffer: offer("opened"),
|
||||
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
|
||||
});
|
||||
consultationAgentPublicEventSchema.parse({
|
||||
type: "run.completed",
|
||||
responseKind: "smalltalk",
|
||||
rectificationOffer: offer("offered", { autoOpen: true }),
|
||||
rectificationDirectOpen: { sourceSessionId: SOURCE_SESSION, mode: "new" },
|
||||
});
|
||||
|
||||
const surface = src("../src/hooks/use-rectification-surface.ts");
|
||||
const fromChat = surface.slice(surface.indexOf("async function openRectificationFromChat"), surface.indexOf("async function openRectificationSession"));
|
||||
assert.match(fromChat, /return null/);
|
||||
assert.match(fromChat, /return openRectificationCase\("homepage", null, sourceSessionId\)/);
|
||||
});
|
||||
|
||||
function cardHtml(record: RectificationOfferRecord) {
|
||||
return renderToString(createElement(RectificationOfferCard, { offer: record, disabled: false, onStart() {} }));
|
||||
}
|
||||
|
||||
test("a direct-open offer still shows the ordinary card until the case is bound", () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const html = cardHtml(pending);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点/);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.equal(html.includes("继续核对出生时间"), false);
|
||||
assert.match(html, new RegExp(rectificationOfferCardCopy(pending).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
|
||||
|
||||
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
|
||||
const bound = bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION });
|
||||
const opened = bound![0]!.rectificationOffer as RectificationOfferRecord;
|
||||
assert.equal(opened.status, "opened");
|
||||
assert.equal(opened.autoOpen, true);
|
||||
assert.equal(opened.caseId, CASE_ID);
|
||||
const openedHtml = cardHtml(opened);
|
||||
assert.match(openedHtml, new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.equal(openedHtml.includes(RECTIFICATION_OFFER_START_LABEL), false);
|
||||
});
|
||||
|
||||
test("a failed direct open leaves the offer offered and clickable", () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const messages = [{ role: "assistant", text: RECTIFICATION_DIRECT_NEW_COPY, responseKind: "smalltalk", rectificationOffer: pending }];
|
||||
const opened = null;
|
||||
const next = opened ? bindOfferToCase(messages, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) : null;
|
||||
assert.equal(next, null);
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
assert.equal((messages[0]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
const html = cardHtml(pending);
|
||||
assert.equal(html.includes(RECTIFICATION_OFFER_OPENED_COPY), false);
|
||||
assert.match(html, new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
assert.equal(html.includes("disabled=\"\""), false);
|
||||
assert.equal(html.includes("disabled=\"disabled\""), false);
|
||||
});
|
||||
|
||||
test("stored offers offered, opened, and returned still parse; returned hides the card", () => {
|
||||
for (const status of ["offered", "opened", "returned"] as const) {
|
||||
const parsed = parseRectificationOfferRecord(offer(status));
|
||||
assert.equal(parsed?.status, status);
|
||||
assert.equal(parsed?.autoOpen, undefined);
|
||||
assert.equal("autoOpen" in (parsed ?? {}), false);
|
||||
}
|
||||
const flagged = parseRectificationOfferRecord(offer("offered", { autoOpen: true }));
|
||||
assert.equal(flagged?.status, "offered");
|
||||
assert.equal(flagged?.autoOpen, true);
|
||||
assert.equal(parseRectificationOfferRecord({ ...offer("opened"), extra: true }), null);
|
||||
assert.equal(cardHtml(offer("returned")), "");
|
||||
assert.match(cardHtml(offer("opened")), new RegExp(RECTIFICATION_OFFER_OPENED_COPY));
|
||||
assert.match(cardHtml(offer("offered")), new RegExp(RECTIFICATION_OFFER_START_LABEL));
|
||||
});
|
||||
|
||||
test("the free reply stores the direct-open offer still offered, with autoOpen", () => {
|
||||
// The record is not opened yet. The name of the attacher is historical.
|
||||
const sentence = RECTIFICATION_DIRECT_NEW_COPY;
|
||||
const card = offer("offered", { autoOpen: true });
|
||||
const attached = attachOpenedOfferToFreeMessage(
|
||||
[{ role: "user", text: "请帮我核对出生时间" }, { role: "assistant", text: sentence, responseKind: "smalltalk" }],
|
||||
{ sentence, offer: card },
|
||||
);
|
||||
assert.ok(Array.isArray(attached));
|
||||
assert.deepEqual(attached[1]!.rectificationOffer, card);
|
||||
assert.equal((attached[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
assert.equal(attachOpenedOfferToFreeMessage(attached, { sentence, offer: card }), "present");
|
||||
});
|
||||
|
||||
@@ -279,6 +279,53 @@ test("the open route binds the offer only for a consultation source; the source
|
||||
assert.doesNotMatch(server, /createAdminSupabaseClient/);
|
||||
});
|
||||
|
||||
test("an auto-open offer shows the ordinary card until the case is bound, and a reload keeps autoOpen", async () => {
|
||||
const pending = offer("offered", { autoOpen: true });
|
||||
const waiting = [
|
||||
{ role: "user" as const, text: "请帮我核对出生时间" },
|
||||
{ role: "assistant" as const, text: "好,我们开始核对出生时间。", responseKind: "smalltalk" as const, rectificationOffer: pending },
|
||||
];
|
||||
const html = renderToString(createElement(ChatTranscript, transcriptProps(waiting, [])));
|
||||
assert.match(html, /开始核对出生时间/);
|
||||
assert.match(html, /大约 8 题,会用掉 3 点;开始答第一题时才扣。/);
|
||||
assert.doesNotMatch(html, /出生时间核对已经开始/);
|
||||
assert.doesNotMatch(html, /继续核对出生时间/);
|
||||
|
||||
const bound = bindOfferToCase(waiting, { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
const openedHtml = renderToString(createElement(ChatTranscript, transcriptProps(bound as ChatMessage[], [])));
|
||||
assert.match(openedHtml, /出生时间核对已经开始/);
|
||||
assert.match(openedHtml, /继续核对出生时间/);
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).status, "opened");
|
||||
assert.equal((bound[1]!.rectificationOffer as RectificationOfferRecord).autoOpen, true);
|
||||
|
||||
const failed = waiting;
|
||||
assert.equal((failed[1]!.rectificationOffer as RectificationOfferRecord).status, "offered");
|
||||
|
||||
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
|
||||
const reloaded = readSessions([{
|
||||
id: SOURCE_SESSION,
|
||||
title: "虚构",
|
||||
theme: "marriage",
|
||||
messages: [{ role: "assistant", text: "还没打开", rectificationOffer: pending }],
|
||||
}], null);
|
||||
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.status, "offered");
|
||||
assert.equal(reloaded.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, true);
|
||||
const old = readSessions([{
|
||||
id: SOURCE_SESSION,
|
||||
title: "虚构",
|
||||
theme: "marriage",
|
||||
messages: [
|
||||
{ role: "assistant", text: "a", rectificationOffer: offer("offered") },
|
||||
{ role: "assistant", text: "b", rectificationOffer: offer("opened", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
|
||||
{ role: "assistant", text: "c", rectificationOffer: offer("returned", { caseId: CASE_ID, rectificationSessionId: RECT_SESSION }) },
|
||||
],
|
||||
}], null);
|
||||
assert.deepEqual(old.sessions[0]!.messages.map((message) => message.rectificationOffer?.status), ["offered", "opened", "returned"]);
|
||||
assert.equal(old.sessions[0]!.messages[0]!.rectificationOffer?.autoOpen, undefined);
|
||||
const returnedHtml = renderToString(createElement(ChatTranscript, transcriptProps(old.sessions[0]!.messages.slice(2) as ChatMessage[], [])));
|
||||
assert.doesNotMatch(returnedHtml, /rectification-offer-card/);
|
||||
});
|
||||
|
||||
test("stored sessions keep the offer and the divider when the browser reads them back (BUG-1248/1250)", async () => {
|
||||
const { readSessions } = await import("../src/lib/home-cloud-sync.ts");
|
||||
const bound = bindOfferToCase(conversation(), { caseId: CASE_ID, rectificationSessionId: RECT_SESSION })!;
|
||||
|
||||
Reference in New Issue
Block a user