fix: preserve interaction replies across chat recovery

This commit is contained in:
2026-10-29 17:53:33 +08:00
parent fbe273e90a
commit 52681a8451
38 changed files with 1623 additions and 314 deletions
+64
View File
@@ -0,0 +1,64 @@
import type { Express } from "express";
import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { interactionQuestionsSchema } from "../chat/interaction-request-contract.js";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
const identity = { session_id: z.string().min(1).max(128) };
const call = z.object({ ...identity, call_id: z.string().min(1).max(128), tool: z.string().min(1).max(200) }).strict();
/** Only trusted executors enter this gate; model input never selects an owner or turn. */
export function registerChatInteractionBridge(app: Express, options: { internalToken: string; interactions: InteractionRequestStore; runtime?: OpencodeRuntimeAdapter }) {
const calls = new Map<string, { turn: string; active: Map<string, string> }>();
for (const operation of ["policy", "enter", "leave", "create"] as const) {
app.post(`/internal/chat-interactions/${operation}`, async (req, res) => {
if (!isTrustedInternalBridgeRequest(req, options.internalToken)) { res.status(403).json({ message: "forbidden" }); return; }
try {
const body = operation === "policy" ? z.object(identity).strict().parse(req.body)
: operation === "create" ? call.extend({ questions: interactionQuestionsSchema }).parse(req.body) : call.parse(req.body);
const context = getRuntimeSessionContext(body.session_id);
const enabled = Boolean(context?.hostTurnId && context.hostMessageId && context.capabilities?.includes("host-question@1")
&& context.purpose !== "task_execution" && context.learningMode !== "review");
if (operation === "policy") {
const requested = context && enabled ? await options.interactions.getForTurn(context, context.hostTurnId!) : null;
res.json({ enabled, waiting: Boolean(requested) }); return;
}
const input = call.parse({ session_id: body.session_id, call_id: "call_id" in body ? body.call_id : "", tool: "tool" in body ? body.tool : "" });
if (!context) { res.status(403).json({ message: "No authorized host turn is bound to this session" }); return; }
if (!enabled || !context) {
if (input.tool === "ask_user") { res.status(403).json({ message: "Host questions are not enabled for this turn" }); return; }
res.json({ allowed: true }); return;
}
let state = calls.get(context.sessionId);
if (!state || state.turn !== context.hostTurnId) { state = { turn: context.hostTurnId!, active: new Map() }; calls.set(context.sessionId, state); }
if (operation === "leave") { state.active.delete(input.call_id); res.json({ allowed: true }); return; }
if (context.hostQuestionClosed) { res.status(409).json({ message: "The owning turn has been stopped" }); return; }
if (operation === "create") {
if (input.tool !== "ask_user" || state.active.get(input.call_id) !== "ask_user" || !("questions" in body)) {
res.status(409).json({ message: "Question tool has not acquired the turn" }); return;
}
const previous = await options.interactions.getBlocking(context);
if (previous?.delivery && options.runtime && await options.runtime.agentMessageState(context.sessionId, previous.delivery.native_message_id) === "delivered") {
await options.interactions.updateDelivery(context, previous.interaction_id, { expected_revision: previous.delivery.revision, status: "delivered", response_id: previous.delivery.response_id });
}
const record = await options.interactions.create(context, { message_id: context.hostMessageId!, turn_id: context.hostTurnId!, call_id: input.call_id, questions: body.questions });
res.json(record); return;
}
const blocking = await options.interactions.getBlocking(context);
const requested = await options.interactions.getForTurn(context, context.hostTurnId!);
if (requested || blocking?.status === "pending" || input.tool === "question" || [...state.active.values()].includes("ask_user")
|| (input.tool === "ask_user" && state.active.size > 0)) {
res.status(409).json({ message: "Finish other tools before asking; after asking, wait for the user" }); return;
}
state.active.set(input.call_id, input.tool);
res.json({ allowed: true });
} catch (error) {
if (error instanceof z.ZodError) { res.status(400).json({ message: "invalid interaction bridge request" }); return; }
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 503;
res.status(status).json({ message: "Interaction state is unavailable; no question was acknowledged" });
}
});
}
}
+9
View File
@@ -1,3 +1,5 @@
import { registerInteractionRequestRoutes } from "./interaction-request-routes.js";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { Router, type NextFunction, type Request, type Response } from "express";
import { agentModelOptions, resolveDefaultModel } from "../chat/models.js";
@@ -31,6 +33,7 @@ import { registerArtifactRoutes } from "./artifact-routes.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
export type BuildChatRouterOptions = {
interactionRequestStore?: InteractionRequestStore;
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
learningOrchestrator: LearningOrchestrator;
@@ -77,6 +80,8 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
models: agentModelOptions,
});
});
if (options.interactionRequestStore) registerInteractionRequestRoutes(chatRouter, { sessions: sessionMetadataStore, interactions: options.interactionRequestStore,
runtime, isRunning: (sessionId) => activeRuns.get(sessionId)?.status === "running" });
registerArtifactRoutes(chatRouter, { sessionMetadataStore, runtime });
chatRouter.get("/ui-registry", (_req, res) => {
@@ -131,6 +136,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
});
registerChatSessionRoutes(chatRouter, {
interactionRequestStore: options.interactionRequestStore,
learningOrchestrator,
frontendActionCoordinator,
runState,
@@ -145,6 +151,8 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
if (agentMessages) registerAgentMessageRoutes(chatRouter, { sessions: sessionMetadataStore, messages: agentMessages });
registerChatAuxiliaryRoutes(chatRouter, {
interactionRequestStore: options.interactionRequestStore,
runtime,
activeRuns,
lastRunStatuses,
sessionBridge,
@@ -162,6 +170,7 @@ export const buildChatRouter = (options: BuildChatRouterOptions) => {
registerResponseInputRoutes(chatRouter, { runtime, sessionMetadataStore, runState });
registerChatStreamRoute(chatRouter, {
interactionRequestStore: options.interactionRequestStore,
agentMessages,
frontendActionCoordinator,
learningOrchestrator,
+19
View File
@@ -1,5 +1,8 @@
import { type Router } from "express";
import { z } from "zod";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { getRuntimeSessionContext, setRuntimeSessionContext } from "../runtime/sessionContext.js";
import { getLocalAgentContext } from "../context/localContext.js";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
@@ -20,6 +23,8 @@ const abortPayloadSchema = z.object({
});
type RegisterAuxiliaryRoutesOptions = {
interactionRequestStore?: InteractionRequestStore;
runtime?: OpencodeRuntimeAdapter;
activeRuns: Map<string, ActiveRun>;
lastRunStatuses: Map<string, RunStatus>;
sessionBridge: ChatSessionBridge;
@@ -37,6 +42,8 @@ const toSessionUiStateContext = (record: { sessionId: string; actorKey: string;
export const registerChatAuxiliaryRoutes = (
chatRouter: Router,
{
interactionRequestStore,
runtime,
activeRuns,
lastRunStatuses,
sessionBridge,
@@ -70,6 +77,18 @@ export const registerChatAuxiliaryRoutes = (
return;
}
frontendActionCoordinator.cancelSession(sessionRecord.sessionId);
const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId);
if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true });
activeRuns.get(sessionRecord.sessionId)?.controller.abort();
await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord));
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord));
if (pendingInteraction?.delivery && runtime && interactionRequestStore) {
const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id);
const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id);
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.interaction_id, {
expected_revision: latest.delivery.revision, status,
});
}
activeRuns.get(sessionRecord.sessionId)?.inputs?.close();
const binding = await sessionBridge.abort({
clientSessionId: sessionRecord.sessionId,
+69 -281
View File
@@ -5,14 +5,12 @@ import { z } from "zod";
import { getLocalAgentContext, getTrustedProductCredential } from "../context/localContext.js";
import { logger } from "../logger.js";
import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { type SessionMetadataStore } from "../sessions/metadataStore.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
updateLastAssistantQuestion,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
@@ -37,32 +35,7 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore;
};
type ResolvedPendingQuestion = {
requestId: string;
question: NonNullable<ReturnType<ActiveRun["pendingQuestions"]["get"]>>;
};
const toSessionUiStateContext = (sessionId: string) => ({
sessionId,
});
const resolvePendingQuestion = (
run: ActiveRun,
requestId: string,
): ResolvedPendingQuestion | null => {
const exactQuestion = run.pendingQuestions.get(requestId);
if (exactQuestion) {
return { requestId, question: exactQuestion };
}
for (const [pendingRequestId, question] of run.pendingQuestions.entries()) {
if (question.tool?.callID === requestId) {
return { requestId: pendingRequestId, question };
}
}
return null;
};
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = (
chatRouter: Router,
@@ -213,265 +186,80 @@ export const registerChatInteractionRoutes = (
}
});
chatRouter.post("/question/:request_id/reply", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionReplyPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
const isRecord = (value: unknown): value is Record<string, unknown> => Boolean(value && typeof value === "object" && !Array.isArray(value));
const questionHandler = (action: "status" | "reply" | "reject") => async (req: import("express").Request, res: import("express").Response) => {
res.setHeader("Cache-Control", "private, no-store");
const requestId = typeof req.params.request_id === "string" ? req.params.request_id.trim() : undefined;
const parsed = action === "status"
? questionRejectPayloadSchema.strict().safeParse({ session_id: req.params.session_id })
: (action === "reply" ? questionReplyPayloadSchema.strict() : questionRejectPayloadSchema.strict()).safeParse(req.body);
if (!requestId || requestId.length > 128 || !parsed.success) { res.status(400).json({ code: "QUESTION_INVALID", message: "invalid question request" }); return; }
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
const session = await sessionMetadataStore.get(getLocalAgentContext(req), parsed.data.session_id);
if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; }
const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey };
const saved = await sessionUiStateStore.read(context);
const run = activeRuns.get(session.sessionId);
// Only host-persisted questions grant access to the native form API.
const candidates: Record<string, unknown>[] = [];
for (const message of saved?.messages ?? []) {
if (!isRecord(message)) continue;
const historical = Array.isArray(message.questions) ? message.questions : [];
const canonical = Array.isArray(message.parts) ? message.parts.flatMap((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) ? [{ ...part.data, requestId: part.data.request_id }] : []) : [];
for (const q of [...historical, ...canonical]) if (isRecord(q) && (q.requestId === requestId || (isRecord(q.tool) && q.tool.callID === requestId))) candidates.push(q);
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
const stored = candidates.find((q) => !isRecord(q.tool) || q.requestId !== q.tool.callID) ?? candidates[0];
if (!stored || typeof stored.requestId !== "string") { res.status(404).json({ code: "QUESTION_NOT_FOUND", message: "question request not found" }); return; }
const canonicalId = stored.requestId;
let confirmedStatus = ["answered", "rejected", "invalidated"].includes(String(stored.status)) ? String(stored.status) : undefined;
for (const message of saved?.messages ?? []) {
if (!isRecord(message) || !Array.isArray(message.parts)) continue;
for (const part of message.parts) {
if (isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId) confirmedStatus = typeof part.data.status === "string" ? part.data.status : part.data.rejected === true ? "rejected" : "answered";
}
}
const pendingQuestion = resolvePendingQuestion(run, requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
// A host-confirmed reply remains authoritative after native forms are pruned.
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
const persist = async (nextStatus: string, answers?: string[][]) => {
const update = (messages: unknown[]) => messages.map((message) => {
if (!isRecord(message)) return message;
const questions = Array.isArray(message.questions) ? message.questions.map((q: unknown) => isRecord(q) && q.requestId === canonicalId ? { ...q, status: nextStatus, ...(answers ? { answers } : {}), error: undefined } : q) : undefined;
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
if (parts && nextStatus !== "pending" && parts.some((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) && part.data.request_id === canonicalId)) {
const response = { type: "data-question_response", data: { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}) } };
const index = parts.findIndex((part: unknown) => isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId);
if (index >= 0) parts[index] = { ...parts[index], data: { ...(isRecord(parts[index]) && isRecord(parts[index].data) ? parts[index].data : {}), ...response.data } }; else parts.push(response);
}
return { ...message, ...(questions ? { questions } : {}), ...(parts ? { parts } : {}) };
});
// Update the live projection before entering the same atomic persistence queue.
if (run) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); }
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) }));
};
try {
await runtime.replyQuestion({
requestId: pendingQuestion.requestId,
sessionId: sessionRecord.sessionId,
answers: parsed.data.answers,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reply failed",
detail: error instanceof Error ? error.message : String(error),
});
if (action === "status" || status !== "pending") {
await persist(status);
res.status(action === "status" ? 200 : 409).json({ session_id: session.sessionId, request_id: canonicalId, status, ...(action !== "status" ? { code: "QUESTION_RESOLVED" } : {}) });
return;
}
run.pendingQuestions.delete(pendingQuestion.requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "answered",
answers: parsed.data.answers,
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
answers: parsed.data.answers,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
answers: parsed.data.answers,
});
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
await persist(action === "reply" ? "answered" : "rejected", answers);
for (const subscriber of run?.subscribers ?? []) subscriber.write("question_response", { session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reply route failed");
res.status(500).json({
message: "question reply route failed",
detail,
});
// Native connectivity failure is not proof that a historical question expired.
if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", message: "session not found" }); return; }
const tag = isRecord(error) ? error._tag : undefined;
if (tag === "FormInvalidAnswerError") { res.status(400).json({ code: "QUESTION_INVALID_ANSWER", message: "invalid question answer" }); return; }
if (tag === "FormAlreadySettledError" || tag === "FormNotFoundError") { res.status(409).json({ code: "QUESTION_RESOLVED", message: "question is no longer pending" }); return; }
logger.warn({ err: error }, "legacy question reconciliation unavailable");
res.status(503).json({ code: "QUESTION_UNAVAILABLE", message: "question state temporarily unavailable" });
}
});
chatRouter.post("/question/:request_id/reject", async (req, res) => {
const requestId = req.params.request_id?.trim();
const parsed = questionRejectPayloadSchema.safeParse(req.body);
if (!requestId) {
res.status(400).json({ message: "request_id is required" });
return;
}
if (!parsed.success) {
res.status(400).json({
message: "invalid request payload",
detail: parsed.error.flatten(),
});
return;
}
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
const userId = authContext.userId;
const actorKey = authContext.actorKey;
const projectKey = authContext.projectKey;
const sessionRecord = await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
parsed.data.session_id,
);
if (!sessionRecord) {
res.status(404).json({ message: "session not found" });
return;
}
const run = activeRuns.get(sessionRecord.sessionId);
if (!run) {
res.status(409).json({ message: "session is not waiting for questions" });
return;
}
const pendingQuestion = resolvePendingQuestion(run, requestId);
if (!pendingQuestion) {
res.status(404).json({ message: "question request not found" });
return;
}
const persistQuestionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
});
};
try {
await runtime.rejectQuestion({
requestId: pendingQuestion.requestId,
sessionId: sessionRecord.sessionId,
});
} catch (error) {
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reject question",
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question error state",
);
});
res.status(502).json({
message: "question reject failed",
detail: error instanceof Error ? error.message : String(error),
});
return;
}
run.pendingQuestions.delete(pendingQuestion.requestId);
run.messages = updateLastAssistantQuestion(
run.messages,
pendingQuestion.requestId,
(question) => ({
...question,
status: "rejected",
repliedAt: Date.now(),
error: undefined,
}),
);
await persistQuestionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist question reject state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("question_response", {
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
rejected: true,
});
}
if (
run.status !== "running" &&
run.pendingPermissions.size === 0 &&
run.pendingQuestions.size === 0
) {
activeRuns.delete(sessionRecord.sessionId);
}
res.status(202).json({
session_id: pendingQuestion.question.session_id,
request_id: pendingQuestion.requestId,
rejected: true,
});
} catch (error) {
const detail = error instanceof Error ? error.message : String(error);
logger.error({ err: error }, "question reject route failed");
res.status(500).json({
message: "question reject route failed",
detail,
});
}
});
};
chatRouter.get("/session/:session_id/questions/:request_id", questionHandler("status"));
chatRouter.post("/question/:request_id/reply", questionHandler("reply"));
chatRouter.post("/question/:request_id/reject", questionHandler("reject"));
};
+19 -2
View File
@@ -5,6 +5,8 @@ import { type Router } from "express";
import { z } from "zod";
import { type ChatSessionBridge } from "../chat/sessionBridge.js";
import { projectInteractionReplyMessages } from "../chat/interaction-message-presentation.js";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { agentModelOptions } from "../chat/models.js";
import { config } from "../config.js";
import {
@@ -76,6 +78,7 @@ const forkPayloadSchema = z.object({
});
type RegisterChatSessionRoutesOptions = {
interactionRequestStore?: InteractionRequestStore;
learningOrchestrator: LearningOrchestrator;
frontendActionCoordinator: FrontendActionCoordinator;
runState: ChatRunState;
@@ -100,6 +103,7 @@ export const registerChatSessionRoutes = (
options: RegisterChatSessionRoutesOptions,
) => {
const {
interactionRequestStore,
frontendActionCoordinator,
learningOrchestrator,
runState,
@@ -113,6 +117,17 @@ export const registerChatSessionRoutes = (
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
if (!interactionRequestStore) return messages;
const ids = new Set(messages.flatMap((message) => {
if (!message || typeof message !== "object") return [];
const value = message as Record<string, unknown>;
return value.role === "user" && typeof value.id === "string" ? [value.id] : [];
}));
const replies = await interactionRequestStore.forMessages(toSessionUiStateContext(record), ids);
return projectInteractionReplyMessages(messages, replies);
};
const refreshCollaborationTitle = async (record: SessionRecord, credential: ProductCredential | null, knownTaskTitle?: string, signal?: AbortSignal) => {
if (readPurpose(record) !== "task_collaboration" || !record.taskId
|| !isGenericTaskCollaborationTitle(record.title)) return record;
@@ -603,8 +618,8 @@ export const registerChatSessionRoutes = (
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
try {
res.json(sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]),
...query.data }));
const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data });
res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
} catch (error) {
if (!(error instanceof HistoryCursorError)) throw error;
res.status(400).json({ message: error.message });
@@ -659,6 +674,7 @@ export const registerChatSessionRoutes = (
status: sessionRecord.status,
session_id: sessionRecord.sessionId,
...messagePayload,
messages: await withInteractionReplies(sessionRecord, messagePayload.messages),
execution_plans: state?.executionPlans ?? [],
parent_session_id: sessionRecord.parentSessionId,
is_streaming: activeRuns.get(sessionRecord.sessionId)?.status === "running",
@@ -709,6 +725,7 @@ export const registerChatSessionRoutes = (
toSse("state", {
session_id: sessionRecord.sessionId,
...messagePayload,
messages: await withInteractionReplies(sessionRecord, messagePayload.messages),
execution_plans: run ? undefined : state?.executionPlans,
is_streaming: run?.status === "running",
run_status: getSessionRunStatus(sessionRecord.sessionId),
+51 -2
View File
@@ -47,6 +47,7 @@ import {
normalizeTodoPriority,
normalizeTodoStatus,
normalizeToolParams,
sanitizeToolProgressInput,
normalizeToolStatus,
type PermissionRequestPayload,
type TodoUpdatePayload,
@@ -70,6 +71,8 @@ export {
export type ApprovalMode = "request" | "always";
type StreamPromptOptions = {
interactionMessageId?: string;
onHostQuestion?: (interactionId: string) => Promise<Record<string, unknown>>;
onPromptDispatched?: () => void;
onInboxEvent?: (type: string, data: { inboxID?: string; delivery?: "steer" | "queue" }) => void;
waitForUserInput?: () => Promise<boolean>;
@@ -98,6 +101,7 @@ type ProgressPayload = {
status: ProgressStatus;
title: string;
detail?: string;
tool?: { name: string; call_id?: string; input?: Record<string, unknown> };
};
const getPermissionTarget = (metadata: unknown) => {
@@ -158,10 +162,13 @@ export const streamPromptResponse = async ({
onPromptDispatched,
onInboxEvent,
waitForUserInput,
interactionMessageId,
onHostQuestion,
}: StreamPromptOptions): Promise<{
aborted: boolean;
failed: boolean;
toolCallCount: number;
waitingInput?: boolean;
}> => {
const eventStream = await runtime.subscribeEvents();
const iterator = eventStream[Symbol.asyncIterator]();
@@ -191,6 +198,7 @@ export const streamPromptResponse = async ({
let promptSettled = false;
let aborted = signal?.aborted ?? false;
let failed = false;
let waitingInput = false;
let finalAnswer: string | undefined;
const presentedAnswerIds = new Set<string>();
let finalAnswerUiBlockIds: string[] = [];
@@ -227,7 +235,7 @@ export const streamPromptResponse = async ({
})
: null;
const emitProgress = ({ id, phase, status, title, detail }: ProgressPayload) => {
const emitProgress = ({ id, phase, status, title, detail, tool }: ProgressPayload) => {
if (status === "running" && finalizedProgressIds.has(id)) {
return;
}
@@ -246,6 +254,7 @@ export const streamPromptResponse = async ({
status,
title,
detail,
tool,
started_at: startedAt,
elapsed_ms: Math.max(0, now - startedAt),
});
@@ -262,6 +271,7 @@ export const streamPromptResponse = async ({
status,
title,
detail,
tool,
started_at: startedAt,
ended_at: now,
duration_ms: durationMs,
@@ -278,7 +288,15 @@ export const streamPromptResponse = async ({
const promptPromise = Promise.resolve().then(async () => {
await beforePrompt?.();
await runtime.prompt(sessionId, message, toRuntimeModel(model));
if (interactionMessageId) {
const result = await runtime.continueInteraction(sessionId, interactionMessageId, message, toRuntimeModel(model), signal);
if (result === "completed") {
const recovered = await runtime.readInteractionResult(sessionId, interactionMessageId);
tokenSmoother.writeToken(recovered);
emittedText = true;
done = true;
}
} else await runtime.prompt(sessionId, message, toRuntimeModel(model));
onPromptDispatched?.();
})
.then(() => {
@@ -749,6 +767,30 @@ export const streamPromptResponse = async ({
});
}
if (part.type === "tool") {
if (part.tool === "ask_user") {
// Reserve every lifecycle state before generic tool bookkeeping:
// a running call must not consume its completed-question identity.
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
const output = parseToolOutput(part.state.output);
if (!onHostQuestion || !isObjectRecord(output) || typeof output.interaction_id !== "string") {
throw new Error("Host question completed without a valid persisted interaction");
}
emittedToolParts.add(part.id);
const record = await onHostQuestion(output.interaction_id);
write("interaction_request", { ...record, session_id: clientSessionId });
await runtime.abortSession(sessionId);
await runtime.waitForSessionIdle(sessionId, undefined, true);
waitingInput = true;
done = true;
} else if (part.state.status === "error") {
emitProgress({
id: part.id, phase: "tool", status: "error",
title: getToolProgressTitle(part.tool, part.state.status),
detail: part.state.error,
});
}
continue;
}
if (part.tool === "present_answer") {
if (suppressAnswerUi) continue;
if (part.state.status === "completed" && !emittedToolParts.has(part.id)) {
@@ -863,6 +905,7 @@ export const streamPromptResponse = async ({
phase: "tool",
status: normalizeToolStatus(part.state.status),
title: getToolProgressTitle(part.tool, part.state.status),
tool: { name: part.tool, call_id: part.callID, input: sanitizeToolProgressInput(toolParams) },
detail: buildToolProgressDetail(
part.tool,
part.state.status,
@@ -1035,6 +1078,12 @@ export const streamPromptResponse = async ({
}
await promptPromise;
if (waitingInput) {
await tokenSmoother.flush();
emitProgress({ id: "request-completed", phase: "question", status: "completed", title: "等待你的回答", detail: "问题已保存,可以切换会话后继续回答。" });
write("done", { session_id: clientSessionId, reason: "waiting_input" });
return { aborted: false, failed: false, toolCallCount, waitingInput: true };
}
if (!emittedText && !finalAnswer) {
logDevelopmentDebug("no streamed text emitted, falling back to messages()", {
...debugContext,
+39 -4
View File
@@ -1,5 +1,6 @@
import type { Event as OpencodeEvent, Part } from "@opencode-ai/sdk/v2";
import { containsForbiddenPersistentContent } from "../utils/persistencePolicy.js";
import { logger } from "../logger.js";
import { type QuestionAnswers } from "../runtime/opencode.js";
@@ -61,8 +62,8 @@ const isDevelopmentDebugLoggingEnabled = process.env.NODE_ENV === "development";
const toolLabels: Record<string, string> = {
clear_scada_analysis: "清除 SCADA 地图分析",
memory_manager: "记忆写入",
render_scada_analysis: "渲染 SCADA 地图分析",
memory_manager: "记忆写入",
session_search: "历史会话检索",
skill_manager: "流程沉淀",
};
@@ -356,6 +357,40 @@ export const normalizeToolStatus = (status: string) => {
return "running";
};
/** Browser-visible tool inputs: retain useful values without credentials or large payloads. */
export const sanitizeToolProgressInput = (params: Record<string, unknown>): Record<string, unknown> => {
let remaining = 16_000;
let nodes = 512;
const visit = (value: unknown, depth: number): unknown => {
if (remaining <= 0 || nodes-- <= 0) return "[truncated]";
if (typeof value === "string") {
if (containsForbiddenPersistentContent(value)) return "[redacted]";
const limit = Math.min(remaining, 4_000);
remaining -= Math.min(value.length, limit);
return value.length > limit ? `${value.slice(0, limit)}… [truncated]` : value;
}
if (value === null || typeof value === "boolean" || typeof value === "number") return value;
if (depth >= 5) return "[truncated]";
if (Array.isArray(value)) {
const items = value.slice(0, 64).map((item) => visit(item, depth + 1));
if (value.length > 64) items.push("[truncated]");
return items;
}
if (value && typeof value === "object") {
const entries = Object.entries(value).slice(0, 64).map(([rawKey, item]) => {
const key = rawKey.slice(0, 100);
remaining -= key.length;
return [key, /authorization|cookie|password|secret|token|api[_-]?key|credential|headers?/i.test(key)
? "[redacted]" : visit(item, depth + 1)];
});
if (Object.keys(value).length > 64) entries.push(["…", "[truncated]"]);
return Object.fromEntries(entries);
}
return "[unsupported]";
};
return visit(params, 0) as Record<string, unknown>;
};
const formatProgressValue = (value: unknown): string => {
if (typeof value === "string") {
return value.length > 120 ? `${value.slice(0, 117)}...` : value;
@@ -435,11 +470,11 @@ export const buildToolProgressDetail = (
error?: string,
) => {
const toolName = toolLabels[tool] ?? tool;
const reasonText = reason ? `;说明:${reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(params)}`;
const reasonText = reason ? `;说明:${containsForbiddenPersistentContent(reason) ? "[redacted]" : reason}` : "";
const paramsText = `;关键参数:${summarizeToolParams(sanitizeToolProgressInput(params))}`;
if (status === "error") {
const errorText = error ? `;错误:${error}` : "";
const errorText = error ? `;错误:${containsForbiddenPersistentContent(error) ? "[redacted]" : error}` : "";
return `${toolName} 调用失败${reasonText}${paramsText}${errorText}`;
}
if (status === "completed") {
+108 -12
View File
@@ -1,5 +1,9 @@
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker } from "../chat/response-inputs.js";
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js";
import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.js";
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
@@ -158,9 +162,9 @@ export const toCollaborationMessages = (messages: UIMessage[]) =>
messages.flatMap((message) => {
if (message.role !== "user" && message.role !== "assistant") return [];
const content = readUiMessageText(message);
const reference = message.parts.filter((part) => part.type === "data-task_context");
const reference = message.parts.filter((part) => part.type === "data-task_context" || part.type === "data-interaction_reply");
const rich = message.parts.some((part) => part.type === "data-answer_ui" || part.type === "data-final_answer");
const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference];
const parts = rich ? message.parts.filter((part) => ["text", "data-answer_ui", "data-final_answer", "data-task_context", "data-interaction_reply"].includes(part.type)) : [{ type: "text" as const, text: content }, ...reference];
return content || rich ? [{ ...message, parts } as UIMessage] : [];
});
@@ -248,6 +252,7 @@ export const buildFailedTaskExecutionPlan = (history: Record<string, unknown>[])
};
type RegisterChatStreamRouteOptions = {
interactionRequestStore?: InteractionRequestStore;
agentMessages?: AgentMessageCoordinator;
frontendActionCoordinator: FrontendActionCoordinator;
learningOrchestrator: LearningOrchestrator;
@@ -263,6 +268,7 @@ type RegisterChatStreamRouteOptions = {
export const registerChatStreamRoute = (
chatRouter: Router,
{
interactionRequestStore,
frontendActionCoordinator,
learningOrchestrator,
memoryStore,
@@ -276,6 +282,7 @@ export const registerChatStreamRoute = (
}: RegisterChatStreamRouteOptions,
) => {
const { activeRuns, lastRunStatuses } = runState;
const waitingInputs = new WaitingResponseInputQueue(runtime);
const startingSessions = new Set<string>();
chatRouter.post("/responses", async (req, res) => {
const request = responseRequestSchema.safeParse(req.body);
@@ -300,6 +307,9 @@ export const registerChatStreamRoute = (
let wire: ReturnType<typeof createResponsesProjection> | undefined;
let boundOperationalSessionId: string | undefined;
let startingSessionId: string | undefined;
let continuation: InteractionRequest | null = null;
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined;
let waitingInteraction: InteractionRequest | null = null;
try {
const authContext = getLocalAgentContext(req);
const projectId = authContext.projectId;
@@ -311,7 +321,7 @@ export const registerChatStreamRoute = (
const requestUiMessages = toUiMessages(parsed.data.messages);
const requestPromptText =
parsed.data.message?.trim() || readLatestUserText(requestUiMessages);
const promptText = trigger
let promptText = trigger
? buildAgentTriggerPrompt(trigger)
: requestPromptText;
if (!promptText) {
@@ -348,8 +358,49 @@ export const registerChatStreamRoute = (
res.status(409).json({ message: "trigger does not match session" });
return;
}
if (input.tjwater.interaction_id && (!existingSessionRecord || !interactionRequestStore || trigger || input.tjwater.delivery || input.tjwater.input_id)) {
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
}
if (existingSessionRecord && interactionRequestStore) {
continuationScope = toSessionUiStateContext(existingSessionRecord);
if (input.tjwater.interaction_id) {
continuation = await interactionRequestStore.get(continuationScope, input.tjwater.interaction_id);
if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") {
res.status(409).json({ code: "INTERACTION_NOT_READY", message: "Question has no answer waiting to continue" }); return;
}
promptText = renderInteractionAnswer(continuation);
}
const blocking = await interactionRequestStore.getBlocking(continuationScope);
waitingInteraction = blocking ? await reconcileInteractionDelivery(continuationScope, blocking, interactionRequestStore, runtime,
activeRuns.get(existingSessionRecord.sessionId)?.status === "running") : null;
if (continuation && waitingInteraction?.interaction_id === continuation.interaction_id) continuation = waitingInteraction;
if (waitingInteraction?.delivery?.status === "delivered") waitingInteraction = null;
if (waitingInteraction && waitingInteraction.interaction_id !== continuation?.interaction_id
&& !(input.tjwater.delivery && input.tjwater.input_id && !input.stream && !trigger && readPurpose(existingSessionRecord) !== "task_execution")) {
res.status(409).json({ code: "INTERACTION_PENDING", message: "请先回答或取消当前提问。" }); return;
}
if (continuation?.delivery?.status === "delivered") {
// An acknowledged retry must not append or reorder persisted conversation messages.
const text = await runtime.readInteractionResult(existingSessionRecord.sessionId, continuation.delivery.native_message_id);
if (input.stream) {
res.setHeader("Content-Type", "text/event-stream; charset=utf-8");
res.setHeader("Cache-Control", "private, no-store");
}
const replay = createResponsesProjection({ model: parsed.data.model ?? config.OPENCODE_MODEL, conversationId: existingSessionRecord.sessionId,
emit: (event) => { if (input.stream) res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`); },
});
replay.write({ type: "start", messageId: `answer_${continuation.delivery.native_message_id}` });
replay.write({ type: "text-start", id: "recovered-answer" });
replay.write({ type: "text-delta", id: "recovered-answer", delta: text });
replay.write({ type: "text-end", id: "recovered-answer" });
replay.finish();
if (input.stream) res.end(); else res.json(replay.response);
return;
}
}
const running = existingSessionRecord ? activeRuns.get(existingSessionRecord.sessionId) : undefined;
if (running?.status === "running") {
if (continuation) { res.status(409).json({ code: "INTERACTION_TURN_BUSY", message: "答案已保存,请等待当前轮次结束后继续。" }); return; }
if (!input.tjwater.delivery || !input.tjwater.input_id || input.stream || trigger || readPurpose(existingSessionRecord!) === "task_execution") {
res.status(409).json({ message: "Running conversations require a non-streaming steer/queue input with input_id" });
return;
@@ -362,8 +413,16 @@ export const registerChatStreamRoute = (
res.status(202).json({ object: "tjwater.response_input", session_id: existingSessionRecord!.sessionId, ...receipt });
return;
}
if (waitingInteraction && !continuation && existingSessionRecord && input.tjwater.delivery && input.tjwater.input_id && !input.stream && !trigger
&& readPurpose(existingSessionRecord) !== "task_execution") {
const receipt = await waitingInputs.submit(existingSessionRecord.sessionId, {
id: input.tjwater.input_id, text: promptText, delivery: input.tjwater.delivery, status: "pending"
});
res.status(202).json({ object: "tjwater.response_input", session_id: existingSessionRecord.sessionId, ...receipt });
return;
}
if (input.tjwater.delivery || input.tjwater.input_id) {
res.status(409).json({ message: "The response has finished; send a new response instead" });
res.status(409).json({ code: "RESPONSE_FINISHED", message: "The response has finished; send a new response instead" });
return;
}
if (
@@ -386,6 +445,7 @@ export const registerChatStreamRoute = (
}
const { binding, requestContext, created } = await sessionBridge.resolve({
preserveExecution: Boolean(continuation),
sessionId: requestedSessionId,
network: authContext.network,
projectId,
@@ -458,7 +518,7 @@ export const registerChatStreamRoute = (
toSessionUiStateContext(activeSessionRecord),
);
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
res.status(409).json({ message: "client_message_id already exists in this conversation" });
return;
}
@@ -469,7 +529,14 @@ export const registerChatStreamRoute = (
const visibleRequestMessages = sessionPurpose === "task_collaboration"
? toCollaborationMessages(requestUiMessages)
: requestUiMessages;
const requestMessages =
const continuationAssistantId = continuation ? `answer_${continuation.delivery!.native_message_id}` : undefined;
const continuationUserExists = continuation && visiblePersistedMessages.some((message) => message.id === continuation!.delivery!.native_message_id);
const continuationReply = continuation ? createInteractionReplyMessage(continuation) : undefined;
const requestMessages = continuationReply
? [...visiblePersistedMessages.filter((message) => message.id !== continuationAssistantId)
.map((message) => message.id === continuationReply.id ? { ...message, ...continuationReply } : message),
...(continuationUserExists ? [] : [continuationReply])]
:
visibleRequestMessages.length > 0
? visibleRequestMessages
: [
@@ -531,7 +598,8 @@ export const registerChatStreamRoute = (
startingSessionId = undefined;
lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
const assistantUiMessageId = `msg_${randomUUID().replaceAll("-", "")}`;
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
const syncLatestUiMessages = () => {
latestUiMessages = [
...requestMessages,
@@ -637,14 +705,14 @@ export const registerChatStreamRoute = (
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = "completed";
lastRunStatuses.set(clientSessionId, "completed");
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
@@ -903,7 +971,18 @@ export const registerChatStreamRoute = (
publish("user_input", { session_id: clientSessionId, ...receipt });
});
activeRun.inputs = inputTracker;
if (continuation && interactionRequestStore && continuationScope && continuation.delivery?.status !== "delivered") {
continuation = await interactionRequestStore.claimDelivery(continuationScope, continuation.interaction_id, {
expected_revision: continuation.delivery!.revision, response_id: wire.response.id,
});
}
const streamResult = await streamPromptResponse({
interactionMessageId: continuation?.delivery?.native_message_id,
onHostQuestion: interactionRequestStore ? async (id) => {
const record = await interactionRequestStore.get(sessionUiStateContext, id);
if (!record || record.turn_id !== wire!.response.id || record.message_id !== assistantUiMessageId) throw new Error("Question does not belong to this turn");
return { ...record };
} : undefined,
runtime,
sessionId: binding.sessionId,
clientSessionId,
@@ -1021,7 +1100,16 @@ export const registerChatStreamRoute = (
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
if (!streamResult.aborted && !streamResult.failed) {
if (continuation && interactionRequestStore && continuationScope) continuation = await interactionRequestStore.get(continuationScope, continuation.interaction_id);
if (continuation && interactionRequestStore && continuationScope && continuation.delivery && !["delivered", "cancelled"].includes(continuation.delivery.status)) {
const nativeState = await runtime.agentMessageState(binding.sessionId, continuation.delivery!.native_message_id);
continuation = await interactionRequestStore.updateDelivery(continuationScope, continuation.interaction_id, {
expected_revision: continuation.delivery!.revision, status: nativeState === "delivered" ? "delivered" : "blocked",
response_id: wire.response.id, ...(nativeState !== "delivered" ? { error_code: "CONTINUATION_NOT_CONSUMED" } : {}),
});
publish("interaction_request", { ...continuation, session_id: clientSessionId });
}
if (!streamResult.aborted && !streamResult.failed && !streamResult.waitingInput) {
const messages = await runtime.messages(binding.sessionId, 60);
const assistantMessage = [...messages]
.reverse()
@@ -1188,6 +1276,14 @@ export const registerChatStreamRoute = (
if (boundOperationalSessionId) {
releaseOperationalTaskCredential(boundOperationalSessionId);
}
if (continuation && continuationScope && interactionRequestStore) {
try {
const latest = await interactionRequestStore.get(continuationScope, continuation.interaction_id);
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(continuationScope, latest.interaction_id, {
expected_revision: latest.delivery.revision, status: "blocked", error_code: "CONTINUATION_UNCONFIRMED", response_id: latest.delivery.response_id,
});
} catch { /* Keep the durable input identity even when state storage is unavailable. */ }
}
logger.error({ err: error }, "chat stream failed");
if (wire) {
wire.write({ type: "error", errorText: "Agent response failed" });
+6 -1
View File
@@ -7,7 +7,7 @@ import {
type TodoUpdatePayload,
} from "./chatStream.js";
export type RunStatus = "running" | "completed" | "error" | "aborted";
export type RunStatus = "running" | "waiting_input" | "completed" | "error" | "aborted";
export type StreamSubscriber = {
write: (event: string, data: Record<string, unknown>) => void;
@@ -82,8 +82,13 @@ export const upsertBackendProgress = (
const next = Array.isArray(progress) ? [...progress] : [];
const id = typeof payload.id === "string" ? payload.id : `progress-${Date.now()}`;
const index = next.findIndex((item) => isObjectRecord(item) && item.id === id);
const existing = index >= 0 && isObjectRecord(next[index]) ? next[index] : undefined;
const tool = isObjectRecord(payload.tool) && typeof payload.tool.name === "string"
? { name: payload.tool.name, callId: typeof payload.tool.call_id === "string" ? payload.tool.call_id : undefined, input: isObjectRecord(payload.tool.input) ? payload.tool.input : undefined }
: existing?.tool;
const nextItem = {
id,
tool,
phase: typeof payload.phase === "string" ? payload.phase : "progress",
status:
payload.status === "completed" || payload.status === "error"
+60
View File
@@ -0,0 +1,60 @@
import type { Request, Response, Router } from "express";
import { z } from "zod";
import { getLocalAgentContext } from "../context/localContext.js";
import { InteractionRequestError, type InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import type { OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-reconciliation.js";
const identifier = z.string().min(1).max(128);
const listQuerySchema = z.object({
limit: z.string().regex(/^[1-9]\d?$/).transform(Number).pipe(z.number().max(50)).optional(),
cursor: identifier.optional(),
}).strict();
type Options = { sessions: SessionMetadataStore; interactions: InteractionRequestStore;
runtime?: Pick<OpencodeRuntimeAdapter, "agentMessageState" | "readInteractionResult">;
isRunning?: (sessionId: string) => boolean };
/** These routes only persist user intent. Authorized /responses owns model continuation. */
export function registerInteractionRequestRoutes(router: Router, options: Options) {
const handle = (operation: "list" | "get" | "draft" | "reply" | "skip" | "cancel" | "resume") => async (req: Request, res: Response) => {
res.setHeader("Cache-Control", "private, no-store");
try {
const sessionId = identifier.parse(req.params.session_id);
const session = await options.sessions.get(getLocalAgentContext(req), sessionId);
if (!session) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; }
const context = { sessionId: session.sessionId, actorKey: session.actorKey, projectKey: session.projectKey };
if (operation === "list") {
const page = await options.interactions.list(context, listQuerySchema.parse(req.query));
const items = await Promise.all(page.items.map((record) => reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId))));
res.json({ ...page, items });
return;
}
const interactionId = identifier.parse(req.params.interaction_id);
if (operation === "get") {
if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400);
const record = await options.interactions.get(context, interactionId);
if (!record) { res.status(404).json({ code: "interaction_not_found", detail: "interaction not found" }); return; }
res.json(await reconcileInteractionDelivery(context, record, options.interactions, options.runtime, options.isRunning?.(sessionId)));
return;
}
if (Object.keys(req.query).length) throw new InteractionRequestError("invalid_query", 400);
const record = operation === "draft"
? await options.interactions.updateDraft(context, interactionId, req.body)
: await options.interactions[operation](context, interactionId, req.body);
// A queued or blocked delivery is a successful saved answer, never an inference error.
res.json(record);
} catch (error) {
if (error instanceof z.ZodError) { res.status(400).json({ code: "INTERACTION_INVALID", detail: "invalid interaction request" }); return; }
if (error instanceof InteractionRequestError) { res.status(error.status).json({ code: error.code, detail: error.message, ...(error.current ? { current: error.current } : {}) }); return; }
if (error instanceof SessionContextMismatchError) { res.status(404).json({ code: "SESSION_NOT_FOUND", detail: "session not found" }); return; }
res.status(503).json({ code: "INTERACTION_UNAVAILABLE", detail: "interaction unavailable" });
}
};
const base = "/session/:session_id/interactions";
router.get(base, handle("list"));
router.get(`${base}/:interaction_id`, handle("get"));
router.put(`${base}/:interaction_id/draft`, handle("draft"));
for (const operation of ["reply", "skip", "cancel", "resume"] as const) router.post(`${base}/:interaction_id/${operation}`, handle(operation));
}