feat(coding-agent): added tool.<name>(args) execution in Python prelude
- Added `tool.<name>(args)` execution in Python prelude and docs, enabling direct tool call syntax. - Added per-execution Python tool-bridge registration, env wiring, and executor lifecycle management. - Implemented authenticated loopback `/v1/tool` handling with session/name validation and structured responses. - Updated runtime and lint handling to permit controlled global eval usage in indirect eval paths. - Added tests for success, failures, invalid bodies, and `emitStatus` propagation in Python tool bridges.
This commit is contained in:
@@ -1,13 +1,15 @@
|
||||
# Changelog
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Breaking Changes
|
||||
|
||||
- Changed the `timeoutMs` execution option to no longer be enforced during worker-based JS runs, so callers must rely on external cancellation signals for time limits
|
||||
|
||||
### Added
|
||||
|
||||
- Added Python `tool.<name>(args)` support to `executePython` sessions so evaluated Python code can invoke session tools through the prelude `tool` proxy
|
||||
- Added per-execution Python tool bridge session registration and loopback endpoint wiring so Python tool calls resolve to host tools and return tool results
|
||||
- Added status-event forwarding for Python tool bridge calls so `tool` invocations can emit execution status updates
|
||||
- Added browser-tab JavaScript execution through the shared runtime so tab runs now expose the standard helper globals (`read`, `write`, `sort`, `uniq`, `counter`, `diff`, `tree`, `env`, `output`, `display`, and `tool`)
|
||||
- Added static ESM `import` support to browser-tab JavaScript by rewriting top-level imports and resolving them against the tab session context
|
||||
- Added substring fallback matching to `HistoryStorage.search` so infix and short-token queries that FTS5 prefix matching misses are still returned
|
||||
|
||||
@@ -14,6 +14,10 @@ export function indirectEval(source: string, filename?: string): unknown {
|
||||
const withPragma = filename ? `${source}\n//# sourceURL=${filename}` : source;
|
||||
// Read `eval` via a property access so the call site is *indirect* (global scope),
|
||||
// not direct (this module's lexical scope). The cast erases the DOM lib return type.
|
||||
// We deliberately avoid `node:vm` because Bun crashes the parent with SIGTRAP when
|
||||
// Worker.terminate() fires mid-`vm.runInContext` synchronous loop — indirect eval is
|
||||
// the executor for user code in the worker.
|
||||
// biome-ignore lint/security/noGlobalEval: see comment above — this is the executor.
|
||||
const geval = globalThis.eval as (src: string) => unknown;
|
||||
return geval(withPragma);
|
||||
}
|
||||
|
||||
@@ -172,7 +172,6 @@ export function demoteTopLevelLexicals(code: string): string {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Strip TypeScript syntax (type annotations, `interface`, `as`, `satisfies`, generics in
|
||||
* call expressions, etc.) before the import/lexical rewriters parse the code. We use Bun's
|
||||
|
||||
@@ -34,7 +34,6 @@ export interface RuntimeOptions {
|
||||
* via `setRunScope()` instead.
|
||||
*/
|
||||
extraGlobals?: Record<string, unknown>;
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +147,6 @@ function formatConsoleArgs(args: unknown[]): string {
|
||||
.join(" ");
|
||||
}
|
||||
|
||||
|
||||
function buildRequire(cwd: string): NodeJS.Require {
|
||||
return createRequire(pathToFileURL(path.join(cwd, "[eval]")).href);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { getProjectDir, logger } from "@oh-my-pi/pi-utils";
|
||||
import { OutputSink } from "../../session/streaming-output";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import type { JsStatusEvent } from "../js/shared/types";
|
||||
import { shutdownSharedGateway } from "./gateway-coordinator";
|
||||
import {
|
||||
checkPythonKernelAvailability,
|
||||
@@ -8,6 +10,7 @@ import {
|
||||
type KernelExecuteResult,
|
||||
PythonKernel,
|
||||
} from "./kernel";
|
||||
import { ensurePyToolBridge, registerPyToolBridge } from "./tool-bridge";
|
||||
|
||||
const IDLE_TIMEOUT_MS = 5 * 60 * 1000; // 5 minutes
|
||||
const MAX_KERNEL_SESSIONS = 4;
|
||||
@@ -49,6 +52,18 @@ export interface PythonExecutorOptions {
|
||||
/** Artifact path/id for full output storage */
|
||||
artifactPath?: string;
|
||||
artifactId?: string;
|
||||
/**
|
||||
* ToolSession used to resolve host-side `tool.<name>(args)` calls made from
|
||||
* the Python prelude's bridge proxy. When omitted, the bridge env vars are
|
||||
* not injected and any `tool.foo(...)` raises in Python.
|
||||
*/
|
||||
toolSession?: ToolSession;
|
||||
/** Callback for status events emitted by tool bridge invocations. */
|
||||
emitStatus?: (event: JsStatusEvent) => void;
|
||||
/** @internal Bridge session id, set by `executePython` before delegating. */
|
||||
bridgeSessionId?: string;
|
||||
/** @internal Bridge endpoint info, set by `executePython` before delegating. */
|
||||
bridge?: { url: string; token: string };
|
||||
}
|
||||
|
||||
export interface PythonKernelExecutor {
|
||||
@@ -113,6 +128,10 @@ interface KernelSessionExecutionOptions {
|
||||
signal?: AbortSignal;
|
||||
deadlineMs?: number;
|
||||
kernelOwnerId?: string;
|
||||
/** Bridge session identifier exported into the kernel env as PI_TOOL_BRIDGE_SESSION. */
|
||||
bridgeSessionId?: string;
|
||||
/** Cached bridge connection info. When present, env vars for tool.<name>() get injected. */
|
||||
bridge?: { url: string; token: string };
|
||||
}
|
||||
|
||||
class PythonExecutionCancelledError extends Error {
|
||||
@@ -137,10 +156,20 @@ function getExecutionDeadlineMs(options?: Pick<PythonExecutorOptions, "deadlineM
|
||||
* directory (preferred by the prelude when resolving output IDs, so subagents
|
||||
* see the parent's flat dir instead of a non-existent sibling).
|
||||
*/
|
||||
function buildKernelEnv(options: { sessionFile?: string; artifactsDir?: string }): Record<string, string> | undefined {
|
||||
function buildKernelEnv(options: {
|
||||
sessionFile?: string;
|
||||
artifactsDir?: string;
|
||||
bridgeSessionId?: string;
|
||||
bridge?: { url: string; token: string };
|
||||
}): Record<string, string> | undefined {
|
||||
const env: Record<string, string> = {};
|
||||
if (options.sessionFile) env.PI_SESSION_FILE = options.sessionFile;
|
||||
if (options.artifactsDir) env.PI_ARTIFACTS_DIR = options.artifactsDir;
|
||||
if (options.bridge && options.bridgeSessionId) {
|
||||
env.PI_TOOL_BRIDGE_URL = options.bridge.url;
|
||||
env.PI_TOOL_BRIDGE_TOKEN = options.bridge.token;
|
||||
env.PI_TOOL_BRIDGE_SESSION = options.bridgeSessionId;
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : undefined;
|
||||
}
|
||||
|
||||
@@ -871,6 +900,20 @@ async function executeWithKernel(
|
||||
const deadlineMs = getExecutionDeadlineMs(options);
|
||||
let executionTimeoutMs: number | undefined;
|
||||
|
||||
const emitStatus =
|
||||
options?.emitStatus ??
|
||||
((event: JsStatusEvent) => {
|
||||
displayOutputs.push({ type: "status", event });
|
||||
});
|
||||
const unregisterBridge =
|
||||
options?.toolSession && options?.bridgeSessionId
|
||||
? registerPyToolBridge(options.bridgeSessionId, {
|
||||
toolSession: options.toolSession,
|
||||
signal: options.signal,
|
||||
emitStatus,
|
||||
})
|
||||
: null;
|
||||
|
||||
try {
|
||||
executionTimeoutMs = requireRemainingTimeoutMs(deadlineMs);
|
||||
const result = await kernel.execute(code, {
|
||||
@@ -923,6 +966,8 @@ async function executeWithKernel(
|
||||
const error = err instanceof Error ? err : new Error(String(err));
|
||||
logger.error("Python execution failed", { error: error.message });
|
||||
throw error;
|
||||
} finally {
|
||||
unregisterBridge?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -954,7 +999,20 @@ export async function executePython(code: string, options?: PythonExecutorOption
|
||||
|
||||
const kernelMode = executionOptions.kernelMode ?? "session";
|
||||
|
||||
if (executionOptions.toolSession && !executionOptions.bridge) {
|
||||
try {
|
||||
executionOptions.bridge = await ensurePyToolBridge();
|
||||
} catch (err) {
|
||||
logger.warn("Failed to start Python tool bridge", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (kernelMode === "per-call") {
|
||||
if (executionOptions.bridge && !executionOptions.bridgeSessionId) {
|
||||
executionOptions.bridgeSessionId = `py-bridge:${crypto.randomUUID()}`;
|
||||
}
|
||||
const env = buildKernelEnv(executionOptions);
|
||||
requireRemainingTimeoutMs(deadlineMs);
|
||||
const startOptions = buildKernelStartOptions(cwd, env, executionOptions);
|
||||
@@ -967,6 +1025,9 @@ export async function executePython(code: string, options?: PythonExecutorOption
|
||||
}
|
||||
|
||||
const sessionId = executionOptions.sessionId ?? `session:${cwd}`;
|
||||
if (executionOptions.bridge && !executionOptions.bridgeSessionId) {
|
||||
executionOptions.bridgeSessionId = sessionId;
|
||||
}
|
||||
if (executionOptions.reset) {
|
||||
const existing = kernelSessions.get(sessionId);
|
||||
if (existing) {
|
||||
|
||||
@@ -41,6 +41,7 @@ export default {
|
||||
artifactPath: opts.artifactPath,
|
||||
artifactId: opts.artifactId,
|
||||
onChunk: opts.onChunk,
|
||||
toolSession: opts.session,
|
||||
};
|
||||
const result = await executePython(code, executorOptions);
|
||||
return {
|
||||
|
||||
@@ -372,3 +372,87 @@ if "__omp_prelude_loaded__" not in globals():
|
||||
|
||||
return current
|
||||
|
||||
|
||||
class _ToolCallable:
|
||||
"""Invokes one host-side tool via the loopback HTTP bridge."""
|
||||
|
||||
__slots__ = ("_proxy", "_name")
|
||||
|
||||
def __init__(self, proxy: "_ToolProxy", name: str):
|
||||
self._proxy = proxy
|
||||
self._name = name
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<tool.{self._name}>"
|
||||
|
||||
def __call__(self, args=None, /, **kwargs):
|
||||
import urllib.request, urllib.error
|
||||
if args is None:
|
||||
merged: dict = {}
|
||||
elif isinstance(args, dict):
|
||||
merged = dict(args)
|
||||
else:
|
||||
raise TypeError(
|
||||
f"tool.{self._name}(...) expects a dict of arguments (got {type(args).__name__})"
|
||||
)
|
||||
merged.update(kwargs)
|
||||
if "_i" not in merged:
|
||||
merged["_i"] = "py prelude"
|
||||
payload = json.dumps(
|
||||
{"session": self._proxy._session, "name": self._name, "args": merged}
|
||||
).encode("utf-8")
|
||||
req = urllib.request.Request(
|
||||
f"{self._proxy._base}/v1/tool",
|
||||
data=payload,
|
||||
method="POST",
|
||||
headers={
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self._proxy._token}",
|
||||
},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
body = resp.read()
|
||||
except urllib.error.HTTPError as exc:
|
||||
body = exc.read()
|
||||
try:
|
||||
data = json.loads(body)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(
|
||||
f"tool.{self._name}: bridge returned non-JSON response: {body[:200]!r}"
|
||||
) from None
|
||||
if not isinstance(data, dict) or not data.get("ok"):
|
||||
msg = (data or {}).get("error") if isinstance(data, dict) else None
|
||||
raise RuntimeError(msg or f"tool.{self._name} failed")
|
||||
return data.get("value")
|
||||
|
||||
class _ToolProxy:
|
||||
"""`tool.<name>(args)` proxy mirroring the JS runtime bridge."""
|
||||
|
||||
__slots__ = ("_base", "_token", "_session")
|
||||
|
||||
def __init__(self, base: str, token: str, session: str):
|
||||
self._base = base.rstrip("/")
|
||||
self._token = token
|
||||
self._session = session
|
||||
|
||||
def __getattr__(self, name: str) -> _ToolCallable:
|
||||
if name.startswith("_"):
|
||||
raise AttributeError(name)
|
||||
return _ToolCallable(self, name)
|
||||
|
||||
def __getitem__(self, name: str) -> _ToolCallable:
|
||||
return _ToolCallable(self, name)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<tool proxy session={self._session}>"
|
||||
|
||||
if all(
|
||||
_k in os.environ
|
||||
for _k in ("PI_TOOL_BRIDGE_URL", "PI_TOOL_BRIDGE_TOKEN", "PI_TOOL_BRIDGE_SESSION")
|
||||
):
|
||||
tool = _ToolProxy(
|
||||
os.environ["PI_TOOL_BRIDGE_URL"],
|
||||
os.environ["PI_TOOL_BRIDGE_TOKEN"],
|
||||
os.environ["PI_TOOL_BRIDGE_SESSION"],
|
||||
)
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
/**
|
||||
* HTTP loopback bridge that lets the Python kernel synchronously invoke
|
||||
* host-side tools by name, mirroring the JS worker's `tool.<name>(args)` proxy.
|
||||
*
|
||||
* The Python prelude builds a `tool` proxy that POSTs to `/v1/tool` over a
|
||||
* 127.0.0.1 loopback socket; the host resolves the request against the
|
||||
* `ToolSession` registered for the current execution and forwards to the same
|
||||
* `callSessionTool` implementation the JS bridge uses.
|
||||
*/
|
||||
import { logger } from "@oh-my-pi/pi-utils";
|
||||
import type { ToolSession } from "../../tools";
|
||||
import { callSessionTool, type JsStatusEvent } from "../js/tool-bridge";
|
||||
|
||||
export interface PyToolBridgeEntry {
|
||||
toolSession: ToolSession;
|
||||
signal?: AbortSignal;
|
||||
emitStatus?: (event: JsStatusEvent) => void;
|
||||
}
|
||||
|
||||
export interface PyToolBridgeInfo {
|
||||
url: string;
|
||||
token: string;
|
||||
}
|
||||
|
||||
interface BridgeServer {
|
||||
info: PyToolBridgeInfo;
|
||||
stop: () => Promise<void>;
|
||||
}
|
||||
|
||||
const registrations = new Map<string, PyToolBridgeEntry>();
|
||||
let serverPromise: Promise<BridgeServer> | null = null;
|
||||
|
||||
async function startServer(): Promise<BridgeServer> {
|
||||
const token = crypto.randomUUID();
|
||||
const server = Bun.serve({
|
||||
hostname: "127.0.0.1",
|
||||
port: 0,
|
||||
async fetch(req) {
|
||||
const url = new URL(req.url);
|
||||
if (req.method !== "POST" || url.pathname !== "/v1/tool") {
|
||||
return new Response("Not Found", { status: 404 });
|
||||
}
|
||||
if (req.headers.get("authorization") !== `Bearer ${token}`) {
|
||||
return new Response("Forbidden", { status: 403 });
|
||||
}
|
||||
|
||||
let body: { session?: unknown; name?: unknown; args?: unknown };
|
||||
try {
|
||||
body = (await req.json()) as { session?: unknown; name?: unknown; args?: unknown };
|
||||
} catch {
|
||||
return Response.json({ ok: false, error: "Invalid JSON body" }, { status: 400 });
|
||||
}
|
||||
const sessionId = typeof body.session === "string" ? body.session : "";
|
||||
const name = typeof body.name === "string" ? body.name : "";
|
||||
if (!sessionId || !name) {
|
||||
return Response.json({ ok: false, error: "Missing session/name" }, { status: 400 });
|
||||
}
|
||||
const entry = registrations.get(sessionId);
|
||||
if (!entry) {
|
||||
return Response.json(
|
||||
{ ok: false, error: `No active Python tool bridge session: ${sessionId}` },
|
||||
{ status: 200 },
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const value = await callSessionTool(name, body.args, {
|
||||
session: entry.toolSession,
|
||||
signal: entry.signal,
|
||||
emitStatus: entry.emitStatus,
|
||||
});
|
||||
return Response.json({ ok: true, value });
|
||||
} catch (err) {
|
||||
return Response.json({
|
||||
ok: false,
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const info: PyToolBridgeInfo = {
|
||||
url: `http://${server.hostname}:${server.port}`,
|
||||
token,
|
||||
};
|
||||
logger.debug("Python tool bridge listening", { url: info.url });
|
||||
|
||||
return {
|
||||
info,
|
||||
stop: async () => {
|
||||
await server.stop(true);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/** Starts the bridge server lazily and returns its connection info. */
|
||||
export async function ensurePyToolBridge(): Promise<PyToolBridgeInfo> {
|
||||
if (!serverPromise) {
|
||||
serverPromise = startServer();
|
||||
}
|
||||
try {
|
||||
const server = await serverPromise;
|
||||
return server.info;
|
||||
} catch (err) {
|
||||
serverPromise = null;
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a tool session for the duration of one execution. The returned
|
||||
* function MUST be called to remove the entry once execution finishes.
|
||||
*/
|
||||
export function registerPyToolBridge(sessionId: string, entry: PyToolBridgeEntry): () => void {
|
||||
registrations.set(sessionId, entry);
|
||||
return () => {
|
||||
if (registrations.get(sessionId) === entry) {
|
||||
registrations.delete(sessionId);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Stop the bridge and clear registrations. Test-only / shutdown helper. */
|
||||
export async function disposePyToolBridge(): Promise<void> {
|
||||
registrations.clear();
|
||||
const pending = serverPromise;
|
||||
serverPromise = null;
|
||||
if (!pending) return;
|
||||
try {
|
||||
const server = await pending;
|
||||
await server.stop();
|
||||
} catch (err) {
|
||||
logger.debug("Failed to stop Python tool bridge", {
|
||||
error: err instanceof Error ? err.message : String(err),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -50,10 +50,10 @@ env(key?=None, value?=None) → str | None | dict
|
||||
No args → full environment as dict. One arg → value of `key`. Two args → set `key=value` and return value.
|
||||
output(*ids, format?="raw", query?=None, offset?=None, limit?=None) → str | dict | list[dict]
|
||||
Read task/agent output by ID. Single id returns text/dict; multiple ids return a list.
|
||||
tool.<name>(args) → unknown
|
||||
Invoke any session tool by name. `args` is the tool's parameter object.
|
||||
```
|
||||
|
||||
{{#if js}}**JavaScript only:** `tool.<name>(args)` invokes any session tool directly (e.g. `await tool.read({ path: "src/foo.ts" })`).
|
||||
{{/if}}</prelude>
|
||||
</prelude>
|
||||
|
||||
<output>
|
||||
Cells render like a Jupyter notebook. `display(value)` renders non-presentable data as an interactive JSON tree. Presentable values (figures, images, dataframes, etc.) use their native representation.
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
import { afterAll, describe, expect, it } from "bun:test";
|
||||
import type { AgentTool, AgentToolResult } from "@oh-my-pi/pi-agent-core";
|
||||
import {
|
||||
disposePyToolBridge,
|
||||
ensurePyToolBridge,
|
||||
registerPyToolBridge,
|
||||
} from "@oh-my-pi/pi-coding-agent/eval/py/tool-bridge";
|
||||
import type { ToolSession } from "@oh-my-pi/pi-coding-agent/tools";
|
||||
|
||||
interface FakeCall {
|
||||
id: string;
|
||||
args: unknown;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
|
||||
function makeFakeTool(name: string, calls: FakeCall[], result: AgentToolResult): AgentTool {
|
||||
const tool = {
|
||||
name,
|
||||
label: name,
|
||||
description: name,
|
||||
parameters: { type: "object" },
|
||||
async execute(id: string, args: unknown, signal?: AbortSignal): Promise<AgentToolResult> {
|
||||
calls.push({ id, args, signal });
|
||||
return result;
|
||||
},
|
||||
} as unknown as AgentTool;
|
||||
return tool;
|
||||
}
|
||||
|
||||
function makeSession(tools: Map<string, AgentTool>): ToolSession {
|
||||
return { getToolByName: (name: string) => tools.get(name) } as unknown as ToolSession;
|
||||
}
|
||||
|
||||
async function call(
|
||||
info: { url: string; token: string },
|
||||
body: Record<string, unknown>,
|
||||
overrides?: { token?: string },
|
||||
): Promise<Response> {
|
||||
return await fetch(`${info.url}/v1/tool`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${overrides?.token ?? info.token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("Python tool bridge HTTP server", () => {
|
||||
afterAll(async () => {
|
||||
await disposePyToolBridge();
|
||||
});
|
||||
|
||||
it("dispatches calls to the registered ToolSession and returns the tool value", async () => {
|
||||
const calls: FakeCall[] = [];
|
||||
const readTool = makeFakeTool("read", calls, {
|
||||
content: [{ type: "text", text: "file body" }],
|
||||
});
|
||||
const session = makeSession(new Map([["read", readTool]]));
|
||||
const info = await ensurePyToolBridge();
|
||||
const unregister = registerPyToolBridge("test-session-1", { toolSession: session });
|
||||
try {
|
||||
const res = await call(info, {
|
||||
session: "test-session-1",
|
||||
name: "read",
|
||||
args: { path: "foo.ts", _i: "py prelude" },
|
||||
});
|
||||
const body = await res.json();
|
||||
expect(res.status).toBe(200);
|
||||
expect(body).toEqual({ ok: true, value: "file body" });
|
||||
expect(calls).toHaveLength(1);
|
||||
// `_i` survives the bridge round trip so transcript renderers have a label.
|
||||
expect((calls[0]!.args as { _i?: string })._i).toBe("py prelude");
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("returns ok=false when no session is registered for the given id", async () => {
|
||||
const info = await ensurePyToolBridge();
|
||||
const res = await call(info, { session: "missing", name: "read", args: {} });
|
||||
expect(res.status).toBe(200);
|
||||
const body = (await res.json()) as { ok: boolean; error?: string };
|
||||
expect(body.ok).toBe(false);
|
||||
expect(typeof body.error).toBe("string");
|
||||
});
|
||||
|
||||
it("surfaces tool errors as ok=false with the error message", async () => {
|
||||
const session = {
|
||||
getToolByName: (_: string) =>
|
||||
({
|
||||
name: "boom",
|
||||
label: "boom",
|
||||
description: "boom",
|
||||
parameters: { type: "object" },
|
||||
async execute(): Promise<AgentToolResult> {
|
||||
throw new Error("kapow");
|
||||
},
|
||||
}) as unknown as AgentTool,
|
||||
} as unknown as ToolSession;
|
||||
const info = await ensurePyToolBridge();
|
||||
const unregister = registerPyToolBridge("err-session", { toolSession: session });
|
||||
try {
|
||||
const res = await call(info, { session: "err-session", name: "boom", args: {} });
|
||||
expect(res.status).toBe(200);
|
||||
const body = await res.json();
|
||||
expect(body).toEqual({ ok: false, error: "kapow" });
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects requests with a bad bearer token", async () => {
|
||||
const info = await ensurePyToolBridge();
|
||||
const res = await call(info, { session: "anything", name: "read", args: {} }, { token: "wrong" });
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
it("returns 400 when body is missing required fields", async () => {
|
||||
const info = await ensurePyToolBridge();
|
||||
const res = await call(info, { name: "read" });
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
|
||||
it("invokes emitStatus alongside the tool result", async () => {
|
||||
const calls: FakeCall[] = [];
|
||||
const readTool = makeFakeTool("read", calls, {
|
||||
content: [{ type: "text", text: "abc" }],
|
||||
});
|
||||
const session = makeSession(new Map([["read", readTool]]));
|
||||
const info = await ensurePyToolBridge();
|
||||
const statusEvents: Array<{ op: string }> = [];
|
||||
const unregister = registerPyToolBridge("status-session", {
|
||||
toolSession: session,
|
||||
emitStatus: event => statusEvents.push(event),
|
||||
});
|
||||
try {
|
||||
const res = await call(info, {
|
||||
session: "status-session",
|
||||
name: "read",
|
||||
args: { path: "foo.ts" },
|
||||
});
|
||||
expect(res.status).toBe(200);
|
||||
expect(statusEvents).toHaveLength(1);
|
||||
expect(statusEvents[0]!.op).toBe("read");
|
||||
} finally {
|
||||
unregister();
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user