refactor: consolidate chat state and task bridges

Centralize session scope, chat projections and typed task bridges.
Keep historical interaction replies authoritative and publish terminal state to subscribers. Validate Server contracts before replacing generated artifacts.
This commit is contained in:
2026-11-09 17:55:00 +08:00
parent addc81e106
commit 6279a4b555
31 changed files with 849 additions and 522 deletions
+221
View File
@@ -0,0 +1,221 @@
import type { UIMessage, UIMessageChunk } from "ai";
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import type { PermissionRequestPayload, QuestionRequestPayload, TodoUpdatePayload } from "./chatStream.js";
import {
type ActiveRun, type RunStatus, updateLastAssistantMessage, applyInteractionResponse,
completeBackendProgress, cancelBackendTodos, upsertBackendProgress,
toFrontendPermission, upsertBackendQuestion, upsertBackendTodoUpdate,
appendBackendToolArtifact, appendBackendUiEnvelope,
} from "./chatUiState.js";
/** Compatibility fields are a projection of the same accepted event as canonical parts. */
function reduceRunEvent(activeRun: ActiveRun, event: string, data: Record<string, unknown>, onStatus: (status: RunStatus) => void) {
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
onStatus(activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error" || event === "aborted") {
activeRun.status = event === "aborted" || activeRun.status === "aborted" ? "aborted" : "error";
onStatus(activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && (reply || data.status === "error")) {
if (reply) activeRun.pendingPermissions.delete(requestId);
activeRun.messages = applyInteractionResponse(activeRun.messages, "permission_response", data);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = applyInteractionResponse(activeRun.messages, "question_response", data);
}
} else if (event === "execution_plan") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), data],
}));
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
}
}
/** All host mutations go through the accumulator before subscribers or persistence. */
export function publishRunEvent(run: ActiveRun, event: string, data: Record<string, unknown>, notify = true) {
if (run.applyEvent) run.applyEvent(event, data);
else reduceRunEvent(run, event, data, () => {});
if (notify) for (const subscriber of run.subscribers) {
if (event === "done" || event === "error" || event === "aborted") {
subscriber.write("run_status", { ...data, id: "run-status", status: run.status });
}
subscriber.write(event, data);
}
}
/** One event owner updates the reconnect view and canonical wire/storage projections. */
export function createChatRunAccumulator(options: {
run: ActiveRun;
messages: UIMessage[];
messageId: string;
sessionId: string;
collaboration: boolean;
write: (chunk: UIMessageChunk) => void;
onStatus: (status: RunStatus) => void;
}) {
const activeRun = options.run;
let history = [...options.messages];
const syncParts = () => {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, id: options.messageId, parts: projection.parts.map((part) => ({ ...part })),
}));
};
const projection = createAnswerStreamProjection({
messageId: options.messageId, sessionId: options.sessionId,
write: options.write, onChange: syncParts,
});
const apply = (event: string, data: Record<string, unknown>) => {
reduceRunEvent(activeRun, event, data, options.onStatus);
let historicalResponse = false;
if (event === "permission_response" || event === "question_response") {
const updated = applyInteractionResponse(history, event, data);
historicalResponse = updated !== history;
history = updated;
if (historicalResponse) {
const byId = new Map(history.map((message) => [message.id, message]));
activeRun.messages = activeRun.messages.map((message) => {
if (!message || typeof message !== "object" || !("id" in message)) return message;
const canonical = byId.get(String(message.id));
return canonical ? { ...message, parts: canonical.parts } : message;
});
}
}
const hidden = options.collaboration && ["progress", "todo_update", "tool_call", "ui_envelope", "execution_plan"].includes(event);
if (!hidden && !historicalResponse) {
if (event === "token") projection.appendText(typeof data.content === "string" ? data.content : "");
else if (event === "done" || event === "error" || event === "aborted") {
const message = typeof data.message === "string" ? data.message : event === "aborted" ? "请求已中断" : "Agent stream failed";
if (!projection.parts.some((part) => ["text", "data-final_answer", "data-answer_ui"].includes(String(part.type)))) {
const assistant = activeRun.messages.at(-1) as { content?: string } | undefined;
projection.appendText(assistant?.content || message);
}
projection.recordData("run_status", { id: "run-status", status: activeRun.status, ...(event === "done" ? {} : { message }) });
if (event !== "done" && !projection.finished) options.write({ type: "error", errorText: message });
projection.finish(event === "done" ? "stop" : "error");
} else if (["permission_response", "question_response"].includes(event)) projection.recordData(event, data);
else projection.appendData(event, data);
}
syncParts();
};
const uiMessages = (): UIMessage[] => [...history, {
id: options.messageId, role: "assistant", parts: projection.parts.map((part) => ({ ...part })),
} as UIMessage];
activeRun.applyEvent = apply;
activeRun.uiMessages = uiMessages;
return {
apply,
finish: projection.finish,
get finished() { return projection.finished; },
uiMessages,
appendInput(message: UIMessage, text: string) {
if (history.some((item) => item.id === message.id)) return;
history.push(message);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: message.id, role: "user", content: text }, ...(tail ? [tail] : [])];
},
};
}
+12 -25
View File
@@ -1,3 +1,5 @@
import { publishRunEvent } from "./chat-run-accumulator.js";
import { toSessionScope } from "../sessions/session-scope.js";
import { type Router } from "express";
import { z } from "zod";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
@@ -13,9 +15,6 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
import {
type ActiveRun,
type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js";
const abortPayloadSchema = z.object({
@@ -33,11 +32,6 @@ type RegisterAuxiliaryRoutesOptions = {
frontendActionCoordinator: FrontendActionCoordinator;
};
const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
export const registerChatAuxiliaryRoutes = (
chatRouter: Router,
@@ -80,12 +74,12 @@ export const registerChatAuxiliaryRoutes = (
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));
await interactionRequestStore?.cancelPending(toSessionScope(sessionRecord));
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionScope(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, {
const latest = await interactionRequestStore.get(toSessionScope(sessionRecord), pendingInteraction.interaction_id);
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionScope(sessionRecord), latest.interaction_id, {
expected_revision: latest.delivery.revision, status,
});
}
@@ -99,28 +93,21 @@ export const registerChatAuxiliaryRoutes = (
run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
publishRunEvent(run, "aborted", { session_id: parsed.data.session_id, message: "请求已中断" }, false);
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
toSessionScope(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
...currentState,
messages: run.uiMessages?.() ?? run.messages,
});
for (const subscriber of run.subscribers) {
subscriber.write("error", {
session_id: parsed.data.session_id,
message: "请求已中断",
status: "aborted",
});
}
// The generation route emits its terminal Response after cleanup and
+22 -51
View File
@@ -1,3 +1,5 @@
import { publishRunEvent } from "./chat-run-accumulator.js";
import { toSessionScope } from "../sessions/session-scope.js";
import { refreshSessionCredential } from "../product/credential-refresh.js";
import { type Router } from "express";
import { z } from "zod";
@@ -8,9 +10,7 @@ import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import {
type ActiveRun,
toPermissionStatus,
updateLastAssistantPermission,
type ActiveRun, applyInteractionResponse,
} from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({
@@ -35,7 +35,6 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = (
chatRouter: Router,
@@ -104,12 +103,13 @@ export const registerChatInteractionRoutes = (
}
const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId),
toSessionScope(sessionRecord),
);
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages,
...currentState,
messages: run.uiMessages?.() ?? run.messages,
});
};
@@ -121,18 +121,10 @@ export const registerChatInteractionRoutes = (
message: parsed.data.message,
});
} catch (error) {
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
publishRunEvent(run, "permission_response", {
session_id: sessionRecord.sessionId, request_id: requestId, status: "error",
error: error instanceof Error ? error.message : "failed to reply permission",
});
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
@@ -146,30 +138,15 @@ export const registerChatInteractionRoutes = (
return;
}
run.pendingPermissions.delete(requestId);
const status = toPermissionStatus(parsed.data.reply);
run.messages = updateLastAssistantPermission(
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
publishRunEvent(run, "permission_response", {
session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply,
});
await persistPermissionState().catch((persistError) => {
logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state",
);
});
for (const subscriber of run.subscribers) {
subscriber.write("permission_response", {
session_id: sessionRecord.sessionId,
request_id: requestId,
reply: parsed.data.reply,
});
}
res.status(202).json({
session_id: sessionRecord.sessionId,
@@ -222,20 +199,15 @@ export const registerChatInteractionRoutes = (
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 } : {}) };
});
const response = {
session_id: session.sessionId, request_id: canonicalId, status: nextStatus,
rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}),
};
// 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) }));
if (run && nextStatus !== "pending") publishRunEvent(run, "question_response", response);
await sessionUiStateStore.update(context, (latest) => ({
...latest, messages: applyInteractionResponse(latest.messages, "question_response", response),
}));
};
if (action === "status" || status !== "pending") {
await persist(status);
@@ -246,7 +218,6 @@ export const registerChatInteractionRoutes = (
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) {
-21
View File
@@ -174,27 +174,6 @@ export const generateSessionTitle = async (
}
};
export const getConversationTurnStats = async (
runtime: OpencodeRuntimeAdapter,
sessionId: string,
) => {
const messages = await runtime.messages(sessionId, 12);
return messages.reduce(
(stats, message) => {
if (message.info.role === "user") {
stats.userMessageCount += 1;
} else if (message.info.role === "assistant") {
stats.assistantMessageCount += 1;
}
return stats;
},
{
userMessageCount: 0,
assistantMessageCount: 0,
},
);
};
export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore,
actorKey: string,
+24 -25
View File
@@ -1,3 +1,4 @@
import { toSessionScope } from "../sessions/session-scope.js";
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
import { matchHistoryText } from "../sessions/history-search.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
@@ -107,11 +108,6 @@ type RegisterChatSessionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore;
};
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -176,6 +172,12 @@ export const registerChatSessionRoutes = (
const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus;
const readMessages = (record: SessionRecord, state: { messages: unknown[] } | null) => {
if (readPurpose(record) === "task_collaboration") return state?.messages ?? [];
const run = activeRuns.get(record.sessionId);
return run?.uiMessages?.() ?? run?.messages ?? state?.messages ?? [];
};
const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
if (!interactionRequestStore) return messages;
const ids = new Set(messages.flatMap((message) => {
@@ -183,7 +185,7 @@ export const registerChatSessionRoutes = (
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);
const replies = await interactionRequestStore.forMessages(toSessionScope(record), ids);
return projectInteractionReplyMessages(messages, replies);
};
@@ -192,7 +194,7 @@ export const registerChatSessionRoutes = (
|| !isGenericTaskCollaborationTitle(record.title)) return record;
if ((!credential && !knownTaskTitle) || signal?.aborted) return record;
try {
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
const state = await sessionUiStateStore.read(toSessionScope(record));
if (state?.isTitleManuallyEdited) return record;
let taskTitle = knownTaskTitle;
if (!taskTitle && credential) {
@@ -206,7 +208,7 @@ export const registerChatSessionRoutes = (
}
}
if (!taskTitle?.trim()) return record;
const latestState = await sessionUiStateStore.read(toSessionUiStateContext(record));
const latestState = await sessionUiStateStore.read(toSessionScope(record));
if (latestState?.isTitleManuallyEdited) return record;
return await sessionMetadataStore.touch(record, { title: taskTitle }, {
preserveUpdatedAt: true, expectedTitle: record.title ?? "",
@@ -297,7 +299,7 @@ export const registerChatSessionRoutes = (
sessionId,
);
if (!record) return null;
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
const state = await sessionUiStateStore.read(toSessionScope(record));
if (!state) return null;
return { record, state, taskTitle };
};
@@ -603,7 +605,7 @@ export const registerChatSessionRoutes = (
const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` });
const messages = await withInteractionReplies(history.record, history.state.messages ?? []);
await sessionUiStateStore.write(
toSessionUiStateContext(titled),
toSessionScope(titled),
buildForkedSessionUiState({ messages }, {
keepMessageCount: messages.length,
targetSessionId: titled.sessionId,
@@ -771,8 +773,8 @@ export const registerChatSessionRoutes = (
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
for (const record of records) {
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionScope(record)) : null;
const messages = readMessages(record, state);
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
}
let page;
@@ -828,9 +830,8 @@ export const registerChatSessionRoutes = (
const context = getLocalAgentContext(req);
const record = await sessionMetadataStore.get(context, req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; }
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
const state = await sessionUiStateStore.read(toSessionScope(record));
const messages = readMessages(record, state);
try {
const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data });
res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
@@ -871,10 +872,9 @@ export const registerChatSessionRoutes = (
sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req));
const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord),
toSessionScope(sessionRecord),
);
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
const messages = readMessages(sessionRecord, state);
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
@@ -929,9 +929,8 @@ export const registerChatSessionRoutes = (
res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
: run?.messages ?? state?.messages ?? [];
const state = await sessionUiStateStore.read(toSessionScope(sessionRecord));
const messages = readMessages(sessionRecord, state);
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages };
@@ -1005,11 +1004,11 @@ export const registerChatSessionRoutes = (
title,
});
const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord),
toSessionScope(nextSessionRecord),
);
if (state) {
await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord),
toSessionScope(nextSessionRecord),
{
...state,
isTitleManuallyEdited:
@@ -1134,7 +1133,7 @@ export const registerChatSessionRoutes = (
}
const sourceState = sourceSessionRecord
? await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord),
toSessionScope(sourceSessionRecord),
)
: null;
const forkTitle = sourceSessionRecord?.title
@@ -1145,7 +1144,7 @@ export const registerChatSessionRoutes = (
{ title: forkTitle },
);
await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord),
toSessionScope(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId,
+23 -254
View File
@@ -1,3 +1,4 @@
import { toSessionScope } from "../sessions/session-scope.js";
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
@@ -5,7 +6,7 @@ import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-r
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 { createChatRunAccumulator, publishRunEvent } from "./chat-run-accumulator.js";
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express";
@@ -47,7 +48,6 @@ import {
} from "../product/operational-task-client.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import {
buildPromptWithLearningContext,
@@ -59,29 +59,14 @@ import {
} from "./chatSession.js";
import {
collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse,
type TodoUpdatePayload,
} from "./chatStream.js";
import { type ChatRunState } from "./chatRunState.js";
import {
type ActiveRun,
type StreamSubscriber,
appendBackendToolArtifact,
appendBackendUiEnvelope,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages,
isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js";
import {
agentTriggerContextSchema,
@@ -106,11 +91,6 @@ const payloadSchema = z.object({
}).strict().optional(),
});
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const runtimeHasConversation = async (
runtime: OpencodeRuntimeAdapter,
@@ -311,7 +291,7 @@ export const registerChatStreamRoute = (
let boundOperationalSessionId: string | undefined;
let startingSessionId: string | undefined;
let continuation: InteractionRequest | null = null;
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined;
let continuationScope: ReturnType<typeof toSessionScope> | undefined;
let waitingInteraction: InteractionRequest | null = null;
try {
const authContext = getLocalAgentContext(req);
@@ -365,7 +345,7 @@ export const registerChatStreamRoute = (
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
}
if (existingSessionRecord && interactionRequestStore) {
continuationScope = toSessionUiStateContext(existingSessionRecord);
continuationScope = toSessionScope(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") {
@@ -523,7 +503,7 @@ export const registerChatStreamRoute = (
sessionId: requestContext.clientSessionId,
};
const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord),
toSessionScope(activeSessionRecord),
);
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
@@ -551,7 +531,6 @@ export const registerChatStreamRoute = (
...visiblePersistedMessages,
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages);
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
@@ -605,44 +584,28 @@ export const registerChatStreamRoute = (
startingSessions.delete(activeSessionRecord.sessionId);
startingSessionId = undefined;
lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
const sessionUiStateContext = toSessionScope(activeSessionRecord);
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
const syncLatestUiMessages = () => {
latestUiMessages = [
...requestMessages,
{
id: assistantUiMessageId,
role: "assistant",
parts: assistantParts.map((part) => ({ ...part })),
} as UIMessage,
];
};
const writeUiChunk = (chunk: UIMessageChunk) => {
// Persist canonical parts after disconnect; the wire emitter checks the socket.
wire!.write(chunk);
};
const projection = createAnswerStreamProjection({
messageId: assistantUiMessageId, sessionId: clientSessionId,
write: writeUiChunk, onChange: syncLatestUiMessages,
const accumulator = createChatRunAccumulator({
run: activeRun, messages: requestMessages, messageId: assistantUiMessageId, sessionId: clientSessionId,
collaboration: sessionPurpose === "task_collaboration", write: (chunk) => wire!.write(chunk),
onStatus: (status) => lastRunStatuses.set(clientSessionId, status),
});
const assistantParts = projection.parts;
const writeTextDelta = projection.appendText;
const writeDataPart = projection.appendData;
const finishUiMessage = projection.finish;
const writeUiChunk = (chunk: UIMessageChunk) => wire!.write(chunk);
writeUiChunk({ type: "start", messageId: assistantUiMessageId });
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
});
const queueSessionUiStatePersist = () => {
const snapshot = {
executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
};
persistQueue = persistQueue
.catch((error) => {
@@ -654,36 +617,6 @@ export const registerChatStreamRoute = (
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue;
};
const primarySubscriber: StreamSubscriber = {
write: (event, data) => {
if (event === "token") {
writeTextDelta(typeof data.content === "string" ? data.content : "");
return;
}
if (event === "done") {
finishUiMessage("stop");
return;
}
if (event === "error") {
writeUiChunk({
type: "error",
errorText:
typeof data.message === "string"
? data.message
: "Agent stream failed",
});
finishUiMessage("error");
return;
}
writeDataPart(event, data);
},
close: () => {
if (!res.writableEnded && !res.destroyed) {
res.end();
}
},
};
activeRun.subscribers.add(primarySubscriber);
let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve();
const publish = (event: string, data: Record<string, unknown>) => {
@@ -697,131 +630,11 @@ export const registerChatStreamRoute = (
: assistantUiMessageId,
}
: data;
if (event === "token") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
isError: false,
}));
} else if (event === "final_answer") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message, content: typeof data.content === "string" ? data.content : message.content,
}));
} else if (event === "progress") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
progress: upsertBackendProgress(message.progress, data),
}));
} else if (event === "done") {
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
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
progress: completeBackendProgress(message.progress),
}));
} else if (event === "error") {
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
lastRunStatuses.set(clientSessionId, activeRun.status);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
} else if (event === "permission_request") {
const payload = data as PermissionRequestPayload;
activeRun.pendingPermissions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
permissions: [
...(Array.isArray(message.permissions) ? message.permissions : []),
toFrontendPermission(payload),
],
}));
} else if (event === "permission_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
const reply =
data.reply === "once" || data.reply === "always" || data.reply === "reject"
? data.reply
: undefined;
if (requestId && reply) {
activeRun.pendingPermissions.delete(requestId);
activeRun.messages = updateLastAssistantPermission(
activeRun.messages,
requestId,
(permission) => ({
...permission,
status: toPermissionStatus(reply),
repliedAt: Date.now(),
}),
);
}
} else if (event === "question_request") {
const payload = data as QuestionRequestPayload;
let shouldTrackQuestion = true;
if (payload.tool?.callID) {
if (payload.request_id !== payload.tool.callID) {
activeRun.pendingQuestions.delete(payload.tool.callID);
} else {
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
(question) =>
question.tool?.callID === payload.tool?.callID &&
question.request_id !== payload.tool?.callID,
);
if (hasActionableQuestion) {
activeRun.messages = updateLastAssistantMessage(
activeRun.messages,
(message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}),
);
shouldTrackQuestion = false;
}
}
}
if (shouldTrackQuestion) {
activeRun.pendingQuestions.set(payload.request_id, payload);
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
questions: upsertBackendQuestion(message.questions, payload),
}));
}
} else if (event === "question_response") {
const requestId =
typeof data.request_id === "string" ? data.request_id : undefined;
if (requestId) {
activeRun.pendingQuestions.delete(requestId);
activeRun.messages = updateLastAssistantQuestion(
activeRun.messages,
requestId,
(question) => ({
...question,
status: data.rejected === true ? "rejected" : "answered",
repliedAt: Date.now(),
answers: Array.isArray(data.answers) ? data.answers : question.answers,
error: undefined,
}),
);
}
} else if (event === "execution_plan") {
if (event === "execution_plan") {
try {
const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId);
executionPlanHistory = [...executionPlanHistory, snapshot];
subscriberData = snapshot;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), snapshot],
}));
if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) {
taskArtifactPublishQueue = taskArtifactPublishQueue
.catch(() => undefined)
@@ -843,23 +656,6 @@ export const registerChatStreamRoute = (
});
return;
}
} else if (event === "todo_update") {
const payload = data as TodoUpdatePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
todos: upsertBackendTodoUpdate(message.todos, payload),
}));
} else if (event === "tool_call") {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
artifacts: appendBackendToolArtifact(message.artifacts, data),
}));
} else if (event === "ui_envelope") {
const payload = data as UIEnvelopePayload;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
}));
}
const hiddenCollaborationEvent = sessionPurpose === "task_collaboration" && [
@@ -869,12 +665,7 @@ export const registerChatStreamRoute = (
"ui_envelope",
"execution_plan",
].includes(event);
if (!hiddenCollaborationEvent) {
for (const subscriber of activeRun.subscribers) {
subscriber.write(event, subscriberData);
}
}
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, id: assistantUiMessageId, parts: assistantParts.map((part) => ({ ...part })) }));
publishRunEvent(activeRun, event, subscriberData, !hiddenCollaborationEvent);
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
});
@@ -944,10 +735,7 @@ export const registerChatStreamRoute = (
: typeof generation === "number" && generation > 0 ? generation : null,
report_revision: reportRef.success ? reportRef.data.revision : null,
};
const part = { type: "data-task_context" as const, data: reference };
assistantParts.push(part);
writeUiChunk(part);
syncLatestUiMessages();
accumulator.apply("task_context", reference);
}
const preparedMessage = taskContext
? [
@@ -970,11 +758,8 @@ export const registerChatStreamRoute = (
const messaging = getTrustedProductCredential(req) ? agentMessages : undefined;
messaging?.start(activeSessionRecord);
const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => {
if (receipt.status === "delivered" && !requestMessages.some((message) => message.id === receipt.id)) {
requestMessages.push({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: receipt.id, role: "user", content: receipt.text }, ...(tail ? [tail] : [])];
syncLatestUiMessages();
if (receipt.status === "delivered") {
accumulator.appendInput({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage, receipt.text);
}
publish("user_input", { session_id: clientSessionId, ...receipt });
});
@@ -1125,14 +910,14 @@ export const registerChatStreamRoute = (
const assistantMessage = [...messages]
.reverse()
.find((message) => message.info.role === "assistant");
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
const assistantText = readUiMessageText(accumulator.uiMessages().at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
const latestSessionRecord =
(await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId,
)) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord),
toSessionScope(latestSessionRecord),
);
const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle;
@@ -1229,29 +1014,13 @@ export const registerChatStreamRoute = (
await taskArtifactPublishQueue.catch(() => undefined);
unregisterFrontendActionSink?.();
if (abortController.signal.aborted && !taskLeaseFailure) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
if (!projection.finished) {
writeUiChunk({
type: "error",
errorText: "请求已中断",
});
finishUiMessage("error");
}
publishRunEvent(activeRun, "aborted", { session_id: clientSessionId, message: "请求已中断" });
void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
});
}
if (!projection.finished) {
finishUiMessage(activeRun.status === "error" ? "error" : "stop");
if (!accumulator.finished) {
publishRunEvent(activeRun, activeRun.status === "error" ? "error" : "done", { session_id: clientSessionId });
}
await queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
+45 -36
View File
@@ -15,6 +15,8 @@ export type StreamSubscriber = {
};
export type ActiveRun = {
applyEvent?: (event: string, data: Record<string, unknown>) => void;
uiMessages?: () => unknown[];
inputs?: ResponseInputTracker;
clientSessionId: string;
controller: AbortController;
@@ -175,43 +177,50 @@ export const updateLastAssistantMessage = (
return messages;
};
export const updateLastAssistantPermission = (
messages: unknown[],
requestId: string,
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const permissions = Array.isArray(message.permissions)
? message.permissions
: [];
return {
...message,
permissions: permissions.map((permission) =>
isObjectRecord(permission) && permission.requestId === requestId
? updater(permission)
: permission,
),
};
});
export const updateLastAssistantQuestion = (
messages: unknown[],
requestId: string,
updater: (question: Record<string, unknown>) => Record<string, unknown>,
) =>
updateLastAssistantMessage(messages, (message) => {
const questions = Array.isArray(message.questions)
? message.questions
: [];
return {
...message,
questions: questions.map((question) =>
isObjectRecord(question) && question.requestId === requestId
? updater(question)
: question,
),
};
/** Replies belong to the assistant that requested input, including earlier turns. */
export function applyInteractionResponse<T>(
messages: T[],
event: "permission_response" | "question_response",
data: Record<string, unknown>,
): T[] {
const requestId = data.request_id;
if (typeof requestId !== "string") return messages;
const permission = event === "permission_response";
const field = permission ? "permissions" : "questions";
const requestType = permission ? "data-permission_request" : "data-question_request";
let changed = false;
const next = messages.map((message) => {
if (!isObjectRecord(message) || message.role !== "assistant") return message;
const entries = Array.isArray(message[field]) ? message[field] as unknown[] : undefined;
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
const ownsRequest = entries?.some((entry) => isObjectRecord(entry) && entry.requestId === requestId)
|| parts?.some((part) => isObjectRecord(part) && part.type === requestType
&& isObjectRecord(part.data) && part.data.request_id === requestId);
if (!ownsRequest) return message;
changed = true;
const updated = entries?.map((entry) => {
if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry;
return permission ? {
...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply),
error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(),
} : {
...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered",
...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(),
};
});
if (parts && data.status !== "pending") {
const type = `data-${event}`;
const index = parts.findIndex((part) => isObjectRecord(part) && part.type === type
&& isObjectRecord(part.data) && part.data.request_id === requestId);
const previous = index >= 0 && isObjectRecord(parts[index]) ? parts[index] : undefined;
const part = { ...previous, type, data: { ...(isObjectRecord(previous?.data) ? previous.data : {}), ...data } };
if (index >= 0) parts[index] = part;
else parts.push(part);
}
return { ...message, ...(updated ? { [field]: updated } : {}), ...(parts ? { parts } : {}) };
});
return changed ? next : messages;
}
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "present_view") {
+16
View File
@@ -0,0 +1,16 @@
import type { Request, Response } from "express";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
export function readTaskBridgeContext(req: Request, token: string, allowQuery = false) {
if (!isTrustedInternalBridgeRequest(req, token)) return null;
const runtimeSessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim()
: allowQuery && typeof req.query.session_id === "string" ? req.query.session_id.trim() : "";
const context = runtimeSessionId ? getRuntimeSessionContext(runtimeSessionId) : null;
return context?.taskId ? { context, runtimeSessionId, taskId: context.taskId } : null;
}
export function replyTaskBridgeError(res: Response, error: unknown, fallback: string) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : fallback });
}
+65
View File
@@ -0,0 +1,65 @@
import type { Express } from "express";
import { operationalTaskClient, expressAccessRequestSchema, proposedTaskActionSchema, humanTaskEvidenceCommandSchema, type OperationalTaskClient } from "../product/operational-task-client.js";
import { readTaskBridgeContext, replyTaskBridgeError } from "./task-bridge-context.js";
export function registerTaskCommandBridge(app: Express, options: {
internalToken: string;
client?: Pick<OperationalTaskClient, "requestDataAccess" | "proposeAction" | "addHumanEvidence">;
}) {
const client = options.client ?? operationalTaskClient;
app.post("/internal/operational-task/access-requests", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge || bridge.context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
}
const parsed = expressAccessRequestSchema.safeParse(req.body?.request);
if (!parsed.success) {
res.status(400).json({ message: "invalid access request" });
return;
}
try {
res.json(await client.requestDataAccess(bridge.runtimeSessionId, bridge.taskId, {
...parsed.data, sourceSessionId: bridge.context.clientSessionId,
}));
} catch (error) {
replyTaskBridgeError(res, error, "access request failed");
}
});
app.post("/internal/operational-task/actions", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge) {
res.status(403).json({ message: "operational task context unavailable" });
return;
}
const parsed = proposedTaskActionSchema.safeParse(req.body?.action);
if (!parsed.success) {
res.status(400).json({ message: "invalid proposed action", detail: parsed.error.flatten() });
return;
}
try {
res.json(await client.proposeAction(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
replyTaskBridgeError(res, error, "task action proposal failed");
}
});
app.post("/internal/operational-task/human-evidence", async (req, res) => {
const bridge = readTaskBridgeContext(req, options.internalToken, true);
if (!bridge || bridge.context.purpose !== "task_collaboration") {
res.status(403).json({ message: "human evidence requires a task collaboration session" });
return;
}
const parsed = humanTaskEvidenceCommandSchema.safeParse(req.body?.command);
if (!parsed.success) {
res.status(400).json({ message: "invalid human evidence", detail: parsed.error.flatten() });
return;
}
try {
res.json(await client.addHumanEvidence(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} catch (error) {
replyTaskBridgeError(res, error, "human evidence publish failed");
}
});
}
+7 -8
View File
@@ -4,7 +4,7 @@ import { z } from "zod";
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js";
import { taskPresentationBridgeSchema } from "../product/task-presentation.js";
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
import { readTaskBridgeContext, replyTaskBridgeError } from "./task-bridge-context.js";
const artifactsInputSchema = taskArtifactBridgeSchema.and(z.object({ session_id: z.string().trim().min(1).max(128) }));
@@ -19,12 +19,12 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(403).json({ message: "forbidden" });
return;
}
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
if (!context?.taskId) {
const bridge = readTaskBridgeContext(req, options.internalToken);
if (!bridge) {
res.status(403).json({ message: "operational task context unavailable" });
return;
}
const { context, runtimeSessionId: sessionId, taskId } = bridge;
if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" });
return;
@@ -36,7 +36,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() });
return;
}
res.json(await client.publishPresentation(sessionId, context.taskId, {
res.json(await client.publishPresentation(sessionId, taskId, {
requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId,
command: parsed.data.command,
@@ -47,7 +47,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() });
return;
}
res.json(await client.publishArtifacts(sessionId, context.taskId, {
res.json(await client.publishArtifacts(sessionId, taskId, {
requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId,
evidence: parsed.data.evidence,
@@ -57,8 +57,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
}));
}
} catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
res.status(status).json({ message: error instanceof Error ? error.message : `task ${kind} publish failed` });
replyTaskBridgeError(res, error, `task ${kind} publish failed`);
}
});
}