fix(rectification): continue after structured choices
This commit is contained in:
@@ -50,6 +50,7 @@ import { isNearBottom, shouldShowJumpToLatest } from "@/lib/rectification-sticky
|
||||
import {
|
||||
CHOICE_ACTION,
|
||||
STOP_ACTION,
|
||||
shouldContinueAfterStructuredChoice,
|
||||
stableChoiceActionKey,
|
||||
type ChoiceOptionId,
|
||||
} from "@/lib/rectification-agentic/v9/choice-action";
|
||||
@@ -285,6 +286,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
const [copiedMessageKey, setCopiedMessageKey] = useState<string | null>(null);
|
||||
const [regeneratingMessageKey, setRegeneratingMessageKey] = useState<string | null>(null);
|
||||
const [choiceNonce, setChoiceNonce] = useState(0);
|
||||
const choiceContinuationPending = useRef(false);
|
||||
const conversation = useRef<HTMLElement>(null);
|
||||
const workspace = useRef<HTMLDivElement>(null);
|
||||
const composer = useRef<HTMLTextAreaElement>(null);
|
||||
@@ -428,7 +430,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
return () => controller.abort();
|
||||
}, [applyCaseSnapshot, caseId, sessionId]);
|
||||
|
||||
const send = useCallback(async (action: "opening" | "message", messageText: string) => {
|
||||
const send = useCallback(async (action: "opening" | "message" | "read_only", messageText: string) => {
|
||||
const trimmed = action === "message" ? messageText.trim() : "";
|
||||
if ((action === "message" && !trimmed) || busy || readonly) return;
|
||||
setError("");
|
||||
@@ -472,7 +474,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
action,
|
||||
modelId: selectedModelId,
|
||||
...(action === "message" ? { message: trimmed } : {}),
|
||||
origin: "typed",
|
||||
origin: action === "read_only" ? "choice_click" : "typed",
|
||||
clientActionId: requestId,
|
||||
}),
|
||||
});
|
||||
@@ -805,7 +807,7 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
}
|
||||
const narration = typeof payload?.narration === "string" && payload.narration.trim()
|
||||
? payload.narration.trim()
|
||||
: "已记录你的选择。正在准备下一步。";
|
||||
: "已记录你的选择。";
|
||||
setMessages((current) => current.map((message) => message.renderKey === assistantRenderKey
|
||||
? {
|
||||
...message,
|
||||
@@ -820,6 +822,9 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
]);
|
||||
onCompleted?.();
|
||||
await loadCaseSnapshot();
|
||||
if (shouldContinueAfterStructuredChoice(payload?.nextAction)) {
|
||||
choiceContinuationPending.current = true;
|
||||
}
|
||||
} catch {
|
||||
setMessages((current) => current.filter((message) => (
|
||||
message.renderKey !== userRenderKey && message.renderKey !== assistantRenderKey
|
||||
@@ -843,6 +848,12 @@ export function RectificationAgenticChat(props: RectificationAgenticChatProps) {
|
||||
setPending,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!choiceContinuationPending.current || busy || readonly) return;
|
||||
choiceContinuationPending.current = false;
|
||||
void send("read_only", "");
|
||||
}, [busy, readonly, send]);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTurns.length > 0) {
|
||||
if (shouldStartOpening) onOpeningConsumed?.();
|
||||
|
||||
@@ -89,4 +89,4 @@ export function evidenceWritesAllowed(
|
||||
export const MAX_RESUMABLE_CASES_PER_USER = 1;
|
||||
|
||||
export const RECTIFICATION_SKILL_NAME = "jyotish-birth-time-rectification";
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.11";
|
||||
export const RECTIFICATION_SKILL_VERSION = "10.0.12";
|
||||
|
||||
@@ -85,12 +85,20 @@ export function composeChoiceNarration(input: {
|
||||
return "已按你的选择先看到当前范围。独立核对尚未完成,这不是最终校正结果。";
|
||||
}
|
||||
if (!input.scoring) {
|
||||
return "已记录你的选择。这是盘外核对,不会改候选分数。正在准备下一步。";
|
||||
return "已记录你的选择。这是盘外核对,不会改候选分数。";
|
||||
}
|
||||
if (input.appliedInference) {
|
||||
return "已记录你的选择,并更新了候选比较。正在准备下一步。";
|
||||
return "已记录你的选择,并更新了候选比较。";
|
||||
}
|
||||
return "已记录你的选择。正在准备下一步。";
|
||||
return "已记录你的选择。";
|
||||
}
|
||||
|
||||
export function shouldContinueAfterStructuredChoice(nextAction: unknown): boolean {
|
||||
if (!nextAction || typeof nextAction !== "object") return false;
|
||||
const type = (nextAction as { type?: unknown }).type;
|
||||
return type === "ask_fact_collection"
|
||||
|| type === "ask_candidate_discriminator"
|
||||
|| type === "ask_holdout_validation";
|
||||
}
|
||||
|
||||
export function stableChoiceActionKey(focusId: string, optionId: ChoiceOptionId): string {
|
||||
|
||||
@@ -581,8 +581,7 @@ test("live answer.delta is the model reply, not a spoken-thinking split", () =>
|
||||
});
|
||||
|
||||
test("does not auto-submit a suggestion during render or recovery", () => {
|
||||
assert.match(chat, /origin: "typed"/);
|
||||
assert.match(chat, /origin: "choice_click"/);
|
||||
assert.match(chat, /origin: action === "read_only" \? "choice_click" : "typed"/);
|
||||
assert.doesNotMatch(chat, /origin: "suggestion_click"/);
|
||||
assert.doesNotMatch(chat, /origin: "system_recovery"/);
|
||||
assert.doesNotMatch(chat, /2002/);
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
STOP_ACTION,
|
||||
composeChoiceNarration,
|
||||
quoteIsFromAssistantQuestion,
|
||||
shouldContinueAfterStructuredChoice,
|
||||
userVisibleChoiceLine,
|
||||
} from "../src/lib/rectification-agentic/v9/choice-action.ts";
|
||||
import { isNearBottom, shouldFollowLatest, shouldShowJumpToLatest } from "../src/lib/rectification-sticky-scroll.ts";
|
||||
@@ -316,6 +317,34 @@ test("truncated and timed-out runs return concrete finish reasons", () => {
|
||||
assert.equal(isIncompleteRunBanner("已记录你的选择,并更新了候选比较。"), false);
|
||||
});
|
||||
|
||||
test("structured choice only continues through the agent when another question is required", () => {
|
||||
for (const type of [
|
||||
"ask_fact_collection",
|
||||
"ask_candidate_discriminator",
|
||||
"ask_holdout_validation",
|
||||
]) {
|
||||
assert.equal(shouldContinueAfterStructuredChoice({ type }), true);
|
||||
}
|
||||
for (const type of [
|
||||
"offer_provisional_range",
|
||||
"complete_with_range",
|
||||
"ready_to_adopt",
|
||||
]) {
|
||||
assert.equal(shouldContinueAfterStructuredChoice({ type }), false);
|
||||
}
|
||||
assert.equal(shouldContinueAfterStructuredChoice(null), false);
|
||||
});
|
||||
|
||||
test("structured choice narration never persists a fake loading state", () => {
|
||||
for (const narration of [
|
||||
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: true }),
|
||||
composeChoiceNarration({ optionId: "A", scoring: true, appliedInference: false }),
|
||||
composeChoiceNarration({ optionId: "A", scoring: false, appliedInference: false }),
|
||||
]) {
|
||||
assert.doesNotMatch(narration, /正在准备下一步/);
|
||||
}
|
||||
});
|
||||
|
||||
test("the public agent route treats structured choice as a non-model command", () => {
|
||||
const route = readFileSync(new URL("../src/app/api/rectification/agent/route.ts", import.meta.url), "utf8");
|
||||
const start = route.indexOf("if (isStructuredChoice)");
|
||||
@@ -332,6 +361,8 @@ test("the public agent route treats structured choice as a non-model command", (
|
||||
const chat = readFileSync(new URL("../src/components/rectification-agentic-chat.tsx", import.meta.url), "utf8");
|
||||
assert.match(chat, /isPersistedFocusId\(focusId\)/);
|
||||
assert.match(chat, /focusId,/);
|
||||
assert.match(chat, /shouldContinueAfterStructuredChoice\(payload\?\.nextAction\)/);
|
||||
assert.match(chat, /send\("read_only", ""\)/);
|
||||
assert.match(chat, /回到最新/);
|
||||
assert.match(chat, /followTailRef\.current/);
|
||||
});
|
||||
|
||||
@@ -206,7 +206,7 @@ test("holdout not_ready forbids unique-minute copy and still blocks confirm", as
|
||||
assert.match(agentSource, /session_outcome=adopt_representative/);
|
||||
assert.doesNotMatch(agentSource, /±2 分钟/);
|
||||
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 13);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
|
||||
|
||||
const accounting = fakeAccounting({
|
||||
...receiptHandlers,
|
||||
|
||||
@@ -1037,9 +1037,9 @@ test("rescore failure does not fail the evidence write", async () => {
|
||||
assert.ok(result.rescore.error_code);
|
||||
});
|
||||
|
||||
test("public tool surface stays at 13 and new cases bind 10.0.11", () => {
|
||||
test("public tool surface stays at 13 and new cases bind 10.0.12", () => {
|
||||
assert.equal(PUBLIC_RECTIFICATION_TOOLS.length, 13);
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
|
||||
const deprecated = resolveExactSkillPackage(
|
||||
"jyotish-birth-time-rectification",
|
||||
"10.0.2",
|
||||
|
||||
@@ -206,9 +206,9 @@ test("read-case evidence context keeps day labels and confirm does not rewrite d
|
||||
assert.equal("p_occurred_from" in confirmCall.args, false);
|
||||
});
|
||||
|
||||
test("new-case skill identity is 10.0.11 and the prompt prefers batch ingest", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
|
||||
assert.match(skill, /^version: 10\.0\.11$/m);
|
||||
test("new-case skill identity is 10.0.12 and the prompt prefers batch ingest", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
|
||||
assert.match(skill, /^version: 10\.0\.12$/m);
|
||||
assert.match(skill, /不要对同一句用户消息里的多件事件逐条 propose\+confirm/);
|
||||
assert.match(agentSource, /当前轮新事件一律走 rectification-record-evidence-batch/);
|
||||
assert.doesNotMatch(agentSource, /分别调用 rectification-propose-evidence 和 rectification-confirm-evidence/);
|
||||
|
||||
@@ -105,11 +105,11 @@ test("system prompt carries only high-priority boundaries, never the method copy
|
||||
test("agent pins the dedicated rectification skill and its fixed version", () => {
|
||||
assert.equal(RECTIFICATION_V9_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(basename(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_SKILL_NAME);
|
||||
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.11"));
|
||||
assert.ok(RECTIFICATION_V9_PACKAGE_PATH.endsWith("skills/jyotish-birth-time-rectification/versions/10.0.12"));
|
||||
assert.notEqual(RECTIFICATION_V9_SKILL_PATH, RECTIFICATION_V9_PACKAGE_PATH);
|
||||
assert.equal(realpathSync(RECTIFICATION_V9_SKILL_PATH), RECTIFICATION_V9_PACKAGE_PATH);
|
||||
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
|
||||
});
|
||||
|
||||
test("step budgets are bounded per action with a hard ceiling", () => {
|
||||
|
||||
@@ -94,9 +94,9 @@ test("terminal transitions are one-way and evidence writes stop at terminal", ()
|
||||
|
||||
test("the active rectification skill pins the v10 identity and lives in the right directory", () => {
|
||||
assert.equal(RECTIFICATION_SKILL_NAME, "jyotish-birth-time-rectification");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.11");
|
||||
assert.equal(RECTIFICATION_SKILL_VERSION, "10.0.12");
|
||||
assert.match(skill, /^---\nname: jyotish-birth-time-rectification/m);
|
||||
assert.match(skill, /^version: 10\.0\.11$/m);
|
||||
assert.match(skill, /^version: 10\.0\.12$/m);
|
||||
for (const reference of references) {
|
||||
const content = readFileSync(`${skillDirectory}/references/${reference}`, "utf8");
|
||||
assert.ok(content.length > 0, `${reference} must be non-empty`);
|
||||
|
||||
@@ -209,7 +209,7 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
|
||||
session_id: SESSION_ID,
|
||||
status: "draft",
|
||||
should_start_opening: true,
|
||||
skill_version: "10.0.11",
|
||||
skill_version: "10.0.12",
|
||||
};
|
||||
}
|
||||
return null;
|
||||
@@ -247,11 +247,11 @@ test("open RPC passes the pinned skill and server-derived baseline only", async
|
||||
});
|
||||
assert.equal(response.disposition, "created");
|
||||
assert.equal(response.shouldStartOpening, true);
|
||||
assert.equal(response.skillVersion, "10.0.11");
|
||||
assert.equal(response.skillVersion, "10.0.12");
|
||||
const openCall = accounting.calls.find((call) => call.fn === "open_agentic_rectification_case_v2");
|
||||
assert.ok(openCall);
|
||||
assert.equal(openCall.args.p_skill_name, "jyotish-birth-time-rectification");
|
||||
assert.equal(openCall.args.p_skill_version, "10.0.11");
|
||||
assert.equal(openCall.args.p_skill_version, "10.0.12");
|
||||
assert.equal(openCall.args.p_user_id, "user-1");
|
||||
// The server derives the baseline; the request never carries it from the browser.
|
||||
assert.equal("birth_date" in openCall.args, false);
|
||||
|
||||
@@ -85,8 +85,8 @@ test("checked-in registry verifies hashed product packages and leaves consult on
|
||||
[
|
||||
{
|
||||
name: "jyotish-birth-time-rectification",
|
||||
version: "10.0.11",
|
||||
sha256: "66a3282be7b227bb6f77038455b8bc17ddaadaf0279a8b9a94284e9d186975c9",
|
||||
version: "10.0.12",
|
||||
sha256: "cc4510111f43f109b8483e3fd9699dbd15ef77f14a45ba1149697412075800be",
|
||||
},
|
||||
{
|
||||
name: "jyotish-personal-report",
|
||||
@@ -142,6 +142,13 @@ test("checked-in registry verifies hashed product packages and leaves consult on
|
||||
deprecated1002.sha256,
|
||||
"8d7aa2d4bea0414e9a89ef908ccbc8c708c98f79f5b78ae4f7dc229b5f7dbb30",
|
||||
);
|
||||
const deprecated1011 = resolveExactSkillPackage(
|
||||
"jyotish-birth-time-rectification",
|
||||
"10.0.11",
|
||||
"66a3282be7b227bb6f77038455b8bc17ddaadaf0279a8b9a94284e9d186975c9",
|
||||
{ projectRoot },
|
||||
);
|
||||
assert.equal(deprecated1011.status, "deprecated");
|
||||
});
|
||||
|
||||
test("exact resolution of an old deprecated package is independent of active switches", (t) => {
|
||||
|
||||
Reference in New Issue
Block a user