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:
2026-10-19 18:46:58 +08:00
parent f5c3cde2c2
commit 6a49d2209e
164 changed files with 12286 additions and 2368 deletions
+84 -4
View File
@@ -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);
});
});