fix(rectification): keep model confirmations separate from question slots

This commit is contained in:
Jesse_Chen
2026-08-31 04:50:39 +08:00
parent db6716e76c
commit 79304c31fc
3 changed files with 3 additions and 123 deletions
@@ -47,18 +47,6 @@ import {
createStepAnswerState,
flushStepAnswerOnStreamFinish,
} from "./step-answer";
import {
bindSpokenToOpenQuestion,
CHOICE_CARD_CONTINUATION_ACK,
composeRectificationTurnNarration,
openQuestionPromptFromToolResult,
publicNarrationDtoFromDossier,
} from "./turn-narration";
import { isSafeCollectSpokenPrompt } from "./spoken-answer";
import {
collectSpokenPromptForNewFocus,
composeCollectSpokenAssistantText,
} from "./turn-decision";
import {
defaultMessageOrigin,
isRectificationMessageOrigin,
@@ -660,7 +648,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
let finishReason: ReturnType<typeof toAgentModelFinishReason> | null = null;
const stepAnswer = createStepAnswerState();
let persistedPrompt: string | null = null;
let spokenRaw = "";
let visibleEmitted = "";
@@ -685,31 +672,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
await emit({ type: "answer.delta", text: visible, replace: true });
};
const emitNewCollectSpokenIfNeeded = async (): Promise<void> => {
if (collectSpokenEmitted) return;
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const fromFocus = collectSpokenPromptForNewFocus({
previousFocusId,
focus: latest.conversationSummary.activeFocus,
});
const prompt = fromFocus && isSafeCollectSpokenPrompt(fromFocus) ? fromFocus : null;
if (!prompt) return;
const { composed, delta } = composeCollectSpokenAssistantText(answerText, prompt);
if (!delta) return;
if (!answerText.trim()) {
await emitVisibleSpoken(composed);
} else {
answerText = composed;
answerDeltas.push(delta);
await emit({ type: "answer.delta", text: delta });
}
collectSpokenEmitted = true;
} catch {
// Visibility fallback must not fail the turn.
}
};
const publishSpokenStep = async (pieces: readonly string[], live = false) => {
const joined = pieces.join("");
if (!joined) return;
@@ -717,11 +679,7 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
const spoken = live ? joined : joined.trim();
if (!spoken) return;
spokenRaw += spoken;
const visible = persistedPrompt
? bindSpokenToOpenQuestion(spokenRaw, persistedPrompt)
: spokenRaw;
if (!visible && persistedPrompt) return;
await emitVisibleSpoken(visible);
await emitVisibleSpoken(spokenRaw);
};
const retractSpoken = async () => {
@@ -759,9 +717,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
}
}
const stampedPrompt = openQuestionPromptFromToolResult(chunk);
if (stampedPrompt) persistedPrompt = stampedPrompt;
const stepEffect = applyStepAnswerChunk(
stepAnswer,
chunk,
@@ -843,30 +798,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (flushed.kind === "publish") await publishSpokenStep(flushed.pieces);
}
const flushPersistedPrompt = async (): Promise<boolean> => {
if (!persistedPrompt) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
persistedPrompt = publicNarrationDtoFromDossier(latest).nextQuestion;
} catch {
// Keep whatever prompt the tool result already stamped.
}
}
if (!persistedPrompt) return false;
const bound = bindSpokenToOpenQuestion(spokenRaw || answerText, persistedPrompt);
if (bound.trim()) {
await emitVisibleSpoken(bound);
return true;
}
if (visibleEmitted.trim() && visibleEmitted !== CHOICE_CARD_CONTINUATION_ACK) {
await emitVisibleSpoken(CHOICE_CARD_CONTINUATION_ACK);
return true;
}
if (answerText.trim()) return true;
await emitVisibleSpoken(CHOICE_CARD_CONTINUATION_ACK);
return true;
};
const discriminatorInvariant = async (): Promise<{ ok: true } | { ok: false; errorCode: string }> => {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
@@ -924,20 +855,10 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
stepCount: toolsUsed.size,
maxSteps,
});
if (mapped === "run_timeout") {
if (await flushPersistedPrompt()) {
await emitNewCollectSpokenIfNeeded();
return completeAttempt();
}
return failedAttempt(attemptId, "run_timeout");
}
if (mapped === "run_timeout") return failedAttempt(attemptId, "run_timeout");
if (streamFailed || abortController.signal.aborted) return failedAttempt(attemptId, mapped ?? "stream_aborted");
if (!finished) return failedAttempt(attemptId, mapped ?? "stream_unfinished");
if (mapped === "answer_truncated") {
if (await flushPersistedPrompt()) {
await emitNewCollectSpokenIfNeeded();
return completeAttempt();
}
return {
ok: false,
status: "failed",
@@ -956,26 +877,6 @@ export async function runV9AgentTurn(options: V9AgentRunOptions): Promise<V9Agen
if (mapped === "max_steps" || mapped === "provider_error") {
return failedAttempt(attemptId, mapped);
}
if (await flushPersistedPrompt()) {
await emitNewCollectSpokenIfNeeded();
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);
return completeAttempt();
}
await emitNewCollectSpokenIfNeeded();
if (!answerText.trim()) {
try {
const latest = await loadV9CaseDossier(accounting, userId, caseId);
const narration = composeRectificationTurnNarration(publicNarrationDtoFromDossier(latest));
if (narration.trim()) {
answerText = narration;
answerDeltas.push(narration);
await emit({ type: "answer.delta", text: narration });
}
} catch {
// Fall through to empty_stream.
}
}
if (!answerText.trim()) return failedAttempt(attemptId, "empty_stream");
const invariant = await discriminatorInvariant();
if (!invariant.ok) return failedAttempt(attemptId, invariant.errorCode);