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
+2 -1
View File
@@ -122,7 +122,8 @@ bindings. Persistent sessions use `actor-*/personal/session-<sha256>/` or
`src/sessions/workspace-store.ts` owns chat directories and immutable ownership `src/sessions/workspace-store.ts` owns chat directories and immutable ownership
files. Inject the same store into metadata, UI state and transcript stores in files. Inject the same store into metadata, UI state and transcript stores in
`src/server.ts`. Keep `data/session-metadata/` as the owner/history index; new `src/server.ts`. Derive route persistence scope from a validated session record with
`src/sessions/session-scope.ts`; UI state contexts require actor, project and session IDs. Keep `data/session-metadata/` as the owner/history index; new
messages, transcripts and learning state go only to `chat/`. Preserve full identity checks on messages, transcripts and learning state go only to `chat/`. Preserve full identity checks on
reads and lists, session filename collision rejection, and serialized ownership reads and lists, session filename collision rejection, and serialized ownership
creation. Scope changes in the frontend start a new conversation and use creation. Scope changes in the frontend start a new conversation and use
+2 -1
View File
@@ -70,9 +70,10 @@ COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
COPY package.json bun.lock ./ COPY package.json bun.lock ./
COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./ COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./
COPY scripts ./scripts COPY scripts ./scripts
COPY openapi ./openapi
COPY src ./src COPY src ./src
COPY .opencode ./.opencode COPY .opencode ./.opencode
RUN bun run check RUN bun run check && bun run check:openapi
FROM build AS runner FROM build AS runner
WORKDIR /app WORKDIR /app
+5
View File
@@ -572,6 +572,11 @@ OpenCode 按标准 MCP `tools/call` 调用,以 `_meta["ai.opencode/sessionID"]
### 历史窗口接入与分页 ### 历史窗口接入与分页
历史专用接口契约见 [history.openapi.json](openapi/history.openapi.json);`openapi/openapi.json` 仍是产品 Server 的上游快照。 历史专用接口契约见 [history.openapi.json](openapi/history.openapi.json);`openapi/openapi.json` 仍是产品 Server 的上游快照。
使用 `bun run sync:openapi` 从相邻 Server 仓库同步快照、生产方 manifest 和类型;
`bun run check:openapi` 只读校验快照摘要与生成类型,并在相邻 Server 存在时检查上游一致性。
也可通过 `--upstream /path/to/server-v1.openapi.json` 指定生产方契约;同步时要求同目录的 `manifest.json`。
独立仓库或容器构建没有相邻 Server 时只证明本地快照一致,检查输出会明确说明;发布前需在集成工作区完成上游检查。
- `GET /api/v1/sessions?purpose=ordinary_chat&limit=30&q=...&cursor=...` 返回 `sessions`、`has_more`、`next_cursor`,搜索覆盖当前验证范围全部主题和未分类会话的标题与用户/助手可见正文,返回 `search_snippet`;前端以 `include_collaboration=true` 同时包含手动任务协作(继续排除后台执行会话),可选 `topic_id` 筛选主题或 `unclassified`;按更新时间及 ID 倒序,游标绑定用户、项目、用途、主题筛选和查询。 - `GET /api/v1/sessions?purpose=ordinary_chat&limit=30&q=...&cursor=...` 返回 `sessions`、`has_more`、`next_cursor`,搜索覆盖当前验证范围全部主题和未分类会话的标题与用户/助手可见正文,返回 `search_snippet`;前端以 `include_collaboration=true` 同时包含手动任务协作(继续排除后台执行会话),可选 `topic_id` 筛选主题或 `unclassified`;按更新时间及 ID 倒序,游标绑定用户、项目、用途、主题筛选和查询。
- `GET /api/v1/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。 - `GET /api/v1/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。
+10
View File
@@ -67,3 +67,13 @@ business task.
Running text additions use `POST /api/v1/responses` with stream=false, conversation, `tjwater.delivery="steer" | "queue"` and a stable tjwater.input_id. HTTP 202/tjwater.response_input is an inbox receipt, not execution completion. The existing SSE connection carries tjwater.user_input pending/delivered states; delivered means the native inbox took the input. `GET /api/v1/session/:session_id/inputs` lists pending input; PATCH of its /:input_id delivery can change its mode before processing and returns 409 afterward. Restored session streams retain these semantics. Running text additions use `POST /api/v1/responses` with stream=false, conversation, `tjwater.delivery="steer" | "queue"` and a stable tjwater.input_id. HTTP 202/tjwater.response_input is an inbox receipt, not execution completion. The existing SSE connection carries tjwater.user_input pending/delivered states; delivered means the native inbox took the input. `GET /api/v1/session/:session_id/inputs` lists pending input; PATCH of its /:input_id delivery can change its mode before processing and returns 409 afterward. Restored session streams retain these semantics.
A pending host question or failed continuation does not disable input admission. With no active host turn, steer/queue messages enter the native inbox with resume=false and remain available for the next owned continuation. This does not answer a host question or launch inference. A lost delivery acknowledgement can be reconciled only after verifying both native consumption and completed related output. HTTP 409 with code RESPONSE_FINISHED means the input was rejected before admission because the turn has ended; the client may refresh state and send it as a new response. Other failures do not authorize automatic resubmission. A pending host question or failed continuation does not disable input admission. With no active host turn, steer/queue messages enter the native inbox with resume=false and remain available for the next owned continuation. This does not answer a host question or launch inference. A lost delivery acknowledgement can be reconciled only after verifying both native consumption and completed related output. HTTP 409 with code RESPONSE_FINISHED means the input was rejected before admission because the turn has ended; the client may refresh state and send it as a new response. Other failures do not authorize automatic resubmission.
## Chat state projections
`src/routes/chat-run-accumulator.ts` consumes native and host events before publishing them.
Permission replies (including `permission_response` with `status="error"` and `error`),
question reconciliation and abort must use `publishRunEvent`, rather than mutate legacy messages.
Canonical parts are the saved history; reconnect compatibility fields are projections of the same events.
`data-run_status` retains completed, waiting_input, error or aborted state for reloads. Host-confirmed
interaction updates may update stored parts after a response has finished without reopening its wire stream.
UI, redacted transcript and native execution history retain their separate purposes and retention rules.
+12
View File
@@ -4683,6 +4683,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -4846,6 +4851,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
@@ -5660,6 +5666,11 @@
"title": "Execution Request Id", "title": "Execution Request Id",
"type": "string" "type": "string"
}, },
"execution_scope": {
"minLength": 1,
"title": "Execution Scope",
"type": "string"
},
"handling_context": { "handling_context": {
"additionalProperties": { "additionalProperties": {
"$ref": "#/components/schemas/JsonValue" "$ref": "#/components/schemas/JsonValue"
@@ -5773,6 +5784,7 @@
} }
}, },
"required": [ "required": [
"execution_scope",
"project_id", "project_id",
"task_id", "task_id",
"task_type", "task_type",
+9
View File
@@ -0,0 +1,9 @@
{
"contract_version": "1.0.0",
"contracts": {
"server": {
"file": "server-v1.openapi.json",
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
}
}
}
+2 -1
View File
@@ -11,7 +11,8 @@
"dev": "bun --watch src/server.ts", "dev": "bun --watch src/server.ts",
"build": "bun run check", "build": "bun run check",
"generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts", "generate:openapi": "openapi-typescript openapi/openapi.json -o src/generated/server-api.d.ts",
"check:openapi": "bun run generate:openapi && git diff --exit-code -- openapi/openapi.json src/generated/server-api.d.ts", "check:openapi": "bun scripts/server-contract.ts",
"sync:openapi": "bun scripts/server-contract.ts --sync",
"check": "bun run typecheck && bun run typecheck:opencode", "check": "bun run typecheck && bun run typecheck:opencode",
"check:models": "bun scripts/check-models.ts", "check:models": "bun scripts/check-models.ts",
"check:websearch": "bun scripts/check-websearch.ts", "check:websearch": "bun scripts/check-websearch.ts",
+57
View File
@@ -0,0 +1,57 @@
import { createHash, randomUUID } from "node:crypto";
import { readFile, writeFile, rename, rm } from "node:fs/promises";
import { dirname, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import openapiTS, { astToString, COMMENT_HEADER } from "openapi-typescript";
const local = new URL("../openapi/openapi.json", import.meta.url);
const manifest = new URL("../openapi/server-manifest.json", import.meta.url);
const generated = new URL("../src/generated/server-api.d.ts", import.meta.url);
const defaultUpstream = new URL("../../next-tjwater-server/contracts/server-v1.openapi.json", import.meta.url);
export function assertContractDigest(raw: string, expected: string) {
if (createHash("sha256").update(raw).digest("hex") !== expected) throw new Error("Server contract digest differs; run bun run sync:openapi");
}
async function main() {
const sync = process.argv.includes("--sync");
const upstreamIndex = process.argv.indexOf("--upstream");
if (upstreamIndex >= 0 && !process.argv[upstreamIndex + 1]) throw new Error("--upstream requires an OpenAPI file");
const upstream = upstreamIndex >= 0 ? resolve(process.argv[upstreamIndex + 1]) : defaultUpstream;
let upstreamRaw: string | undefined;
try { upstreamRaw = await readFile(upstream, "utf8"); }
catch (error) {
if (sync || upstreamIndex >= 0 || (error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
}
if (sync) {
const producerManifest = JSON.parse(await readFile(resolve(
dirname(upstream instanceof URL ? fileURLToPath(upstream) : upstream), "manifest.json"
), "utf8"));
assertContractDigest(upstreamRaw!, producerManifest.contracts.server.sha256);
// Complete validation and generation before replacing any checked-in artifact.
const types = COMMENT_HEADER + astToString(await openapiTS(JSON.parse(upstreamRaw!)));
const artifacts = [
{ target: local, content: upstreamRaw! },
{ target: manifest, content: JSON.stringify(producerManifest, null, 2) + "\n" },
{ target: generated, content: types },
].map((artifact) => ({ ...artifact, temporary: new URL(`${artifact.target.href}.${randomUUID()}.tmp`) }));
try {
for (const artifact of artifacts) await writeFile(artifact.temporary, artifact.content);
for (const artifact of artifacts) await rename(artifact.temporary, artifact.target);
} finally {
await Promise.all(artifacts.map((artifact) => rm(artifact.temporary, { force: true })));
}
console.log("Server OpenAPI snapshot, producer digest and types synchronized.");
return;
}
const raw = await readFile(local, "utf8");
const receipt = JSON.parse(await readFile(manifest, "utf8"));
assertContractDigest(raw, receipt.contracts.server.sha256);
if (upstreamRaw !== undefined && upstreamRaw !== raw) throw new Error("Upstream Server contract changed; run bun run sync:openapi");
if (await readFile(generated, "utf8") !== COMMENT_HEADER + astToString(await openapiTS(JSON.parse(raw)))) throw new Error("Generated Server types differ; run bun run generate:openapi");
console.log(upstreamRaw === undefined
? "Local Server contract digest and types checked; upstream unavailable in this checkout."
: "Upstream Server contract, digest and generated types checked.");
}
if (import.meta.main) await main();
+6 -4
View File
@@ -33,8 +33,8 @@ export function createAnswerStreamProjection(options: {
} } as UIMessageChunk); } } as UIMessageChunk);
options.onChange(); options.onChange();
}; };
const appendData = (event: string, data: Record<string, unknown>) => { const updateData = (event: string, data: Record<string, unknown>, hostState = false) => {
if (finished) return; if (finished && !hostState) return;
if (event === "answer_ui") endText(); if (event === "answer_ui") endText();
const type = `data-${event}`; const type = `data-${event}`;
const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string"); const id = [data.id, data.request_id, data.envelope_id, data.block_id].find((value): value is string => typeof value === "string");
@@ -42,9 +42,11 @@ export function createAnswerStreamProjection(options: {
const part = id ? { type, id, data } : { type, data }; const part = id ? { type, id, data } : { type, data };
if (existing >= 0) parts[existing] = part; if (existing >= 0) parts[existing] = part;
else parts.push(part); else parts.push(part);
options.write(part as UIMessageChunk); if (!finished) options.write(part as UIMessageChunk);
options.onChange(); options.onChange();
}; };
const appendData = (event: string, data: Record<string, unknown>) => updateData(event, data);
const recordData = (event: string, data: Record<string, unknown>) => updateData(event, data, true);
const finish = (finishReason: "stop" | "error" = "stop") => { const finish = (finishReason: "stop" | "error" = "stop") => {
if (finished) return; if (finished) return;
if (!parts.length) appendText("Agent 已完成处理。"); if (!parts.length) appendText("Agent 已完成处理。");
@@ -53,5 +55,5 @@ export function createAnswerStreamProjection(options: {
options.onChange(); options.onChange();
options.write({ type: "finish", finishReason }); options.write({ type: "finish", finishReason });
}; };
return { parts, appendText, appendData, finish, get finished() { return finished; } }; return { parts, appendText, appendData, recordData, finish, get finished() { return finished; } };
} }
+4
View File
@@ -7507,6 +7507,8 @@ export interface components {
* Format: uuid * Format: uuid
*/ */
execution_request_id: string; execution_request_id: string;
/** Execution Scope */
execution_scope: string;
/** Handling Context */ /** Handling Context */
handling_context?: { handling_context?: {
[key: string]: components["schemas"]["JsonValue"]; [key: string]: components["schemas"]["JsonValue"];
@@ -7863,6 +7865,8 @@ export interface components {
* Format: uuid * Format: uuid
*/ */
execution_request_id: string; execution_request_id: string;
/** Execution Scope */
execution_scope: string;
/** Handling Context */ /** Handling Context */
handling_context?: { handling_context?: {
[key: string]: components["schemas"]["JsonValue"]; [key: string]: components["schemas"]["JsonValue"];
+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 { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js"; import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
@@ -13,9 +15,6 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
import { import {
type ActiveRun, type ActiveRun,
type RunStatus, type RunStatus,
cancelBackendTodos,
completeBackendProgress,
updateLastAssistantMessage,
} from "./chatUiState.js"; } from "./chatUiState.js";
const abortPayloadSchema = z.object({ const abortPayloadSchema = z.object({
@@ -33,11 +32,6 @@ type RegisterAuxiliaryRoutesOptions = {
frontendActionCoordinator: FrontendActionCoordinator; frontendActionCoordinator: FrontendActionCoordinator;
}; };
const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
export const registerChatAuxiliaryRoutes = ( export const registerChatAuxiliaryRoutes = (
chatRouter: Router, chatRouter: Router,
@@ -80,12 +74,12 @@ export const registerChatAuxiliaryRoutes = (
const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId); const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId);
if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true }); if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true });
activeRuns.get(sessionRecord.sessionId)?.controller.abort(); activeRuns.get(sessionRecord.sessionId)?.controller.abort();
await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord)); await interactionRequestStore?.cancelPending(toSessionScope(sessionRecord));
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord)); const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionScope(sessionRecord));
if (pendingInteraction?.delivery && runtime && interactionRequestStore) { if (pendingInteraction?.delivery && runtime && interactionRequestStore) {
const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id); const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id);
const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id); const latest = await interactionRequestStore.get(toSessionScope(sessionRecord), pendingInteraction.interaction_id);
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.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, expected_revision: latest.delivery.revision, status,
}); });
} }
@@ -99,28 +93,21 @@ export const registerChatAuxiliaryRoutes = (
run.status = "aborted"; run.status = "aborted";
lastRunStatuses.set(parsed.data.session_id, "aborted"); lastRunStatuses.set(parsed.data.session_id, "aborted");
run.controller.abort(); run.controller.abort();
run.messages = updateLastAssistantMessage(run.messages, (message) => ({ publishRunEvent(run, "aborted", { session_id: parsed.data.session_id, message: "请求已中断" }, false);
...message,
content:
typeof message.content === "string" && message.content.trim()
? message.content
: "⚠️ **请求已中断**",
isError: true,
progress: completeBackendProgress(message.progress),
todos: cancelBackendTodos(message.todos),
}));
const currentState = await sessionUiStateStore.read( const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord), toSessionScope(sessionRecord),
); );
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), { await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages, ...currentState,
messages: run.uiMessages?.() ?? run.messages,
}); });
for (const subscriber of run.subscribers) { for (const subscriber of run.subscribers) {
subscriber.write("error", { subscriber.write("error", {
session_id: parsed.data.session_id, session_id: parsed.data.session_id,
message: "请求已中断", message: "请求已中断",
status: "aborted",
}); });
} }
// The generation route emits its terminal Response after cleanup and // 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 { refreshSessionCredential } from "../product/credential-refresh.js";
import { type Router } from "express"; import { type Router } from "express";
import { z } from "zod"; import { z } from "zod";
@@ -8,9 +10,7 @@ import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js"; import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import { import {
type ActiveRun, type ActiveRun, applyInteractionResponse,
toPermissionStatus,
updateLastAssistantPermission,
} from "./chatUiState.js"; } from "./chatUiState.js";
const permissionReplyPayloadSchema = z.object({ const permissionReplyPayloadSchema = z.object({
@@ -35,7 +35,6 @@ type RegisterInteractionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
}; };
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
export const registerChatInteractionRoutes = ( export const registerChatInteractionRoutes = (
chatRouter: Router, chatRouter: Router,
@@ -104,12 +103,13 @@ export const registerChatInteractionRoutes = (
} }
const persistPermissionState = async () => { const persistPermissionState = async () => {
const currentState = await sessionUiStateStore.read( const currentState = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord.sessionId), toSessionScope(sessionRecord),
); );
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), { await sessionUiStateStore.write(toSessionScope(sessionRecord), {
sessionId: sessionRecord.sessionId, sessionId: sessionRecord.sessionId,
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
messages: run.messages, ...currentState,
messages: run.uiMessages?.() ?? run.messages,
}); });
}; };
@@ -121,18 +121,10 @@ export const registerChatInteractionRoutes = (
message: parsed.data.message, message: parsed.data.message,
}); });
} catch (error) { } catch (error) {
run.messages = updateLastAssistantPermission( publishRunEvent(run, "permission_response", {
run.messages, session_id: sessionRecord.sessionId, request_id: requestId, status: "error",
requestId, error: error instanceof Error ? error.message : "failed to reply permission",
(permission) => ({ });
...permission,
status: "error",
error:
error instanceof Error
? error.message
: "failed to reply permission",
}),
);
await persistPermissionState().catch((persistError) => { await persistPermissionState().catch((persistError) => {
logger.warn( logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId }, { err: persistError, sessionId: sessionRecord.sessionId },
@@ -146,30 +138,15 @@ export const registerChatInteractionRoutes = (
return; return;
} }
run.pendingPermissions.delete(requestId); publishRunEvent(run, "permission_response", {
const status = toPermissionStatus(parsed.data.reply); session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply,
run.messages = updateLastAssistantPermission( });
run.messages,
requestId,
(permission) => ({
...permission,
status,
repliedAt: Date.now(),
}),
);
await persistPermissionState().catch((persistError) => { await persistPermissionState().catch((persistError) => {
logger.warn( logger.warn(
{ err: persistError, sessionId: sessionRecord.sessionId }, { err: persistError, sessionId: sessionRecord.sessionId },
"failed to persist permission reply state", "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({ res.status(202).json({
session_id: sessionRecord.sessionId, session_id: sessionRecord.sessionId,
@@ -222,20 +199,15 @@ export const registerChatInteractionRoutes = (
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId }); const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!); const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
const persist = async (nextStatus: string, answers?: string[][]) => { const persist = async (nextStatus: string, answers?: string[][]) => {
const update = (messages: unknown[]) => messages.map((message) => { const response = {
if (!isRecord(message)) return message; session_id: session.sessionId, request_id: canonicalId, status: nextStatus,
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; rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}),
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. // 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); } if (run && nextStatus !== "pending") publishRunEvent(run, "question_response", response);
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) })); await sessionUiStateStore.update(context, (latest) => ({
...latest, messages: applyInteractionResponse(latest.messages, "question_response", response),
}));
}; };
if (action === "status" || status !== "pending") { if (action === "status" || status !== "pending") {
await persist(status); await persist(status);
@@ -246,7 +218,6 @@ export const registerChatInteractionRoutes = (
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] }); if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId }); else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
await persist(action === "reply" ? "answered" : "rejected", answers); 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); 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 }) }); res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
} catch (error) { } 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 ( export const buildPromptWithLearningContext = async (
memoryStore: MemoryStore, memoryStore: MemoryStore,
actorKey: string, 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 { registerChatTopicRoutes } from "./chat-topic-routes.js";
import { matchHistoryText } from "../sessions/history-search.js"; import { matchHistoryText } from "../sessions/history-search.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js"; import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
@@ -107,11 +108,6 @@ type RegisterChatSessionRoutesOptions = {
sessionUiStateStore: SessionUiStateStore; sessionUiStateStore: SessionUiStateStore;
}; };
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const toSse = (event: string, data: Record<string, unknown>) => const toSse = (event: string, data: Record<string, unknown>) =>
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
@@ -176,6 +172,12 @@ export const registerChatSessionRoutes = (
const { activeRuns, lastRunStatuses } = runState; const { activeRuns, lastRunStatuses } = runState;
const getSessionRunStatus = runState.getStatus; 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[]) => { const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
if (!interactionRequestStore) return messages; if (!interactionRequestStore) return messages;
const ids = new Set(messages.flatMap((message) => { const ids = new Set(messages.flatMap((message) => {
@@ -183,7 +185,7 @@ export const registerChatSessionRoutes = (
const value = message as Record<string, unknown>; const value = message as Record<string, unknown>;
return value.role === "user" && typeof value.id === "string" ? [value.id] : []; 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); return projectInteractionReplyMessages(messages, replies);
}; };
@@ -192,7 +194,7 @@ export const registerChatSessionRoutes = (
|| !isGenericTaskCollaborationTitle(record.title)) return record; || !isGenericTaskCollaborationTitle(record.title)) return record;
if ((!credential && !knownTaskTitle) || signal?.aborted) return record; if ((!credential && !knownTaskTitle) || signal?.aborted) return record;
try { try {
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
if (state?.isTitleManuallyEdited) return record; if (state?.isTitleManuallyEdited) return record;
let taskTitle = knownTaskTitle; let taskTitle = knownTaskTitle;
if (!taskTitle && credential) { if (!taskTitle && credential) {
@@ -206,7 +208,7 @@ export const registerChatSessionRoutes = (
} }
} }
if (!taskTitle?.trim()) return record; if (!taskTitle?.trim()) return record;
const latestState = await sessionUiStateStore.read(toSessionUiStateContext(record)); const latestState = await sessionUiStateStore.read(toSessionScope(record));
if (latestState?.isTitleManuallyEdited) return record; if (latestState?.isTitleManuallyEdited) return record;
return await sessionMetadataStore.touch(record, { title: taskTitle }, { return await sessionMetadataStore.touch(record, { title: taskTitle }, {
preserveUpdatedAt: true, expectedTitle: record.title ?? "", preserveUpdatedAt: true, expectedTitle: record.title ?? "",
@@ -297,7 +299,7 @@ export const registerChatSessionRoutes = (
sessionId, sessionId,
); );
if (!record) return null; if (!record) return null;
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
if (!state) return null; if (!state) return null;
return { record, state, taskTitle }; return { record, state, taskTitle };
}; };
@@ -603,7 +605,7 @@ export const registerChatSessionRoutes = (
const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` }); const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` });
const messages = await withInteractionReplies(history.record, history.state.messages ?? []); const messages = await withInteractionReplies(history.record, history.state.messages ?? []);
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(titled), toSessionScope(titled),
buildForkedSessionUiState({ messages }, { buildForkedSessionUiState({ messages }, {
keepMessageCount: messages.length, keepMessageCount: messages.length,
targetSessionId: titled.sessionId, targetSessionId: titled.sessionId,
@@ -771,8 +773,8 @@ export const registerChatSessionRoutes = (
const searchable: Array<SessionRecord & { searchSnippet?: string }> = []; const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
for (const record of records) { for (const record of records) {
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase()); const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null; const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionScope(record)) : null;
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? []; const messages = readMessages(record, state);
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined }); searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
} }
let page; let page;
@@ -828,9 +830,8 @@ export const registerChatSessionRoutes = (
const context = getLocalAgentContext(req); const context = getLocalAgentContext(req);
const record = await sessionMetadataStore.get(context, req.params.session_id); const record = await sessionMetadataStore.get(context, req.params.session_id);
if (!record) { res.status(404).json({ message: "session not found" }); return; } if (!record) { res.status(404).json({ message: "session not found" }); return; }
const state = await sessionUiStateStore.read(toSessionUiStateContext(record)); const state = await sessionUiStateStore.read(toSessionScope(record));
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(record, state);
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
try { try {
const page = 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) }); res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
@@ -871,10 +872,9 @@ export const registerChatSessionRoutes = (
sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req)); sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req));
const state = await sessionUiStateStore.read( const state = await sessionUiStateStore.read(
toSessionUiStateContext(sessionRecord), toSessionScope(sessionRecord),
); );
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(sessionRecord, state);
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, { const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit, scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages }; }) : { messages };
@@ -929,9 +929,8 @@ export const registerChatSessionRoutes = (
res.flushHeaders?.(); res.flushHeaders?.();
const run = activeRuns.get(sessionRecord.sessionId); const run = activeRuns.get(sessionRecord.sessionId);
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord)); const state = await sessionUiStateStore.read(toSessionScope(sessionRecord));
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? [] const messages = readMessages(sessionRecord, state);
: run?.messages ?? state?.messages ?? [];
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, { const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit, scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
}) : { messages }; }) : { messages };
@@ -1005,11 +1004,11 @@ export const registerChatSessionRoutes = (
title, title,
}); });
const state = await sessionUiStateStore.read( const state = await sessionUiStateStore.read(
toSessionUiStateContext(nextSessionRecord), toSessionScope(nextSessionRecord),
); );
if (state) { if (state) {
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(nextSessionRecord), toSessionScope(nextSessionRecord),
{ {
...state, ...state,
isTitleManuallyEdited: isTitleManuallyEdited:
@@ -1134,7 +1133,7 @@ export const registerChatSessionRoutes = (
} }
const sourceState = sourceSessionRecord const sourceState = sourceSessionRecord
? await sessionUiStateStore.read( ? await sessionUiStateStore.read(
toSessionUiStateContext(sourceSessionRecord), toSessionScope(sourceSessionRecord),
) )
: null; : null;
const forkTitle = sourceSessionRecord?.title const forkTitle = sourceSessionRecord?.title
@@ -1145,7 +1144,7 @@ export const registerChatSessionRoutes = (
{ title: forkTitle }, { title: forkTitle },
); );
await sessionUiStateStore.write( await sessionUiStateStore.write(
toSessionUiStateContext(titledTargetSessionRecord), toSessionScope(titledTargetSessionRecord),
buildForkedSessionUiState(sourceState, { buildForkedSessionUiState(sourceState, {
keepMessageCount: parsed.data.keep_message_count, keepMessageCount: parsed.data.keep_message_count,
targetSessionId: nextSessionId, 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 type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js"; import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.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 type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js"; import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js";
import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.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 type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
import { credentialRefreshCoordinator } from "../product/credential-refresh.js"; import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
import { type Router } from "express"; import { type Router } from "express";
@@ -47,7 +48,6 @@ import {
} from "../product/operational-task-client.js"; } from "../product/operational-task-client.js";
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js"; import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
import { type SessionUiStateStore } from "../sessions/uiStateStore.js"; import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
import { toActorKey, toProjectKey } from "../utils/fileStore.js"; import { toActorKey, toProjectKey } from "../utils/fileStore.js";
import { import {
buildPromptWithLearningContext, buildPromptWithLearningContext,
@@ -59,29 +59,14 @@ import {
} from "./chatSession.js"; } from "./chatSession.js";
import { import {
collectTextContent, collectTextContent,
type PermissionRequestPayload,
type QuestionRequestPayload,
streamPromptResponse, streamPromptResponse,
type TodoUpdatePayload,
} from "./chatStream.js"; } from "./chatStream.js";
import { type ChatRunState } from "./chatRunState.js"; import { type ChatRunState } from "./chatRunState.js";
import { import {
type ActiveRun, type ActiveRun,
type StreamSubscriber,
appendBackendToolArtifact,
appendBackendUiEnvelope,
cancelBackendTodos,
completeBackendProgress,
createInitialStreamingMessages, createInitialStreamingMessages,
isObjectRecord, isObjectRecord,
toFrontendPermission,
toPermissionStatus,
updateLastAssistantMessage, updateLastAssistantMessage,
updateLastAssistantPermission,
updateLastAssistantQuestion,
upsertBackendProgress,
upsertBackendQuestion,
upsertBackendTodoUpdate,
} from "./chatUiState.js"; } from "./chatUiState.js";
import { import {
agentTriggerContextSchema, agentTriggerContextSchema,
@@ -106,11 +91,6 @@ const payloadSchema = z.object({
}).strict().optional(), }).strict().optional(),
}); });
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
sessionId: sessionRecord.sessionId,
actorKey: sessionRecord.actorKey,
projectKey: sessionRecord.projectKey,
});
const runtimeHasConversation = async ( const runtimeHasConversation = async (
runtime: OpencodeRuntimeAdapter, runtime: OpencodeRuntimeAdapter,
@@ -311,7 +291,7 @@ export const registerChatStreamRoute = (
let boundOperationalSessionId: string | undefined; let boundOperationalSessionId: string | undefined;
let startingSessionId: string | undefined; let startingSessionId: string | undefined;
let continuation: InteractionRequest | null = null; let continuation: InteractionRequest | null = null;
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined; let continuationScope: ReturnType<typeof toSessionScope> | undefined;
let waitingInteraction: InteractionRequest | null = null; let waitingInteraction: InteractionRequest | null = null;
try { try {
const authContext = getLocalAgentContext(req); const authContext = getLocalAgentContext(req);
@@ -365,7 +345,7 @@ export const registerChatStreamRoute = (
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return; res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
} }
if (existingSessionRecord && interactionRequestStore) { if (existingSessionRecord && interactionRequestStore) {
continuationScope = toSessionUiStateContext(existingSessionRecord); continuationScope = toSessionScope(existingSessionRecord);
if (input.tjwater.interaction_id) { if (input.tjwater.interaction_id) {
continuation = await interactionRequestStore.get(continuationScope, 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") { if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") {
@@ -523,7 +503,7 @@ export const registerChatStreamRoute = (
sessionId: requestContext.clientSessionId, sessionId: requestContext.clientSessionId,
}; };
const initialSessionState = await sessionUiStateStore.read( const initialSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(activeSessionRecord), toSessionScope(activeSessionRecord),
); );
const persistedUiMessages = toUiMessages(initialSessionState?.messages); const persistedUiMessages = toUiMessages(initialSessionState?.messages);
if (!continuation && 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)) {
@@ -551,7 +531,6 @@ export const registerChatStreamRoute = (
...visiblePersistedMessages, ...visiblePersistedMessages,
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id), createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
]; ];
let latestUiMessages = requestMessages;
const baseMessages = toLegacyMessages(requestMessages); const baseMessages = toLegacyMessages(requestMessages);
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8); const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
@@ -605,44 +584,28 @@ export const registerChatStreamRoute = (
startingSessions.delete(activeSessionRecord.sessionId); startingSessions.delete(activeSessionRecord.sessionId);
startingSessionId = undefined; startingSessionId = undefined;
lastRunStatuses.set(clientSessionId, "running"); lastRunStatuses.set(clientSessionId, "running");
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord); const sessionUiStateContext = toSessionScope(activeSessionRecord);
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`; const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId }); setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
const syncLatestUiMessages = () => { const accumulator = createChatRunAccumulator({
latestUiMessages = [ run: activeRun, messages: requestMessages, messageId: assistantUiMessageId, sessionId: clientSessionId,
...requestMessages, collaboration: sessionPurpose === "task_collaboration", write: (chunk) => wire!.write(chunk),
{ onStatus: (status) => lastRunStatuses.set(clientSessionId, status),
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 assistantParts = projection.parts; const writeUiChunk = (chunk: UIMessageChunk) => wire!.write(chunk);
const writeTextDelta = projection.appendText;
const writeDataPart = projection.appendData;
const finishUiMessage = projection.finish;
writeUiChunk({ type: "start", messageId: assistantUiMessageId }); writeUiChunk({ type: "start", messageId: assistantUiMessageId });
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, { let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
executionPlans: executionPlanHistory, executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages, messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
}); });
const queueSessionUiStatePersist = () => { const queueSessionUiStatePersist = () => {
const snapshot = { const snapshot = {
executionPlans: executionPlanHistory, executionPlans: executionPlanHistory,
sessionId: activeSessionRecord.sessionId, sessionId: activeSessionRecord.sessionId,
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false, isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages, messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
}; };
persistQueue = persistQueue persistQueue = persistQueue
.catch((error) => { .catch((error) => {
@@ -654,36 +617,6 @@ export const registerChatStreamRoute = (
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot)); .then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
return persistQueue; 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(); let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve();
const publish = (event: string, data: Record<string, unknown>) => { const publish = (event: string, data: Record<string, unknown>) => {
@@ -697,131 +630,11 @@ export const registerChatStreamRoute = (
: assistantUiMessageId, : assistantUiMessageId,
} }
: data; : data;
if (event === "token") { if (event === "execution_plan") {
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") {
try { try {
const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId); const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId);
executionPlanHistory = [...executionPlanHistory, snapshot]; executionPlanHistory = [...executionPlanHistory, snapshot];
subscriberData = snapshot; subscriberData = snapshot;
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
...message,
executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), snapshot],
}));
if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) { if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) {
taskArtifactPublishQueue = taskArtifactPublishQueue taskArtifactPublishQueue = taskArtifactPublishQueue
.catch(() => undefined) .catch(() => undefined)
@@ -843,23 +656,6 @@ export const registerChatStreamRoute = (
}); });
return; 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" && [ const hiddenCollaborationEvent = sessionPurpose === "task_collaboration" && [
@@ -869,12 +665,7 @@ export const registerChatStreamRoute = (
"ui_envelope", "ui_envelope",
"execution_plan", "execution_plan",
].includes(event); ].includes(event);
if (!hiddenCollaborationEvent) { publishRunEvent(activeRun, event, subscriberData, !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 })) }));
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); 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, : typeof generation === "number" && generation > 0 ? generation : null,
report_revision: reportRef.success ? reportRef.data.revision : null, report_revision: reportRef.success ? reportRef.data.revision : null,
}; };
const part = { type: "data-task_context" as const, data: reference }; accumulator.apply("task_context", reference);
assistantParts.push(part);
writeUiChunk(part);
syncLatestUiMessages();
} }
const preparedMessage = taskContext const preparedMessage = taskContext
? [ ? [
@@ -970,11 +758,8 @@ export const registerChatStreamRoute = (
const messaging = getTrustedProductCredential(req) ? agentMessages : undefined; const messaging = getTrustedProductCredential(req) ? agentMessages : undefined;
messaging?.start(activeSessionRecord); messaging?.start(activeSessionRecord);
const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => { const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => {
if (receipt.status === "delivered" && !requestMessages.some((message) => message.id === receipt.id)) { if (receipt.status === "delivered") {
requestMessages.push({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage); accumulator.appendInput({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage, receipt.text);
const tail = activeRun.messages.at(-1);
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: receipt.id, role: "user", content: receipt.text }, ...(tail ? [tail] : [])];
syncLatestUiMessages();
} }
publish("user_input", { session_id: clientSessionId, ...receipt }); publish("user_input", { session_id: clientSessionId, ...receipt });
}); });
@@ -1125,14 +910,14 @@ export const registerChatStreamRoute = (
const assistantMessage = [...messages] const assistantMessage = [...messages]
.reverse() .reverse()
.find((message) => message.info.role === "assistant"); .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 = const latestSessionRecord =
(await sessionMetadataStore.get( (await sessionMetadataStore.get(
{ actorKey, projectId, projectKey, userId }, { actorKey, projectId, projectKey, userId },
activeSessionRecord.sessionId, activeSessionRecord.sessionId,
)) ?? activeSessionRecord; )) ?? activeSessionRecord;
const latestSessionState = await sessionUiStateStore.read( const latestSessionState = await sessionUiStateStore.read(
toSessionUiStateContext(latestSessionRecord), toSessionScope(latestSessionRecord),
); );
const existingSessionTitle = latestSessionRecord.title; const existingSessionTitle = latestSessionRecord.title;
let sessionTitle = existingSessionTitle; let sessionTitle = existingSessionTitle;
@@ -1229,29 +1014,13 @@ export const registerChatStreamRoute = (
await taskArtifactPublishQueue.catch(() => undefined); await taskArtifactPublishQueue.catch(() => undefined);
unregisterFrontendActionSink?.(); unregisterFrontendActionSink?.();
if (abortController.signal.aborted && !taskLeaseFailure) { if (abortController.signal.aborted && !taskLeaseFailure) {
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ publishRunEvent(activeRun, "aborted", { session_id: clientSessionId, 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");
}
void queueSessionUiStatePersist().catch((error) => { void queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state"); logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
}); });
} }
if (!projection.finished) { if (!accumulator.finished) {
finishUiMessage(activeRun.status === "error" ? "error" : "stop"); publishRunEvent(activeRun, activeRun.status === "error" ? "error" : "done", { session_id: clientSessionId });
} }
await queueSessionUiStatePersist().catch((error) => { await queueSessionUiStatePersist().catch((error) => {
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state"); 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 = { export type ActiveRun = {
applyEvent?: (event: string, data: Record<string, unknown>) => void;
uiMessages?: () => unknown[];
inputs?: ResponseInputTracker; inputs?: ResponseInputTracker;
clientSessionId: string; clientSessionId: string;
controller: AbortController; controller: AbortController;
@@ -175,43 +177,50 @@ export const updateLastAssistantMessage = (
return messages; return messages;
}; };
export const updateLastAssistantPermission = ( /** Replies belong to the assistant that requested input, including earlier turns. */
messages: unknown[], export function applyInteractionResponse<T>(
requestId: string, messages: T[],
updater: (permission: Record<string, unknown>) => Record<string, unknown>, event: "permission_response" | "question_response",
) => data: Record<string, unknown>,
updateLastAssistantMessage(messages, (message) => { ): T[] {
const permissions = Array.isArray(message.permissions) const requestId = data.request_id;
? message.permissions if (typeof requestId !== "string") return messages;
: []; const permission = event === "permission_response";
return { const field = permission ? "permissions" : "questions";
...message, const requestType = permission ? "data-permission_request" : "data-question_request";
permissions: permissions.map((permission) => let changed = false;
isObjectRecord(permission) && permission.requestId === requestId const next = messages.map((message) => {
? updater(permission) if (!isObjectRecord(message) || message.role !== "assistant") return message;
: permission, 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);
export const updateLastAssistantQuestion = ( if (!ownsRequest) return message;
messages: unknown[], changed = true;
requestId: string, const updated = entries?.map((entry) => {
updater: (question: Record<string, unknown>) => Record<string, unknown>, if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry;
) => return permission ? {
updateLastAssistantMessage(messages, (message) => { ...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply),
const questions = Array.isArray(message.questions) error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(),
? message.questions } : {
: []; ...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered",
return { ...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(),
...message, };
questions: questions.map((question) => });
isObjectRecord(question) && question.requestId === requestId if (parts && data.status !== "pending") {
? updater(question) const type = `data-${event}`;
: question, 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 => { const getToolArtifactKind = (tool: string): ToolArtifactKind => {
if (tool === "present_view") { 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 { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js"; import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js";
import { taskPresentationBridgeSchema } from "../product/task-presentation.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) })); 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" }); res.status(403).json({ message: "forbidden" });
return; return;
} }
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : ""; const bridge = readTaskBridgeContext(req, options.internalToken);
const context = sessionId ? getRuntimeSessionContext(sessionId) : null; if (!bridge) {
if (!context?.taskId) {
res.status(403).json({ message: "operational task context unavailable" }); res.status(403).json({ message: "operational task context unavailable" });
return; return;
} }
const { context, runtimeSessionId: sessionId, taskId } = bridge;
if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") { if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") {
res.status(403).json({ message: "task execution context required" }); res.status(403).json({ message: "task execution context required" });
return; return;
@@ -36,7 +36,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() }); res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() });
return; return;
} }
res.json(await client.publishPresentation(sessionId, context.taskId, { res.json(await client.publishPresentation(sessionId, taskId, {
requestId: parsed.data.request_id, requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId, sourceSessionId: context.clientSessionId,
command: parsed.data.command, 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() }); res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() });
return; return;
} }
res.json(await client.publishArtifacts(sessionId, context.taskId, { res.json(await client.publishArtifacts(sessionId, taskId, {
requestId: parsed.data.request_id, requestId: parsed.data.request_id,
sourceSessionId: context.clientSessionId, sourceSessionId: context.clientSessionId,
evidence: parsed.data.evidence, evidence: parsed.data.evidence,
@@ -57,8 +57,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
})); }));
} }
} catch (error) { } catch (error) {
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502; replyTaskBridgeError(res, error, `task ${kind} publish failed`);
res.status(status).json({ message: error instanceof Error ? error.message : `task ${kind} publish failed` });
} }
}); });
} }
-7
View File
@@ -381,13 +381,6 @@ export class OpencodeRuntimeAdapter {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }
async sendPrompt(sessionId: string, text: string) {
await this.prompt(sessionId, text);
// 当前 SDK 响应风格下,prompt() 本身不会直接返回完整 assistant parts,
// 所以这里紧跟一次 messages() 回读,给上层路由统一消费。
return this.messages(sessionId);
}
async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) { async generateText(prompt: string, model?: RuntimeModelOverride, signal?: AbortSignal) {
const client = await this.ensureClient(); const client = await this.ensureClient();
const selected = model ?? parseModelReference(config.OPENCODE_MODEL); const selected = model ?? parseModelReference(config.OPENCODE_MODEL);
+2 -80
View File
@@ -37,12 +37,7 @@ import { createAgentMessageQuery, registerAgentMessageBridge } from "./routes/ag
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js"; import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
import { opencodeRuntime } from "./runtime/opencode.js"; import { opencodeRuntime } from "./runtime/opencode.js";
import { getRuntimeSessionContext } from "./runtime/sessionContext.js"; import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
import { import { registerTaskCommandBridge } from "./routes/task-command-bridge.js";
operationalTaskClient,
expressAccessRequestSchema,
humanTaskEvidenceCommandSchema,
proposedTaskActionSchema,
} from "./product/operational-task-client.js";
import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js"; import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js";
const app = express(); const app = express();
@@ -112,80 +107,7 @@ app.post("/internal/frontend-actions/request", async (req, res) => {
} }
}); });
const readOperationalTaskBridgeContext = (req: express.Request) => { registerTaskCommandBridge(app, { internalToken });
if (!isTrustedInternalBridgeRequest(req, internalToken)) return null;
const runtimeSessionId = typeof req.body?.session_id === "string"
? req.body.session_id.trim()
: 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;
};
app.post("/internal/operational-task/access-requests", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
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 operationalTaskClient.requestDataAccess(bridge.runtimeSessionId, bridge.taskId, {
...parsed.data, sourceSessionId: bridge.context.clientSessionId,
}));
} 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 : "access request failed" });
}
});
app.post("/internal/operational-task/actions", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
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 operationalTaskClient.proposeAction(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} 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 action proposal failed" });
}
});
app.post("/internal/operational-task/human-evidence", async (req, res) => {
const bridge = readOperationalTaskBridgeContext(req);
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 operationalTaskClient.addHumanEvidence(bridge.runtimeSessionId, bridge.taskId, parsed.data));
} 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 : "human evidence publish failed" });
}
});
app.get("/health", async (_req, res) => { app.get("/health", async (_req, res) => {
try { try {
+8
View File
@@ -0,0 +1,8 @@
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
/** Always derive persistence scope from a host-validated session record. */
export const toSessionScope = (record: SessionWorkspaceContext): SessionWorkspaceContext => ({
sessionId: record.sessionId,
actorKey: record.actorKey,
projectKey: record.projectKey,
});
+2 -5
View File
@@ -1,3 +1,4 @@
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
import { join } from "node:path"; import { join } from "node:path";
import { config } from "../config.js"; import { config } from "../config.js";
@@ -17,11 +18,7 @@ export type SessionUiStateRecord = {
executionPlans?: Record<string, unknown>[]; executionPlans?: Record<string, unknown>[];
}; };
type SessionUiStateContext = { type SessionUiStateContext = SessionWorkspaceContext;
sessionId: string;
actorKey?: string;
projectKey?: string;
};
export class SessionUiStateStore { export class SessionUiStateStore {
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {} constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
+47
View File
@@ -0,0 +1,47 @@
import { expect, it } from "bun:test";
import type { UIMessageChunk } from "ai";
import { createChatRunAccumulator, publishRunEvent } from "../../src/routes/chat-run-accumulator.js";
import { createInitialStreamingMessages, type ActiveRun } from "../../src/routes/chatUiState.js";
it.each([false, true])("projects one event stream into reconnect and persisted messages (collaboration=%s)", (collaboration) => {
const run: ActiveRun = {
clientSessionId: "s", controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages([], "查询"), pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const chunks: UIMessageChunk[] = [];
const stream = createChatRunAccumulator({ run, messages: [{ id: "user", role: "user", parts: [{ type: "text", text: "查询" }] }],
messageId: "answer", sessionId: "s", collaboration, write: (chunk) => chunks.push(chunk), onStatus() {} });
stream.apply("progress", { id: "query", status: "running", title: "查询资料" });
stream.apply("token", { content: "已有数据" });
stream.apply("permission_request", { request_id: "permission", session_id: "s", permission: "shell", patterns: [], always: [], created_at: 1 });
stream.apply("permission_response", { request_id: "permission", reply: "once" });
stream.appendInput({ id: "extra", role: "user", parts: [{ type: "text", text: "补充" }] }, "补充");
stream.appendInput({ id: "extra", role: "user", parts: [] }, "重复");
stream.apply("final_answer", { content: "结论" });
stream.apply("done", {});
expect(run.status).toBe("completed");
expect(run.pendingPermissions.size).toBe(0);
expect(run.messages.at(-1)).toMatchObject({ id: "answer", content: "结论", permissions: [{ status: "approved_once" }], progress: [{ status: "completed" }, { status: "completed" }] });
const saved = stream.uiMessages();
expect(saved.map((message) => message.id)).toEqual(["user", "extra", "answer"]);
expect(saved.at(-1)?.parts).toEqual((run.messages.at(-1) as { parts: typeof saved[number]["parts"] }).parts);
expect(saved.at(-1)?.parts.some((part) => part.type === "data-progress")).toBe(!collaboration);
expect(chunks.filter((chunk) => chunk.type === "finish")).toHaveLength(1);
});
it.each(["done", "error", "aborted"])("publishes the same %s terminal state to reconnect subscribers and storage", (event) => {
const run: ActiveRun = {
clientSessionId: "s", controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages([], "查询"), pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const received: Array<{ event: string; data: Record<string, unknown> }> = [];
run.subscribers.add({ write: (event, data) => received.push({ event, data }), close() {} });
const stream = createChatRunAccumulator({ run, messages: [], messageId: "answer", sessionId: "s",
collaboration: false, write() {}, onStatus() {} });
publishRunEvent(run, "progress", { id: "tool", title: "正在处理", status: "running", phase: "tool" });
publishRunEvent(run, "token", { content: "部分结果" });
publishRunEvent(run, event, { session_id: "s", message: "执行已停止" });
const canonical = stream.uiMessages().at(-1)?.parts.find((part) => part.type === "data-run_status");
expect(received.at(-2)).toMatchObject({ event: "run_status", data: { status: event === "done" ? "completed" : event } });
expect(canonical).toMatchObject({ data: { status: received.at(-2)?.data.status } });
});
@@ -0,0 +1,58 @@
import { createChatRunAccumulator } from "../../src/routes/chat-run-accumulator.js";
import { expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLocalAgentContext } from "../../src/context/localContext.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import type { ActiveRun } from "../../src/routes/chatUiState.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it.each([false, true])("persists permission replies in the owner's workspace (native failure=%s)", async (nativeFailure) => {
const root = await mkdtemp(join(tmpdir(), "permission-persistence-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
const context = { ...getLocalAgentContext(), sessionId: "permission-session" };
await sessions.ensure(context);
const messages = [{ id: "answer", role: "assistant", permissions: [] }];
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages });
let calls = 0;
const runtime = { replyPermission: async () => { calls++; if (nativeFailure) throw new Error("native unavailable"); } } as unknown as OpencodeRuntimeAdapter;
const run: ActiveRun = {
clientSessionId: context.sessionId, controller: new AbortController(), messages,
pendingPermissions: new Map([["permission", { request_id: "permission", session_id: context.sessionId, permission: "shell", patterns: [], always: [], created_at: Date.now() }]]),
pendingQuestions: new Map(), subscribers: new Set(), status: "running",
};
const accumulator = createChatRunAccumulator({ run, messages: [], messageId: "answer", sessionId: context.sessionId,
collaboration: false, write() {}, onStatus() {} });
accumulator.apply("permission_request", run.pendingPermissions.get("permission")! as unknown as Record<string, unknown>);
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: run.messages });
const app = express(); app.use(express.json());
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/permission/permission/reply`;
const reply = (headers = {}) => fetch(url, { method: "POST", headers: { "Content-Type": "application/json", ...headers }, body: JSON.stringify({ session_id: context.sessionId, reply: "once" }) });
try {
expect((await reply({ "X-Agent-Local-User": "other" })).status).toBe(404);
expect(calls).toBe(0);
expect((await reply()).status).toBe(nativeFailure ? 502 : 202);
expect(calls).toBe(1);
expect(run.pendingPermissions.has("permission")).toBe(nativeFailure);
const saved = await states.read(context);
expect(saved?.isTitleManuallyEdited).toBe(true);
const parts = (saved?.messages[0] as { parts: Array<{ type: string; data: Record<string, unknown> }> }).parts;
expect(parts.find((part) => part.type === "data-permission_response")?.data).toMatchObject(nativeFailure
? { request_id: "permission", status: "error", error: "native unavailable" }
: { request_id: "permission", reply: "once" });
expect(run.messages[0]).toMatchObject({ permissions: [{ requestId: "permission", status: nativeFailure ? "error" : "approved_once" }] });
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+71
View File
@@ -0,0 +1,71 @@
import { expect, it } from "bun:test";
import express from "express";
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { UIMessage } from "ai";
import { getLocalAgentContext } from "../../src/context/localContext.js";
import { createChatRunAccumulator } from "../../src/routes/chat-run-accumulator.js";
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
import { createInitialStreamingMessages, type ActiveRun } from "../../src/routes/chatUiState.js";
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
it("keeps historical question invalidation across the next live snapshot", async () => {
const root = await mkdtemp(join(tmpdir(), "question-persistence-"));
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
const context = { ...getLocalAgentContext(), sessionId: "history-session" };
await sessions.ensure(context);
const history: UIMessage[] = [{ id: "old-answer", role: "assistant", parts: [
{ type: "data-question_request", data: { request_id: "old-question", session_id: context.sessionId,
questions: [{ question: "继续吗?", options: [] }] } },
{ type: "data-final_answer", data: { content: "此前的结果" } },
] }, { id: "user", role: "user", parts: [{ type: "text", text: "新一轮" }] }];
const run: ActiveRun = {
clientSessionId: context.sessionId, controller: new AbortController(), status: "running",
messages: createInitialStreamingMessages(history, "新一轮"),
pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(),
};
const stream = createChatRunAccumulator({ run, messages: history, messageId: "current-answer",
sessionId: context.sessionId, collaboration: false, write() {}, onStatus() {} });
stream.apply("token", { content: "正在核查" });
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: stream.uiMessages() });
let nativeReads = 0;
const runtime = { getQuestionStatus: async () => { nativeReads++; return "missing"; } } as unknown as OpencodeRuntimeAdapter;
const app = express();
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime,
sessionMetadataStore: sessions, sessionUiStateStore: states });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const url = `http://127.0.0.1:${(server.address() as { port: number }).port}/session/${context.sessionId}/questions/old-question`;
const resolved = (messages: unknown[]) => {
const message = messages[0] as UIMessage;
expect(message.id).toBe("old-answer");
const part = message.parts.find((part) => part.type === "data-question_response");
expect(part).toMatchObject({ type: "data-question_response", data: { request_id: "old-question", status: "invalidated" } });
};
try {
expect((await fetch(url, { headers: { "X-Agent-Local-User": "other" } })).status).toBe(404);
expect(nativeReads).toBe(0);
const response = await fetch(url);
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({ status: "invalidated" });
resolved((await states.read(context))!.messages);
resolved(run.messages);
resolved(stream.uiMessages());
expect(stream.uiMessages().at(-1)?.parts.some((part) => part.type === "data-question_response")).toBe(false);
stream.apply("token", { content: ",继续查询" });
await states.write(context, { ...(await states.read(context))!, messages: stream.uiMessages() });
resolved((await states.read(context))!.messages);
expect((await states.read(context))?.isTitleManuallyEdited).toBe(true);
await fetch(url);
expect(nativeReads).toBe(1);
} finally {
await new Promise<void>((resolve) => server.close(() => resolve()));
await rm(root, { recursive: true, force: true });
}
});
+30
View File
@@ -296,6 +296,11 @@ it("marks an explicitly aborted response incomplete and preserves scoped history
const text = await response.text(); const text = await response.text();
expect(text).toContain("event: response.incomplete"); expect(text).toContain("event: response.incomplete");
expect(text).not.toContain("event: response.completed"); expect(text).not.toContain("event: response.completed");
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const saved = (await states.read(context))!.messages as Array<{ parts: Array<{ type: string; data?: { status?: string } }> }>;
expect(saved.at(-1)?.parts.find((part) => part.type === "data-run_status")?.data?.status).toBe("aborted");
const loaded = await http(`${base}/api/v1/session/owned`, { headers: { Authorization: "Bearer owner", "X-Project-Id": project } });
expect((await loaded.json()).messages).toEqual(saved);
}); });
it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => { it("continues a paged conversation from the complete saved history and preserves client message IDs", async () => {
@@ -316,3 +321,28 @@ it("continues a paged conversation from the complete saved history and preserves
expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409); expect((await call({ input: "重复提交", conversation: "owned", tjwater: { client_message_id: "client-turn-a" } })).status).toBe(409);
expect(prompted).toHaveLength(1); expect(prompted).toHaveLength(1);
}); });
it("uses the same canonical live messages for session reads and older pages", async () => {
gate = new Promise<void>((resolve) => { release = resolve; });
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), sessionId: "owned" };
const history = Array.from({ length: 5 }, (_, index) => ({ id: `old-${index}`, role: index % 2 ? "assistant" : "user",
parts: [{ type: "text", text: `历史 ${index}` }] as unknown[] }));
history[1].parts.push({ type: "data-question_request", data: { request_id: "old-question", session_id: "owned", questions: [] } });
await states.write(context, { sessionId: "owned", messages: history });
const response = await call({ input: "新一轮", conversation: "owned", stream: true, tjwater: { client_message_id: "live-user" } });
const headers = { Authorization: "Bearer owner", "X-Project-Id": project };
const live = await http(`${base}/api/v1/session/owned`, { headers });
const snapshot = await live.json();
expect(snapshot.is_streaming).toBe(true);
expect(snapshot.messages).toHaveLength(7);
expect(snapshot.messages.slice(0, 5)).toEqual(history);
expect(snapshot.messages[5].id).toBe("live-user");
const recent = await http(`${base}/api/v1/session/owned?message_limit=2`, { headers });
const page = await recent.json();
expect(page.messages).toEqual(snapshot.messages.slice(-2));
const older = await http(`${base}/api/v1/session/owned/messages?limit=40&before=${encodeURIComponent(page.message_page.before_cursor)}`, { headers });
expect(older.status).toBe(200);
expect((await older.json()).messages).toEqual(history);
release();
await response.text();
});
+39
View File
@@ -0,0 +1,39 @@
import { expect, it } from "bun:test";
import { createHash } from "node:crypto";
import { copyFile, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { astToString, COMMENT_HEADER, default as openapiTS } from "openapi-typescript";
it.each(["valid", "invalid-schema", "invalid-digest"])("synchronizes the complete artifact group or preserves it (%s)", async (scenario) => {
const root = await mkdtemp(join(tmpdir(), "server-contract-sync-"));
try {
for (const directory of ["scripts", "openapi", "src/generated", "upstream"]) await mkdir(join(root, directory), { recursive: true });
await copyFile(new URL("../../scripts/server-contract.ts", import.meta.url), join(root, "scripts/server-contract.ts"));
await symlink(new URL("../../node_modules", import.meta.url).pathname, join(root, "node_modules"), "dir");
const targets = ["openapi/openapi.json", "openapi/server-manifest.json", "src/generated/server-api.d.ts"];
for (const target of targets) await writeFile(join(root, target), `original:${target}`);
const schema = { openapi: scenario === "invalid-schema" ? "9.9.9" : "3.1.0", info: { title: "Fixture", version: "1" }, paths: {} };
const raw = JSON.stringify(schema);
const manifest = { contracts: { server: { sha256: scenario === "invalid-digest" ? "incorrect" : createHash("sha256").update(raw).digest("hex") } } };
await writeFile(join(root, "upstream/schema.json"), raw);
await writeFile(join(root, "upstream/manifest.json"), JSON.stringify(manifest));
const child = Bun.spawn([process.execPath, "scripts/server-contract.ts", "--sync", "--upstream", "upstream/schema.json"], {
cwd: root, stdout: "pipe", stderr: "pipe",
});
const [code, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
if (scenario === "valid") {
expect(code).toBe(0);
expect(await readFile(join(root, targets[0]), "utf8")).toBe(raw);
expect(JSON.parse(await readFile(join(root, targets[1]), "utf8"))).toEqual(manifest);
expect(await readFile(join(root, targets[2]), "utf8")).toBe(COMMENT_HEADER + astToString(await openapiTS(schema)));
} else {
expect(code).not.toBe(0);
expect(stderr).toContain(scenario === "invalid-schema" ? "Unsupported OpenAPI version" : "contract digest differs");
for (const target of targets) expect(await readFile(join(root, target), "utf8")).toBe(`original:${target}`);
}
expect((await readdir(join(root, "openapi"))).sort()).toEqual(["openapi.json", "server-manifest.json"]);
} finally {
await rm(root, { recursive: true, force: true });
}
});
+45
View File
@@ -0,0 +1,45 @@
import { expect, it } from "bun:test";
import express from "express";
import type { OperationalTaskClient } from "../../src/product/operational-task-client.js";
import { registerTaskCommandBridge } from "../../src/routes/task-command-bridge.js";
import { removeRuntimeSessionContext, setRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
it("preserves command-specific session purposes and trusted bridge authentication", async () => {
const calls: unknown[][] = [];
const client = {
requestDataAccess: async (...args: unknown[]) => { calls.push(args); return {}; },
proposeAction: async (...args: unknown[]) => { calls.push(args); throw Object.assign(new Error("conflict"), { status: 409 }); },
addHumanEvidence: async (...args: unknown[]) => { calls.push(args); return {}; },
} as unknown as Pick<OperationalTaskClient, "requestDataAccess" | "proposeAction" | "addHumanEvidence">;
for (const purpose of ["task_execution", "task_collaboration"] as const) setRuntimeSessionContext({
sessionId: purpose, clientSessionId: `client-${purpose}`, taskId: "task", purpose,
actorKey: "actor", projectKey: "project", traceId: "test",
});
const app = express(); app.use(express.json());
registerTaskCommandBridge(app, { internalToken: "test-token", client });
const server = app.listen(0, "127.0.0.1");
await new Promise<void>((resolve) => server.once("listening", resolve));
const post = (kind: string, session: string, data: object, headers = {}) => fetch(`http://127.0.0.1:${(server.address() as { port: number }).port}/internal/operational-task/${kind}`, {
method: "POST", headers: { "Content-Type": "application/json", "x-agent-internal-token": "test-token", ...headers }, body: JSON.stringify({ session_id: session, ...data }),
});
const requestId = "60000000-0000-4000-8000-000000000001";
const access = { request: { requestId, nodeId: "read", scopes: ["scada_devices"], reason: "查询" } };
const evidence = { command: { request_id: requestId, evidence: { evidence_id: "e1", kind: "human_input", summary: "已复核", source_label: "用户", observed_at: "2026-11-09T00:00:00Z" } } };
try {
expect((await post("access-requests", "task_execution", access, { Origin: "http://browser.test" })).status).toBe(403);
expect((await post("access-requests", "task_execution", access, { "x-agent-internal-token": "wrong" })).status).toBe(403);
expect((await post("access-requests", "task_collaboration", access)).status).toBe(403);
expect((await post("human-evidence", "task_execution", evidence)).status).toBe(403);
expect(calls).toHaveLength(0);
expect((await post("access-requests", "task_execution", access)).status).toBe(200);
expect(calls[0]).toEqual(["task_execution", "task", { ...access.request, sourceSessionId: "client-task_execution" }]);
expect((await post("human-evidence", "task_collaboration", evidence)).status).toBe(200);
const action = { action: { request_id: requestId, reason: "核验", kind: "create_work_order", payload: { title: "采样", description: "核验", work_order_type: "general" } } };
expect((await post("actions", "task_execution", action)).status).toBe(409);
expect((await post("actions", "task_collaboration", action)).status).toBe(409);
expect((await post("actions", "unknown", action)).status).toBe(403);
} finally {
removeRuntimeSessionContext("task_execution"); removeRuntimeSessionContext("task_collaboration");
await new Promise<void>((resolve) => server.close(() => resolve()));
}
});
+3 -3
View File
@@ -10,10 +10,10 @@ it("restores plan history independently of chat messages after reconnecting", as
const store = new SessionUiStateStore(directory); const store = new SessionUiStateStore(directory);
await store.initialize(); await store.initialize();
const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }]; const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }];
await store.write({ sessionId: "session-1" }, { sessionId: "session-1", messages: [], executionPlans }); await store.write({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" }, { sessionId: "session-1", messages: [], executionPlans });
const reloaded = await new SessionUiStateStore(directory).read({ sessionId: "session-1" }); const reloaded = await new SessionUiStateStore(directory).read({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" });
expect(reloaded?.executionPlans).toEqual(executionPlans); expect(reloaded?.executionPlans).toEqual(executionPlans);
expect(await store.read({ sessionId: "other-session" })).toBeNull(); expect(await store.read({ sessionId: "other-session", actorKey: "actor-test", projectKey: "project-test" })).toBeNull();
} finally { } finally {
await rm(directory, { recursive: true, force: true }); await rm(directory, { recursive: true, force: true });
} }