Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
116 changes: 112 additions & 4 deletions packages/compass-agent/src/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import {
resolveModelSelector,
resolvePersona,
} from "./cli";
import { CommsBroker, createCommsTools } from "./comms";
import {
AgentControlSchema,
AgentSessionState,
Expand All @@ -55,6 +56,7 @@ import {
PostConversationFrameResponseSchema,
type PublishFrameRequest,
} from "./gen/compass/v1/agent_gateway_pb";
import { createLifecycleTools, LifecycleBroker } from "./lifecycle";
import { createTeeSessionStorage } from "./session-tee";
import type { RunnerTransport } from "./transport/index";
import { createPublishSpine } from "./transport/publish-spine";
Expand Down Expand Up @@ -1605,6 +1607,7 @@ interface SeenConfig {
disableExtensionDiscovery?: boolean;
customTools?: unknown[];
enableMCP?: boolean;
autoApprove?: boolean;
}

// The `name` of each captured skill, narrowing with `in`/`typeof` (no fabricated
Expand All @@ -1619,6 +1622,18 @@ function skillNames(skills: unknown[] | undefined): string[] {
return out;
}

// The `name` of each captured custom tool — same `in`/`typeof` narrowing as
// skillNames, over the customTools option array.
function toolNames(tools: unknown[] | undefined): string[] {
const out: string[] = [];
for (const t of tools ?? []) {
if (!t || typeof t !== "object") continue;
if (!("name" in t) || typeof t.name !== "string") continue;
out.push(t.name);
}
return out;
}

describe("main wires the mounted agent-config into createAgentSession", () => {
test("a populated mount → skills, extension paths, and MCP tools all reach the options", async () => {
const mount = scratch();
Expand Down Expand Up @@ -1676,12 +1691,98 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
expect(opts.disableExtensionDiscovery).toBe(true);
// MCP: the parsed config reached the connector, and its tools reached
// customTools with enableMCP:false (never a passed mcpManager, which would
// not surface its tools).
// not surface its tools). The array now also carries the native
// comms/lifecycle tools (merged in main), so this is a containment check,
// not identity — the dedicated native-tools test below pins those.
expect(connectedWith).toEqual({ db: { command: "db-mcp" } });
expect(opts.customTools).toBe(mcpTools);
expect(opts.customTools).toEqual(expect.arrayContaining(mcpTools));
expect(opts.enableMCP).toBe(false);
});

test("the native comms + lifecycle tools reach customTools alongside the MCP tools", async () => {
// gap-1 (SEA-1741): main constructs the comms/lifecycle brokers from the
// existing transport and merges their tools into customTools so the
// container agent can spawn/post. Derive the EXPECTED names at runtime from
// the same factories main uses (a rename reddens here, never silently
// skips), rather than hardcode-guessing them. The brokers are never called
// during registration, so a stub transport whose bodies never run suffices.
const fakeTransport = {
comms: async () => ({}) as never,
lifecycle: async () => ({}) as never,
};
const expectedNames = [
...createCommsTools(new CommsBroker(fakeTransport)),
...createLifecycleTools(new LifecycleBroker(fakeTransport)),
].map((t) => t.name);

const session = fakeSession();
const seen: SeenConfig[] = [];
await main(
{ HOME: scratch() },
{
configMount: scratch(),
connectMcp: () =>
Promise.resolve({
tools: [] as never,
disconnect: () => Promise.resolve(),
}),
createSession: (options) => {
seen.push({
customTools: options.customTools,
autoApprove: options.autoApprove,
});
return Promise.resolve({
session: session as unknown as AgentSession,
});
},
createTransport: () =>
fakeCarrier(emptyLog(), { control: emptyControlStream }),
},
);

expect(seen).toHaveLength(1);
const names = toolNames(seen[0].customTools);
// Every native tool the factories produce reached customTools.
for (const name of expectedNames) expect(names).toContain(name);
// Discriminating anchor: the two confirmed lifecycle names (lifecycle.ts:144).
expect(names).toContain("agents_spawn_peer");
expect(names).toContain("agents_despawn_peer");
// Headless approval policy (SEA-1741): the entrypoint pins autoApprove so
// the write-approval natives auto-execute with no human in the container.
expect(seen[0].autoApprove).toBe(true);
});

test("every native's execute keeps arity 2 — tripwire on the customToolToDefinition arg-shuffle", () => {
// SEA-1741 seam invariant. The natives are `AgentTool`s registered through
// `customTools`; the SDK classifies a marker-less AgentTool as a CustomTool
// and runs it through `customToolToDefinition`, which invokes `execute`
// with the CustomTool arg order (toolCallId, params, onUpdate, ctx, signal)
// — NOT the AgentTool order (toolCallId, params, signal, onUpdate, ctx). So
// args 3-5 arrive SHUFFLED, and the wiring in cli.ts main() is sound ONLY
// while no native reads past `params`. This is a TRIPWIRE, not a total
// guard: pinning `execute.length === 2` reddens the LIKELY regression —
// adding a plain positional 3rd param (`signal`) to consume a shuffled arg.
// It does NOT catch a rest (`...args`) or defaulted (`signal = …`) param,
// which read arg 3 while keeping `.length === 2`; the load-bearing guard is
// the invariant itself (see cli.ts). If a native must consume its
// AbortSignal, it cannot go through this seam — see the comment in cli.ts.
const fakeTransport = {
comms: async () => ({}) as never,
lifecycle: async () => ({}) as never,
};
const natives = [
...createCommsTools(new CommsBroker(fakeTransport)),
...createLifecycleTools(new LifecycleBroker(fakeTransport)),
];
expect(natives).toHaveLength(6);
for (const tool of natives) {
expect({ name: tool.name, arity: tool.execute.length }).toEqual({
name: tool.name,
arity: 2,
});
}
});

test("an UNCONFIGURED mount → skills [], no extension paths, no MCP tools, and main resolves", async () => {
// A present-but-empty mount root (no current/). The default connectMcp runs
// (empty configs → no dial, empty tools), so this exercises the real
Expand Down Expand Up @@ -1719,7 +1820,11 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
expect(seen[0].skills).toEqual([]);
expect(seen[0].additionalExtensionPaths).toEqual([]);
expect(seen[0].disableExtensionDiscovery).toBe(true);
expect(seen[0].customTools).toEqual([]);
// No MCP tools (empty mount → empty connect), but the comms/lifecycle
// natives are ALWAYS merged in (SEA-1741) — so customTools carries exactly
// those, and never a discovered MCP tool.
expect(toolNames(seen[0].customTools)).toContain("agents_spawn_peer");
expect(seen[0].customTools).toHaveLength(6);
expect(seen[0].enableMCP).toBe(false);
});

Expand Down Expand Up @@ -1748,7 +1853,10 @@ describe("main wires the mounted agent-config into createAgentSession", () => {
);
expect(skillNames(seen[0].skills)).toEqual(["only"]);
expect(seen[0].additionalExtensionPaths).toEqual([]);
expect(seen[0].customTools).toEqual([]);
// No MCP tools from a skills-only mount, but the natives always merge in
// (SEA-1741) — so customTools is exactly the six comms/lifecycle natives.
expect(toolNames(seen[0].customTools)).toContain("comms_post_message");
expect(seen[0].customTools).toHaveLength(6);
});

// The MCP manager teardown — what main() alone owns (the SDK never
Expand Down
71 changes: 70 additions & 1 deletion packages/compass-agent/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import {
type IndexedSessionStorage,
SessionManager,
Settings,
type ToolDefinition,
} from "@oh-my-pi/pi-coding-agent";
import { loadCapability } from "@oh-my-pi/pi-coding-agent/capability";
import {
Expand All @@ -49,12 +50,20 @@ import {
import { MCPManager } from "@oh-my-pi/pi-coding-agent/mcp";
import { YAML } from "bun";
import { CompassAgent } from "./agent";
import {
CommsBroker,
createCommsTools,
} from "./comms";
import {
AGENT_CONFIG_MOUNT_PATH,
loadMountedConfig,
type MountedMcp,
} from "./config-reader";
import type { FrameSink } from "./frame";
import {
createLifecycleTools,
LifecycleBroker,
} from "./lifecycle";
import {
createTeeSessionStorage,
type TranscriptTeeBackend,
Expand Down Expand Up @@ -501,6 +510,50 @@ export async function main(
);
const sink = createSocketFrameSink(transport);

// Native comms + lifecycle tools (SEA-1741 gap-1). The existing `transport`
// is reused directly: `RunnerTransport` structurally satisfies both
// `CommsTransport` and `LifecycleTransport` (each is a one-method subset —
// comms.ts:74 / lifecycle.ts), so the brokers wrap it with no adapter. Their
// tools are merged into `customTools` below, flowing through the same
// customTools→state.tools→#withNatives natives path as the MCP tools — so the
// container agent's `comms_post_message` / `agents_spawn_peer` emissions
// resolve as session natives rather than "unknown tool".
const commsBroker = new CommsBroker(transport);
const lifecycleBroker = new LifecycleBroker(transport);
// The comms/lifecycle natives are authored as `AgentTool` (pi-agent-core)
// because CompassAgent's `#withNatives` mechanism (agent.ts) operates on
// `AgentTool[]`. `createAgentSession`'s `customTools` wants
// `(CustomTool | ToolDefinition)[]`, and the SDK exposes no dedicated native
// seam, so we register the `AgentTool[]` through `customTools` with a single
// documented assertion. The assertion to the `ToolDefinition` arm is
// TYPE-sound: the only compile-time gap is generic variance on the OPTIONAL
// renderCall/renderResult (`AgentTool` TTheme=unknown vs `ToolDefinition`
// Theme/Component) — fields these headless tools never define.
//
// RUNTIME mechanism (subtle — do not "simplify" the invariant below away):
// an `AgentTool` object literal carries no `__isToolDefinition` marker, so
// the SDK classifies it as a CustomTool (`isCustomTool`, sdk.ts:876) and runs
// it through `customToolToDefinition` (sdk.ts:915) — NOT the verbatim
// pass-through arm. That wrapper invokes `execute` with the CustomTool arg
// convention `(toolCallId, params, onUpdate, ctx, signal)` (sdk.ts:927),
// whereas `AgentTool.execute` is `(toolCallId, params, signal, onUpdate, ctx)`
// (pi-agent-core types.ts:612-616) — so args 3-5 arrive SHUFFLED. This is
// safe ONLY because every native's `execute` body reads solely
// `(toolCallId, params)` and ignores args 3-5 (comms.ts / lifecycle.ts). A
// test in cli.test.ts is a TRIPWIRE on the likely regression: it pins each
// native's `execute.length === 2`, so adding a plain positional 3rd param
// (`signal`) to consume a shuffled arg reddens it. The pin is not a total
// guard — a rest (`...args`) or defaulted (`signal = …`) param reads arg 3
// while keeping `.length === 2` — so the load-bearing rule is this invariant
// itself, not the arity check. If a native ever needs its AbortSignal or
// onUpdate (e.g. wiring cancellation), it CANNOT go through this seam — the
// SDK must gain a real native-registration path, or the tool must be a true
// `ToolDefinition`. Do not consume args 3-5 here.
const nativeTools = [
...createCommsTools(commsBroker),
...createLifecycleTools(lifecycleBroker),
] as ToolDefinition[];

// The tee session storage, wrapped + initialize()d (its scan of the session
// dir must complete before SessionManager.create so synchronous resume
// lookups see the keyspace). SESSION_DIR is the SDK-default HOME-relative dir
Expand Down Expand Up @@ -630,8 +683,24 @@ export async function main(
skills: mounted.skills,
additionalExtensionPaths: mounted.additionalExtensionPaths,
disableExtensionDiscovery: mounted.disableExtensionDiscovery,
customTools: mcp.tools,
// The connected MCP tools MERGED with the native comms/lifecycle tools
// (SEA-1741 gap-1, constructed above): all reach the session as natives via
// the same customTools→state.tools→#withNatives path, so the container
// agent can spawn peers and post to channels.
customTools: [...mcp.tools, ...nativeTools],
enableMCP: false,
// Headless approval policy (SEA-1741, design compass-agent-comms-tools
// §"the container runs headless with write-approval tools auto-executing"):
// the container has NO human to answer an approval prompt, and the native
// comms/lifecycle tools declare approval:"write" — so without auto-approve
// a write-approval tool would block forever and never execute. Pin the
// yolo-default policy here in the entrypoint. Unconditional by design: the
// safety rests on an EXTERNAL invariant — this bin is exec'd only by the
// Runner as the in-container headless entrypoint (`if (import.meta.main)`,
// the sole createAgentSession call in the package), never interactively. If
// that ever changes, gate this on an explicit headless signal so the
// auto-approve posture fails safe outside a container.
autoApprove: true,
// Fleet config object injection (SEA-1678 pivot):
// - `rules` (CP-4): the fleet rules COMPOSED with the checkout's
// discovered rules (both load; fleet-first), computed above. Passed
Expand Down
6 changes: 3 additions & 3 deletions packages/compass-agent/src/comms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,9 +255,9 @@ function presenceLabel(presence: AgentPresence): string {
/**
* The native comms tool set. Four tools; never an ask-answering one.
*
* NOT YET WIRED: there is no container entrypoint in this repo, so this has no
* non-test caller. The registration leg is tracked separately — until it lands,
* the end-to-end contract is exercised only by this package's tests.
* Wired into the container entrypoint by `cli.ts main()` (SEA-1741): the tools
* are merged into the session's `customTools` and so register as `#withNatives`
* natives. This package's tests also exercise the end-to-end contract directly.
*/
export function createCommsTools(broker: CommsBroker): AgentTool[] {
const postMessage: AgentTool<typeof postParameters> = {
Expand Down
7 changes: 3 additions & 4 deletions packages/compass-agent/src/lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,9 @@ function lifecycleFailure(
/**
* The native lifecycle tool set. Exactly two tools: spawn and despawn a peer.
*
* NOT YET WIRED: there is no container entrypoint in this repo, so this has no
* non-test caller. The registration leg is tracked separately (index.ts:13 is
* the seam, beside createCommsTools) — until it lands, the end-to-end contract
* is exercised only by this package's tests.
* Wired into the container entrypoint by `cli.ts main()` (SEA-1741): the tools
* are merged into the session's `customTools` and so register as `#withNatives`
* natives. This package's tests also exercise the end-to-end contract directly.
*/
export function createLifecycleTools(broker: LifecycleBroker): AgentTool[] {
const spawnPeer: AgentTool<typeof spawnParameters> = {
Expand Down
Loading