mirror of
https://github.com/docker/docker-agent.git
synced 2026-11-06 15:07:58 +00:00
248 lines
9.3 KiB
JavaScript
248 lines
9.3 KiB
JavaScript
// bridge_test.js exercises the JS surface of cmd/wasm under Node: session
|
|
// handles, the stateless chat() call and their failure modes. Node has no
|
|
// network from inside the Go wasm runtime, so model calls fail; the tests
|
|
// check that failures surface as rejections and events, never as hangs.
|
|
//
|
|
// Run with: node --test cmd/wasm/node/bridge_test.js
|
|
//
|
|
// The wasm binary must be built first (see boot.js).
|
|
|
|
"use strict";
|
|
|
|
const assert = require("node:assert/strict");
|
|
const { before, describe, it } = require("node:test");
|
|
const { bootDockerAgent } = require("./boot.js");
|
|
|
|
const unreachableAgent = `
|
|
providers:
|
|
local:
|
|
provider: openai
|
|
base_url: http://127.0.0.1:9/v1
|
|
token_key: LOCAL_KEY
|
|
agents:
|
|
root:
|
|
model: local/test-model
|
|
instruction: hi
|
|
`;
|
|
|
|
let dockerAgent;
|
|
before(async () => {
|
|
dockerAgent = await bootDockerAgent();
|
|
});
|
|
|
|
describe("createSession", () => {
|
|
it("returns a handle with the documented methods", async () => {
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } });
|
|
for (const name of ["send", "confirm", "respondToElicitation", "restart", "abort", "close"]) {
|
|
assert.equal(typeof session[name], "function", name);
|
|
}
|
|
await session.close();
|
|
});
|
|
|
|
it("only sees the env it was given, never the process env", async () => {
|
|
process.env.LOCAL_KEY = "leaked";
|
|
await assert.rejects(dockerAgent.createSession({ yaml: unreachableAgent }), /LOCAL_KEY/);
|
|
});
|
|
|
|
it("rejects configs that need a host process or local files", async () => {
|
|
const cases = [
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
toolsets:
|
|
- type: mcp
|
|
command: npx
|
|
`, /stdio MCP servers/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
toolsets:
|
|
- type: shell
|
|
`, /toolset type "shell"/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
hooks:
|
|
session_start:
|
|
- type: command
|
|
command: echo
|
|
`, /command hooks are not supported/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: dmr/ai/qwen3
|
|
`, /provider "dmr"/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
add_prompt_files: [README.md]
|
|
`, /add_prompt_files/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
toolsets:
|
|
- type: memory
|
|
path: ./memory.db
|
|
`, /memory path/],
|
|
[`
|
|
agents:
|
|
root:
|
|
model: openai/gpt-4o
|
|
toolsets:
|
|
- type: openapi
|
|
url: ./openapi.yaml
|
|
`, /only http\(s\) specs/],
|
|
];
|
|
for (const [yaml, want] of cases) {
|
|
await assert.rejects(dockerAgent.createSession({ yaml, env: { OPENAI_API_KEY: "k" } }), want);
|
|
}
|
|
});
|
|
|
|
it("accepts the portable example with its builtin toolsets and features", async () => {
|
|
const yaml = require("node:fs").readFileSync(require("node:path").join(__dirname, "..", "examples", "portable-team.yaml"), "utf8");
|
|
const session = await dockerAgent.createSession({ yaml, env: { OPENROUTER_API_KEY: "k", GITHUB_TOKEN: "t" } });
|
|
await session.close();
|
|
});
|
|
|
|
it("rejects an unknown agent name and a non-https tool proxy", async () => {
|
|
const env = { LOCAL_KEY: "k" };
|
|
await assert.rejects(dockerAgent.createSession({ yaml: unreachableAgent, env, agentName: "nope" }), /agent "nope" not found/);
|
|
await assert.rejects(dockerAgent.createSession({ yaml: unreachableAgent, env, toolProxy: "http://proxy.example.com" }), /must use https/);
|
|
});
|
|
|
|
it("rejects a concurrent send, aborts, restarts and closes cleanly", async () => {
|
|
const events = [];
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } }, (ev) => events.push(ev));
|
|
|
|
const first = session.send("hello");
|
|
await assert.rejects(session.send("again"), /run is already active/);
|
|
session.abort();
|
|
await assert.rejects(first, /aborted/);
|
|
assert.ok(!events.some((ev) => ev.type === "finish"), "an aborted turn does not finish");
|
|
|
|
await session.restart();
|
|
const second = session.send("after restart");
|
|
session.abort();
|
|
await assert.rejects(second, /aborted/);
|
|
|
|
await session.close();
|
|
await session.close();
|
|
await assert.rejects(session.send("late"), /session is closed/);
|
|
await assert.rejects(session.restart(), /session is closed/);
|
|
session.abort();
|
|
});
|
|
|
|
it("honours an abort issued synchronously after send", async () => {
|
|
const events = [];
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } }, (ev) => events.push(ev));
|
|
|
|
// No await in between: the turn must already be registered when abort runs.
|
|
const turn = session.send("hello");
|
|
session.abort();
|
|
await assert.rejects(turn, /aborted/);
|
|
assert.deepEqual(events, [], "a turn aborted before it ran emits nothing");
|
|
|
|
// The slot is free again and the session still works.
|
|
await assert.rejects(session.send("again"), /127\.0\.0\.1:9/);
|
|
await session.close();
|
|
});
|
|
|
|
it("rejects confirm when no tool confirmation is pending", async () => {
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } });
|
|
await assert.rejects(session.confirm(true), /no tool confirmation is pending/);
|
|
await assert.rejects(session.send("hello"), /127\.0\.0\.1:9/);
|
|
await assert.rejects(session.confirm("approve"), /no tool confirmation is pending/);
|
|
await session.close();
|
|
});
|
|
|
|
it("surfaces a failed model call as an error event and a rejection", async () => {
|
|
const events = [];
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } }, (ev) => events.push(ev));
|
|
await assert.rejects(session.send("hello"), /127\.0\.0\.1:9/);
|
|
assert.ok(events.some((ev) => ev.type === "error" && /127\.0\.0\.1:9/.test(ev.message)), JSON.stringify(events));
|
|
assert.ok(!events.some((ev) => ev.type === "finish"));
|
|
await session.close();
|
|
});
|
|
|
|
it("validates confirm and respondToElicitation arguments", async () => {
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } });
|
|
await assert.rejects(session.confirm("maybe"), /unknown decision "maybe"/);
|
|
await assert.rejects(session.confirm({ decision: "approve_tool" }), /requires toolName/);
|
|
await assert.rejects(session.respondToElicitation({ action: "accept" }), /id is required/);
|
|
await assert.rejects(session.respondToElicitation({ id: "x", action: "shrug" }), /unknown elicitation action/);
|
|
await assert.rejects(session.respondToElicitation({ id: "stale", action: "decline" }));
|
|
await session.close();
|
|
});
|
|
|
|
it("ignores what a session's onEvent returns", async () => {
|
|
// A never-settling promise from every event must not stall the turn.
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } }, () => new Promise(() => {}));
|
|
await assert.rejects(session.send("hello"), /127\.0\.0\.1:9/);
|
|
await session.close();
|
|
});
|
|
|
|
it("survives an onEvent handler that throws or rejects", async () => {
|
|
let calls = 0;
|
|
const session = await dockerAgent.createSession({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" } }, () => {
|
|
calls++;
|
|
if (calls % 2) throw new Error("boom");
|
|
return Promise.reject(new Error("boom"));
|
|
});
|
|
await assert.rejects(session.send("hello"), /127\.0\.0\.1:9/);
|
|
assert.ok(calls > 0);
|
|
// The rejection is logged off the event loop; a second turn proves nothing wedged.
|
|
await assert.rejects(session.send("again"), /127\.0\.0\.1:9/);
|
|
await session.close();
|
|
});
|
|
});
|
|
|
|
describe("chat", () => {
|
|
it("requires the last message to come from the user", async () => {
|
|
await assert.rejects(
|
|
dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages: [{ role: "assistant", content: "?" }] }),
|
|
/last message must be from the user/,
|
|
);
|
|
await assert.rejects(dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages: "nope" }), /messages/);
|
|
});
|
|
|
|
it("is cancelled by abort() and does not need an onEvent handler", async () => {
|
|
const pending = dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages: [{ role: "user", content: "hi" }] });
|
|
dockerAgent.abort();
|
|
await assert.rejects(pending, /aborted/);
|
|
});
|
|
|
|
it("carries history with tool calls and reports model errors", async () => {
|
|
const events = [];
|
|
const messages = [
|
|
{ role: "user", content: "echo" },
|
|
{ role: "assistant", tool_calls: [{ id: "c0", type: "function", function: { name: "echo", arguments: "{}" } }] },
|
|
{ role: "tool", tool_call_id: "c0", content: "ok" },
|
|
{ role: "user", content: "again" },
|
|
];
|
|
await assert.rejects(
|
|
dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages }, (ev) => events.push(ev)),
|
|
/127\.0\.0\.1:9/,
|
|
);
|
|
assert.ok(events.some((ev) => ev.type === "error"));
|
|
});
|
|
|
|
it("does not wait on onEvent for events that need no answer", async () => {
|
|
const messages = [{ role: "user", content: "hi" }];
|
|
// A never-settling promise would hang the call if it were awaited; a
|
|
// rejection is logged from the Go side without blocking the event loop.
|
|
await assert.rejects(
|
|
dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages }, () => new Promise(() => {})),
|
|
/127\.0\.0\.1:9/,
|
|
);
|
|
await assert.rejects(
|
|
dockerAgent.chat({ yaml: unreachableAgent, env: { LOCAL_KEY: "k" }, messages }, () => Promise.reject(new Error("boom"))),
|
|
/127\.0\.0\.1:9/,
|
|
);
|
|
});
|
|
});
|