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:
@@ -122,7 +122,8 @@ bindings. Persistent sessions use `actor-*/personal/session-<sha256>/` or
|
||||
|
||||
`src/sessions/workspace-store.ts` owns chat directories and immutable ownership
|
||||
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
|
||||
reads and lists, session filename collision rejection, and serialized ownership
|
||||
creation. Scope changes in the frontend start a new conversation and use
|
||||
|
||||
+2
-1
@@ -70,9 +70,10 @@ COPY --from=deps /app/.opencode/node_modules ./.opencode/node_modules
|
||||
COPY package.json bun.lock ./
|
||||
COPY tsconfig.json opencode.json agent-models.json README.md .gitignore ./
|
||||
COPY scripts ./scripts
|
||||
COPY openapi ./openapi
|
||||
COPY src ./src
|
||||
COPY .opencode ./.opencode
|
||||
RUN bun run check
|
||||
RUN bun run check && bun run check:openapi
|
||||
|
||||
FROM build AS runner
|
||||
WORKDIR /app
|
||||
|
||||
@@ -572,6 +572,11 @@ OpenCode 按标准 MCP `tools/call` 调用,以 `_meta["ai.opencode/sessionID"]
|
||||
### 历史窗口接入与分页
|
||||
|
||||
历史专用接口契约见 [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/session/:session_id?message_limit=40` 返回最近消息及 `message_page: {has_more, before_cursor}`;`GET /api/v1/session/:session_id/messages?limit=40&before=...` 读取更早消息,每页内部按原有消息顺序排列。
|
||||
|
||||
@@ -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.
|
||||
|
||||
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.
|
||||
|
||||
@@ -4683,6 +4683,11 @@
|
||||
"title": "Execution Request Id",
|
||||
"type": "string"
|
||||
},
|
||||
"execution_scope": {
|
||||
"minLength": 1,
|
||||
"title": "Execution Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"handling_context": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
@@ -4846,6 +4851,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"execution_scope",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"task_type",
|
||||
@@ -5660,6 +5666,11 @@
|
||||
"title": "Execution Request Id",
|
||||
"type": "string"
|
||||
},
|
||||
"execution_scope": {
|
||||
"minLength": 1,
|
||||
"title": "Execution Scope",
|
||||
"type": "string"
|
||||
},
|
||||
"handling_context": {
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/JsonValue"
|
||||
@@ -5773,6 +5784,7 @@
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"execution_scope",
|
||||
"project_id",
|
||||
"task_id",
|
||||
"task_type",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"contract_version": "1.0.0",
|
||||
"contracts": {
|
||||
"server": {
|
||||
"file": "server-v1.openapi.json",
|
||||
"sha256": "426b8d9170f253aa7132e9376031a7da8c04ac6f6e693948890d6f343420af1b"
|
||||
}
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -11,7 +11,8 @@
|
||||
"dev": "bun --watch src/server.ts",
|
||||
"build": "bun run check",
|
||||
"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:models": "bun scripts/check-models.ts",
|
||||
"check:websearch": "bun scripts/check-websearch.ts",
|
||||
|
||||
@@ -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();
|
||||
@@ -33,8 +33,8 @@ export function createAnswerStreamProjection(options: {
|
||||
} } as UIMessageChunk);
|
||||
options.onChange();
|
||||
};
|
||||
const appendData = (event: string, data: Record<string, unknown>) => {
|
||||
if (finished) return;
|
||||
const updateData = (event: string, data: Record<string, unknown>, hostState = false) => {
|
||||
if (finished && !hostState) return;
|
||||
if (event === "answer_ui") endText();
|
||||
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");
|
||||
@@ -42,9 +42,11 @@ export function createAnswerStreamProjection(options: {
|
||||
const part = id ? { type, id, data } : { type, data };
|
||||
if (existing >= 0) parts[existing] = part;
|
||||
else parts.push(part);
|
||||
options.write(part as UIMessageChunk);
|
||||
if (!finished) options.write(part as UIMessageChunk);
|
||||
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") => {
|
||||
if (finished) return;
|
||||
if (!parts.length) appendText("Agent 已完成处理。");
|
||||
@@ -53,5 +55,5 @@ export function createAnswerStreamProjection(options: {
|
||||
options.onChange();
|
||||
options.write({ type: "finish", finishReason });
|
||||
};
|
||||
return { parts, appendText, appendData, finish, get finished() { return finished; } };
|
||||
return { parts, appendText, appendData, recordData, finish, get finished() { return finished; } };
|
||||
}
|
||||
|
||||
Vendored
+4
@@ -7507,6 +7507,8 @@ export interface components {
|
||||
* Format: uuid
|
||||
*/
|
||||
execution_request_id: string;
|
||||
/** Execution Scope */
|
||||
execution_scope: string;
|
||||
/** Handling Context */
|
||||
handling_context?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
@@ -7863,6 +7865,8 @@ export interface components {
|
||||
* Format: uuid
|
||||
*/
|
||||
execution_request_id: string;
|
||||
/** Execution Scope */
|
||||
execution_scope: string;
|
||||
/** Handling Context */
|
||||
handling_context?: {
|
||||
[key: string]: components["schemas"]["JsonValue"];
|
||||
|
||||
@@ -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] : [])];
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publishRunEvent } from "./chat-run-accumulator.js";
|
||||
import { toSessionScope } from "../sessions/session-scope.js";
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
@@ -13,9 +15,6 @@ import { type FrontendActionCoordinator } from "../frontendAction/coordinator.js
|
||||
import {
|
||||
type ActiveRun,
|
||||
type RunStatus,
|
||||
cancelBackendTodos,
|
||||
completeBackendProgress,
|
||||
updateLastAssistantMessage,
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const abortPayloadSchema = z.object({
|
||||
@@ -33,11 +32,6 @@ type RegisterAuxiliaryRoutesOptions = {
|
||||
frontendActionCoordinator: FrontendActionCoordinator;
|
||||
};
|
||||
|
||||
const toSessionUiStateContext = (record: { sessionId: string; actorKey: string; projectKey: string }) => ({
|
||||
sessionId: record.sessionId,
|
||||
actorKey: record.actorKey,
|
||||
projectKey: record.projectKey,
|
||||
});
|
||||
|
||||
export const registerChatAuxiliaryRoutes = (
|
||||
chatRouter: Router,
|
||||
@@ -80,12 +74,12 @@ export const registerChatAuxiliaryRoutes = (
|
||||
const runtimeContext = getRuntimeSessionContext(sessionRecord.sessionId);
|
||||
if (runtimeContext) setRuntimeSessionContext({ ...runtimeContext, hostQuestionClosed: true });
|
||||
activeRuns.get(sessionRecord.sessionId)?.controller.abort();
|
||||
await interactionRequestStore?.cancelPending(toSessionUiStateContext(sessionRecord));
|
||||
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionUiStateContext(sessionRecord));
|
||||
await interactionRequestStore?.cancelPending(toSessionScope(sessionRecord));
|
||||
const pendingInteraction = await interactionRequestStore?.getBlocking(toSessionScope(sessionRecord));
|
||||
if (pendingInteraction?.delivery && runtime && interactionRequestStore) {
|
||||
const status = await runtime.stopInteractionInput(sessionRecord.sessionId, pendingInteraction.delivery.native_message_id);
|
||||
const latest = await interactionRequestStore.get(toSessionUiStateContext(sessionRecord), pendingInteraction.interaction_id);
|
||||
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionUiStateContext(sessionRecord), latest.interaction_id, {
|
||||
const latest = await interactionRequestStore.get(toSessionScope(sessionRecord), pendingInteraction.interaction_id);
|
||||
if (latest?.delivery && !["delivered", "cancelled"].includes(latest.delivery.status)) await interactionRequestStore.updateDelivery(toSessionScope(sessionRecord), latest.interaction_id, {
|
||||
expected_revision: latest.delivery.revision, status,
|
||||
});
|
||||
}
|
||||
@@ -99,28 +93,21 @@ export const registerChatAuxiliaryRoutes = (
|
||||
run.status = "aborted";
|
||||
lastRunStatuses.set(parsed.data.session_id, "aborted");
|
||||
run.controller.abort();
|
||||
run.messages = updateLastAssistantMessage(run.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
publishRunEvent(run, "aborted", { session_id: parsed.data.session_id, message: "请求已中断" }, false);
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
toSessionScope(sessionRecord),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord), {
|
||||
await sessionUiStateStore.write(toSessionScope(sessionRecord), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
...currentState,
|
||||
messages: run.uiMessages?.() ?? run.messages,
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("error", {
|
||||
session_id: parsed.data.session_id,
|
||||
message: "请求已中断",
|
||||
status: "aborted",
|
||||
});
|
||||
}
|
||||
// The generation route emits its terminal Response after cleanup and
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { publishRunEvent } from "./chat-run-accumulator.js";
|
||||
import { toSessionScope } from "../sessions/session-scope.js";
|
||||
import { refreshSessionCredential } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
import { z } from "zod";
|
||||
@@ -8,9 +10,7 @@ import { type OpencodeRuntimeAdapter } from "../runtime/opencode.js";
|
||||
import { SessionContextMismatchError, type SessionMetadataStore } from "../sessions/metadataStore.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import {
|
||||
type ActiveRun,
|
||||
toPermissionStatus,
|
||||
updateLastAssistantPermission,
|
||||
type ActiveRun, applyInteractionResponse,
|
||||
} from "./chatUiState.js";
|
||||
|
||||
const permissionReplyPayloadSchema = z.object({
|
||||
@@ -35,7 +35,6 @@ type RegisterInteractionRoutesOptions = {
|
||||
sessionUiStateStore: SessionUiStateStore;
|
||||
};
|
||||
|
||||
const toSessionUiStateContext = (sessionId: string) => ({ sessionId });
|
||||
|
||||
export const registerChatInteractionRoutes = (
|
||||
chatRouter: Router,
|
||||
@@ -104,12 +103,13 @@ export const registerChatInteractionRoutes = (
|
||||
}
|
||||
const persistPermissionState = async () => {
|
||||
const currentState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord.sessionId),
|
||||
toSessionScope(sessionRecord),
|
||||
);
|
||||
await sessionUiStateStore.write(toSessionUiStateContext(sessionRecord.sessionId), {
|
||||
await sessionUiStateStore.write(toSessionScope(sessionRecord), {
|
||||
sessionId: sessionRecord.sessionId,
|
||||
isTitleManuallyEdited: currentState?.isTitleManuallyEdited ?? false,
|
||||
messages: run.messages,
|
||||
...currentState,
|
||||
messages: run.uiMessages?.() ?? run.messages,
|
||||
});
|
||||
};
|
||||
|
||||
@@ -121,18 +121,10 @@ export const registerChatInteractionRoutes = (
|
||||
message: parsed.data.message,
|
||||
});
|
||||
} catch (error) {
|
||||
run.messages = updateLastAssistantPermission(
|
||||
run.messages,
|
||||
requestId,
|
||||
(permission) => ({
|
||||
...permission,
|
||||
status: "error",
|
||||
error:
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "failed to reply permission",
|
||||
}),
|
||||
);
|
||||
publishRunEvent(run, "permission_response", {
|
||||
session_id: sessionRecord.sessionId, request_id: requestId, status: "error",
|
||||
error: error instanceof Error ? error.message : "failed to reply permission",
|
||||
});
|
||||
await persistPermissionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
@@ -146,30 +138,15 @@ export const registerChatInteractionRoutes = (
|
||||
return;
|
||||
}
|
||||
|
||||
run.pendingPermissions.delete(requestId);
|
||||
const status = toPermissionStatus(parsed.data.reply);
|
||||
run.messages = updateLastAssistantPermission(
|
||||
run.messages,
|
||||
requestId,
|
||||
(permission) => ({
|
||||
...permission,
|
||||
status,
|
||||
repliedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
publishRunEvent(run, "permission_response", {
|
||||
session_id: sessionRecord.sessionId, request_id: requestId, reply: parsed.data.reply,
|
||||
});
|
||||
await persistPermissionState().catch((persistError) => {
|
||||
logger.warn(
|
||||
{ err: persistError, sessionId: sessionRecord.sessionId },
|
||||
"failed to persist permission reply state",
|
||||
);
|
||||
});
|
||||
for (const subscriber of run.subscribers) {
|
||||
subscriber.write("permission_response", {
|
||||
session_id: sessionRecord.sessionId,
|
||||
request_id: requestId,
|
||||
reply: parsed.data.reply,
|
||||
});
|
||||
}
|
||||
|
||||
res.status(202).json({
|
||||
session_id: sessionRecord.sessionId,
|
||||
@@ -222,20 +199,15 @@ export const registerChatInteractionRoutes = (
|
||||
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
|
||||
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
|
||||
const persist = async (nextStatus: string, answers?: string[][]) => {
|
||||
const update = (messages: unknown[]) => messages.map((message) => {
|
||||
if (!isRecord(message)) return message;
|
||||
const questions = Array.isArray(message.questions) ? message.questions.map((q: unknown) => isRecord(q) && q.requestId === canonicalId ? { ...q, status: nextStatus, ...(answers ? { answers } : {}), error: undefined } : q) : undefined;
|
||||
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
|
||||
if (parts && nextStatus !== "pending" && parts.some((part: unknown) => isRecord(part) && part.type === "data-question_request" && isRecord(part.data) && part.data.request_id === canonicalId)) {
|
||||
const response = { type: "data-question_response", data: { session_id: session.sessionId, request_id: canonicalId, status: nextStatus, rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}) } };
|
||||
const index = parts.findIndex((part: unknown) => isRecord(part) && part.type === "data-question_response" && isRecord(part.data) && part.data.request_id === canonicalId);
|
||||
if (index >= 0) parts[index] = { ...parts[index], data: { ...(isRecord(parts[index]) && isRecord(parts[index].data) ? parts[index].data : {}), ...response.data } }; else parts.push(response);
|
||||
}
|
||||
return { ...message, ...(questions ? { questions } : {}), ...(parts ? { parts } : {}) };
|
||||
});
|
||||
const response = {
|
||||
session_id: session.sessionId, request_id: canonicalId, status: nextStatus,
|
||||
rejected: nextStatus === "rejected" || nextStatus === "invalidated", ...(answers ? { answers } : {}),
|
||||
};
|
||||
// Update the live projection before entering the same atomic persistence queue.
|
||||
if (run) { run.messages = update(run.messages); if (nextStatus !== "pending") run.pendingQuestions.delete(canonicalId); }
|
||||
await sessionUiStateStore.update(context, (latest) => ({ ...latest, messages: update(latest.messages) }));
|
||||
if (run && nextStatus !== "pending") publishRunEvent(run, "question_response", response);
|
||||
await sessionUiStateStore.update(context, (latest) => ({
|
||||
...latest, messages: applyInteractionResponse(latest.messages, "question_response", response),
|
||||
}));
|
||||
};
|
||||
if (action === "status" || status !== "pending") {
|
||||
await persist(status);
|
||||
@@ -246,7 +218,6 @@ export const registerChatInteractionRoutes = (
|
||||
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
|
||||
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
|
||||
await persist(action === "reply" ? "answered" : "rejected", answers);
|
||||
for (const subscriber of run?.subscribers ?? []) subscriber.write("question_response", { session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
|
||||
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
|
||||
res.status(202).json({ session_id: session.sessionId, request_id: canonicalId, ...(action === "reply" ? { answers } : { rejected: true }) });
|
||||
} catch (error) {
|
||||
|
||||
@@ -174,27 +174,6 @@ export const generateSessionTitle = async (
|
||||
}
|
||||
};
|
||||
|
||||
export const getConversationTurnStats = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
sessionId: string,
|
||||
) => {
|
||||
const messages = await runtime.messages(sessionId, 12);
|
||||
return messages.reduce(
|
||||
(stats, message) => {
|
||||
if (message.info.role === "user") {
|
||||
stats.userMessageCount += 1;
|
||||
} else if (message.info.role === "assistant") {
|
||||
stats.assistantMessageCount += 1;
|
||||
}
|
||||
return stats;
|
||||
},
|
||||
{
|
||||
userMessageCount: 0,
|
||||
assistantMessageCount: 0,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
export const buildPromptWithLearningContext = async (
|
||||
memoryStore: MemoryStore,
|
||||
actorKey: string,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { toSessionScope } from "../sessions/session-scope.js";
|
||||
import { registerChatTopicRoutes } from "./chat-topic-routes.js";
|
||||
import { matchHistoryText } from "../sessions/history-search.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
@@ -107,11 +108,6 @@ type RegisterChatSessionRoutesOptions = {
|
||||
sessionUiStateStore: SessionUiStateStore;
|
||||
};
|
||||
|
||||
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
actorKey: sessionRecord.actorKey,
|
||||
projectKey: sessionRecord.projectKey,
|
||||
});
|
||||
|
||||
const toSse = (event: string, data: Record<string, unknown>) =>
|
||||
`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`;
|
||||
@@ -176,6 +172,12 @@ export const registerChatSessionRoutes = (
|
||||
const { activeRuns, lastRunStatuses } = runState;
|
||||
const getSessionRunStatus = runState.getStatus;
|
||||
|
||||
const readMessages = (record: SessionRecord, state: { messages: unknown[] } | null) => {
|
||||
if (readPurpose(record) === "task_collaboration") return state?.messages ?? [];
|
||||
const run = activeRuns.get(record.sessionId);
|
||||
return run?.uiMessages?.() ?? run?.messages ?? state?.messages ?? [];
|
||||
};
|
||||
|
||||
const withInteractionReplies = async (record: SessionRecord, messages: unknown[]) => {
|
||||
if (!interactionRequestStore) return messages;
|
||||
const ids = new Set(messages.flatMap((message) => {
|
||||
@@ -183,7 +185,7 @@ export const registerChatSessionRoutes = (
|
||||
const value = message as Record<string, unknown>;
|
||||
return value.role === "user" && typeof value.id === "string" ? [value.id] : [];
|
||||
}));
|
||||
const replies = await interactionRequestStore.forMessages(toSessionUiStateContext(record), ids);
|
||||
const replies = await interactionRequestStore.forMessages(toSessionScope(record), ids);
|
||||
return projectInteractionReplyMessages(messages, replies);
|
||||
};
|
||||
|
||||
@@ -192,7 +194,7 @@ export const registerChatSessionRoutes = (
|
||||
|| !isGenericTaskCollaborationTitle(record.title)) return record;
|
||||
if ((!credential && !knownTaskTitle) || signal?.aborted) return record;
|
||||
try {
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const state = await sessionUiStateStore.read(toSessionScope(record));
|
||||
if (state?.isTitleManuallyEdited) return record;
|
||||
let taskTitle = knownTaskTitle;
|
||||
if (!taskTitle && credential) {
|
||||
@@ -206,7 +208,7 @@ export const registerChatSessionRoutes = (
|
||||
}
|
||||
}
|
||||
if (!taskTitle?.trim()) return record;
|
||||
const latestState = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const latestState = await sessionUiStateStore.read(toSessionScope(record));
|
||||
if (latestState?.isTitleManuallyEdited) return record;
|
||||
return await sessionMetadataStore.touch(record, { title: taskTitle }, {
|
||||
preserveUpdatedAt: true, expectedTitle: record.title ?? "",
|
||||
@@ -297,7 +299,7 @@ export const registerChatSessionRoutes = (
|
||||
sessionId,
|
||||
);
|
||||
if (!record) return null;
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const state = await sessionUiStateStore.read(toSessionScope(record));
|
||||
if (!state) return null;
|
||||
return { record, state, taskTitle };
|
||||
};
|
||||
@@ -603,7 +605,7 @@ export const registerChatSessionRoutes = (
|
||||
const titled = await sessionMetadataStore.touch(record, { title: `${history.taskTitle} · 调试` });
|
||||
const messages = await withInteractionReplies(history.record, history.state.messages ?? []);
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(titled),
|
||||
toSessionScope(titled),
|
||||
buildForkedSessionUiState({ messages }, {
|
||||
keepMessageCount: messages.length,
|
||||
targetSessionId: titled.sessionId,
|
||||
@@ -771,8 +773,8 @@ export const registerChatSessionRoutes = (
|
||||
const searchable: Array<SessionRecord & { searchSnippet?: string }> = [];
|
||||
for (const record of records) {
|
||||
const titleMatches = (record.title ?? "新对话").toLocaleLowerCase().includes((query.data.q ?? "").toLocaleLowerCase());
|
||||
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionUiStateContext(record)) : null;
|
||||
const messages = activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
const state = query.data.q && !titleMatches ? await sessionUiStateStore.read(toSessionScope(record)) : null;
|
||||
const messages = readMessages(record, state);
|
||||
searchable.push({ ...record, searchSnippet: query.data.q && !titleMatches ? matchHistoryText(messages, query.data.q) : undefined });
|
||||
}
|
||||
let page;
|
||||
@@ -828,9 +830,8 @@ export const registerChatSessionRoutes = (
|
||||
const context = getLocalAgentContext(req);
|
||||
const record = await sessionMetadataStore.get(context, req.params.session_id);
|
||||
if (!record) { res.status(404).json({ message: "session not found" }); return; }
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(record));
|
||||
const messages = readPurpose(record) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(record.sessionId)?.messages ?? state?.messages ?? [];
|
||||
const state = await sessionUiStateStore.read(toSessionScope(record));
|
||||
const messages = readMessages(record, state);
|
||||
try {
|
||||
const page = sessionMessagePage(messages, { scope: historyScope([record.actorKey, record.projectKey, record.sessionId]), ...query.data });
|
||||
res.json({ ...page, messages: await withInteractionReplies(record, page.messages) });
|
||||
@@ -871,10 +872,9 @@ export const registerChatSessionRoutes = (
|
||||
sessionRecord = await refreshCollaborationTitle(sessionRecord, getTrustedProductCredential(req));
|
||||
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sessionRecord),
|
||||
toSessionScope(sessionRecord),
|
||||
);
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: activeRuns.get(sessionRecord.sessionId)?.messages ?? state?.messages ?? [];
|
||||
const messages = readMessages(sessionRecord, state);
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
@@ -929,9 +929,8 @@ export const registerChatSessionRoutes = (
|
||||
res.flushHeaders?.();
|
||||
|
||||
const run = activeRuns.get(sessionRecord.sessionId);
|
||||
const state = await sessionUiStateStore.read(toSessionUiStateContext(sessionRecord));
|
||||
const messages = readPurpose(sessionRecord) === "task_collaboration" ? state?.messages ?? []
|
||||
: run?.messages ?? state?.messages ?? [];
|
||||
const state = await sessionUiStateStore.read(toSessionScope(sessionRecord));
|
||||
const messages = readMessages(sessionRecord, state);
|
||||
const messagePayload = query.data.message_limit ? sessionMessagePage(messages, {
|
||||
scope: historyScope([sessionRecord.actorKey, sessionRecord.projectKey, sessionId]), limit: query.data.message_limit,
|
||||
}) : { messages };
|
||||
@@ -1005,11 +1004,11 @@ export const registerChatSessionRoutes = (
|
||||
title,
|
||||
});
|
||||
const state = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
toSessionScope(nextSessionRecord),
|
||||
);
|
||||
if (state) {
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(nextSessionRecord),
|
||||
toSessionScope(nextSessionRecord),
|
||||
{
|
||||
...state,
|
||||
isTitleManuallyEdited:
|
||||
@@ -1134,7 +1133,7 @@ export const registerChatSessionRoutes = (
|
||||
}
|
||||
const sourceState = sourceSessionRecord
|
||||
? await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(sourceSessionRecord),
|
||||
toSessionScope(sourceSessionRecord),
|
||||
)
|
||||
: null;
|
||||
const forkTitle = sourceSessionRecord?.title
|
||||
@@ -1145,7 +1144,7 @@ export const registerChatSessionRoutes = (
|
||||
{ title: forkTitle },
|
||||
);
|
||||
await sessionUiStateStore.write(
|
||||
toSessionUiStateContext(titledTargetSessionRecord),
|
||||
toSessionScope(titledTargetSessionRecord),
|
||||
buildForkedSessionUiState(sourceState, {
|
||||
keepMessageCount: parsed.data.keep_message_count,
|
||||
targetSessionId: nextSessionId,
|
||||
|
||||
+23
-254
@@ -1,3 +1,4 @@
|
||||
import { toSessionScope } from "../sessions/session-scope.js";
|
||||
import type { TaskCalculationWaiter } from "../mcp/task-calculation-waiter.js";
|
||||
import { createResponsesProjection, responseRequestSchema, responsePrompt } from "../chat/responses-protocol.js";
|
||||
import { ResponseInputTracker, WaitingResponseInputQueue } from "../chat/response-inputs.js";
|
||||
@@ -5,7 +6,7 @@ import { reconcileInteractionDelivery } from "../sessions/interaction-delivery-r
|
||||
import type { InteractionRequestStore } from "../sessions/interaction-request-store.js";
|
||||
import { renderInteractionAnswer, type InteractionRequest } from "../chat/interaction-request-contract.js";
|
||||
import { createInteractionReplyMessage } from "../chat/interaction-message-presentation.js";
|
||||
import { createAnswerStreamProjection } from "../chat/answer-stream-projection.js";
|
||||
import { createChatRunAccumulator, publishRunEvent } from "./chat-run-accumulator.js";
|
||||
import type { AgentMessageCoordinator } from "../chat/agent-message-coordinator.js";
|
||||
import { credentialRefreshCoordinator } from "../product/credential-refresh.js";
|
||||
import { type Router } from "express";
|
||||
@@ -47,7 +48,6 @@ import {
|
||||
} from "../product/operational-task-client.js";
|
||||
import { type SessionTranscriptStore } from "../sessions/transcriptStore.js";
|
||||
import { type SessionUiStateStore } from "../sessions/uiStateStore.js";
|
||||
import type { UIEnvelopePayload } from "../uiEnvelope/types.js";
|
||||
import { toActorKey, toProjectKey } from "../utils/fileStore.js";
|
||||
import {
|
||||
buildPromptWithLearningContext,
|
||||
@@ -59,29 +59,14 @@ import {
|
||||
} from "./chatSession.js";
|
||||
import {
|
||||
collectTextContent,
|
||||
type PermissionRequestPayload,
|
||||
type QuestionRequestPayload,
|
||||
streamPromptResponse,
|
||||
type TodoUpdatePayload,
|
||||
} from "./chatStream.js";
|
||||
import { type ChatRunState } from "./chatRunState.js";
|
||||
import {
|
||||
type ActiveRun,
|
||||
type StreamSubscriber,
|
||||
appendBackendToolArtifact,
|
||||
appendBackendUiEnvelope,
|
||||
cancelBackendTodos,
|
||||
completeBackendProgress,
|
||||
createInitialStreamingMessages,
|
||||
isObjectRecord,
|
||||
toFrontendPermission,
|
||||
toPermissionStatus,
|
||||
updateLastAssistantMessage,
|
||||
updateLastAssistantPermission,
|
||||
updateLastAssistantQuestion,
|
||||
upsertBackendProgress,
|
||||
upsertBackendQuestion,
|
||||
upsertBackendTodoUpdate,
|
||||
} from "./chatUiState.js";
|
||||
import {
|
||||
agentTriggerContextSchema,
|
||||
@@ -106,11 +91,6 @@ const payloadSchema = z.object({
|
||||
}).strict().optional(),
|
||||
});
|
||||
|
||||
const toSessionUiStateContext = (sessionRecord: SessionRecord) => ({
|
||||
sessionId: sessionRecord.sessionId,
|
||||
actorKey: sessionRecord.actorKey,
|
||||
projectKey: sessionRecord.projectKey,
|
||||
});
|
||||
|
||||
const runtimeHasConversation = async (
|
||||
runtime: OpencodeRuntimeAdapter,
|
||||
@@ -311,7 +291,7 @@ export const registerChatStreamRoute = (
|
||||
let boundOperationalSessionId: string | undefined;
|
||||
let startingSessionId: string | undefined;
|
||||
let continuation: InteractionRequest | null = null;
|
||||
let continuationScope: ReturnType<typeof toSessionUiStateContext> | undefined;
|
||||
let continuationScope: ReturnType<typeof toSessionScope> | undefined;
|
||||
let waitingInteraction: InteractionRequest | null = null;
|
||||
try {
|
||||
const authContext = getLocalAgentContext(req);
|
||||
@@ -365,7 +345,7 @@ export const registerChatStreamRoute = (
|
||||
res.status(400).json({ message: "Interaction continuation requires an existing conversation" }); return;
|
||||
}
|
||||
if (existingSessionRecord && interactionRequestStore) {
|
||||
continuationScope = toSessionUiStateContext(existingSessionRecord);
|
||||
continuationScope = toSessionScope(existingSessionRecord);
|
||||
if (input.tjwater.interaction_id) {
|
||||
continuation = await interactionRequestStore.get(continuationScope, input.tjwater.interaction_id);
|
||||
if (!continuation?.delivery || !["answered", "skipped"].includes(continuation.status) || continuation.delivery.status === "cancelled") {
|
||||
@@ -523,7 +503,7 @@ export const registerChatStreamRoute = (
|
||||
sessionId: requestContext.clientSessionId,
|
||||
};
|
||||
const initialSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(activeSessionRecord),
|
||||
toSessionScope(activeSessionRecord),
|
||||
);
|
||||
const persistedUiMessages = toUiMessages(initialSessionState?.messages);
|
||||
if (!continuation && input.tjwater.client_message_id && persistedUiMessages.some((message) => message.id === input.tjwater.client_message_id)) {
|
||||
@@ -551,7 +531,6 @@ export const registerChatStreamRoute = (
|
||||
...visiblePersistedMessages,
|
||||
createFallbackUserUiMessage(promptText, input.tjwater.client_message_id),
|
||||
];
|
||||
let latestUiMessages = requestMessages;
|
||||
const baseMessages = toLegacyMessages(requestMessages);
|
||||
const recentTurns = await sessionTranscriptStore.getRecentTurns(historyContext, 8);
|
||||
|
||||
@@ -605,44 +584,28 @@ export const registerChatStreamRoute = (
|
||||
startingSessions.delete(activeSessionRecord.sessionId);
|
||||
startingSessionId = undefined;
|
||||
lastRunStatuses.set(clientSessionId, "running");
|
||||
const sessionUiStateContext = toSessionUiStateContext(activeSessionRecord);
|
||||
const sessionUiStateContext = toSessionScope(activeSessionRecord);
|
||||
const assistantUiMessageId = continuationAssistantId ?? `msg_${randomUUID().replaceAll("-", "")}`;
|
||||
setRuntimeSessionContext({ ...getRuntimeSessionContext(binding.sessionId)!, hostTurnId: wire.response.id, hostMessageId: assistantUiMessageId });
|
||||
const syncLatestUiMessages = () => {
|
||||
latestUiMessages = [
|
||||
...requestMessages,
|
||||
{
|
||||
id: assistantUiMessageId,
|
||||
role: "assistant",
|
||||
parts: assistantParts.map((part) => ({ ...part })),
|
||||
} as UIMessage,
|
||||
];
|
||||
};
|
||||
const writeUiChunk = (chunk: UIMessageChunk) => {
|
||||
// Persist canonical parts after disconnect; the wire emitter checks the socket.
|
||||
wire!.write(chunk);
|
||||
};
|
||||
const projection = createAnswerStreamProjection({
|
||||
messageId: assistantUiMessageId, sessionId: clientSessionId,
|
||||
write: writeUiChunk, onChange: syncLatestUiMessages,
|
||||
const accumulator = createChatRunAccumulator({
|
||||
run: activeRun, messages: requestMessages, messageId: assistantUiMessageId, sessionId: clientSessionId,
|
||||
collaboration: sessionPurpose === "task_collaboration", write: (chunk) => wire!.write(chunk),
|
||||
onStatus: (status) => lastRunStatuses.set(clientSessionId, status),
|
||||
});
|
||||
const assistantParts = projection.parts;
|
||||
const writeTextDelta = projection.appendText;
|
||||
const writeDataPart = projection.appendData;
|
||||
const finishUiMessage = projection.finish;
|
||||
const writeUiChunk = (chunk: UIMessageChunk) => wire!.write(chunk);
|
||||
writeUiChunk({ type: "start", messageId: assistantUiMessageId });
|
||||
let persistQueue = sessionUiStateStore.write(sessionUiStateContext, {
|
||||
executionPlans: executionPlanHistory,
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages,
|
||||
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
|
||||
});
|
||||
const queueSessionUiStatePersist = () => {
|
||||
const snapshot = {
|
||||
executionPlans: executionPlanHistory,
|
||||
sessionId: activeSessionRecord.sessionId,
|
||||
isTitleManuallyEdited: initialSessionState?.isTitleManuallyEdited ?? false,
|
||||
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(latestUiMessages) : latestUiMessages,
|
||||
messages: sessionPurpose === "task_collaboration" ? toCollaborationMessages(accumulator.uiMessages()) : accumulator.uiMessages(),
|
||||
};
|
||||
persistQueue = persistQueue
|
||||
.catch((error) => {
|
||||
@@ -654,36 +617,6 @@ export const registerChatStreamRoute = (
|
||||
.then(() => sessionUiStateStore.write(sessionUiStateContext, snapshot));
|
||||
return persistQueue;
|
||||
};
|
||||
const primarySubscriber: StreamSubscriber = {
|
||||
write: (event, data) => {
|
||||
if (event === "token") {
|
||||
writeTextDelta(typeof data.content === "string" ? data.content : "");
|
||||
return;
|
||||
}
|
||||
if (event === "done") {
|
||||
finishUiMessage("stop");
|
||||
return;
|
||||
}
|
||||
if (event === "error") {
|
||||
writeUiChunk({
|
||||
type: "error",
|
||||
errorText:
|
||||
typeof data.message === "string"
|
||||
? data.message
|
||||
: "Agent stream failed",
|
||||
});
|
||||
finishUiMessage("error");
|
||||
return;
|
||||
}
|
||||
writeDataPart(event, data);
|
||||
},
|
||||
close: () => {
|
||||
if (!res.writableEnded && !res.destroyed) {
|
||||
res.end();
|
||||
}
|
||||
},
|
||||
};
|
||||
activeRun.subscribers.add(primarySubscriber);
|
||||
let taskArtifactPublishQueue: Promise<unknown> = Promise.resolve();
|
||||
|
||||
const publish = (event: string, data: Record<string, unknown>) => {
|
||||
@@ -697,131 +630,11 @@ export const registerChatStreamRoute = (
|
||||
: assistantUiMessageId,
|
||||
}
|
||||
: data;
|
||||
if (event === "token") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content: `${typeof message.content === "string" ? message.content : ""}${typeof data.content === "string" ? data.content : ""}`,
|
||||
isError: false,
|
||||
}));
|
||||
} else if (event === "final_answer") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message, content: typeof data.content === "string" ? data.content : message.content,
|
||||
}));
|
||||
} else if (event === "progress") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
progress: upsertBackendProgress(message.progress, data),
|
||||
}));
|
||||
} else if (event === "done") {
|
||||
activeRun.status = data.reason === "waiting_input" ? "waiting_input" : "completed";
|
||||
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: data.reason === "waiting_input" ? "等待你的回答。" : "Agent 已完成处理,但没有生成文本回答。请查看过程记录,或换个更具体的问题重试。",
|
||||
progress: completeBackendProgress(message.progress),
|
||||
}));
|
||||
} else if (event === "error") {
|
||||
activeRun.status = activeRun.status === "aborted" ? "aborted" : "error";
|
||||
lastRunStatuses.set(clientSessionId, activeRun.status);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: `⚠️ **错误:** ${typeof data.message === "string" ? data.message : "unknown error"}`,
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
} else if (event === "permission_request") {
|
||||
const payload = data as PermissionRequestPayload;
|
||||
activeRun.pendingPermissions.set(payload.request_id, payload);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
permissions: [
|
||||
...(Array.isArray(message.permissions) ? message.permissions : []),
|
||||
toFrontendPermission(payload),
|
||||
],
|
||||
}));
|
||||
} else if (event === "permission_response") {
|
||||
const requestId =
|
||||
typeof data.request_id === "string" ? data.request_id : undefined;
|
||||
const reply =
|
||||
data.reply === "once" || data.reply === "always" || data.reply === "reject"
|
||||
? data.reply
|
||||
: undefined;
|
||||
if (requestId && reply) {
|
||||
activeRun.pendingPermissions.delete(requestId);
|
||||
activeRun.messages = updateLastAssistantPermission(
|
||||
activeRun.messages,
|
||||
requestId,
|
||||
(permission) => ({
|
||||
...permission,
|
||||
status: toPermissionStatus(reply),
|
||||
repliedAt: Date.now(),
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (event === "question_request") {
|
||||
const payload = data as QuestionRequestPayload;
|
||||
let shouldTrackQuestion = true;
|
||||
if (payload.tool?.callID) {
|
||||
if (payload.request_id !== payload.tool.callID) {
|
||||
activeRun.pendingQuestions.delete(payload.tool.callID);
|
||||
} else {
|
||||
const hasActionableQuestion = [...activeRun.pendingQuestions.values()].some(
|
||||
(question) =>
|
||||
question.tool?.callID === payload.tool?.callID &&
|
||||
question.request_id !== payload.tool?.callID,
|
||||
);
|
||||
if (hasActionableQuestion) {
|
||||
activeRun.messages = updateLastAssistantMessage(
|
||||
activeRun.messages,
|
||||
(message) => ({
|
||||
...message,
|
||||
questions: upsertBackendQuestion(message.questions, payload),
|
||||
}),
|
||||
);
|
||||
shouldTrackQuestion = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (shouldTrackQuestion) {
|
||||
activeRun.pendingQuestions.set(payload.request_id, payload);
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
questions: upsertBackendQuestion(message.questions, payload),
|
||||
}));
|
||||
}
|
||||
} else if (event === "question_response") {
|
||||
const requestId =
|
||||
typeof data.request_id === "string" ? data.request_id : undefined;
|
||||
if (requestId) {
|
||||
activeRun.pendingQuestions.delete(requestId);
|
||||
activeRun.messages = updateLastAssistantQuestion(
|
||||
activeRun.messages,
|
||||
requestId,
|
||||
(question) => ({
|
||||
...question,
|
||||
status: data.rejected === true ? "rejected" : "answered",
|
||||
repliedAt: Date.now(),
|
||||
answers: Array.isArray(data.answers) ? data.answers : question.answers,
|
||||
error: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
} else if (event === "execution_plan") {
|
||||
if (event === "execution_plan") {
|
||||
try {
|
||||
const snapshot = createExecutionPlanSnapshot(data, [{ executionPlans: executionPlanHistory }], clientSessionId);
|
||||
executionPlanHistory = [...executionPlanHistory, snapshot];
|
||||
subscriberData = snapshot;
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
executionPlans: [...(Array.isArray(message.executionPlans) ? message.executionPlans : []), snapshot],
|
||||
}));
|
||||
if (activeSessionRecord.taskId && executionPlanNeedsTaskPublish(data)) {
|
||||
taskArtifactPublishQueue = taskArtifactPublishQueue
|
||||
.catch(() => undefined)
|
||||
@@ -843,23 +656,6 @@ export const registerChatStreamRoute = (
|
||||
});
|
||||
return;
|
||||
}
|
||||
} else if (event === "todo_update") {
|
||||
const payload = data as TodoUpdatePayload;
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
todos: upsertBackendTodoUpdate(message.todos, payload),
|
||||
}));
|
||||
} else if (event === "tool_call") {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
artifacts: appendBackendToolArtifact(message.artifacts, data),
|
||||
}));
|
||||
} else if (event === "ui_envelope") {
|
||||
const payload = data as UIEnvelopePayload;
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
uiEnvelopes: appendBackendUiEnvelope(message.uiEnvelopes, payload),
|
||||
}));
|
||||
}
|
||||
|
||||
const hiddenCollaborationEvent = sessionPurpose === "task_collaboration" && [
|
||||
@@ -869,12 +665,7 @@ export const registerChatStreamRoute = (
|
||||
"ui_envelope",
|
||||
"execution_plan",
|
||||
].includes(event);
|
||||
if (!hiddenCollaborationEvent) {
|
||||
for (const subscriber of activeRun.subscribers) {
|
||||
subscriber.write(event, subscriberData);
|
||||
}
|
||||
}
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({ ...message, id: assistantUiMessageId, parts: assistantParts.map((part) => ({ ...part })) }));
|
||||
publishRunEvent(activeRun, event, subscriberData, !hiddenCollaborationEvent);
|
||||
void queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
});
|
||||
@@ -944,10 +735,7 @@ export const registerChatStreamRoute = (
|
||||
: typeof generation === "number" && generation > 0 ? generation : null,
|
||||
report_revision: reportRef.success ? reportRef.data.revision : null,
|
||||
};
|
||||
const part = { type: "data-task_context" as const, data: reference };
|
||||
assistantParts.push(part);
|
||||
writeUiChunk(part);
|
||||
syncLatestUiMessages();
|
||||
accumulator.apply("task_context", reference);
|
||||
}
|
||||
const preparedMessage = taskContext
|
||||
? [
|
||||
@@ -970,11 +758,8 @@ export const registerChatStreamRoute = (
|
||||
const messaging = getTrustedProductCredential(req) ? agentMessages : undefined;
|
||||
messaging?.start(activeSessionRecord);
|
||||
const inputTracker = new ResponseInputTracker(runtime, binding.sessionId, (receipt) => {
|
||||
if (receipt.status === "delivered" && !requestMessages.some((message) => message.id === receipt.id)) {
|
||||
requestMessages.push({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage);
|
||||
const tail = activeRun.messages.at(-1);
|
||||
activeRun.messages = [...activeRun.messages.slice(0, -1), { id: receipt.id, role: "user", content: receipt.text }, ...(tail ? [tail] : [])];
|
||||
syncLatestUiMessages();
|
||||
if (receipt.status === "delivered") {
|
||||
accumulator.appendInput({ id: receipt.id, role: "user", parts: [{ type: "text", text: receipt.text }] } as UIMessage, receipt.text);
|
||||
}
|
||||
publish("user_input", { session_id: clientSessionId, ...receipt });
|
||||
});
|
||||
@@ -1125,14 +910,14 @@ export const registerChatStreamRoute = (
|
||||
const assistantMessage = [...messages]
|
||||
.reverse()
|
||||
.find((message) => message.info.role === "assistant");
|
||||
const assistantText = readUiMessageText(latestUiMessages.at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
|
||||
const assistantText = readUiMessageText(accumulator.uiMessages().at(-1)) || collectTextContent(assistantMessage?.parts ?? []);
|
||||
const latestSessionRecord =
|
||||
(await sessionMetadataStore.get(
|
||||
{ actorKey, projectId, projectKey, userId },
|
||||
activeSessionRecord.sessionId,
|
||||
)) ?? activeSessionRecord;
|
||||
const latestSessionState = await sessionUiStateStore.read(
|
||||
toSessionUiStateContext(latestSessionRecord),
|
||||
toSessionScope(latestSessionRecord),
|
||||
);
|
||||
const existingSessionTitle = latestSessionRecord.title;
|
||||
let sessionTitle = existingSessionTitle;
|
||||
@@ -1229,29 +1014,13 @@ export const registerChatStreamRoute = (
|
||||
await taskArtifactPublishQueue.catch(() => undefined);
|
||||
unregisterFrontendActionSink?.();
|
||||
if (abortController.signal.aborted && !taskLeaseFailure) {
|
||||
activeRun.messages = updateLastAssistantMessage(activeRun.messages, (message) => ({
|
||||
...message,
|
||||
content:
|
||||
typeof message.content === "string" && message.content.trim()
|
||||
? message.content
|
||||
: "⚠️ **请求已中断**",
|
||||
isError: true,
|
||||
progress: completeBackendProgress(message.progress),
|
||||
todos: cancelBackendTodos(message.todos),
|
||||
}));
|
||||
if (!projection.finished) {
|
||||
writeUiChunk({
|
||||
type: "error",
|
||||
errorText: "请求已中断",
|
||||
});
|
||||
finishUiMessage("error");
|
||||
}
|
||||
publishRunEvent(activeRun, "aborted", { session_id: clientSessionId, message: "请求已中断" });
|
||||
void queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist aborted chat stream state");
|
||||
});
|
||||
}
|
||||
if (!projection.finished) {
|
||||
finishUiMessage(activeRun.status === "error" ? "error" : "stop");
|
||||
if (!accumulator.finished) {
|
||||
publishRunEvent(activeRun, activeRun.status === "error" ? "error" : "done", { session_id: clientSessionId });
|
||||
}
|
||||
await queueSessionUiStatePersist().catch((error) => {
|
||||
logger.warn({ err: error, sessionId: clientSessionId }, "failed to persist chat stream state");
|
||||
|
||||
+45
-36
@@ -15,6 +15,8 @@ export type StreamSubscriber = {
|
||||
};
|
||||
|
||||
export type ActiveRun = {
|
||||
applyEvent?: (event: string, data: Record<string, unknown>) => void;
|
||||
uiMessages?: () => unknown[];
|
||||
inputs?: ResponseInputTracker;
|
||||
clientSessionId: string;
|
||||
controller: AbortController;
|
||||
@@ -175,43 +177,50 @@ export const updateLastAssistantMessage = (
|
||||
return messages;
|
||||
};
|
||||
|
||||
export const updateLastAssistantPermission = (
|
||||
messages: unknown[],
|
||||
requestId: string,
|
||||
updater: (permission: Record<string, unknown>) => Record<string, unknown>,
|
||||
) =>
|
||||
updateLastAssistantMessage(messages, (message) => {
|
||||
const permissions = Array.isArray(message.permissions)
|
||||
? message.permissions
|
||||
: [];
|
||||
return {
|
||||
...message,
|
||||
permissions: permissions.map((permission) =>
|
||||
isObjectRecord(permission) && permission.requestId === requestId
|
||||
? updater(permission)
|
||||
: permission,
|
||||
),
|
||||
};
|
||||
});
|
||||
|
||||
export const updateLastAssistantQuestion = (
|
||||
messages: unknown[],
|
||||
requestId: string,
|
||||
updater: (question: Record<string, unknown>) => Record<string, unknown>,
|
||||
) =>
|
||||
updateLastAssistantMessage(messages, (message) => {
|
||||
const questions = Array.isArray(message.questions)
|
||||
? message.questions
|
||||
: [];
|
||||
return {
|
||||
...message,
|
||||
questions: questions.map((question) =>
|
||||
isObjectRecord(question) && question.requestId === requestId
|
||||
? updater(question)
|
||||
: question,
|
||||
),
|
||||
};
|
||||
/** Replies belong to the assistant that requested input, including earlier turns. */
|
||||
export function applyInteractionResponse<T>(
|
||||
messages: T[],
|
||||
event: "permission_response" | "question_response",
|
||||
data: Record<string, unknown>,
|
||||
): T[] {
|
||||
const requestId = data.request_id;
|
||||
if (typeof requestId !== "string") return messages;
|
||||
const permission = event === "permission_response";
|
||||
const field = permission ? "permissions" : "questions";
|
||||
const requestType = permission ? "data-permission_request" : "data-question_request";
|
||||
let changed = false;
|
||||
const next = messages.map((message) => {
|
||||
if (!isObjectRecord(message) || message.role !== "assistant") return message;
|
||||
const entries = Array.isArray(message[field]) ? message[field] as unknown[] : undefined;
|
||||
const parts = Array.isArray(message.parts) ? [...message.parts] : undefined;
|
||||
const ownsRequest = entries?.some((entry) => isObjectRecord(entry) && entry.requestId === requestId)
|
||||
|| parts?.some((part) => isObjectRecord(part) && part.type === requestType
|
||||
&& isObjectRecord(part.data) && part.data.request_id === requestId);
|
||||
if (!ownsRequest) return message;
|
||||
changed = true;
|
||||
const updated = entries?.map((entry) => {
|
||||
if (!isObjectRecord(entry) || entry.requestId !== requestId) return entry;
|
||||
return permission ? {
|
||||
...entry, status: data.status === "error" ? "error" : toPermissionStatus(data.reply as PermissionReply),
|
||||
error: data.status === "error" ? data.error : undefined, repliedAt: Date.now(),
|
||||
} : {
|
||||
...entry, status: typeof data.status === "string" ? data.status : data.rejected === true ? "rejected" : "answered",
|
||||
...(Array.isArray(data.answers) ? { answers: data.answers } : {}), error: undefined, repliedAt: Date.now(),
|
||||
};
|
||||
});
|
||||
if (parts && data.status !== "pending") {
|
||||
const type = `data-${event}`;
|
||||
const index = parts.findIndex((part) => isObjectRecord(part) && part.type === type
|
||||
&& isObjectRecord(part.data) && part.data.request_id === requestId);
|
||||
const previous = index >= 0 && isObjectRecord(parts[index]) ? parts[index] : undefined;
|
||||
const part = { ...previous, type, data: { ...(isObjectRecord(previous?.data) ? previous.data : {}), ...data } };
|
||||
if (index >= 0) parts[index] = part;
|
||||
else parts.push(part);
|
||||
}
|
||||
return { ...message, ...(updated ? { [field]: updated } : {}), ...(parts ? { parts } : {}) };
|
||||
});
|
||||
return changed ? next : messages;
|
||||
}
|
||||
|
||||
const getToolArtifactKind = (tool: string): ToolArtifactKind => {
|
||||
if (tool === "present_view") {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { z } from "zod";
|
||||
import { isTrustedInternalBridgeRequest } from "../product/internal-bridge-auth.js";
|
||||
import { operationalTaskClient, type OperationalTaskClient, taskArtifactBridgeSchema } from "../product/operational-task-client.js";
|
||||
import { taskPresentationBridgeSchema } from "../product/task-presentation.js";
|
||||
import { getRuntimeSessionContext } from "../runtime/sessionContext.js";
|
||||
import { readTaskBridgeContext, replyTaskBridgeError } from "./task-bridge-context.js";
|
||||
|
||||
const artifactsInputSchema = taskArtifactBridgeSchema.and(z.object({ session_id: z.string().trim().min(1).max(128) }));
|
||||
|
||||
@@ -19,12 +19,12 @@ export function registerTaskPublicationBridge(app: Express, options: {
|
||||
res.status(403).json({ message: "forbidden" });
|
||||
return;
|
||||
}
|
||||
const sessionId = typeof req.body?.session_id === "string" ? req.body.session_id.trim() : "";
|
||||
const context = sessionId ? getRuntimeSessionContext(sessionId) : null;
|
||||
if (!context?.taskId) {
|
||||
const bridge = readTaskBridgeContext(req, options.internalToken);
|
||||
if (!bridge) {
|
||||
res.status(403).json({ message: "operational task context unavailable" });
|
||||
return;
|
||||
}
|
||||
const { context, runtimeSessionId: sessionId, taskId } = bridge;
|
||||
if ((kind === "presentation" || req.body?.progress !== undefined) && context.purpose !== "task_execution") {
|
||||
res.status(403).json({ message: "task execution context required" });
|
||||
return;
|
||||
@@ -36,7 +36,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
|
||||
res.status(400).json({ message: "invalid task presentation", detail: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
res.json(await client.publishPresentation(sessionId, context.taskId, {
|
||||
res.json(await client.publishPresentation(sessionId, taskId, {
|
||||
requestId: parsed.data.request_id,
|
||||
sourceSessionId: context.clientSessionId,
|
||||
command: parsed.data.command,
|
||||
@@ -47,7 +47,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
|
||||
res.status(400).json({ message: "invalid task artifacts", detail: parsed.error.flatten() });
|
||||
return;
|
||||
}
|
||||
res.json(await client.publishArtifacts(sessionId, context.taskId, {
|
||||
res.json(await client.publishArtifacts(sessionId, taskId, {
|
||||
requestId: parsed.data.request_id,
|
||||
sourceSessionId: context.clientSessionId,
|
||||
evidence: parsed.data.evidence,
|
||||
@@ -57,8 +57,7 @@ export function registerTaskPublicationBridge(app: Express, options: {
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
const status = error && typeof error === "object" && "status" in error && typeof error.status === "number" ? error.status : 502;
|
||||
res.status(status).json({ message: error instanceof Error ? error.message : `task ${kind} publish failed` });
|
||||
replyTaskBridgeError(res, error, `task ${kind} publish failed`);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -381,13 +381,6 @@ export class OpencodeRuntimeAdapter {
|
||||
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) {
|
||||
const client = await this.ensureClient();
|
||||
const selected = model ?? parseModelReference(config.OPENCODE_MODEL);
|
||||
|
||||
+2
-80
@@ -37,12 +37,7 @@ import { createAgentMessageQuery, registerAgentMessageBridge } from "./routes/ag
|
||||
import { FrontendActionCoordinator, FrontendActionError } from "./frontendAction/coordinator.js";
|
||||
import { opencodeRuntime } from "./runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "./runtime/sessionContext.js";
|
||||
import {
|
||||
operationalTaskClient,
|
||||
expressAccessRequestSchema,
|
||||
humanTaskEvidenceCommandSchema,
|
||||
proposedTaskActionSchema,
|
||||
} from "./product/operational-task-client.js";
|
||||
import { registerTaskCommandBridge } from "./routes/task-command-bridge.js";
|
||||
import { isTrustedInternalBridgeRequest } from "./product/internal-bridge-auth.js";
|
||||
|
||||
const app = express();
|
||||
@@ -112,80 +107,7 @@ app.post("/internal/frontend-actions/request", async (req, res) => {
|
||||
}
|
||||
});
|
||||
|
||||
const readOperationalTaskBridgeContext = (req: express.Request) => {
|
||||
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" });
|
||||
}
|
||||
});
|
||||
registerTaskCommandBridge(app, { internalToken });
|
||||
|
||||
app.get("/health", async (_req, res) => {
|
||||
try {
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { config } from "../config.js";
|
||||
@@ -17,11 +18,7 @@ export type SessionUiStateRecord = {
|
||||
executionPlans?: Record<string, unknown>[];
|
||||
};
|
||||
|
||||
type SessionUiStateContext = {
|
||||
sessionId: string;
|
||||
actorKey?: string;
|
||||
projectKey?: string;
|
||||
};
|
||||
type SessionUiStateContext = SessionWorkspaceContext;
|
||||
|
||||
export class SessionUiStateStore {
|
||||
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -296,6 +296,11 @@ it("marks an explicitly aborted response incomplete and preserves scoped history
|
||||
const text = await response.text();
|
||||
expect(text).toContain("event: response.incomplete");
|
||||
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 () => {
|
||||
@@ -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(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();
|
||||
});
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
@@ -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()));
|
||||
}
|
||||
});
|
||||
@@ -10,10 +10,10 @@ it("restores plan history independently of chat messages after reconnecting", as
|
||||
const store = new SessionUiStateStore(directory);
|
||||
await store.initialize();
|
||||
const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }];
|
||||
await store.write({ sessionId: "session-1" }, { sessionId: "session-1", messages: [], executionPlans });
|
||||
const reloaded = await new SessionUiStateStore(directory).read({ sessionId: "session-1" });
|
||||
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", actorKey: "actor-test", projectKey: "project-test" });
|
||||
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 {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user