refactor: streamline query requests and session storage
This commit is contained in:
@@ -203,7 +203,8 @@ export class McpQueryClient {
|
|||||||
if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required");
|
if (!owner.context.projectId || owner.context.projectKey.startsWith("personal-") || !owner.context.purpose || !["ordinary_chat", "task_execution", "task_collaboration"].includes(owner.context.purpose)) throw error(403, "Project query context required");
|
||||||
// No model-supplied identity, scope, bearer or Server URL can pass this boundary.
|
// No model-supplied identity, scope, bearer or Server URL can pass this boundary.
|
||||||
const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema;
|
const schema = this.catalog().find((tool) => tool.name === name)!.inputSchema;
|
||||||
const budget = queryTimeouts(this.tools.find((tool) => tool.name === name)!, args);
|
const definition = this.tools.find((tool) => tool.name === name)!;
|
||||||
|
const budget = queryTimeouts(definition, args);
|
||||||
const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]);
|
const reserved = new Set(["context_handle", "authorization", "headers", "project_id", "projectId", "network", "url", "server_url", "user_id", "subject", "client_id", "purpose", "native_session_id", "parent_session_id", "task_id", "scope", "scopes"]);
|
||||||
if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter");
|
if (Object.keys(args).some((key) => reserved.has(key) || !Object.hasOwn(schema.properties ?? {}, key))) throw error(400, "Unknown query parameter");
|
||||||
const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal);
|
const token = await this.userToken(owner.runtimeSessionId, owner.context.projectId, signal);
|
||||||
@@ -223,8 +224,7 @@ export class McpQueryClient {
|
|||||||
const handle = body.context_handle;
|
const handle = body.context_handle;
|
||||||
try {
|
try {
|
||||||
return await this.withClient(token.token, project, signal, async (client) => {
|
return await this.withClient(token.token, project, signal, async (client) => {
|
||||||
await client.listTools(undefined, { signal, timeout: 5000 });
|
return client.callTool({ name, arguments: { ...args, ...(definition.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs, toolDefinition: definition });
|
||||||
return client.callTool({ name, arguments: { ...args, ...(this.tools.find((tool) => tool.name === name)!.inputSchema.properties?.timeout_seconds ? { timeout_seconds: budget.serverMs / 1000 } : {}), context_handle: handle }, _meta: { "io.tjwater/callId": callId } }, { signal, timeout: budget.callMs });
|
|
||||||
}, budget.callMs);
|
}, budget.callMs);
|
||||||
} finally {
|
} finally {
|
||||||
// Expiry remains authoritative if transport cancellation prevents revocation.
|
// Expiry remains authoritative if transport cancellation prevents revocation.
|
||||||
|
|||||||
@@ -196,7 +196,8 @@ export const registerChatInteractionRoutes = (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A host-confirmed reply remains authoritative after native forms are pruned.
|
// A host-confirmed reply remains authoritative after native forms are pruned.
|
||||||
const nativeStatus = confirmedStatus ? undefined : await runtime.getQuestionStatus({ sessionId: session.sessionId, requestId: canonicalId });
|
const form = confirmedStatus ? undefined : await runtime.getQuestion({ sessionId: session.sessionId, requestId: canonicalId });
|
||||||
|
const nativeStatus = form?.state.status ?? "missing";
|
||||||
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
|
const status = confirmedStatus ?? (nativeStatus === "cancelled" ? "rejected" : nativeStatus === "missing" ? "invalidated" : nativeStatus!);
|
||||||
const persist = async (nextStatus: string, answers?: string[][]) => {
|
const persist = async (nextStatus: string, answers?: string[][]) => {
|
||||||
const response = {
|
const response = {
|
||||||
@@ -215,7 +216,7 @@ export const registerChatInteractionRoutes = (
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
|
const answers = action === "reply" ? questionReplyPayloadSchema.parse(req.body).answers : undefined;
|
||||||
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [] });
|
if (action === "reply") await runtime.replyQuestion({ sessionId: session.sessionId, requestId: canonicalId, answers: answers ?? [], form: form! });
|
||||||
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
|
else await runtime.rejectQuestion({ sessionId: session.sessionId, requestId: canonicalId });
|
||||||
await persist(action === "reply" ? "answered" : "rejected", answers);
|
await persist(action === "reply" ? "answered" : "rejected", answers);
|
||||||
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
|
if (run && run.status !== "running" && !run.pendingPermissions.size && !run.pendingQuestions.size) activeRuns.delete(session.sessionId);
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { LOCAL_QUERY_TOOL_IDS, PRODUCT_MCP_TOOL_IDS, PRODUCT_MCP_WRITE_TOOL_IDS,
|
|||||||
import { assertWebSearchReady } from "./web-search-check.js";
|
import { assertWebSearchReady } from "./web-search-check.js";
|
||||||
import { allowedQueryTools } from "./query-mcp-policy.js";
|
import { allowedQueryTools } from "./query-mcp-policy.js";
|
||||||
import { setTimeout as delayAbortable } from "node:timers/promises";
|
import { setTimeout as delayAbortable } from "node:timers/promises";
|
||||||
import { isMessageNotFoundError, isSessionNotFoundError, type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
import { isMessageNotFoundError, isSessionNotFoundError, type FormDetail, type OpenCodeClient as OpencodeClient } from "@opencode/client";
|
||||||
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
|
import { createRuntimeClient, startOpencode2 } from "./opencode2-server.js";
|
||||||
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
|
import { formAnswers, normalizeRuntimeMessage, Opencode2EventNormalizer } from "./opencode2-events.js";
|
||||||
import { existsSync, readFileSync } from "node:fs";
|
import { existsSync, readFileSync } from "node:fs";
|
||||||
@@ -726,19 +726,18 @@ export class OpencodeRuntimeAdapter {
|
|||||||
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
|
return client.permission.reply({ sessionID: options.sessionId, requestID: options.requestId, decision: options.reply, message: options.message });
|
||||||
}
|
}
|
||||||
|
|
||||||
async replyQuestion(options: { requestId: string; sessionId?: string; answers: QuestionAnswers }) {
|
async replyQuestion(options: { requestId: string; sessionId: string; answers: QuestionAnswers; form: FormDetail }) {
|
||||||
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
|
if (!options.sessionId) throw new Error("OpenCode 2 form replies require sessionId");
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
const form = await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId });
|
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(options.form, options.answers) });
|
||||||
return client.session.form.reply({ sessionID: options.sessionId, formID: options.requestId, answer: formAnswers(form, options.answers) });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getQuestionStatus(options: { requestId: string; sessionId: string }): Promise<"pending" | "answered" | "cancelled" | "missing"> {
|
async getQuestion(options: { requestId: string; sessionId: string }): Promise<FormDetail | null> {
|
||||||
const client = await this.ensureClient();
|
const client = await this.ensureClient();
|
||||||
try { return (await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId })).state.status; }
|
try { return await client.session.form.get({ sessionID: options.sessionId, formID: options.requestId }); }
|
||||||
catch (error) {
|
catch (error) {
|
||||||
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
|
const tag = error && typeof error === "object" && "_tag" in error ? error._tag : undefined;
|
||||||
if (tag === "FormNotFoundError") return "missing";
|
if (tag === "FormNotFoundError") return null;
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-4
@@ -48,9 +48,9 @@ const sessionWorkspaces = new SessionWorkspaceStore(config.CONVERSATION_WORKSPAC
|
|||||||
const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces);
|
const sessionMetadataStore = new SessionMetadataStore(undefined, sessionWorkspaces);
|
||||||
const answerViewStateStore = new AnswerViewStateStore(sessionWorkspaces);
|
const answerViewStateStore = new AnswerViewStateStore(sessionWorkspaces);
|
||||||
const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces);
|
const interactionRequestStore = new InteractionRequestStore(sessionWorkspaces);
|
||||||
const sessionUiStateStore = new SessionUiStateStore(undefined, sessionWorkspaces);
|
const sessionUiStateStore = new SessionUiStateStore(sessionWorkspaces);
|
||||||
const memoryStore = new MemoryStore();
|
const memoryStore = new MemoryStore();
|
||||||
const sessionTranscriptStore = new SessionTranscriptStore(undefined, sessionWorkspaces);
|
const sessionTranscriptStore = new SessionTranscriptStore(sessionWorkspaces);
|
||||||
const learningOrchestrator = new LearningOrchestrator(
|
const learningOrchestrator = new LearningOrchestrator(
|
||||||
opencodeRuntime,
|
opencodeRuntime,
|
||||||
memoryStore,
|
memoryStore,
|
||||||
@@ -169,10 +169,8 @@ const bootstrap = async () => {
|
|||||||
logger.info(migrated, "session records migrated to owned workspaces; legacy sources archived");
|
logger.info(migrated, "session records migrated to owned workspaces; legacy sources archived");
|
||||||
}
|
}
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
sessionUiStateStore.initialize(),
|
|
||||||
learningOrchestrator.initialize(),
|
learningOrchestrator.initialize(),
|
||||||
memoryStore.initialize(),
|
memoryStore.initialize(),
|
||||||
sessionTranscriptStore.initialize(),
|
|
||||||
]);
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { config } from "../config.js";
|
import { config } from "../config.js";
|
||||||
import {
|
import {
|
||||||
atomicWriteJson,
|
|
||||||
ensureDirectory,
|
|
||||||
listJsonFiles,
|
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
toStableId,
|
toStableId,
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
@@ -50,11 +45,7 @@ const DEFAULT_SEARCH_MAX_QUERY_CHARS = 240;
|
|||||||
export class SessionTranscriptStore {
|
export class SessionTranscriptStore {
|
||||||
private readonly writeQueues = new Map<string, Promise<void>>();
|
private readonly writeQueues = new Map<string, Promise<void>>();
|
||||||
|
|
||||||
constructor(private readonly baseDir = config.SESSION_TRANSCRIPT_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
|
constructor(private readonly workspaces: SessionWorkspaceStore) {}
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
if (!this.workspaces) await ensureDirectory(this.baseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
async appendTurn(
|
async appendTurn(
|
||||||
context: SessionTranscriptContext,
|
context: SessionTranscriptContext,
|
||||||
@@ -66,7 +57,7 @@ export class SessionTranscriptStore {
|
|||||||
) {
|
) {
|
||||||
const key = this.filePath(context);
|
const key = this.filePath(context);
|
||||||
return this.serializeWrite(key, async () => {
|
return this.serializeWrite(key, async () => {
|
||||||
if (this.workspaces) await this.workspaces.ensure(this.workspaceContext(context));
|
await this.workspaces.ensure(this.workspaceContext(context));
|
||||||
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
|
// 同一会话的多次写入串行化,防止流式结束和 UI 同步同时写同一个 transcript。
|
||||||
const transcript = (await this.readTranscript(context)) ?? {
|
const transcript = (await this.readTranscript(context)) ?? {
|
||||||
actorKey: context.actorKey,
|
actorKey: context.actorKey,
|
||||||
@@ -148,10 +139,8 @@ export class SessionTranscriptStore {
|
|||||||
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
|
turns: projectTurnsForFork(sourceTranscript?.turns ?? [], keepMessageCount),
|
||||||
updatedAt: timestamp,
|
updatedAt: timestamp,
|
||||||
};
|
};
|
||||||
if (this.workspaces) {
|
await this.workspaces.ensure(this.workspaceContext(targetContext));
|
||||||
await this.workspaces.ensure(this.workspaceContext(targetContext));
|
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
|
||||||
await this.workspaces.validate(this.workspaceContext(targetContext), "transcript.json");
|
|
||||||
}
|
|
||||||
await this.persist(targetContext, nextTranscript);
|
await this.persist(targetContext, nextTranscript);
|
||||||
return nextTranscript;
|
return nextTranscript;
|
||||||
}
|
}
|
||||||
@@ -168,7 +157,7 @@ export class SessionTranscriptStore {
|
|||||||
}
|
}
|
||||||
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
const queryTokens = normalizedQuery.split(/\s+/).filter(Boolean);
|
||||||
const hits: SessionSearchHit[] = [];
|
const hits: SessionSearchHit[] = [];
|
||||||
const files = this.workspaces ? await this.workspaces.list(context, "transcript.json") : await listJsonFiles(this.baseDir);
|
const files = await this.workspaces.list(context, "transcript.json");
|
||||||
for (const file of files) {
|
for (const file of files) {
|
||||||
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
const transcript = await readJsonFile<SessionTranscriptRecord>(file);
|
||||||
if (!transcript) {
|
if (!transcript) {
|
||||||
@@ -205,7 +194,7 @@ export class SessionTranscriptStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async readTranscript(context: SessionTranscriptContext) {
|
private async readTranscript(context: SessionTranscriptContext) {
|
||||||
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
|
if (!await this.workspaces.validate(this.workspaceContext(context), "transcript.json")) return null;
|
||||||
const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
const record = await readJsonFile<SessionTranscriptRecord>(this.filePath(context));
|
||||||
if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey ||
|
if (record && (record.actorKey !== context.actorKey || record.projectKey !== context.projectKey ||
|
||||||
(record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch");
|
(record.clientSessionId || record.sessionId) !== (context.clientSessionId || context.sessionId))) throw new Error("chat transcript ownership mismatch");
|
||||||
@@ -213,16 +202,11 @@ export class SessionTranscriptStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private filePath(context: SessionTranscriptContext) {
|
private filePath(context: SessionTranscriptContext) {
|
||||||
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "transcript.json");
|
return this.workspaces.path(this.workspaceContext(context), "transcript.json");
|
||||||
return join(
|
|
||||||
this.baseDir,
|
|
||||||
`${context.actorKey}__${context.projectKey}__${context.sessionId}.json`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) {
|
private async persist(context: SessionTranscriptContext, transcript: SessionTranscriptRecord) {
|
||||||
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
|
await this.workspaces.writeRecord(this.workspaceContext(context), "transcript.json", transcript);
|
||||||
else await atomicWriteJson(this.filePath(context), transcript);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private workspaceContext(context: SessionTranscriptContext) {
|
private workspaceContext(context: SessionTranscriptContext) {
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
import type { SessionWorkspaceContext } from "../runtime/conversationWorkspace.js";
|
||||||
import { join } from "node:path";
|
|
||||||
|
|
||||||
import { config } from "../config.js";
|
|
||||||
import { type SessionWorkspaceStore } from "./workspace-store.js";
|
import { type SessionWorkspaceStore } from "./workspace-store.js";
|
||||||
import {
|
import {
|
||||||
atomicWriteJson,
|
|
||||||
ensureDirectory,
|
|
||||||
readJsonFile,
|
readJsonFile,
|
||||||
removeFileIfExists,
|
removeFileIfExists,
|
||||||
slugify,
|
|
||||||
} from "../utils/fileStore.js";
|
} from "../utils/fileStore.js";
|
||||||
|
|
||||||
export type SessionUiStateRecord = {
|
export type SessionUiStateRecord = {
|
||||||
@@ -21,49 +16,36 @@ export type SessionUiStateRecord = {
|
|||||||
type SessionUiStateContext = SessionWorkspaceContext;
|
type SessionUiStateContext = SessionWorkspaceContext;
|
||||||
|
|
||||||
export class SessionUiStateStore {
|
export class SessionUiStateStore {
|
||||||
constructor(private readonly baseDir = config.SESSION_UI_STATE_STORAGE_DIR, private readonly workspaces?: SessionWorkspaceStore) {}
|
constructor(private readonly workspaces: SessionWorkspaceStore) {}
|
||||||
|
|
||||||
async initialize() {
|
|
||||||
if (!this.workspaces) await ensureDirectory(this.baseDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
async read(context: SessionUiStateContext) {
|
async read(context: SessionUiStateContext) {
|
||||||
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
|
if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return null;
|
||||||
return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
|
return await readJsonFile<SessionUiStateRecord>(this.filePath(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
|
async write(context: SessionUiStateContext, state: SessionUiStateRecord) {
|
||||||
if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
|
if (state.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
|
||||||
if (this.workspaces) {
|
await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
|
||||||
await this.workspaces.ensure(this.workspaceContext(context));
|
|
||||||
await this.workspaces.validate(this.workspaceContext(context), "messages.json");
|
|
||||||
}
|
|
||||||
if (this.workspaces) await this.workspaces.writeRecord(this.workspaceContext(context), "messages.json", state);
|
|
||||||
else await atomicWriteJson(this.filePath(context), state);
|
|
||||||
return state;
|
return state;
|
||||||
}
|
}
|
||||||
|
|
||||||
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
|
async update(context: SessionUiStateContext, change: (state: SessionUiStateRecord) => SessionUiStateRecord) {
|
||||||
if (this.workspaces) return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
|
return this.workspaces.updateRecord(this.workspaceContext(context), "messages.json", async ({ current }) => {
|
||||||
const state = current as SessionUiStateRecord | null;
|
const state = current as SessionUiStateRecord | null;
|
||||||
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
|
if (!state || state.sessionId !== context.sessionId || !Array.isArray(state.messages)) throw new Error("chat state unavailable");
|
||||||
const next = change(state);
|
const next = change(state);
|
||||||
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
|
if (next.sessionId !== context.sessionId) throw new Error("chat state session mismatch");
|
||||||
return { result: next, value: next };
|
return { result: next, value: next };
|
||||||
});
|
});
|
||||||
const state = await this.read(context);
|
|
||||||
if (!state) throw new Error("chat state unavailable");
|
|
||||||
return this.write(context, change(state));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async remove(context: SessionUiStateContext) {
|
async remove(context: SessionUiStateContext) {
|
||||||
if (this.workspaces && !await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
|
if (!await this.workspaces.validate(this.workspaceContext(context), "messages.json")) return;
|
||||||
await removeFileIfExists(this.filePath(context));
|
await removeFileIfExists(this.filePath(context));
|
||||||
}
|
}
|
||||||
|
|
||||||
private filePath(context: SessionUiStateContext) {
|
private filePath(context: SessionUiStateContext) {
|
||||||
if (this.workspaces) return this.workspaces.path(this.workspaceContext(context), "messages.json");
|
return this.workspaces.path(this.workspaceContext(context), "messages.json");
|
||||||
return join(this.baseDir, `${slugify(context.sessionId)}.json`);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private workspaceContext(context: SessionUiStateContext) {
|
private workspaceContext(context: SessionUiStateContext) {
|
||||||
|
|||||||
@@ -99,11 +99,11 @@ describe("locally rendered answers", () => {
|
|||||||
try {
|
try {
|
||||||
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
|
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const store = new SessionUiStateStore(join(root, "legacy"), workspaces);
|
const store = new SessionUiStateStore(workspaces);
|
||||||
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
|
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍待核验。" }];
|
||||||
const messages = [{ id: "a", role: "assistant", parts }];
|
const messages = [{ id: "a", role: "assistant", parts }];
|
||||||
await store.write(owner, { sessionId: owner.sessionId, messages });
|
await store.write(owner, { sessionId: owner.sessionId, messages });
|
||||||
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root));
|
const restored = new SessionUiStateStore(new SessionWorkspaceStore(workspaces.root));
|
||||||
expect((await restored.read(owner))?.messages).toEqual(messages);
|
expect((await restored.read(owner))?.messages).toEqual(messages);
|
||||||
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
|
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
|
||||||
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
|
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ try {
|
|||||||
if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`);
|
if (response.isError) throw new Error(`Extended tool failed through merged MCP: ${name}`);
|
||||||
}
|
}
|
||||||
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
|
if (!requests.includes("server/discover") || !requests.includes("tools/call") || requests.includes("initialize") || requests.includes("GET")) throw new Error("Transport used unexpected protocol requests");
|
||||||
|
if (requests.filter((method) => method === "tools/list").length !== 1) throw new Error("Business queries fetched the catalog again after startup");
|
||||||
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
|
if (grants.join(",") !== "client_credentials,urn:ietf:params:oauth:grant-type:token-exchange") throw new Error("OAuth grants were not validated");
|
||||||
console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`);
|
console.log(`PASS: local MCP ${QUERY_MCP_TOOL_IDS.length}-tool discovery, real Server schemas, OAuth discovery, resource-bound grants, host binding, extended query and revocation over MCP HTTP`);
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -26,6 +26,24 @@ const resourceToken = jwt("resource");
|
|||||||
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
|
const token = () => Response.json({ access_token: resourceToken, token_type: "Bearer", expires_in: 300 });
|
||||||
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
|
function credential(id: string) { sessions.push(id); bindSessionCredential(id, { authorization: `Bearer ${jwt(id)}`, projectId: "project" }); }
|
||||||
|
|
||||||
|
it("uses cached tool definitions without fetching the catalog on each query", async () => {
|
||||||
|
const id = "cached-tool-definition"; credential(id);
|
||||||
|
const client = fixture((async (url, init) => {
|
||||||
|
if (String(url).includes("/token")) return token();
|
||||||
|
return init?.method === "POST"
|
||||||
|
? Response.json({ context_handle: "x".repeat(43) })
|
||||||
|
: new Response(null, { status: 204 });
|
||||||
|
}) as McpHttpFetch);
|
||||||
|
const definition = { ...catalog[0], outputSchema: { type: "object", properties: { ok: { type: "boolean" } } } };
|
||||||
|
Object.assign(client, { tools: [definition], withClient: async (_token: string, _project: string, _signal: AbortSignal, run: (client: unknown) => Promise<unknown>) => run({
|
||||||
|
callTool: async (_params: unknown, options: { toolDefinition: unknown }) => {
|
||||||
|
expect(options.toolDefinition).toBe(definition);
|
||||||
|
return { content: [], structuredContent: { ok: true } };
|
||||||
|
},
|
||||||
|
}) });
|
||||||
|
expect((await client.query(id, owner(id), definition.name, {}, "call")).structuredContent).toEqual({ ok: true });
|
||||||
|
});
|
||||||
|
|
||||||
it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => {
|
it("owns simulation HTTP budgets and rejects model overrides before OAuth", async () => {
|
||||||
const id = "mcp-calculation-budget"; credential(id);
|
const id = "mcp-calculation-budget"; credential(id);
|
||||||
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
|
let requests = 0, transportTimeout = 0, sdkTimeout = 0;
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ it("authenticates state reads and writes against the persisted session owner and
|
|||||||
const context = getLocalAgentContext(ownerRequest);
|
const context = getLocalAgentContext(ownerRequest);
|
||||||
const sessionId = "state-session";
|
const sessionId = "state-session";
|
||||||
await sessions.ensure({ ...context, sessionId });
|
await sessions.ensure({ ...context, sessionId });
|
||||||
await new SessionUiStateStore(join(root, "unused"), workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] });
|
await new SessionUiStateStore(workspaces).write({ ...context, sessionId }, { sessionId, messages: [{ id: "assistant", role: "assistant", parts: [{ type: "data-answer_ui", data: { version: "answer-ui@1", block_id: "block", fallback_text: "fallback", spec: { root: "evidence", elements: { evidence: { type: "Evidence", props: { title: "Evidence", summary: "Summary", source: "source", evidence_id: null, observed_at: null, kind: "observed" }, children: [] } } } } }] }] });
|
||||||
const app = express();
|
const app = express();
|
||||||
app.use(express.json({ limit: "2mb" }));
|
app.use(express.json({ limit: "2mb" }));
|
||||||
app.use(async (req, res, next) => {
|
app.use(async (req, res, next) => {
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ it("persists owned topics, searches old text across themes and preserves chats w
|
|||||||
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch;
|
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("authorization") === "Bearer owner" ? "owner" : "other", project_id: new Headers(init?.headers).get("x-project-id"), network: "supply" })) as typeof fetch;
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
for (const id of ["a", "b", "unclassified"]) {
|
for (const id of ["a", "b", "unclassified"]) {
|
||||||
await sessions.ensure({ ...context, sessionId: id });
|
await sessions.ensure({ ...context, sessionId: id });
|
||||||
await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [
|
await states.write({ ...context, sessionId: id }, { sessionId: id, messages: [
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { attachTrustedAgentContext } from "../../src/context/localContext.js";
|
|||||||
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||||
import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
import { bindSessionCredential, credentialRefreshCoordinator, releaseSessionCredential } from "../../src/product/credential-refresh.js";
|
||||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||||
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||||
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
||||||
import type { ActiveRun } from "../../src/routes/chatUiState.js";
|
import type { ActiveRun } from "../../src/routes/chatUiState.js";
|
||||||
@@ -28,7 +29,7 @@ describe("credential refresh HTTP route", () => {
|
|||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); });
|
router.use((req, res, next) => { void attachTrustedAgentContext(req).then(() => next()).catch(() => res.sendStatus(401)); });
|
||||||
const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]);
|
const activeRuns = new Map<string, ActiveRun>([["credential-route-session", { clientSessionId: "credential-route-session", controller: new AbortController(), messages: [], pendingPermissions: new Map(), pendingQuestions: new Map(), subscribers: new Set(), status: "running" }]]);
|
||||||
registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(join(directory, "ui")) });
|
registerChatInteractionRoutes(router, { activeRuns, runtime: {} as OpencodeRuntimeAdapter, sessionMetadataStore: metadata, sessionUiStateStore: new SessionUiStateStore(new SessionWorkspaceStore(join(directory, "workspaces"))) });
|
||||||
app.use(router);
|
app.use(router);
|
||||||
const server = app.listen(0, "127.0.0.1");
|
const server = app.listen(0, "127.0.0.1");
|
||||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||||
|
|||||||
@@ -115,7 +115,7 @@ it("uses authenticated /responses for saved answers and rejects bypass and forei
|
|||||||
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("Authorization") === "Bearer owner" ? "owner" : "other", project_id: project, network: "fixture" })) as typeof fetch;
|
globalThis.fetch = (async (_input, init) => Response.json({ user_id: new Headers(init?.headers).get("Authorization") === "Bearer owner" ? "owner" : "other", project_id: project, network: "fixture" })) as typeof fetch;
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
const interactions = new InteractionRequestStore(workspaces);
|
const interactions = new InteractionRequestStore(workspaces);
|
||||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
||||||
await sessions.ensure(context);
|
await sessions.ensure(context);
|
||||||
|
|||||||
@@ -5,16 +5,43 @@ import { tmpdir } from "node:os";
|
|||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { getLocalAgentContext } from "../../src/context/localContext.js";
|
import { getLocalAgentContext } from "../../src/context/localContext.js";
|
||||||
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
import { registerChatInteractionRoutes } from "../../src/routes/chatInteractionRoutes.js";
|
||||||
import type { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||||
|
|
||||||
|
it("reads a native form once when checking and answering a persisted question", async () => {
|
||||||
|
const root = await mkdtemp(join(tmpdir(), "question-single-read-"));
|
||||||
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
|
const states = new SessionUiStateStore(workspaces);
|
||||||
|
const context = { ...getLocalAgentContext(), sessionId: "session" };
|
||||||
|
await sessions.ensure(context);
|
||||||
|
await states.write(context, { sessionId: context.sessionId, messages: [{ role: "assistant", questions: [{ requestId: "form", status: "pending" }] }] });
|
||||||
|
const calls: string[] = [];
|
||||||
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
|
ensureClient: async () => ({ session: { form: {
|
||||||
|
get: async () => { calls.push("get"); return { fields: [{ key: "answer", type: "text" }], state: { status: "pending" } }; },
|
||||||
|
reply: async (input: { answer: unknown }) => { calls.push("reply"); expect(input.answer).toEqual({ answer: "A" }); },
|
||||||
|
} } }),
|
||||||
|
});
|
||||||
|
const routes = new Map<string, (req: unknown, res: unknown) => Promise<void>>();
|
||||||
|
const router = { get() {}, post(path: string, handler: (req: unknown, res: unknown) => Promise<void>) { routes.set(path, handler); } };
|
||||||
|
registerChatInteractionRoutes(router as unknown as express.Router, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
|
||||||
|
const req = { params: { request_id: "form" }, body: { session_id: "session", answers: [["A"]] }, header() { return undefined; } };
|
||||||
|
const response = { code: 200, setHeader() {}, status(code: number) { this.code = code; return this; }, json() { return this; } };
|
||||||
|
try {
|
||||||
|
await routes.get("/question/:request_id/reply")!(req, response);
|
||||||
|
expect(response.code).toBe(202);
|
||||||
|
expect(calls).toEqual(["get", "reply"]);
|
||||||
|
} finally { await rm(root, { recursive: true, force: true }); }
|
||||||
|
});
|
||||||
|
|
||||||
it("reconciles persisted legacy forms and replies after the active run was lost", async () => {
|
it("reconciles persisted legacy forms and replies after the active run was lost", async () => {
|
||||||
const root = await mkdtemp(join(tmpdir(), "legacy-question-routes-"));
|
const root = await mkdtemp(join(tmpdir(), "legacy-question-routes-"));
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
const context = { ...getLocalAgentContext(), sessionId: "session" };
|
const context = { ...getLocalAgentContext(), sessionId: "session" };
|
||||||
await sessions.ensure(context);
|
await sessions.ensure(context);
|
||||||
await states.write(context, { sessionId: "session", messages: [{ id: "old", role: "assistant", questions: [{ requestId: "form", status: "pending", tool: { callID: "call" } }] }, { id: "new", role: "assistant", text: "later message", parts: [{ type: "data-question_request", data: { request_id: "form2", session_id: "session", questions: [], tool: { callID: "call2" } } }, { type: "data-question_request", data: { request_id: "form3", session_id: "session", questions: [] } }, { type: "data-question_request", data: { request_id: "form4", session_id: "session", questions: [] } }] }] });
|
await states.write(context, { sessionId: "session", messages: [{ id: "old", role: "assistant", questions: [{ requestId: "form", status: "pending", tool: { callID: "call" } }] }, { id: "new", role: "assistant", text: "later message", parts: [{ type: "data-question_request", data: { request_id: "form2", session_id: "session", questions: [], tool: { callID: "call2" } } }, { type: "data-question_request", data: { request_id: "form3", session_id: "session", questions: [] } }, { type: "data-question_request", data: { request_id: "form4", session_id: "session", questions: [] } }] }] });
|
||||||
@@ -22,7 +49,7 @@ it("reconciles persisted legacy forms and replies after the active run was lost"
|
|||||||
let unavailable = false;
|
let unavailable = false;
|
||||||
let reads = 0;
|
let reads = 0;
|
||||||
let replies = 0;
|
let replies = 0;
|
||||||
const runtime = { getQuestionStatus: async () => { reads++; if (unavailable) throw new Error("private-native-path"); return status; }, replyQuestion: async () => { replies++; status = "answered"; }, rejectQuestion: async () => { status = "cancelled"; } } as unknown as OpencodeRuntimeAdapter;
|
const runtime = { getQuestion: async () => { reads++; if (unavailable) throw new Error("private-native-path"); return status === "missing" ? null : { state: { status } }; }, replyQuestion: async () => { replies++; status = "answered"; }, rejectQuestion: async () => { status = "cancelled"; } } as unknown as OpencodeRuntimeAdapter;
|
||||||
const app = express(); app.use(express.json());
|
const app = express(); app.use(express.json());
|
||||||
registerChatInteractionRoutes(app, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
|
registerChatInteractionRoutes(app, { activeRuns: new Map(), runtime, sessionMetadataStore: sessions, sessionUiStateStore: states });
|
||||||
const server = app.listen(0, "127.0.0.1");
|
const server = app.listen(0, "127.0.0.1");
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ it.each([false, true])("persists permission replies in the owner's workspace (na
|
|||||||
const root = await mkdtemp(join(tmpdir(), "permission-persistence-"));
|
const root = await mkdtemp(join(tmpdir(), "permission-persistence-"));
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
const context = { ...getLocalAgentContext(), sessionId: "permission-session" };
|
const context = { ...getLocalAgentContext(), sessionId: "permission-session" };
|
||||||
await sessions.ensure(context);
|
await sessions.ensure(context);
|
||||||
const messages = [{ id: "answer", role: "assistant", permissions: [] }];
|
const messages = [{ id: "answer", role: "assistant", permissions: [] }];
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ it("keeps historical question invalidation across the next live snapshot", async
|
|||||||
const root = await mkdtemp(join(tmpdir(), "question-persistence-"));
|
const root = await mkdtemp(join(tmpdir(), "question-persistence-"));
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "unused"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
const context = { ...getLocalAgentContext(), sessionId: "history-session" };
|
const context = { ...getLocalAgentContext(), sessionId: "history-session" };
|
||||||
await sessions.ensure(context);
|
await sessions.ensure(context);
|
||||||
const history: UIMessage[] = [{ id: "old-answer", role: "assistant", parts: [
|
const history: UIMessage[] = [{ id: "old-answer", role: "assistant", parts: [
|
||||||
@@ -35,7 +35,7 @@ it("keeps historical question invalidation across the next live snapshot", async
|
|||||||
stream.apply("token", { content: "正在核查" });
|
stream.apply("token", { content: "正在核查" });
|
||||||
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: stream.uiMessages() });
|
await states.write(context, { sessionId: context.sessionId, isTitleManuallyEdited: true, messages: stream.uiMessages() });
|
||||||
let nativeReads = 0;
|
let nativeReads = 0;
|
||||||
const runtime = { getQuestionStatus: async () => { nativeReads++; return "missing"; } } as unknown as OpencodeRuntimeAdapter;
|
const runtime = { getQuestion: async () => { nativeReads++; return null; } } as unknown as OpencodeRuntimeAdapter;
|
||||||
const app = express();
|
const app = express();
|
||||||
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime,
|
registerChatInteractionRoutes(app, { activeRuns: new Map([[context.sessionId, run]]), runtime,
|
||||||
sessionMetadataStore: sessions, sessionUiStateStore: states });
|
sessionMetadataStore: sessions, sessionUiStateStore: states });
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ beforeEach(async () => {
|
|||||||
})) as typeof fetch;
|
})) as typeof fetch;
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
states = new SessionUiStateStore(join(root, "ui"), workspaces);
|
states = new SessionUiStateStore(workspaces);
|
||||||
interactions = new InteractionRequestStore(workspaces);
|
interactions = new InteractionRequestStore(workspaces);
|
||||||
await sessions.initialize();
|
await sessions.initialize();
|
||||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${project}`), projectId: project, userId: "owner", sessionId: "owned" };
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ describe("chat session HTTP isolation", () => {
|
|||||||
|
|
||||||
const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(metadataDir, "workspaces"));
|
||||||
const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces);
|
const sessionMetadataStore = new SessionMetadataStore(metadataDir, workspaces);
|
||||||
const sessionUiStateStore = new SessionUiStateStore(join(metadataDir, "old-ui"), workspaces);
|
const sessionUiStateStore = new SessionUiStateStore(workspaces);
|
||||||
await sessionMetadataStore.initialize();
|
await sessionMetadataStore.initialize();
|
||||||
const ownerContext = {
|
const ownerContext = {
|
||||||
actorKey: toActorKey("product:user-a"),
|
actorKey: toActorKey("product:user-a"),
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ async function fixture() {
|
|||||||
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" };
|
const context = { actorKey: toActorKey("product:owner"), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: "owner" };
|
||||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||||
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
const sessions = new SessionMetadataStore(join(root, "metadata"), workspaces);
|
||||||
const states = new SessionUiStateStore(join(root, "states"), workspaces);
|
const states = new SessionUiStateStore(workspaces);
|
||||||
const taskNames = new Map([[firstTask, "南桥管道爆管调查"], [secondTask, "城南供水压力复核"]]);
|
const taskNames = new Map([[firstTask, "南桥管道爆管调查"], [secondTask, "城南供水压力复核"]]);
|
||||||
const taskReads: string[] = [];
|
const taskReads: string[] = [];
|
||||||
let available = true;
|
let available = true;
|
||||||
|
|||||||
@@ -33,11 +33,11 @@ test("native question lookup distinguishes missing form from unavailable service
|
|||||||
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
const adapter = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
||||||
ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }),
|
ensureClient: async () => ({ session: { form: { get: async () => { if (failure) throw failure; return { state: { status: "answered" } }; } } } }),
|
||||||
}) as OpencodeRuntimeAdapter;
|
}) as OpencodeRuntimeAdapter;
|
||||||
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("missing");
|
expect(await adapter.getQuestion({ sessionId: "s", requestId: "form" })).toBeNull();
|
||||||
failure = new Error("network unavailable");
|
failure = new Error("network unavailable");
|
||||||
await expect(adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable");
|
await expect(adapter.getQuestion({ sessionId: "s", requestId: "form" })).rejects.toThrow("network unavailable");
|
||||||
failure = undefined;
|
failure = undefined;
|
||||||
expect(await adapter.getQuestionStatus({ sessionId: "s", requestId: "form" })).toBe("answered");
|
expect(await adapter.getQuestion({ sessionId: "s", requestId: "form" })).toMatchObject({ state: { status: "answered" } });
|
||||||
});
|
});
|
||||||
test("message admission lookup fails closed on inbox and message transport errors", async () => {
|
test("message admission lookup fails closed on inbox and message transport errors", async () => {
|
||||||
let inboxFailure = true;
|
let inboxFailure = true;
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ describe("answer view state", () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
base = await mkdtemp(join(tmpdir(), "answer-state-"));
|
base = await mkdtemp(join(tmpdir(), "answer-state-"));
|
||||||
workspaces = new SessionWorkspaceStore(join(base, "workspaces"));
|
workspaces = new SessionWorkspaceStore(join(base, "workspaces"));
|
||||||
messages = new SessionUiStateStore(join(base, "unused"), workspaces);
|
messages = new SessionUiStateStore(workspaces);
|
||||||
states = new AnswerViewStateStore(workspaces);
|
states = new AnswerViewStateStore(workspaces);
|
||||||
currentSpec = spec();
|
currentSpec = spec();
|
||||||
await saveMessages();
|
await saveMessages();
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ describe("learning session lifecycle", () => {
|
|||||||
});
|
});
|
||||||
const orchestrator = (runtime: object) => new LearningOrchestrator(
|
const orchestrator = (runtime: object) => new LearningOrchestrator(
|
||||||
runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")),
|
runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")),
|
||||||
new SessionTranscriptStore(join(base, "legacy-transcripts"), records), state,
|
new SessionTranscriptStore(records), state,
|
||||||
);
|
);
|
||||||
|
|
||||||
it("releases both native helper sessions after a completed review and persists progress in chat", async () => {
|
it("releases both native helper sessions after a completed review and persists progress in chat", async () => {
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { mkdtemp, rm } from "node:fs/promises";
|
|||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||||
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
|
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
|
||||||
|
|
||||||
describe("SessionTranscriptStore", () => {
|
describe("SessionTranscriptStore", () => {
|
||||||
@@ -11,8 +12,7 @@ describe("SessionTranscriptStore", () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
tempDir = await mkdtemp(join(tmpdir(), "tjwater-transcript-"));
|
tempDir = await mkdtemp(join(tmpdir(), "tjwater-transcript-"));
|
||||||
store = new SessionTranscriptStore(tempDir);
|
store = new SessionTranscriptStore(new SessionWorkspaceStore(tempDir));
|
||||||
await store.initialize();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
|||||||
@@ -2,16 +2,16 @@ import { expect, it } from "bun:test";
|
|||||||
import { mkdtemp, rm } from "node:fs/promises";
|
import { mkdtemp, rm } from "node:fs/promises";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
|
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||||
|
|
||||||
it("restores plan history independently of chat messages after reconnecting", async () => {
|
it("restores plan history independently of chat messages after reconnecting", async () => {
|
||||||
const directory = await mkdtemp(join(tmpdir(), "tjwater-plan-state-"));
|
const directory = await mkdtemp(join(tmpdir(), "tjwater-plan-state-"));
|
||||||
try {
|
try {
|
||||||
const store = new SessionUiStateStore(directory);
|
const store = new SessionUiStateStore(new SessionWorkspaceStore(directory));
|
||||||
await store.initialize();
|
|
||||||
const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }];
|
const executionPlans = [{ revision: 1, nodes: [{ id: "sampling", status: "waiting" }] }];
|
||||||
await store.write({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" }, { sessionId: "session-1", messages: [], executionPlans });
|
await store.write({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" }, { sessionId: "session-1", messages: [], executionPlans });
|
||||||
const reloaded = await new SessionUiStateStore(directory).read({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" });
|
const reloaded = await new SessionUiStateStore(new SessionWorkspaceStore(directory)).read({ sessionId: "session-1", actorKey: "actor-test", projectKey: "project-test" });
|
||||||
expect(reloaded?.executionPlans).toEqual(executionPlans);
|
expect(reloaded?.executionPlans).toEqual(executionPlans);
|
||||||
expect(await store.read({ sessionId: "other-session", actorKey: "actor-test", projectKey: "project-test" })).toBeNull();
|
expect(await store.read({ sessionId: "other-session", actorKey: "actor-test", projectKey: "project-test" })).toBeNull();
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ describe("owned session workspaces", () => {
|
|||||||
afterEach(async () => { await rm(base, { recursive: true, force: true }); });
|
afterEach(async () => { await rm(base, { recursive: true, force: true }); });
|
||||||
|
|
||||||
it("keeps messages and searchable transcripts private to their user and scope", async () => {
|
it("keeps messages and searchable transcripts private to their user and scope", async () => {
|
||||||
const ui = new SessionUiStateStore(join(base, "old-ui"), records);
|
const ui = new SessionUiStateStore(records);
|
||||||
const transcript = new SessionTranscriptStore(join(base, "old-transcripts"), records);
|
const transcript = new SessionTranscriptStore(records);
|
||||||
await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] });
|
await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] });
|
||||||
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
||||||
expect((await new SessionUiStateStore(join(base, "old-ui"), records).read(owner))?.messages).toHaveLength(1);
|
expect((await new SessionUiStateStore(records).read(owner))?.messages).toHaveLength(1);
|
||||||
expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull();
|
expect(await ui.read({ ...owner, actorKey: "actor-b" })).toBeNull();
|
||||||
expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull();
|
expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull();
|
||||||
expect(await transcript.search(owner, "private")).toHaveLength(2);
|
expect(await transcript.search(owner, "private")).toHaveLength(2);
|
||||||
@@ -36,7 +36,7 @@ describe("owned session workspaces", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("has a separate personal namespace for each user and separates it from projects", async () => {
|
it("has a separate personal namespace for each user and separates it from projects", async () => {
|
||||||
const ui = new SessionUiStateStore(join(base, "old-ui"), records);
|
const ui = new SessionUiStateStore(records);
|
||||||
const personal = { ...owner, projectKey: "personal-actor-a" };
|
const personal = { ...owner, projectKey: "personal-actor-a" };
|
||||||
await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] });
|
await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] });
|
||||||
expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/");
|
expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/");
|
||||||
@@ -60,13 +60,13 @@ describe("owned session workspaces", () => {
|
|||||||
expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 });
|
expect(await records.migrateLegacyRecords(options)).toEqual({ uiStates: 1, transcripts: 1, learningStates: 0, retainedUnownedUiStates: 1 });
|
||||||
expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages);
|
expect(await readFile(records.path(owner, "messages.json"), "utf8")).toBe(messages);
|
||||||
expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns);
|
expect(await readFile(records.path(owner, "transcript.json"), "utf8")).toBe(turns);
|
||||||
await new SessionUiStateStore(uiStateDir, records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] });
|
await new SessionUiStateStore(records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] });
|
||||||
expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0);
|
expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0);
|
||||||
const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat");
|
const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat");
|
||||||
const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!;
|
const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!;
|
||||||
expect(await readFile(join(archive, backup), "utf8")).toBe(messages);
|
expect(await readFile(join(archive, backup), "utf8")).toBe(messages);
|
||||||
expect(await readdir(uiStateDir)).toEqual(["orphan.json"]);
|
expect(await readdir(uiStateDir)).toEqual(["orphan.json"]);
|
||||||
expect((await new SessionUiStateStore(uiStateDir, records).read(owner))?.messages).toEqual([{ text: "new answer" }]);
|
expect((await new SessionUiStateStore(records).read(owner))?.messages).toEqual([{ text: "new answer" }]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("rejects traversal, linked records and mismatched ownership", async () => {
|
it("rejects traversal, linked records and mismatched ownership", async () => {
|
||||||
@@ -75,7 +75,7 @@ describe("owned session workspaces", () => {
|
|||||||
const foreign = join(base, "foreign.json");
|
const foreign = join(base, "foreign.json");
|
||||||
await writeFile(foreign, "private");
|
await writeFile(foreign, "private");
|
||||||
await symlink(foreign, records.path(owner, "messages.json"));
|
await symlink(foreign, records.path(owner, "messages.json"));
|
||||||
await expect(new SessionUiStateStore(join(base, "old-ui"), records).read(owner)).rejects.toThrow("symbolic link");
|
await expect(new SessionUiStateStore(records).read(owner)).rejects.toThrow("symbolic link");
|
||||||
await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" }));
|
await writeFile(join(sessionWorkspaceDirectory(records.root, owner), "chat", "owner.json"), JSON.stringify({ ...owner, actorKey: "actor-b" }));
|
||||||
await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch");
|
await expect(records.ensure(owner)).rejects.toThrow("ownership mismatch");
|
||||||
});
|
});
|
||||||
@@ -114,8 +114,8 @@ describe("owned session workspaces", () => {
|
|||||||
for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path);
|
for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path);
|
||||||
const metadata = new SessionMetadataStore(metadataDir, records);
|
const metadata = new SessionMetadataStore(metadataDir, records);
|
||||||
const { record } = await metadata.ensure(owner);
|
const { record } = await metadata.ensure(owner);
|
||||||
const ui = new SessionUiStateStore(uiStateDir, records);
|
const ui = new SessionUiStateStore(records);
|
||||||
const transcript = new SessionTranscriptStore(transcriptDir, records);
|
const transcript = new SessionTranscriptStore(records);
|
||||||
const learning = new SessionLearningStateStore(learningStateDir, records);
|
const learning = new SessionLearningStateStore(learningStateDir, records);
|
||||||
const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] });
|
const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] });
|
||||||
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
|
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
|
||||||
@@ -153,7 +153,7 @@ describe("owned session workspaces", () => {
|
|||||||
const metadataDir = join(base, "metadata");
|
const metadataDir = join(base, "metadata");
|
||||||
const metadata = new SessionMetadataStore(metadataDir, records);
|
const metadata = new SessionMetadataStore(metadataDir, records);
|
||||||
const { record } = await metadata.ensure(owner);
|
const { record } = await metadata.ensure(owner);
|
||||||
const transcript = new SessionTranscriptStore(join(base, "legacy"), records);
|
const transcript = new SessionTranscriptStore(records);
|
||||||
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
||||||
const markDeleted = records.markDeleted.bind(records);
|
const markDeleted = records.markDeleted.bind(records);
|
||||||
records.markDeleted = async () => { throw new Error("disk write interrupted"); };
|
records.markDeleted = async () => { throw new Error("disk write interrupted"); };
|
||||||
@@ -163,7 +163,7 @@ describe("owned session workspaces", () => {
|
|||||||
await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
|
await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
|
||||||
records = new SessionWorkspaceStore(records.root, records.backupRoot);
|
records = new SessionWorkspaceStore(records.root, records.backupRoot);
|
||||||
const restarted = new SessionMetadataStore(metadataDir, records);
|
const restarted = new SessionMetadataStore(metadataDir, records);
|
||||||
expect(await new SessionTranscriptStore(join(base, "legacy"), records).search(owner, "private")).toEqual([]);
|
expect(await new SessionTranscriptStore(records).search(owner, "private")).toEqual([]);
|
||||||
await expect(restarted.ensure(owner)).rejects.toThrow("session not found");
|
await expect(restarted.ensure(owner)).rejects.toThrow("session not found");
|
||||||
await markDeleted(owner);
|
await markDeleted(owner);
|
||||||
await restarted.remove(record);
|
await restarted.remove(record);
|
||||||
|
|||||||
Reference in New Issue
Block a user