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,169 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,8 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { SessionUiStateStore } from "../../src/sessions/uiStateStore.js";
|
||||
import { SessionWorkspaceStore } from "../../src/sessions/workspace-store.js";
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { parseAnswerUiInput } from "../../src/chat/answer-ui.js";
|
||||
import { streamPromptResponse } from "../../src/routes/chatStream.js";
|
||||
@@ -37,6 +42,21 @@ describe("locally rendered answers", () => {
|
||||
if (!suppressAnswerUi) expect(emitted.findIndex((event) => event.event === "token")).toBeLessThan(emitted.findIndex((event) => event.event === "answer_ui"));
|
||||
}
|
||||
});
|
||||
it("persists rich parts in the owned chat store and restores them without cross-scope access", async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), "tjwater-rich-answer-"));
|
||||
try {
|
||||
const owner = { actorKey: "actor-answer", projectKey: "project-answer", sessionId: "answer-session" };
|
||||
const workspaces = new SessionWorkspaceStore(join(root, "workspaces"));
|
||||
const store = new SessionUiStateStore(join(root, "legacy"), workspaces);
|
||||
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 }];
|
||||
await store.write(owner, { sessionId: owner.sessionId, messages });
|
||||
const restored = new SessionUiStateStore(join(root, "legacy"), new SessionWorkspaceStore(workspaces.root));
|
||||
expect((await restored.read(owner))?.messages).toEqual(messages);
|
||||
expect(await restored.read({ ...owner, actorKey: "actor-other" })).toBeNull();
|
||||
expect(await restored.read({ ...owner, projectKey: "project-other" })).toBeNull();
|
||||
} finally { await rm(root, { recursive: true, force: true }); }
|
||||
});
|
||||
it("preserves UI-only answers and ordered rich parts in task collaboration history", () => {
|
||||
const parts: UIMessage["parts"] = [{ type: "text", text: "演练:" }, { type: "data-answer_ui", id: "pressure", data: parseAnswerUiInput(input) }, { type: "text", text: "仍需核验。" }, { type: "data-final_answer", data: { content: "结论待确认。" } }];
|
||||
const messages = [{ id: "a", role: "assistant", parts: [...parts, { type: "data-progress", data: { title: "查询" } }] }, { id: "b", role: "assistant", parts: [parts[1]] }] as unknown as UIMessage[];
|
||||
|
||||
@@ -11,6 +11,23 @@ import { serviceOpencodeConfig } from "../../src/runtime/opencode2-config.js";
|
||||
import { modelCheckTargets } from "../../src/runtime/model-check.js";
|
||||
|
||||
describe("agent model config", () => {
|
||||
it("disables native local-provider discovery while preserving explicitly configured local models", () => {
|
||||
const providers = Object.fromEntries(["vllm", "lmstudio", "ollama"].map((id) => [id, {
|
||||
name: id, api_url: "http://127.0.0.1:18000/v1", models: { chat: {} },
|
||||
}]));
|
||||
const configuration = parseAgentModelConfig({
|
||||
defaults: { chat: "vllm/chat", task: "lmstudio/chat", learning_gate: "ollama/chat" }, providers,
|
||||
});
|
||||
const native = serviceOpencodeConfig({ plugins: ["opencode.provider.vllm"] }, configuration);
|
||||
for (const id of Object.keys(providers)) {
|
||||
expect(native.plugins).toContain(`-opencode.provider.${id}`);
|
||||
expect(native.providers[id]?.models.chat).toBeDefined();
|
||||
}
|
||||
expect(native.plugins).toContain("*");
|
||||
expect(native.agents.instruction.model).toBe("vllm/chat");
|
||||
expect(native.agents.investigation.model).toBe("lmstudio/chat");
|
||||
});
|
||||
|
||||
it("keeps every exposed option in the supported model list", () => {
|
||||
expect(agentModelOptions.map((model) => model.id)).toEqual(supportedModels);
|
||||
expect(
|
||||
@@ -34,7 +51,7 @@ describe("agent model config", () => {
|
||||
|
||||
it("generates native providers and the public catalog from one custom provider definition", () => {
|
||||
const configuration = parseAgentModelConfig({
|
||||
default_model: "custom/chat", task_model: "custom/org/model", learning_gate_model: "custom/chat",
|
||||
defaults: { chat: "custom/chat", task: "custom/org/model", learning_gate: "custom/chat" },
|
||||
providers: { custom: { name: "自定义服务", api_url: "https://example.com/v1", api_key: "{env:CUSTOM_API_KEY}", models: {
|
||||
chat: { name: "自定义", description: "自定义模型", icon: "bolt", model_id: "wire-chat" },
|
||||
"org/model": {}, hidden: { selectable: false }, image: { disabled: true, capabilities: { tools: false, output: ["image"] } },
|
||||
@@ -59,9 +76,71 @@ describe("agent model config", () => {
|
||||
expect(Object.keys(service.providers)).toEqual(["custom"]);
|
||||
});
|
||||
|
||||
it("requires an explicit nonempty model for every role without inheriting chat", () => {
|
||||
const input = {
|
||||
defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" },
|
||||
providers: { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {} } } },
|
||||
};
|
||||
for (const role of ["chat", "task", "learning_gate"] as const) {
|
||||
const { [role]: omitted, ...remaining } = input.defaults;
|
||||
expect(omitted).toBe("custom/chat");
|
||||
expect(() => parseAgentModelConfig({ ...input, defaults: remaining })).toThrow("invalid agent model configuration structure");
|
||||
for (const value of ["", " ", null, undefined]) {
|
||||
expect(() => parseAgentModelConfig({ ...input, defaults: { ...input.defaults, [role]: value } })).toThrow("invalid agent model configuration structure");
|
||||
}
|
||||
}
|
||||
const configuration = parseAgentModelConfig(input);
|
||||
expect(configuration.defaults).toEqual({ chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" });
|
||||
const native = serviceOpencodeConfig({}, configuration);
|
||||
expect(native.agents.instruction.model).toBe("custom/chat");
|
||||
expect(native.agents.investigation.model).toBe("custom/chat");
|
||||
});
|
||||
|
||||
it("routes same-name models across providers with independent limits and role overrides", () => {
|
||||
const configuration = parseAgentModelConfig({
|
||||
defaults: { chat: "primary/chat", task: "secondary/chat", learning_gate: "secondary/gate" },
|
||||
providers: {
|
||||
primary: { name: "Primary", api_url: "https://primary.example.com/v1", models: { chat: { limit: { context: 32000 } } } },
|
||||
secondary: { name: "Secondary", api_url: "https://secondary.example.com/v1", models: {
|
||||
chat: { model_id: "upstream/chat", limit: { context: 64000 } }, gate: { selectable: false },
|
||||
} },
|
||||
},
|
||||
});
|
||||
expect(getAgentModelOptions(configuration).map(({ id }) => id)).toEqual(["primary/chat", "secondary/chat"]);
|
||||
expect(configuration.defaults.learning_gate).toBe("secondary/gate");
|
||||
const native = serviceOpencodeConfig({}, configuration);
|
||||
expect(native.model).toBe("primary/chat");
|
||||
expect(native.agents.instruction.model).toBe("primary/chat");
|
||||
expect(native.agents.investigation.model).toBe("secondary/chat");
|
||||
expect(native.providers.primary.models.chat.limit?.context).toBe(32000);
|
||||
expect(native.providers.secondary.models.chat).toMatchObject({ modelID: "upstream/chat", limit: { context: 64000 } });
|
||||
expect(modelCheckTargets(configuration).models.map(({ id, wire_id }) => ({ id, wire_id }))).toEqual([
|
||||
{ id: "primary/chat", wire_id: "chat" },
|
||||
{ id: "secondary/chat", wire_id: "upstream/chat" },
|
||||
{ id: "secondary/gate", wire_id: "gate" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("requires grouped defaults and rejects hidden chat defaults and unknown role names", () => {
|
||||
const providers = { custom: { name: "Custom", api_url: "https://example.com/v1", models: { chat: {}, hidden: { selectable: false } } } };
|
||||
const defaults = { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" };
|
||||
for (const input of [
|
||||
{ providers },
|
||||
{ defaults: {}, providers },
|
||||
{ defaults: { ...defaults, typo: "custom/chat" }, providers },
|
||||
{ default_model: "custom/chat", providers },
|
||||
{ defaults, task_model: "custom/chat", providers },
|
||||
]) expect(() => parseAgentModelConfig(input)).toThrow("invalid agent model configuration structure");
|
||||
expect(() => parseAgentModelConfig({ defaults: { ...defaults, chat: "custom/hidden" }, providers })).toThrow("defaults.chat must be selectable");
|
||||
});
|
||||
|
||||
it("rejects unknown, disabled or non-tool role models and invalid endpoints without echoing keys", () => {
|
||||
const base = { default_model: "custom/chat", providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {} } } } };
|
||||
for (const role of ["default_model", "task_model", "learning_gate_model"]) expect(() => parseAgentModelConfig({ ...base, [role]: "custom/missing" })).toThrow("enabled configured text model");
|
||||
const base = { defaults: { chat: "custom/chat", task: "custom/chat", learning_gate: "custom/chat" }, providers: { custom: { name: "Custom", api_url: "https://example.com/v1", api_key: "TEST_ONLY_SECRET", models: { chat: {}, disabled: { disabled: true }, no_tools: { capabilities: { tools: false } }, image: { capabilities: { output: ["image"] } } } } } };
|
||||
for (const role of ["chat", "task", "learning_gate"]) {
|
||||
for (const model of ["custom/missing", "custom/disabled", "custom/no_tools", "custom/image"]) {
|
||||
expect(() => parseAgentModelConfig({ ...base, defaults: { ...base.defaults, [role]: model } })).toThrow(`defaults.${role} must name an enabled configured text model`);
|
||||
}
|
||||
}
|
||||
for (const model of [{ disabled: true }, { capabilities: { tools: false } }, { capabilities: { output: ["image"] } }]) {
|
||||
expect(() => parseAgentModelConfig({ ...base, providers: { custom: { ...base.providers.custom, models: { chat: model } } } })).toThrow();
|
||||
}
|
||||
@@ -89,6 +168,7 @@ describe("agent model config", () => {
|
||||
const native = serviceOpencodeConfig({});
|
||||
expect(Object.keys(native.providers)).toEqual(["waternetwork"]);
|
||||
expect(native.model).toBe("waternetwork/gpt-6-luna");
|
||||
for (const model of Object.values(native.providers.waternetwork.models)) expect(model.limit?.context).toBe(272000);
|
||||
expect(native.providers.waternetwork.models["gpt-6-luna"].limit?.context).toBe(272000);
|
||||
expect(native.providers.waternetwork.models["gpt-6.1-sol"].limit?.context).toBe(475000);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "bun:test";
|
||||
import { createPromptSuggestionService, parseGeneratedSuggestions, promptSuggestionRequestSchema } from "../../src/chat/prompt-suggestions.js";
|
||||
import type { LocalAgentContext } from "../../src/context/localContext.js";
|
||||
|
||||
const activity = { id: "alert:pressure", kind: "alert" as const, title: "城南压力告警", occurred_at: "2026-10-09T07:00:00Z", origin: "backend" as const };
|
||||
const context = { actorKey: "actor-a", projectKey: "project-a", projectId: "project-a", userId: "user-a", network: "supply", traceId: "trace-a" } satisfies LocalAgentContext;
|
||||
const output = JSON.stringify({ suggestions: [{ question: "核对城南压力告警的监测证据与影响范围", activity_id: activity.id }] });
|
||||
|
||||
describe("Agent prompt suggestions", () => {
|
||||
it("accepts only bounded activity snapshots and suggestions referencing supplied records", () => {
|
||||
expect(promptSuggestionRequestSchema.safeParse({ activities: Array.from({ length: 13 }, (_, i) => ({ ...activity, id: String(i) })) }).success).toBe(false);
|
||||
expect(promptSuggestionRequestSchema.safeParse({ activities: [activity, activity] }).success).toBe(false);
|
||||
expect(parseGeneratedSuggestions(`\`\`\`json\n${output}\n\`\`\``, [activity])).toHaveLength(1);
|
||||
expect(() => parseGeneratedSuggestions(output.replace(activity.id, "other-project-alert"), [activity])).toThrow("unknown activity ID");
|
||||
expect(() => parseGeneratedSuggestions(JSON.stringify({ suggestions: [JSON.parse(output).suggestions[0], JSON.parse(output).suggestions[0]] }), [activity])).toThrow("duplicate suggestion");
|
||||
});
|
||||
|
||||
it("coalesces inference for the same actor, project, model and activity without sharing other scopes", async () => {
|
||||
const calls: string[] = [];
|
||||
const generate = createPromptSuggestionService({ generateText: async (prompt) => { calls.push(prompt); return output; } });
|
||||
const request = { activities: [activity] };
|
||||
const [a, b] = await Promise.all([generate(context, request, "test/model"), generate(context, request, "test/model")]);
|
||||
expect(a).toEqual(b);
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0]).toContain(activity.title);
|
||||
await generate({ ...context, actorKey: "actor-b" }, request, "test/model");
|
||||
await generate({ ...context, projectKey: "project-b" }, request, "test/model");
|
||||
await generate(context, request, "test/other-model");
|
||||
await generate(context, { activities: [{ ...activity, title: "城北压力告警" }] }, "test/model");
|
||||
expect(calls).toHaveLength(5);
|
||||
});
|
||||
|
||||
it("does not cache malformed model output and supports generic questions without inventing activity references", async () => {
|
||||
let calls = 0;
|
||||
const generate = createPromptSuggestionService({ generateText: async () => ++calls === 1 ? "invalid JSON" : '{"suggestions":[{"question":"分析供水管网需要优先核对的监测指标","activity_id":null}]}' });
|
||||
await expect(generate(context, { activities: [] }, "test/model")).rejects.toThrow();
|
||||
expect((await generate(context, { activities: [] }, "test/model")).suggestions[0].activity_id).toBeNull();
|
||||
expect(calls).toBe(2);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,50 @@
|
||||
import { expect, it, mock } from "bun:test";
|
||||
import { ResponseInputTracker, type ResponseInput } from "../../src/chat/response-inputs.js";
|
||||
|
||||
const input = (id: number): ResponseInput => ({ id: `msg_${String(id).padStart(32, "0")}`, text: `Input ${id}`, delivery: "steer", status: "pending" });
|
||||
|
||||
it("waits for admission at completion, deduplicates concurrent retries and closes only after native consumption", async () => {
|
||||
let release = () => {};
|
||||
const gate = new Promise<void>((resolve) => { release = resolve; });
|
||||
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: mock(async () => { await gate; }) };
|
||||
const changed = mock((_input: ResponseInput) => {});
|
||||
const tracker = new ResponseInputTracker(runtime, "owned", changed);
|
||||
tracker.markReady();
|
||||
const first = tracker.submit(input(1));
|
||||
const retry = tracker.submit(input(1));
|
||||
const closing = tracker.canClose();
|
||||
release();
|
||||
expect((await first).status).toBe("pending");
|
||||
expect((await retry).status).toBe("pending");
|
||||
expect(runtime.enqueueUserInput).toHaveBeenCalledTimes(1);
|
||||
expect(await closing).toBe(false);
|
||||
tracker.observe("session.inbox.delivery.changed", { inboxID: input(1).id, delivery: "queue" });
|
||||
expect(changed.mock.calls.at(-1)?.[0]).toMatchObject({ delivery: "queue" });
|
||||
tracker.observe("session.inbox.delivered", { inboxID: input(1).id });
|
||||
expect(changed.mock.calls.at(-1)?.[0]).toMatchObject({ status: "delivered" });
|
||||
expect(await tracker.canClose()).toBe(true);
|
||||
await expect(tracker.submit(input(2))).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
|
||||
it("rejects a failed admission and permits a later retry without acknowledging nonexistent input", async () => {
|
||||
let attempts = 0;
|
||||
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: async () => { if (attempts++ === 0) throw new Error("fixture failure"); } };
|
||||
const tracker = new ResponseInputTracker(runtime, "owned", () => {});
|
||||
tracker.markReady();
|
||||
const first = tracker.submit(input(1));
|
||||
const duplicate = tracker.submit(input(1));
|
||||
const results = await Promise.allSettled([first, duplicate]);
|
||||
expect(results.map((result) => result.status)).toEqual(["rejected", "rejected"]);
|
||||
for (const result of results) if (result.status === "rejected") expect(result.reason.message).toBe("fixture failure");
|
||||
await expect(tracker.submit(input(1))).resolves.toMatchObject({ status: "pending" });
|
||||
});
|
||||
|
||||
it("bounds pending input and refuses admission after an explicit stop", async () => {
|
||||
const runtime = { listPendingUserInputs: async () => [], enqueueUserInput: async () => {} };
|
||||
const tracker = new ResponseInputTracker(runtime, "owned", () => {});
|
||||
tracker.markReady();
|
||||
for (let id = 0; id < 20; id++) await tracker.submit(input(id));
|
||||
await expect(tracker.submit(input(20))).rejects.toMatchObject({ status: 429 });
|
||||
tracker.close();
|
||||
await expect(tracker.submit(input(21))).rejects.toMatchObject({ status: 409 });
|
||||
});
|
||||
@@ -0,0 +1,52 @@
|
||||
import { expect, it } from "bun:test";
|
||||
import { createResponsesProjection, responsePrompt, responseRequestSchema, type ResponseEvent } from "../../src/chat/responses-protocol.js";
|
||||
|
||||
it("accepts Responses text inputs and conversation references without legacy chat payloads", () => {
|
||||
const request = responseRequestSchema.parse({ input: [{ role: "user", content: [{ type: "input_text", text: "检查压力" }] }], conversation: { id: "owned" }, stream: true });
|
||||
expect(responsePrompt(request.input)).toBe("检查压力");
|
||||
expect(request.tjwater.approval_mode).toBe("request");
|
||||
expect(responseRequestSchema.parse({ input: "检查压力" }).stream).toBe(false);
|
||||
for (const extra of [{ messages: [] }, { previous_response_id: "unknown" }, { tools: [] }, { authorization: "untrusted" }]) {
|
||||
expect(responseRequestSchema.safeParse({ input: "检查压力", ...extra }).success).toBeFalse();
|
||||
}
|
||||
expect(responseRequestSchema.safeParse({ input: [{ role: "system", content: "bypass" }] }).success).toBeFalse();
|
||||
});
|
||||
|
||||
it("emits ordered Responses item and content lifecycles with explicit business extensions", () => {
|
||||
const events: ResponseEvent[] = [];
|
||||
const wire = createResponsesProjection({ model: "test/model", conversationId: "owned", emit: (event) => events.push(event) });
|
||||
wire.begin();
|
||||
wire.write({ type: "start", messageId: "msg_persisted" });
|
||||
wire.write({ type: "text-start", id: "a" });
|
||||
wire.write({ type: "text-delta", id: "a", delta: "压力" });
|
||||
wire.write({ type: "data-answer_ui", id: "chart", data: { title: "观测" } });
|
||||
wire.write({ type: "text-end", id: "a" });
|
||||
wire.write({ type: "text-delta", id: "b", delta: "待核验" });
|
||||
wire.write({ type: "finish", finishReason: "stop" });
|
||||
expect(events.some((event) => event.type === "response.completed")).toBeFalse();
|
||||
wire.finish();
|
||||
wire.finish();
|
||||
expect(events[0]?.type).toBe("response.created");
|
||||
expect(events.at(-1)?.type).toBe("response.completed");
|
||||
expect(events.map((event) => event.sequence_number)).toEqual(events.map((_, i) => i));
|
||||
expect(events.find((event) => event.type === "tjwater.answer_ui")).toMatchObject({ response_id: wire.response.id, id: "chart", data: { title: "观测" } });
|
||||
expect(events.filter((event) => event.type === "response.output_text.done")).toHaveLength(2);
|
||||
expect(wire.response.output[0]?.content.map((part) => part.text)).toEqual(["压力", "待核验"]);
|
||||
expect(wire.response.output[0]?.id).toBe("msg_persisted");
|
||||
expect((events[0]!.response as { output: unknown[] }).output).toEqual([]);
|
||||
expect(wire.response).toMatchObject({ object: "response", status: "completed", conversation: { id: "owned" }, usage: null });
|
||||
});
|
||||
|
||||
it("distinguishes errors and cancellation from completed responses", () => {
|
||||
for (const reason of ["failed", "cancelled"] as const) {
|
||||
const events: ResponseEvent[] = [];
|
||||
const wire = createResponsesProjection({ model: "test/model", conversationId: "owned", emit: (event) => events.push(event) });
|
||||
wire.write({ type: "text-delta", id: "a", delta: "部分结果" });
|
||||
wire.write({ type: "error", errorText: "计算失败" });
|
||||
wire.finish(reason);
|
||||
expect(events.at(-1)?.type).toBe(reason === "cancelled" ? "response.incomplete" : "response.failed");
|
||||
expect(wire.response.output[0]?.status).toBe("incomplete");
|
||||
expect(wire.response.error?.message).toBe("计算失败");
|
||||
expect(events.some((event) => event.type === "response.completed")).toBeFalse();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user