feat: upgrade investigation and presentation runtime
This commit is contained in:
+75
-56
@@ -12,8 +12,6 @@ import {
|
||||
type PermissionReply,
|
||||
type OpencodeRuntimeAdapter,
|
||||
} from "../runtime/opencode.js";
|
||||
import { toUiEnvelopeFromToolCall } from "../uiEnvelope/fromToolCall.js";
|
||||
import { createEnvelopeId } from "../uiEnvelope/ids.js";
|
||||
import {
|
||||
buildPermissionDetail,
|
||||
buildPermissionV2Detail,
|
||||
@@ -53,6 +51,7 @@ import {
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStreamEvents.js";
|
||||
import { createTokenSmoother } from "./chatTokenSmoother.js";
|
||||
import { AnswerUiPublication } from "../chat/answer-ui-publication.js";
|
||||
import { parseAnswerUiInput } from "../chat/answer-ui.js";
|
||||
|
||||
export {
|
||||
@@ -78,7 +77,7 @@ type StreamPromptOptions = {
|
||||
waitForUserInput?: () => Promise<boolean>;
|
||||
beforePrompt?: () => Promise<void>;
|
||||
beforeIdle?: () => Promise<boolean | string>;
|
||||
beforeRichAnswerClose?: () => Promise<boolean>;
|
||||
beforeAnswerClose?: () => Promise<boolean>;
|
||||
runtime: OpencodeRuntimeAdapter;
|
||||
sessionId: string;
|
||||
clientSessionId: string;
|
||||
@@ -88,7 +87,6 @@ type StreamPromptOptions = {
|
||||
traceId?: string;
|
||||
projectId?: string;
|
||||
signal?: AbortSignal;
|
||||
suppressLegacyFrontendActions?: boolean;
|
||||
suppressAnswerUi?: boolean;
|
||||
write: (event: string, data: Record<string, unknown>) => void;
|
||||
};
|
||||
@@ -153,12 +151,11 @@ export const streamPromptResponse = async ({
|
||||
traceId,
|
||||
projectId,
|
||||
signal,
|
||||
suppressLegacyFrontendActions = false,
|
||||
suppressAnswerUi = false,
|
||||
write,
|
||||
beforePrompt,
|
||||
beforeIdle,
|
||||
beforeRichAnswerClose,
|
||||
beforeAnswerClose,
|
||||
onPromptDispatched,
|
||||
onInboxEvent,
|
||||
waitForUserInput,
|
||||
@@ -200,8 +197,9 @@ export const streamPromptResponse = async ({
|
||||
let failed = false;
|
||||
let waitingInput = false;
|
||||
let finalAnswer: string | undefined;
|
||||
const presentedAnswerIds = new Set<string>();
|
||||
let finalAnswerUiBlockIds: string[] = [];
|
||||
let finalAnswerRefs: unknown;
|
||||
let finalAnswerPublished = false;
|
||||
const answerUiPublication = new AnswerUiPublication();
|
||||
const trackBusinessStage = createBusinessStageTracker((event, data) => write(event, { ...data, session_id: clientSessionId }));
|
||||
const debugContext = {
|
||||
sessionId,
|
||||
@@ -215,9 +213,43 @@ export const streamPromptResponse = async ({
|
||||
delayMs: config.AGENT_TOKEN_SMOOTHING_DELAY_MS,
|
||||
locale: config.AGENT_TOKEN_SMOOTHING_LOCALE,
|
||||
sessionId: clientSessionId,
|
||||
write,
|
||||
write: (event, data) => {
|
||||
if (event === "token" && !firstTokenLogged) {
|
||||
firstTokenLogged = true;
|
||||
logDevelopmentDebug("first response token emitted", {
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||
});
|
||||
}
|
||||
write(event, data);
|
||||
},
|
||||
});
|
||||
|
||||
const publishFinalAnswer = async () => {
|
||||
if (!finalAnswer || finalAnswerPublished) return;
|
||||
await tokenSmoother.flush();
|
||||
write("final_answer", {
|
||||
session_id: clientSessionId, content: finalAnswer,
|
||||
ui_block_ids: answerUiPublication.finalize(finalAnswerRefs),
|
||||
});
|
||||
finalAnswerPublished = true;
|
||||
};
|
||||
|
||||
const tryCompleteAnswer = async () => {
|
||||
if (!finalAnswer || suppressAnswerUi || signal?.aborted) return;
|
||||
if ([...toolStatuses.values()].some((status) => status !== "completed" && status !== "error")) return;
|
||||
if (beforeAnswerClose ? !await beforeAnswerClose() : waitForUserInput && await waitForUserInput()) return;
|
||||
if (signal?.aborted) return;
|
||||
// A validated answer is readable before native cleanup. Chat completion is
|
||||
// independent of Server-owned task completion and native inbox admission.
|
||||
await publishFinalAnswer();
|
||||
await tokenSmoother.close();
|
||||
await runtime.abortSession(sessionId);
|
||||
await runtime.waitForSessionIdle(sessionId);
|
||||
done = true;
|
||||
};
|
||||
|
||||
logDevelopmentDebug("chat stream started", {
|
||||
...debugContext,
|
||||
messageChars: message.length,
|
||||
@@ -225,13 +257,17 @@ export const streamPromptResponse = async ({
|
||||
|
||||
const abortPromise = signal
|
||||
? new Promise<{ type: "abort" }>((resolve) => {
|
||||
if (signal.aborted) {
|
||||
const interrupt = () => {
|
||||
// Stage draining may currently be awaiting paced output. Interrupt
|
||||
// that drain as well, so cancellation never waits for UI pacing.
|
||||
void tokenSmoother.close({ immediate: true });
|
||||
resolve({ type: "abort" });
|
||||
};
|
||||
if (signal.aborted) {
|
||||
interrupt();
|
||||
return;
|
||||
}
|
||||
signal.addEventListener("abort", () => resolve({ type: "abort" }), {
|
||||
once: true,
|
||||
});
|
||||
signal.addEventListener("abort", interrupt, { once: true });
|
||||
})
|
||||
: null;
|
||||
|
||||
@@ -370,6 +406,11 @@ export const streamPromptResponse = async ({
|
||||
if (!isSessionEvent(event, sessionId)) {
|
||||
continue;
|
||||
}
|
||||
if (isPermissionAskedEvent(event) || isPermissionV2AskedEvent(event)
|
||||
|| isQuestionAskedEvent(event) || isQuestionV2AskedEvent(event)) {
|
||||
await tokenSmoother.flush();
|
||||
if (signal?.aborted) { aborted = true; break; }
|
||||
}
|
||||
const inboxEvent = event as unknown as { type: string; properties: { inboxID?: string; delivery?: "steer" | "queue" } };
|
||||
if (inboxEvent.type.startsWith("session.inbox.")) {
|
||||
onInboxEvent?.(inboxEvent.type, inboxEvent.properties);
|
||||
@@ -693,15 +734,6 @@ export const streamPromptResponse = async ({
|
||||
sawResponseActivity = true;
|
||||
const partType = partTypes.get(event.properties.partID);
|
||||
if (partType === "text") {
|
||||
if (!firstTokenLogged) {
|
||||
firstTokenLogged = true;
|
||||
logDevelopmentDebug("first response token emitted", {
|
||||
...debugContext,
|
||||
partId: event.properties.partID,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
sincePromptDispatchMs: Math.max(0, Date.now() - promptStartedAt),
|
||||
});
|
||||
}
|
||||
emittedText = true;
|
||||
tokenSmoother.writeToken(event.properties.delta);
|
||||
} else if (partType === "reasoning") {
|
||||
@@ -736,6 +768,7 @@ export const streamPromptResponse = async ({
|
||||
emittedText = true;
|
||||
tokenSmoother.writeToken(content);
|
||||
}
|
||||
if (part.time?.end) await tokenSmoother.flush();
|
||||
} else if (part.type === "reasoning") {
|
||||
const pending = pendingPartTextDeltas.get(part.id) ?? [];
|
||||
if (pending.length > 0) {
|
||||
@@ -767,6 +800,13 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
}
|
||||
if (part.type === "tool") {
|
||||
// A tool may block for seconds. Release narration before entering it,
|
||||
// including publication and question tools with their own lifecycle.
|
||||
await tokenSmoother.flush();
|
||||
if (signal?.aborted) { aborted = true; break; }
|
||||
const nextToolStatus = String(part.state.status);
|
||||
const previousToolStatus = toolStatuses.get(part.id);
|
||||
toolStatuses.set(part.id, nextToolStatus);
|
||||
if (part.tool === "ask_user") {
|
||||
// Reserve every lifecycle state before generic tool bookkeeping:
|
||||
// a running call must not consume its completed-question identity.
|
||||
@@ -796,14 +836,15 @@ export const streamPromptResponse = async ({
|
||||
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
|
||||
emittedToolParts.add(part.id);
|
||||
const value = parseAnswerUiInput(part.state.input);
|
||||
if (value) {
|
||||
presentedAnswerIds.add(value.block_id);
|
||||
const rejection = value ? answerUiPublication.publish(value) : "回答界面未通过校验,保留文字说明";
|
||||
if (value && !rejection) {
|
||||
await tokenSmoother.flush();
|
||||
write("answer_ui", { ...value, id: value.block_id, session_id: clientSessionId });
|
||||
} else {
|
||||
write("progress", { id: part.id, phase: "visualization", status: "error", title: "回答界面未通过校验,保留文字说明" });
|
||||
write("progress", { id: part.id, phase: "visualization", status: "error", title: rejection ?? "回答界面未通过校验,保留文字说明" });
|
||||
}
|
||||
}
|
||||
await tryCompleteAnswer();
|
||||
continue;
|
||||
}
|
||||
if (part.tool === "final_answer") {
|
||||
@@ -812,16 +853,8 @@ export const streamPromptResponse = async ({
|
||||
if (output && typeof output === "object" && typeof (output as Record<string, unknown>).answer === "string") {
|
||||
finalAnswer = (output as { answer: string }).answer;
|
||||
const refs = (output as { ui_block_ids?: unknown }).ui_block_ids;
|
||||
finalAnswerUiBlockIds = Array.isArray(refs) ? [...new Set(refs.filter((id): id is string => typeof id === "string" && presentedAnswerIds.has(id)))].slice(0, 48) : [];
|
||||
const hasPendingTool = [...toolStatuses.values()].some((status) => status !== "completed" && status !== "error");
|
||||
if (presentedAnswerIds.size > 0 && !suppressAnswerUi && !hasPendingTool
|
||||
&& (!beforeRichAnswerClose || await beforeRichAnswerClose())) {
|
||||
// A final rich-chat answer closes this turn; native interruption stops
|
||||
// automatic revisions without marking a background task complete.
|
||||
await runtime.abortSession(sessionId);
|
||||
await runtime.waitForSessionIdle(sessionId);
|
||||
done = true;
|
||||
}
|
||||
finalAnswerRefs = refs;
|
||||
await tryCompleteAnswer();
|
||||
}
|
||||
}
|
||||
continue;
|
||||
@@ -853,10 +886,7 @@ export const streamPromptResponse = async ({
|
||||
const reason = extractRequestReason(toolParams);
|
||||
const isToolFinalState =
|
||||
part.state.status === "completed" || part.state.status === "error";
|
||||
const nextToolStatus = String(part.state.status);
|
||||
|
||||
if (toolStatuses.get(part.id) !== nextToolStatus) {
|
||||
toolStatuses.set(part.id, nextToolStatus);
|
||||
if (previousToolStatus !== nextToolStatus) {
|
||||
logDevelopmentDebug("tool part status changed", {
|
||||
...debugContext,
|
||||
partId: part.id,
|
||||
@@ -903,9 +933,9 @@ export const streamPromptResponse = async ({
|
||||
emitProgress({
|
||||
id: part.id,
|
||||
phase: "tool",
|
||||
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
|
||||
status: normalizeToolStatus(part.state.status),
|
||||
title: getToolProgressTitle(part.tool, part.state.status, part.state.status === "completed" ? part.state.output : undefined),
|
||||
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
|
||||
detail: buildToolProgressDetail(
|
||||
part.tool,
|
||||
part.state.status,
|
||||
@@ -940,20 +970,9 @@ export const streamPromptResponse = async ({
|
||||
params: toolParams,
|
||||
reason,
|
||||
});
|
||||
const envelope = toUiEnvelopeFromToolCall({
|
||||
tool: part.tool,
|
||||
params: toolParams,
|
||||
reason,
|
||||
});
|
||||
if (envelope) {
|
||||
write("ui_envelope", {
|
||||
session_id: clientSessionId,
|
||||
envelope_id: createEnvelopeId(part.tool),
|
||||
created_at: Date.now(),
|
||||
envelope,
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
await tryCompleteAnswer();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -1057,6 +1076,7 @@ export const streamPromptResponse = async ({
|
||||
}
|
||||
|
||||
if (aborted) {
|
||||
await tokenSmoother.close({ immediate: true });
|
||||
logDevelopmentDebug("chat stream aborting session", {
|
||||
...debugContext,
|
||||
elapsedMs: Math.max(0, Date.now() - requestStartedAt),
|
||||
@@ -1070,7 +1090,6 @@ export const streamPromptResponse = async ({
|
||||
"failed while waiting for aborted opencode session to become idle",
|
||||
);
|
||||
});
|
||||
await tokenSmoother.flush();
|
||||
return { aborted: true, failed: false, toolCallCount };
|
||||
}
|
||||
|
||||
@@ -1094,7 +1113,7 @@ export const streamPromptResponse = async ({
|
||||
await emitFallbackMessage(runtime, sessionId, clientSessionId, tokenSmoother.writeToken);
|
||||
}
|
||||
await tokenSmoother.flush();
|
||||
if (finalAnswer) write("final_answer", { session_id: clientSessionId, content: finalAnswer, ui_block_ids: finalAnswerUiBlockIds });
|
||||
await publishFinalAnswer();
|
||||
emitProgress({
|
||||
id: "request-received",
|
||||
phase: "start",
|
||||
@@ -1123,7 +1142,7 @@ export const streamPromptResponse = async ({
|
||||
});
|
||||
return { aborted: false, failed: false, toolCallCount };
|
||||
} finally {
|
||||
await tokenSmoother.flush();
|
||||
await tokenSmoother.close({ immediate: true });
|
||||
await iterator.return?.();
|
||||
if (!promptSettled && !aborted) {
|
||||
await promptPromise.catch(() => undefined);
|
||||
|
||||
Reference in New Issue
Block a user