feat(agent): unify MCP tools and adopt Responses delivery
Move project and host queries into native MCP with host-owned OAuth/context, while retaining memory, skills, actions and publication plugins. Add nearby-model queries and explicit chat/task/learning model defaults. Adopt Responses HTTP/SSE and native steer/queue inbox delivery, preserving scoped session ownership, pending input edits and investigation communication. Disable local-provider discovery: Server /health allowed native vLLM to probe /v1/models on port 8000. A native before/after regression prevents recurrence. Validation: 170 focused Bun tests; service/plugin typechecks; native discovery, provider-discovery and input-inbox smoke checks; no paid model inference.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { config } from "../../src/config.js";
|
||||
import { LearningOrchestrator } from "../../src/learning/orchestrator.js";
|
||||
import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js";
|
||||
import { MemoryStore } from "../../src/memory/store.js";
|
||||
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
import { type OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
||||
import { getRuntimeSessionContext } from "../../src/runtime/sessionContext.js";
|
||||
|
||||
const owner = { actorKey: "actor-a", projectKey: "personal-actor-a", sessionId: "parent-chat" };
|
||||
const input = {
|
||||
sessionId: owner.sessionId, userMessage: "question", assistantMessage: "answer", toolCallCount: 0,
|
||||
requestContext: { ...owner, clientSessionId: owner.sessionId, traceId: "learning-test" },
|
||||
};
|
||||
const deferred = () => {
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((done) => { resolve = done; });
|
||||
return { resolve, promise };
|
||||
};
|
||||
|
||||
describe("learning session lifecycle", () => {
|
||||
let base: string;
|
||||
let originalAuditPath: string;
|
||||
let originalCooldown: number;
|
||||
let records: SessionWorkspaceStore;
|
||||
let state: SessionLearningStateStore;
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "tjwater-learning-lifecycle-"));
|
||||
originalAuditPath = config.LEARNING_AUDIT_LOG_PATH;
|
||||
originalCooldown = config.LEARNING_GATE_TURN_COOLDOWN;
|
||||
config.LEARNING_AUDIT_LOG_PATH = join(base, "audit.log");
|
||||
config.LEARNING_GATE_TURN_COOLDOWN = 1;
|
||||
records = new SessionWorkspaceStore(join(base, "workspaces"));
|
||||
state = new SessionLearningStateStore(join(base, "legacy-learning"), records);
|
||||
});
|
||||
afterEach(async () => {
|
||||
config.LEARNING_AUDIT_LOG_PATH = originalAuditPath;
|
||||
config.LEARNING_GATE_TURN_COOLDOWN = originalCooldown;
|
||||
await rm(base, { recursive: true, force: true });
|
||||
});
|
||||
const orchestrator = (runtime: object) => new LearningOrchestrator(
|
||||
runtime as OpencodeRuntimeAdapter, new MemoryStore(join(base, "memory"), join(base, "memory-backup")),
|
||||
new SessionTranscriptStore(join(base, "legacy-transcripts"), records), state,
|
||||
);
|
||||
|
||||
it("releases both native helper sessions after a completed review and persists progress in chat", async () => {
|
||||
let nextId = 0;
|
||||
const removed: string[] = [];
|
||||
const finished = deferred();
|
||||
const learning = orchestrator({
|
||||
createSession: async () => ({ id: `helper-${++nextId}` }), prompt: async () => undefined,
|
||||
messages: async (id: string) => [{ info: { role: "assistant" }, parts: [{ type: "text", text: JSON.stringify(
|
||||
id === "helper-1" ? { should_review: true, confidence: 1, focus: "memory", reason: "useful" } : { memories: [], skills: [], summary: "reviewed" },
|
||||
) }] }],
|
||||
removeSession: async (id: string) => { removed.push(id); if (removed.length === 2) finished.resolve(); },
|
||||
});
|
||||
await learning.onTurnCompleted(input);
|
||||
await finished.promise;
|
||||
expect(removed).toEqual(["helper-2", "helper-1"]);
|
||||
expect(getRuntimeSessionContext("helper-1")).toBeNull();
|
||||
expect(getRuntimeSessionContext("helper-2")).toBeNull();
|
||||
expect((await state.read(owner)).lastReviewedTurn).toBe(1);
|
||||
expect(records.path(owner, "learning.json")).toContain("/personal/");
|
||||
});
|
||||
|
||||
it("cancels a running gate without promoting memory or reviving deleted content", async () => {
|
||||
const started = deferred();
|
||||
const interrupted = deferred();
|
||||
const removed: string[] = [];
|
||||
const learning = orchestrator({
|
||||
createSession: async () => ({ id: "cancelled-gate" }),
|
||||
prompt: async () => { started.resolve(); await interrupted.promise; },
|
||||
messages: async () => [{ info: { role: "assistant" }, parts: [{ type: "text", text: '{"should_review":true,"confidence":1,"focus":"memory"}' }] }],
|
||||
abortSession: async () => { interrupted.resolve(); },
|
||||
removeSession: async (id: string) => { removed.push(id); },
|
||||
});
|
||||
await learning.onTurnCompleted(input);
|
||||
await started.promise;
|
||||
await records.markDeleted(owner);
|
||||
await learning.cancelSession(owner.sessionId);
|
||||
await records.purge(owner);
|
||||
expect(removed).toEqual(["cancelled-gate"]);
|
||||
expect(getRuntimeSessionContext("cancelled-gate")).toBeNull();
|
||||
expect(await records.list(owner, "learning.json")).toEqual([]);
|
||||
});
|
||||
|
||||
it("releases the helper when model inference fails", async () => {
|
||||
const finished = deferred();
|
||||
const removed: string[] = [];
|
||||
const learning = orchestrator({
|
||||
createSession: async () => ({ id: "failed-gate" }),
|
||||
prompt: async () => { throw new Error("model unavailable"); },
|
||||
removeSession: async (id: string) => { removed.push(id); finished.resolve(); },
|
||||
});
|
||||
await learning.onTurnCompleted(input);
|
||||
await finished.promise;
|
||||
expect(removed).toEqual(["failed-gate"]);
|
||||
expect(getRuntimeSessionContext("failed-gate")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
|
||||
@@ -157,4 +157,26 @@ describe("SessionMetadataStore", () => {
|
||||
expect((await store.findByTask(context, "50000000-0000-4000-8000-000000000001", "task_collaboration"))?.sessionId).toBe("collaboration");
|
||||
expect((await store.findByRequest(context, "60000000-0000-4000-8000-000000000001"))?.sessionId).toBe("execution");
|
||||
});
|
||||
|
||||
it("updates its scoped index after writes and deletion without rereading unrelated records", async () => {
|
||||
const context = { actorKey: "actor-a", projectKey: "project-a", userId: "user-a" };
|
||||
const { record } = await store.ensure({ ...context, sessionId: "first" });
|
||||
expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["first"]);
|
||||
// A listing already built the index; subsequent calls must not rescan files.
|
||||
await writeFile(join(tempDir, "unrelated.json"), "not valid JSON");
|
||||
await store.touch(record, { title: "updated title" });
|
||||
const listed = await store.list(context);
|
||||
expect(listed[0]?.title).toBe("updated title");
|
||||
listed[0]!.actorKey = "other-actor";
|
||||
expect((await store.list(context))[0]?.actorKey).toBe("actor-a");
|
||||
await store.ensure({ ...context, sessionId: "second" });
|
||||
expect((await store.list(context)).map((item) => item.sessionId).sort()).toEqual(["first", "second"]);
|
||||
await store.remove(record);
|
||||
expect((await store.list(context)).map((item) => item.sessionId)).toEqual(["second"]);
|
||||
expect(await store.get(context, "first")).toBeNull();
|
||||
expect((await store.get(context, "first", { includeDeleted: true }))?.deletedAt).toBeDefined();
|
||||
expect(await store.get({ ...context, userId: "user-b" }, "first", { includeDeleted: true })).toBeNull();
|
||||
await expect(store.touch(record, { title: "revived" })).rejects.toThrow("session not found");
|
||||
await expect(store.ensure({ ...context, sessionId: "first" })).rejects.toThrow("session not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from "bun:test";
|
||||
import { lstat, mkdir, mkdtemp, readFile, readdir, rm, symlink, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||
import { SessionTranscriptStore } from "../../src/sessions/transcriptStore.js";
|
||||
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
||||
import { SessionLearningStateStore } from "../../src/learning/sessionStateStore.js";
|
||||
import { bindScopedConversationWorkspace, createConversationWorkspace, sessionWorkspaceDirectory } from "../../src/runtime/conversationWorkspace.js";
|
||||
|
||||
describe("owned session workspaces", () => {
|
||||
let base: string;
|
||||
let records: SessionWorkspaceStore;
|
||||
const owner = { actorKey: "actor-a", projectKey: "project-a", sessionId: "chat-1" };
|
||||
beforeEach(async () => {
|
||||
base = await mkdtemp(join(tmpdir(), "tjwater-owned-chat-"));
|
||||
records = new SessionWorkspaceStore(join(base, "data", "session-workspaces"));
|
||||
});
|
||||
afterEach(async () => { await rm(base, { recursive: true, force: true }); });
|
||||
|
||||
it("keeps messages and searchable transcripts private to their user and scope", async () => {
|
||||
const ui = new SessionUiStateStore(join(base, "old-ui"), records);
|
||||
const transcript = new SessionTranscriptStore(join(base, "old-transcripts"), records);
|
||||
await ui.write(owner, { sessionId: owner.sessionId, messages: [{ text: "private answer" }] });
|
||||
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 ui.read({ ...owner, actorKey: "actor-b" })).toBeNull();
|
||||
expect(await ui.read({ ...owner, projectKey: "project-b" })).toBeNull();
|
||||
expect(await transcript.search(owner, "private")).toHaveLength(2);
|
||||
expect(await transcript.search({ ...owner, actorKey: "actor-b" }, "private")).toEqual([]);
|
||||
expect(await transcript.search({ ...owner, projectKey: "project-b" }, "private")).toEqual([]);
|
||||
expect((await lstat(records.path(owner, "messages.json"))).mode & 0o777).toBe(0o600);
|
||||
expect((await lstat(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).mode & 0o777).toBe(0o700);
|
||||
await expect(transcript.cloneThread(owner, { ...owner, actorKey: "actor-b" }, 2)).rejects.toThrow("across users or scopes");
|
||||
});
|
||||
|
||||
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 personal = { ...owner, projectKey: "personal-actor-a" };
|
||||
await ui.write(personal, { sessionId: owner.sessionId, messages: [{ text: "personal answer" }] });
|
||||
expect(records.path(personal, "messages.json")).toContain("/actor-a/personal/");
|
||||
expect(await ui.read(owner)).toBeNull();
|
||||
expect(await ui.read({ ...personal, actorKey: "actor-b", projectKey: "personal-actor-b" })).toBeNull();
|
||||
expect(() => records.path({ ...personal, projectKey: "personal-actor-b" }, "messages.json")).toThrow("owner mismatch");
|
||||
});
|
||||
|
||||
it("preserves legacy bytes and keeps backups without overwriting later chat updates", async () => {
|
||||
const metadataDir = join(base, "metadata");
|
||||
const uiStateDir = join(base, "ui");
|
||||
const transcriptDir = join(base, "transcripts");
|
||||
for (const path of [metadataDir, uiStateDir, transcriptDir]) await mkdir(path);
|
||||
await writeFile(join(metadataDir, "chat.json"), JSON.stringify(owner));
|
||||
const messages = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "legacy" }] });
|
||||
const turns = JSON.stringify({ ...owner, turns: [{ userMessage: "legacy question", assistantMessage: "legacy answer" }] });
|
||||
await writeFile(join(uiStateDir, "chat.json"), messages);
|
||||
await writeFile(join(uiStateDir, "orphan.json"), JSON.stringify({ sessionId: "unknown", messages: [] }));
|
||||
await writeFile(join(transcriptDir, "chat.json"), turns);
|
||||
const options = { metadataDir, uiStateDir, transcriptDir };
|
||||
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, "transcript.json"), "utf8")).toBe(turns);
|
||||
await new SessionUiStateStore(uiStateDir, records).write(owner, { sessionId: owner.sessionId, messages: [{ text: "new answer" }] });
|
||||
expect((await records.migrateLegacyRecords(options)).uiStates).toBe(0);
|
||||
const archive = join(sessionWorkspaceDirectory(records.backupRoot, owner), "chat");
|
||||
const backup = (await readdir(archive)).find((name) => name.startsWith("messages-"))!;
|
||||
expect(await readFile(join(archive, backup), "utf8")).toBe(messages);
|
||||
expect(await readdir(uiStateDir)).toEqual(["orphan.json"]);
|
||||
expect((await new SessionUiStateStore(uiStateDir, records).read(owner))?.messages).toEqual([{ text: "new answer" }]);
|
||||
});
|
||||
|
||||
it("rejects traversal, linked records and mismatched ownership", async () => {
|
||||
expect(() => records.path({ ...owner, actorKey: "../other-user" }, "messages.json")).toThrow("invalid workspace scope");
|
||||
await records.ensure(owner);
|
||||
const foreign = join(base, "foreign.json");
|
||||
await writeFile(foreign, "private");
|
||||
await symlink(foreign, records.path(owner, "messages.json"));
|
||||
await expect(new SessionUiStateStore(join(base, "old-ui"), records).read(owner)).rejects.toThrow("symbolic link");
|
||||
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");
|
||||
});
|
||||
|
||||
it("moves computation files beside chat records and refuses another user's binding", async () => {
|
||||
await records.ensure(owner);
|
||||
const old = await createConversationWorkspace(records.root);
|
||||
await writeFile(join(old, "files", "result.txt"), "kept result");
|
||||
const workspace = await bindScopedConversationWorkspace(old, records.root, owner);
|
||||
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result");
|
||||
expect(await bindScopedConversationWorkspace(old, records.root, owner)).toBe(workspace);
|
||||
await expect(bindScopedConversationWorkspace(workspace, records.root, { ...owner, actorKey: "actor-b" })).rejects.toThrow("another user");
|
||||
expect(await readFile(join(workspace, "files", "result.txt"), "utf8")).toBe("kept result");
|
||||
});
|
||||
|
||||
it("checks full identity on history lists and rejects session filename aliases", async () => {
|
||||
const metadata = new SessionMetadataStore(join(base, "metadata"));
|
||||
await metadata.initialize();
|
||||
await metadata.ensure({ ...owner, userId: "user-a", projectId: "project-a" });
|
||||
expect(await metadata.list({ ...owner, userId: "user-b", projectId: "project-a" })).toEqual([]);
|
||||
expect(await metadata.get(owner, "CHAT-1")).toBeNull();
|
||||
await expect(metadata.ensure({ ...owner, sessionId: "CHAT-1" })).rejects.toThrow("session not found");
|
||||
const attempts = await Promise.allSettled([
|
||||
metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-a" }),
|
||||
metadata.ensure({ ...owner, sessionId: "concurrent", userId: "user-b" }),
|
||||
]);
|
||||
expect(attempts.filter((result) => result.status === "fulfilled")).toHaveLength(1);
|
||||
expect(attempts.filter((result) => result.status === "rejected")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("purges owned content and prevents late writes or a legacy import from reviving a deleted chat", async () => {
|
||||
const metadataDir = join(base, "metadata");
|
||||
const uiStateDir = join(base, "ui");
|
||||
const transcriptDir = join(base, "transcripts");
|
||||
const learningStateDir = join(base, "learning");
|
||||
for (const path of [metadataDir, uiStateDir, transcriptDir, learningStateDir]) await mkdir(path);
|
||||
const metadata = new SessionMetadataStore(metadataDir, records);
|
||||
const { record } = await metadata.ensure(owner);
|
||||
const ui = new SessionUiStateStore(uiStateDir, records);
|
||||
const transcript = new SessionTranscriptStore(transcriptDir, records);
|
||||
const learning = new SessionLearningStateStore(learningStateDir, records);
|
||||
const legacyUi = JSON.stringify({ sessionId: owner.sessionId, messages: [{ text: "private content" }] });
|
||||
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
|
||||
await writeFile(join(learningStateDir, `${owner.sessionId}.json`), JSON.stringify({ sessionId: owner.sessionId, lastGatedTurn: 2, lastReviewedTurn: 0, updatedAt: "2026-10-10" }));
|
||||
const sources = { metadataDir, uiStateDir, transcriptDir, learningStateDir };
|
||||
expect((await records.migrateLegacyRecords(sources)).learningStates).toBe(1);
|
||||
expect((await learning.read(owner)).lastGatedTurn).toBe(2);
|
||||
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
||||
const workspace = await bindScopedConversationWorkspace(await createConversationWorkspace(records.root), records.root, owner);
|
||||
await writeFile(join(workspace, "files", "result.txt"), "private result");
|
||||
const foreign = { ...owner, actorKey: "actor-b", sessionId: "chat-2" };
|
||||
await ui.write(foreign, { sessionId: foreign.sessionId, messages: [{ text: "keep this" }] });
|
||||
await metadata.remove(record);
|
||||
expect(await metadata.get(owner, owner.sessionId)).toBeNull();
|
||||
expect(await metadata.list(owner)).toEqual([]);
|
||||
expect(await transcript.search(owner, "private")).toEqual([]);
|
||||
await expect(ui.write(owner, { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
|
||||
await expect(metadata.touch(record, { title: "late title" })).rejects.toThrow("session not found");
|
||||
await expect(learning.completeGate(owner, 4)).rejects.toThrow("session not found");
|
||||
await metadata.purge(record);
|
||||
expect((await readdir(join(sessionWorkspaceDirectory(records.root, owner), "chat"))).sort()).toEqual(["deleted.json", "owner.json"]);
|
||||
await expect(lstat(workspace)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
await expect(lstat(sessionWorkspaceDirectory(records.backupRoot, owner))).rejects.toMatchObject({ code: "ENOENT" });
|
||||
expect((await ui.read(foreign))?.messages).toEqual([{ text: "keep this" }]);
|
||||
await writeFile(join(uiStateDir, "chat.json"), legacyUi);
|
||||
await writeFile(join(transcriptDir, "chat.json"), JSON.stringify({ ...owner, turns: [] }));
|
||||
expect((await records.migrateLegacyRecords(sources)).uiStates).toBe(0);
|
||||
expect(await ui.read(owner)).toBeNull();
|
||||
expect(await readdir(uiStateDir)).toEqual([]);
|
||||
await expect(metadata.ensure(owner)).rejects.toThrow("session not found");
|
||||
await metadata.purge(record);
|
||||
});
|
||||
|
||||
it("honors the durable index deletion when writing the workspace marker fails", async () => {
|
||||
const metadataDir = join(base, "metadata");
|
||||
const metadata = new SessionMetadataStore(metadataDir, records);
|
||||
const { record } = await metadata.ensure(owner);
|
||||
const transcript = new SessionTranscriptStore(join(base, "legacy"), records);
|
||||
await transcript.appendTurn(owner, { userMessage: "private question", assistantMessage: "private answer", toolCallCount: 0 });
|
||||
const markDeleted = records.markDeleted.bind(records);
|
||||
records.markDeleted = async () => { throw new Error("disk write interrupted"); };
|
||||
await expect(metadata.remove(record)).rejects.toThrow("disk write interrupted");
|
||||
expect(await metadata.get(owner, owner.sessionId)).toBeNull();
|
||||
expect(await transcript.search(owner, "private")).toEqual([]);
|
||||
await expect(records.writeRecord(owner, "messages.json", { sessionId: owner.sessionId, messages: [] })).rejects.toThrow("session not found");
|
||||
records = new SessionWorkspaceStore(records.root, records.backupRoot);
|
||||
const restarted = new SessionMetadataStore(metadataDir, records);
|
||||
expect(await new SessionTranscriptStore(join(base, "legacy"), records).search(owner, "private")).toEqual([]);
|
||||
await expect(restarted.ensure(owner)).rejects.toThrow("session not found");
|
||||
await markDeleted(owner);
|
||||
await restarted.remove(record);
|
||||
await restarted.purge(record);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user