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((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((resolve) => server.close(() => resolve())); await rm(root, { recursive: true, force: true }); } });