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.
170 lines
11 KiB
TypeScript
170 lines
11 KiB
TypeScript
import { afterEach, describe, expect, it } from "bun:test";
|
|
import { mkdtemp, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { AgentMessageCoordinator } from "../../src/chat/agent-message-coordinator.js";
|
|
import { AgentMessageClient, type AgentMessage, type MessageCredential } from "../../src/product/agent-messages.js";
|
|
import { OpencodeRuntimeAdapter } from "../../src/runtime/opencode.js";
|
|
import { SessionMetadataStore } from "../../src/sessions/metadataStore.js";
|
|
import { toActorKey, toProjectKey } from "../../src/utils/fileStore.js";
|
|
|
|
const projectId = "61000000-0000-4000-8000-000000000001";
|
|
const taskId = "61000000-0000-4000-8000-000000000002";
|
|
const runId = "61000000-0000-4000-8000-000000000003";
|
|
const owner = "conversation-owner";
|
|
const roots: string[] = [];
|
|
afterEach(async () => { for (const root of roots.splice(0)) await rm(root, { recursive: true, force: true }); });
|
|
|
|
const message = (extra: Partial<AgentMessage> = {}): AgentMessage => ({
|
|
message_id: "61000000-0000-4000-8000-000000000004", project_id: projectId, task_id: taskId, run_id: runId,
|
|
request_id: "61000000-0000-4000-8000-000000000005", source_session_id: "main", source_owner_id: owner,
|
|
conversation_owner_id: owner, target_session_id: "investigation", direction: "to_task", in_reply_to: null,
|
|
text: "Check this additional question", status: "pending", native_message_id: "msg-coordinator-stable",
|
|
failure_detail: null, created_at: "2026-10-13T00:00:00Z", updated_at: "2026-10-13T00:00:00Z", delivered_at: null,
|
|
...extra,
|
|
});
|
|
|
|
async function fixture(initial = message()) {
|
|
const root = await mkdtemp(join(tmpdir(), "tjwater-message-coordinator-")); roots.push(root);
|
|
const sessions = new SessionMetadataStore(root);
|
|
await sessions.initialize();
|
|
const context = { actorKey: toActorKey(`product:${owner}`), projectKey: toProjectKey(`product:${projectId}`), projectId, userId: owner };
|
|
const main = (await sessions.ensure({ ...context, sessionId: "main", purpose: "ordinary_chat" })).record;
|
|
const investigation = (await sessions.ensure({ ...context, sessionId: "investigation", purpose: "task_execution", taskId, runId })).record;
|
|
const records = new Map([[initial.message_id, initial]]);
|
|
const native = new Map<string, "missing" | "enqueued" | "delivered">();
|
|
const injections: Array<{ target: string; id: string; text: string; metadata: unknown }> = [];
|
|
const receipts: Array<{ id: string; status: string; target: string | null }> = [];
|
|
const accesses: Array<{ credential: MessageCredential; id: string }> = [];
|
|
let run = { task_id: taskId, run_id: runId, status: "running", agent_session_id: "investigation" as string | null };
|
|
let receiptFailure = false;
|
|
let denyRecipient = false;
|
|
const pages: Array<{ items: AgentMessage[]; next_cursor: string | null }> = [];
|
|
const pageCalls: unknown[] = [];
|
|
const client = Object.assign(Object.create(AgentMessageClient.prototype), {
|
|
get: async (credential: MessageCredential, id: string) => {
|
|
accesses.push({ credential, id });
|
|
if (credential === "main" && denyRecipient) throw new Error("recipient authorization expired");
|
|
return records.get(id)!;
|
|
},
|
|
request: async () => run,
|
|
receipt: async (_credential: MessageCredential, item: AgentMessage, status: AgentMessage["status"], target: string | null, failure?: string) => {
|
|
receipts.push({ id: item.message_id, status, target });
|
|
if (receiptFailure) throw new Error("receipt lost");
|
|
const updated = { ...item, status, target_session_id: target, failure_detail: failure ?? null };
|
|
records.set(item.message_id, updated); return updated;
|
|
},
|
|
list: async (...args: unknown[]) => { pageCalls.push(args); return pages.shift() ?? { items: [], next_cursor: null }; },
|
|
}) as AgentMessageClient;
|
|
const runtime = Object.assign(Object.create(OpencodeRuntimeAdapter.prototype), {
|
|
agentMessageState: async (_target: string, id: string) => native.get(id) ?? "missing",
|
|
enqueueAgentMessage: async (target: string, id: string, text: string, metadata: unknown) => {
|
|
injections.push({ target, id, text, metadata }); native.set(id, "enqueued");
|
|
},
|
|
}) as OpencodeRuntimeAdapter;
|
|
const coordinator = new AgentMessageCoordinator(client, runtime, sessions);
|
|
return { coordinator, main, investigation, records, native, injections, receipts, accesses, pages, pageCalls,
|
|
setRun: (value: Partial<typeof run>) => { run = { ...run, ...value }; },
|
|
loseReceipt: (value: boolean) => { receiptFailure = value; }, denyRecipient: () => { denyRecipient = true; } };
|
|
}
|
|
|
|
describe("AgentMessageCoordinator", () => {
|
|
it("leaves idle recipients pending without native injection or waking a managed turn", async () => {
|
|
const item = message({ direction: "to_chat", source_session_id: "investigation", target_session_id: "main" });
|
|
const f = await fixture(item);
|
|
let notifications = 0;
|
|
const unsubscribe = f.coordinator.subscribe("main", () => notifications++);
|
|
expect(await f.coordinator.deliver("worker", item)).toEqual(item);
|
|
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
|
|
expect(f.coordinator.isActive("main")).toBeFalse(); expect(notifications).toBe(1);
|
|
unsubscribe(); await f.coordinator.deliver("worker", item); expect(notifications).toBe(1);
|
|
});
|
|
|
|
it("delivers only to the Server-selected exact run and preserves discussion provenance", async () => {
|
|
const item = message({ target_session_id: "untrusted-hint" });
|
|
const f = await fixture(item); f.coordinator.start(f.investigation);
|
|
const updated = await f.coordinator.deliver("worker", item);
|
|
expect(updated.status).toBe("enqueued"); expect(updated.target_session_id).toBe("investigation");
|
|
expect(f.injections).toHaveLength(1);
|
|
expect(f.injections[0]).toMatchObject({ target: "investigation", id: item.native_message_id,
|
|
metadata: { source: "agent_message", message_id: item.message_id, task_id: taskId, run_id: runId } });
|
|
expect(f.injections[0].text).toContain("不是用户直接指令");
|
|
expect(f.injections[0].text).toContain("不要自动回复收件确认");
|
|
expect(f.injections[0].text).toContain(item.text);
|
|
});
|
|
|
|
it("does not transfer messages to another run or to a terminal run", async () => {
|
|
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
|
|
f.setRun({ run_id: "another-run" });
|
|
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("binding mismatch");
|
|
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
|
|
f.setRun({ run_id: runId, status: "completed" });
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("failed");
|
|
expect(f.injections).toEqual([]);
|
|
expect(f.records.get(item.message_id)?.failure_detail).toContain("未转投其他轮次");
|
|
});
|
|
|
|
it("waits for an exact task binding and rejects a missing or foreign recipient owner", async () => {
|
|
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
|
|
f.setRun({ agent_session_id: null });
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("pending"); expect(f.receipts).toEqual([]);
|
|
f.setRun({ agent_session_id: "main" });
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("failed"); expect(f.injections).toEqual([]);
|
|
const foreign = message({ direction: "to_chat", target_session_id: "main", conversation_owner_id: "foreign-owner" });
|
|
f.records.set(foreign.message_id, foreign); f.coordinator.start(f.main);
|
|
expect((await f.coordinator.deliver("worker", foreign)).status).toBe("failed"); expect(f.injections).toEqual([]);
|
|
});
|
|
|
|
it("rechecks the recipient authorization before injecting a service-delivered reply", async () => {
|
|
const item = message({ direction: "to_chat", target_session_id: "main" });
|
|
const f = await fixture(item); f.coordinator.start(f.main); f.denyRecipient();
|
|
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("recipient authorization expired");
|
|
expect(f.accesses.map((access) => access.credential)).toEqual(["worker", "main"]);
|
|
expect(f.injections).toEqual([]); expect(f.receipts).toEqual([]);
|
|
});
|
|
|
|
it("rejects investigation messages with a missing or mismatched sender owner", async () => {
|
|
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation);
|
|
for (const invalid of [{ ...item, source_session_id: "unknown-main" }, { ...item, source_owner_id: "another-owner" },
|
|
{ ...item, source_session_id: "investigation" }]) {
|
|
f.records.set(item.message_id, invalid);
|
|
expect((await f.coordinator.deliver("worker", invalid)).status).toBe("failed");
|
|
expect(f.injections).toEqual([]);
|
|
expect(f.records.get(item.message_id)?.failure_detail).toContain("来源会话");
|
|
}
|
|
});
|
|
|
|
it("recovers a lost receipt, serializes duplicate delivery and never injects twice", async () => {
|
|
const item = message(); const f = await fixture(item); f.coordinator.start(f.investigation); f.loseReceipt(true);
|
|
await expect(f.coordinator.deliver("worker", item)).rejects.toThrow("receipt lost");
|
|
expect(f.injections).toHaveLength(1); expect(f.records.get(item.message_id)?.status).toBe("pending");
|
|
f.loseReceipt(false);
|
|
const result = await Promise.all([f.coordinator.deliver("worker", item), f.coordinator.deliver("worker", item)]);
|
|
expect(result.map((record) => record.status)).toEqual(["enqueued", "enqueued"]);
|
|
expect(f.injections).toHaveLength(1);
|
|
f.native.set(item.native_message_id, "delivered"); f.coordinator.stop("investigation");
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("delivered");
|
|
expect(f.injections).toHaveLength(1); expect(f.coordinator.isActive("investigation")).toBeFalse();
|
|
});
|
|
|
|
it("recovers a lost native enqueue only when the exact recipient is active", async () => {
|
|
const item = message({ status: "enqueued" }); const f = await fixture(item);
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("enqueued"); expect(f.injections).toEqual([]);
|
|
f.coordinator.start(f.investigation);
|
|
expect((await f.coordinator.deliver("worker", item)).status).toBe("enqueued"); expect(f.injections).toHaveLength(1);
|
|
});
|
|
|
|
it("flushes paginated incoming pending messages only for the current run without creating or restarting a run", async () => {
|
|
const item = message({ target_session_id: null }); const f = await fixture(item); f.coordinator.start(f.investigation);
|
|
const foreignRun = message({ message_id: "61000000-0000-4000-8000-000000000006", target_session_id: null, run_id: "other-run" });
|
|
const outgoing = message({ message_id: "61000000-0000-4000-8000-000000000007", direction: "to_chat", target_session_id: "main" });
|
|
f.pages.push({ items: [foreignRun, outgoing], next_cursor: "next-page" }, { items: [item], next_cursor: null });
|
|
expect(await f.coordinator.flush("investigation")).toBeTrue();
|
|
expect(f.injections).toHaveLength(1); expect(f.injections[0].id).toBe(item.native_message_id);
|
|
expect(f.pageCalls).toEqual([["investigation", "investigation", undefined, 50, true], ["investigation", "investigation", "next-page", 50, true]]);
|
|
f.native.set(item.native_message_id, "delivered");
|
|
f.pages.push({ items: [f.records.get(item.message_id)!], next_cursor: null });
|
|
expect(await f.coordinator.flush("investigation")).toBeFalse(); expect(f.injections).toHaveLength(1);
|
|
});
|
|
});
|