diff --git a/docs/plans/2026-08-12-spawn-robustness.md b/docs/plans/2026-08-12-spawn-robustness.md index 4ca2c507..349984bd 100644 --- a/docs/plans/2026-08-12-spawn-robustness.md +++ b/docs/plans/2026-08-12-spawn-robustness.md @@ -79,4 +79,3 @@ 4. Review the final diff and run the bounded local CodeRabbit pre-commit review. 5. Commit with the live agent-identity trailer, push the assigned branch, and open a signed ready-for-review PR. 6. Append the collab log line and inbox-ping `cmuxlayerClaude-9c55eb04` with the PR URL. If the inbox is unarmed, append the PR URL as the final line of `phase-7/findings.md`. - diff --git a/src/daemon.ts b/src/daemon.ts index dbb2c4e2..a7bf0754 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -32,6 +32,7 @@ import { import { ackedIds, dispatchOnce, + formatInboxPing, inboxPath, monitorAlive, readLastAgentHeartbeat, @@ -872,7 +873,10 @@ export class CmuxLayerDaemon { } await guardedRelay({ agent_id: owner.agent_id, - text: `[inbox] monitor recovery message ${message.id} — read ${inboxPath(owner.agent_id, inboxOpts)}, re-arm, then ack`, + text: formatInboxPing( + message, + inboxPath(owner.agent_id, inboxOpts), + ), press_enter: true, allow_busy: true, source_event: "dispatch_nudge", diff --git a/src/inbox.ts b/src/inbox.ts index f9b77e72..077629e1 100644 --- a/src/inbox.ts +++ b/src/inbox.ts @@ -39,6 +39,12 @@ export interface InboxMessage { id: string; ts_ms: number; from: string; + /** Authoritative agent id to use for replies. Never infer this from pane focus. */ + reply_to: string; + /** Optional observed surface ref. Stale-able hint only; never a routing address. */ + via?: string; + /** Observation timestamp paired with via. */ + observed_at?: string; /** Recipient agent id (own-tag) or "orc". Each agent monitors only its own inbox. */ to: string; tag: string; @@ -90,6 +96,12 @@ export type InboxMonitorState = "never-armed" | "alive" | "stale"; export interface DispatchInput { from: string; + /** Resolved sender agent id. Defaults to from for non-engine/internal callers. */ + reply_to?: string; + /** Optional sender surface ref hint. Routing must continue to use reply_to. */ + via?: string; + /** Optional ISO timestamp for the via observation. */ + observed_at?: string; to?: string; tag?: string; task: string; @@ -325,6 +337,13 @@ export function dispatch( id: input.id ?? genId(ts), ts_ms: ts, from: input.from, + reply_to: input.reply_to ?? input.from, + ...(input.via + ? { + via: input.via, + observed_at: input.observed_at ?? new Date(ts).toISOString(), + } + : {}), to: input.to ?? agentId, tag: input.tag ?? "dispatch", task: input.task, @@ -334,6 +353,16 @@ export function dispatch( return msg; } +/** One connector-authored composer shape for all inbox wakes. */ +export function formatInboxPing(message: InboxMessage, path: string): string { + const replyTo = message.reply_to || message.from; + const viaHint = + message.via && message.observed_at + ? ` via:${message.via} observed_at:${message.observed_at}` + : ""; + return `[inbox] ${message.id} — reply_to: ${replyTo}${viaHint} — read ${path}`; +} + export function dispatchOnce( agentId: string, input: DispatchInput & { id: string }, diff --git a/src/server.ts b/src/server.ts index 6b4111b6..7ffddb29 100644 --- a/src/server.ts +++ b/src/server.ts @@ -132,6 +132,7 @@ import { import { dispatch, ensureInboxFile, + formatInboxPing, inboxCursorPath, inboxMonitorState, inboxPath, @@ -497,6 +498,21 @@ const SendToArgsSchema = z.object({ press_enter: z.boolean().optional().default(true), allow_busy: z.boolean().optional().default(false), allow_long_inline: z.boolean().optional().default(false), + targeting: z + .object({ + role: z.enum(["implementor", "reviewer", "gatherer"]).optional(), + workspace: z.string().optional(), + agent_ids: z.array(z.string()).optional(), + exclude: z.array(z.string()).optional().default([]), + }) + .refine( + (targeting) => + targeting.role !== undefined || + targeting.workspace !== undefined || + targeting.agent_ids !== undefined, + "targeting requires at least one of role, workspace, or agent_ids", + ) + .optional(), }); export const THIN_CORE_TOOL_NAMES = new Set([ @@ -507,7 +523,6 @@ export const THIN_CORE_TOOL_NAMES = new Set([ "read_screen", "my_agents", "list_agents", - "broadcast", "close_surface", "dispatch_to_agent", "list_surfaces", @@ -515,9 +530,10 @@ export const THIN_CORE_TOOL_NAMES = new Set([ "stop_agent", ]); -// DRIFT: retire next release. The signed-off prose says 9 legacy names, while -// its exhaustive mapping names these 8; do not invent an unnamed alias. +// DRIFT: retire next release. P6 supplies broadcast as the ninth legacy alias +// by replacing its fan-out semantics with send_to structured targeting. export const THIN_CORE_LEGACY_REPLACEMENTS: Readonly> = { + broadcast: "send_to(targeting={...})", send_to_agent: "send_to(mode=agent)", send_input: "send_to(mode=surface)", send_command: "send_to(mode=command)", @@ -3141,6 +3157,23 @@ export function createServer(opts?: CreateServerOptions): McpServer { // Health/read paths should not fail just because a refresh scan failed. } }; + const resolveCurrentCallerAgent = (): AgentRecord | null => { + const callerSurface = currentCallerContext()?.surfaceId?.trim(); + if (!callerSurface) return null; + const normalizedSurface = callerSurface.toLowerCase(); + const records = [ + ...(context.lifecycleRegistry?.list() ?? []), + ...stateMgr.listStates(), + ].filter((agent) => !TERMINAL_AGENT_STATES.has(agent.state)); + return ( + records.find( + (agent) => + agent.surface_uuid?.trim().toLowerCase() === normalizedSurface, + ) ?? + records.find((agent) => agent.surface_id === callerSurface) ?? + null + ); + }; const resolveModeWorkspace = async ( surface: string, workspace?: string, @@ -8781,7 +8814,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { // directly into the agent's surface, regardless of registry state. server.tool( "dispatch_to_agent", - "Append a task to an agent's inbox FILE (the deterministic write channel). The agent acts on it via a persistent native Monitor on its inbox — NO send_input/TUI typing. If the recipient's monitor heartbeat is stale/absent, nudge='auto' (default) best-effort types a one-line inbox pointer into the agent's surface — independent of agent lifecycle state. A never-armed reader returns a non-retryable error after the durable append; a previously armed but stale reader returns explicit degraded success. Address to:'orc' to flag the orchestrator (own-tag triage). Channel is EPHEMERAL plumbing — set persist:true only for decisions that should be brain_store'd.", + "Append a task to an agent's inbox FILE (the deterministic write channel). The agent acts on it via a persistent native Monitor on its inbox. The durable envelope automatically carries reply_to= plus optional via: and observed_at metadata; reply_to is the only routing address, via is a stale-able hint, and tab names never enter the contract. The only connector-authored composer wake is `[inbox] — reply_to: [ via: observed_at:] — read `. With nudge='auto' (default), an idle live agent is woken once on enqueue; a stale/absent monitor also gets the same best-effort pointer independent of lifecycle state. A never-armed reader returns a non-retryable error after the durable append; a previously armed but stale reader returns explicit degraded success. Address to:'orc' to flag the orchestrator (own-tag triage). Channel is EPHEMERAL plumbing — set persist:true only for decisions that should be brain_store'd.", { agent_id: z .string() @@ -8807,16 +8840,25 @@ export function createServer(opts?: CreateServerOptions): McpServer { .optional() .default("auto") .describe( - "auto: when the recipient's inbox-monitor heartbeat is stale/absent, best-effort type a one-line inbox pointer into its surface (bypasses agent-state gates — works even when registry state is poisoned). never: file append only.", + "auto: wake an idle live agent once on enqueue; when the inbox-monitor heartbeat is stale/absent, best-effort type the same exact inbox pointer into its surface (bypasses agent-state gates — works even when registry state is poisoned). never: file append only.", ), }, ANNOTATIONS.mutating, async (args) => { try { + const callerAgent = resolveCurrentCallerAgent(); + const replyTo = callerAgent?.agent_id ?? args.from.trim(); + if (!replyTo || /[\r\n]/.test(replyTo)) { + throw new Error( + "dispatch_to_agent requires a one-line sender agent_id for reply_to", + ); + } const msg = dispatch( args.agent_id, { from: args.from, + reply_to: replyTo, + ...(callerAgent ? { via: callerAgent.surface_id } : {}), to: args.agent_id, tag: args.tag, task: args.task, @@ -8842,9 +8884,12 @@ export function createServer(opts?: CreateServerOptions): McpServer { reason: string; error_code?: string; } = { attempted: false, sent: false, reason: "" }; + const acceptedRecord = context.lifecycleRegistry?.get(args.agent_id) ?? null; + const wakeIdleAgent = + monitor_alive && acceptedRecord?.state === "idle"; if (args.nudge === "never") { nudge.reason = "nudge disabled by caller"; - } else if (monitor_alive) { + } else if (monitor_alive && !wakeIdleAgent) { nudge.reason = "monitor heartbeat fresh — Monitor will deliver"; } else { // State-independent surface lookup: ANY registry record (including @@ -8869,7 +8914,10 @@ export function createServer(opts?: CreateServerOptions): McpServer { } else { nudge.attempted = true; try { - const pointer = `[inbox] new message from ${msg.from} (id ${msg.id}) — read ${inboxPath(args.agent_id, inboxOpts)}, act, then ack`; + const pointer = formatInboxPing( + msg, + inboxPath(args.agent_id, inboxOpts), + ); await lifecycleAgentInputDeliverer({ agent_id: args.agent_id, text: pointer, @@ -8878,7 +8926,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { source_event: "dispatch_nudge", }); nudge.sent = true; - nudge.reason = `heartbeat stale/absent — typed inbox pointer into ${record.surface_id} (state: ${record.state})`; + nudge.reason = wakeIdleAgent + ? `idle live agent — typed inbox pointer into ${record.surface_id}` + : `heartbeat stale/absent — typed inbox pointer into ${record.surface_id} (state: ${record.state})`; } catch (e) { if (e instanceof DeliverySafetyGateError) { nudge.error_code = e.error_code; @@ -9483,23 +9533,6 @@ export function createServer(opts?: CreateServerOptions): McpServer { return registryDirect; }; - const resolveCurrentCallerAgent = (): AgentRecord | null => { - const callerSurface = currentCallerContext()?.surfaceId?.trim(); - if (!callerSurface) return null; - const normalizedSurface = callerSurface.toLowerCase(); - const records = [...registry.list(), ...stateMgr.listStates()].filter( - (agent) => !TERMINAL_AGENT_STATES.has(agent.state), - ); - return ( - records.find( - (agent) => - agent.surface_uuid?.trim().toLowerCase() === normalizedSurface, - ) ?? - records.find((agent) => agent.surface_id === callerSurface) ?? - null - ); - }; - const resolveManagedDeliveryRoute = async ( agentId: string, ): Promise<{ surface: string; workspace?: string }> => { @@ -12032,6 +12065,36 @@ export function createServer(opts?: CreateServerOptions): McpServer { const agentSeatLabel = (agent: AgentRecord): string => agent.seat_id?.trim() || agent.surface_id || agent.agent_id; + const collectTargetRecords = async (): Promise => { + try { + return await engine.runLifecycleMutation(async () => { + try { + discovery.invalidate(); + const discovered = await discovery.scan(true); + return await registry.listMerged(discovery, { + force: true, + discovered, + }); + } catch (error) { + if (!(error instanceof SurfaceBindingChangedDuringDiscoveryError)) { + throw error; + } + discovery.invalidate(); + return registry.listMerged(discovery, { force: true }); + } + }); + } catch (e) { + if (isSurfaceEnumerationError(e)) { + throw new Error( + `Refusing target resolution because live surface enumeration failed: ${ + e instanceof Error ? e.message : String(e) + }`, + ); + } + throw e; + } + }; + server.tool( "broadcast", `${PANE_INPUT_BREAKAGE_GUIDANCE} Fan out a short pointer-style message to registered agents by role using the same guarded delivery path as send_to. Defaults to role=leads (orchestrator). Inline text is capped at ${SEND_INPUT_MAX_INLINE_CHARS} characters. Returns per-agent receipts so one failed target never hides the rest. ${ZSH_BANG_INLINE_WARNING}`, @@ -12077,39 +12140,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { const isCaller = (agent: AgentRecord): boolean => callerRefs.has(agent.agent_id) || callerRefs.has(agent.surface_id); - const collectTargets = async (): Promise => { - try { - return await engine.runLifecycleMutation(async () => { - try { - return await registry.listMerged(discovery); - } catch (error) { - if ( - !( - error instanceof SurfaceBindingChangedDuringDiscoveryError - ) - ) { - throw error; - } - // The first scan's screen evidence was correctly rejected. - // Retry once from the now-current topology; a second move - // still propagates and fails the broadcast closed. - discovery.invalidate(); - return registry.listMerged(discovery, { force: true }); - } - }); - } catch (e) { - if (isSurfaceEnumerationError(e)) { - throw new Error( - `Refusing broadcast because live surface enumeration failed: ${ - e instanceof Error ? e.message : String(e) - }`, - ); - } - throw e; - } - }; - - const targets = (await collectTargets()).filter( + const targets = (await collectTargetRecords()).filter( (agent) => broadcastRoleMatches( args.role, @@ -12820,7 +12851,7 @@ export function createServer(opts?: CreateServerOptions): McpServer { // 17. send_to server.tool( "send_to", - `${PANE_INPUT_BREAKAGE_GUIDANCE} Unified send path. mode=agent (default) routes by agent_id without exposing surface details; a busy agent returns a nonterminal queued delivery receipt that the lifecycle sweep drains when the agent becomes interactive. mode=surface writes text to a raw surface; mode=command atomically sends a command and Return; mode=key sends one normalized key. Raw-surface modes accept target or surface directly and deliberately do not require a healthy agent registry, preserving the fleet recovery escape hatch. Inline text is capped at ${SEND_INPUT_MAX_INLINE_CHARS} characters by default; use file-backed boot_prompt_path for launcher prompts or allow_long_inline:true only for deliberate raw sends. ${ZSH_BANG_INLINE_WARNING}`, + `${PANE_INPUT_BREAKAGE_GUIDANCE} Unified send path. mode=agent (default) routes by one agent_id or a mutually exclusive structured targeting object {role,workspace,agent_ids,exclude}; targeting.role is the job function implementor|reviewer|gatherer and returns one immutable per-agent receipt set. A busy agent returns a nonterminal queued delivery receipt that the lifecycle sweep drains when the agent becomes interactive. mode=surface writes text to a raw surface; mode=command atomically sends a command and Return; mode=key sends one normalized key. Raw-surface modes accept target or surface directly and deliberately do not require a healthy agent registry, preserving the fleet recovery escape hatch. Inline text is capped at ${SEND_INPUT_MAX_INLINE_CHARS} characters by default; use file-backed boot_prompt_path for launcher prompts or allow_long_inline:true only for deliberate raw sends. ${ZSH_BANG_INLINE_WARNING}`, { ...SendToArgsSchema.shape, text: SendToArgsSchema.shape.text.describe( @@ -12849,6 +12880,9 @@ export function createServer(opts?: CreateServerOptions): McpServer { const args = parsedArgs.data; const mode = args.mode ?? "agent"; + if (args.targeting && mode !== "agent") { + throw new Error("send_to.targeting is supported only in mode=agent"); + } if (mode !== "agent") { const surface = args.surface ?? args.target; if (!surface) { @@ -12909,9 +12943,15 @@ export function createServer(opts?: CreateServerOptions): McpServer { ); } - const agentId = args.agent_id ?? args.target; - if (!agentId) { - throw new Error("send_to mode=agent requires agent_id or target"); + if (args.targeting && (args.agent_id || args.target)) { + throw new Error( + "send_to accepts either targeting or agent_id/target, not both", + ); + } + if (!args.targeting && !args.agent_id && !args.target) { + throw new Error( + "send_to mode=agent requires agent_id/target or targeting", + ); } if (args.text === undefined) { throw new Error("send_to mode=agent requires text"); @@ -12928,6 +12968,274 @@ export function createServer(opts?: CreateServerOptions): McpServer { value: args.text, allowLongInline: args.allow_long_inline, }); + if (args.targeting) { + await awaitLifecycleStart(); + const allTargets = await collectTargetRecords(); + const excludedIds = new Set(args.targeting.exclude); + const scopedWorkspace = await canonicalWorkspaceRef( + args.targeting.workspace, + ); + type TargetPlan = { + requested_agent_id?: string; + agent?: Readonly; + resolution: "resolved" | "filtered_out" | "unknown"; + predicate?: "exclude" | "role" | "workspace"; + }; + const filterPredicate = ( + agent: AgentRecord, + ): TargetPlan["predicate"] | null => { + if (excludedIds.has(agent.agent_id)) return "exclude"; + if ( + args.targeting?.role && + agent.function !== args.targeting.role + ) { + return "role"; + } + if ( + scopedWorkspace && + agent.workspace_id !== scopedWorkspace && + agent.workspace_id !== args.targeting?.workspace + ) { + return "workspace"; + } + return null; + }; + const targetPlan: TargetPlan[] = []; + if (args.targeting.agent_ids) { + for (const requestedId of args.targeting.agent_ids) { + const exact = allTargets.find( + (agent) => agent.agent_id === requestedId, + ); + const candidates = exact + ? [exact] + : allTargets.filter((agent) => + agent.agent_id.startsWith(requestedId), + ); + if (candidates.length > 1) { + throw new Error( + `Ambiguous agent_id prefix "${requestedId}"; candidates: ${candidates + .map((agent) => agent.agent_id) + .sort() + .join(", ")}. Refusing to guess.`, + ); + } + const agent = candidates[0]; + if (!agent) { + targetPlan.push({ + requested_agent_id: requestedId, + resolution: "unknown", + }); + continue; + } + const predicate = filterPredicate(agent); + targetPlan.push({ + requested_agent_id: requestedId, + agent: Object.freeze({ ...agent }), + resolution: predicate ? "filtered_out" : "resolved", + ...(predicate ? { predicate } : {}), + }); + } + } else { + for (const agent of allTargets) { + if (filterPredicate(agent)) continue; + targetPlan.push({ + agent: Object.freeze({ ...agent }), + resolution: "resolved", + }); + } + } + const resolvedTargets = Object.freeze( + targetPlan + .filter( + (entry): entry is TargetPlan & { agent: Readonly } => + entry.resolution === "resolved" && entry.agent !== undefined, + ) + .map((entry) => entry.agent), + ); + for (const agent of resolvedTargets) { + assertInteractiveMultilineInputAllowed({ + tool: "send_to", + value: args.text, + cli: agent.cli, + allowLongInline: args.allow_long_inline, + }); + } + const mutableReceipts: Array> = []; + for (const plan of targetPlan) { + if (plan.resolution !== "resolved" || !plan.agent) { + mutableReceipts.push({ + ...(plan.requested_agent_id + ? { requested_agent_id: plan.requested_agent_id } + : {}), + ...(plan.agent ? { agent_id: plan.agent.agent_id } : {}), + resolution: plan.resolution, + ...(plan.predicate ? { predicate: plan.predicate } : {}), + delivery_state: "skipped", + terminal: true, + submit_verified: null, + accepted: false, + delivered: false, + skipped: + plan.resolution === "unknown" + ? "unknown_agent_id" + : `filtered_out:${plan.predicate}`, + }); + continue; + } + const agent = plan.agent; + const resolutionMetadata = plan.requested_agent_id + ? { + requested_agent_id: plan.requested_agent_id, + resolution: "resolved", + } + : {}; + const skipped = + agent.state === "working" + ? null + : await broadcastSkipReason(agent); + if (skipped) { + mutableReceipts.push({ + ...resolutionMetadata, + agent_id: agent.agent_id, + delivery_state: "skipped", + terminal: true, + submit_verified: null, + accepted: false, + delivered: false, + skipped, + }); + continue; + } + const deliveryId = randomUUID(); + if (!args.allow_busy && agent.state === "working") { + const queued = engine.queueDelivery({ + agent_id: agent.agent_id, + text: args.text, + press_enter: args.press_enter, + source_event: "send_to", + }); + mutableReceipts.push({ + ...resolutionMetadata, + agent_id: agent.agent_id, + delivery_id: queued.delivery_id, + delivery_state: queued.delivery_state, + terminal: queued.terminal, + submit_verified: queued.submit_verified, + accepted: true, + delivered: false, + }); + continue; + } + try { + const delivery = await deliverAgentInput({ + agent_id: agent.agent_id, + text: args.text, + press_enter: args.press_enter, + allow_busy: args.allow_busy, + source_event: "send_to", + delivery_id: deliveryId, + }); + const submitted = engine.resolveDelivery({ + delivery_id: deliveryId, + agent_id: agent.agent_id, + text: args.text, + press_enter: args.press_enter, + source_event: "send_to", + delivery_state: "submitted", + terminal: true, + retry_count: delivery.retry_count, + submit_verified: delivery.submit_verified, + error: null, + }); + mutableReceipts.push({ + ...resolutionMetadata, + agent_id: agent.agent_id, + delivery_id: submitted.delivery_id, + delivery_state: submitted.delivery_state, + terminal: submitted.terminal, + submit_verified: submitted.submit_verified, + accepted: true, + delivered: true, + }); + } catch (error) { + const failed = engine.resolveDelivery( + { + delivery_id: deliveryId, + agent_id: agent.agent_id, + text: args.text, + press_enter: args.press_enter, + source_event: "send_to", + delivery_state: "failed", + terminal: true, + retry_count: + error instanceof SubmitVerificationError + ? error.retry_count + : 0, + submit_verified: + error instanceof SubmitVerificationError ? false : null, + error: error instanceof Error ? error.message : String(error), + }, + { + appendFailureEvent: !( + error instanceof SubmitVerificationError + ), + }, + ); + mutableReceipts.push({ + ...resolutionMetadata, + agent_id: agent.agent_id, + delivery_id: failed.delivery_id, + delivery_state: failed.delivery_state, + terminal: failed.terminal, + submit_verified: failed.submit_verified, + accepted: false, + delivered: false, + error: failed.error, + }); + } + } + const receipts = Object.freeze( + mutableReceipts.map((receipt) => Object.freeze({ ...receipt })), + ); + const submittedCount = receipts.filter( + (receipt) => receipt.delivery_state === "submitted", + ).length; + const queuedCount = receipts.filter( + (receipt) => receipt.delivery_state === "queued", + ).length; + const failedCount = receipts.filter( + (receipt) => receipt.delivery_state === "failed", + ).length; + const skippedCount = receipts.filter( + (receipt) => receipt.delivery_state === "skipped", + ).length; + const data = { + targeting: Object.freeze({ ...args.targeting }), + target_count: receipts.length, + resolved_target_count: resolvedTargets.length, + submitted_count: submittedCount, + queued_count: queuedCount, + delivered_count: submittedCount, + failed_count: failedCount, + skipped_count: skippedCount, + receipts, + }; + if (resolvedTargets.length === 0) { + return err( + new Error("send_to targeting resolved zero targets; refusing silent no-op"), + data, + ); + } + return okFormatted( + `send_to targeting: ${submittedCount} submitted, ${queuedCount} queued, ${failedCount} failed, ${skippedCount} skipped`, + data, + ); + } + + const agentId = args.agent_id ?? args.target; + if (!agentId) { + throw new Error("send_to mode=agent requires agent_id or target"); + } const targetAgent = engine.getAgentState(agentId) ?? registry.get(agentId); assertInteractiveMultilineInputAllowed({ diff --git a/tests/daemon.test.ts b/tests/daemon.test.ts index 4df10c54..15fde9ec 100644 --- a/tests/daemon.test.ts +++ b/tests/daemon.test.ts @@ -23,7 +23,7 @@ import { readMonitorRegistry, registerMonitor, } from "../src/monitor-registry.js"; -import { ack, readInbox } from "../src/inbox.js"; +import { ack, inboxPath, readInbox } from "../src/inbox.js"; const TEST_ROOT = join("/tmp", "cmuxlayer-daemon-test"); const TEST_OBSERVER_OWNER = "cmux:/tmp/cmux-daemon-test.sock"; @@ -920,15 +920,15 @@ describe("CmuxLayerDaemon", () => { collapsed_reason: "owner-wedged", }); expect(monitorOwnerWedgedNotify).toHaveBeenCalledTimes(1); - expect(guardedRelays[0]).toHaveBeenCalledWith( - expect.objectContaining({ - agent_id: "worker-a", - text: expect.stringContaining("read"), - press_enter: true, - allow_busy: true, - source_event: "dispatch_nudge", - }), - ); + const rearmMessage = readInbox("worker-a", { baseDir: inboxBaseDir })[0]!; + expect(rearmMessage.reply_to).toBe("cmuxlayer-daemon"); + expect(guardedRelays[0]).toHaveBeenCalledWith({ + agent_id: "worker-a", + text: `[inbox] ${rearmMessage.id} — reply_to: ${rearmMessage.reply_to} — read ${inboxPath("worker-a", { baseDir: inboxBaseDir })}`, + press_enter: true, + allow_busy: true, + source_event: "dispatch_nudge", + }); expect(clients[0]?.send).not.toHaveBeenCalled(); expect(clients[0]?.sendKey).not.toHaveBeenCalled(); }); diff --git a/tests/default-palette.test.ts b/tests/default-palette.test.ts index d2bbad66..d747fe07 100644 --- a/tests/default-palette.test.ts +++ b/tests/default-palette.test.ts @@ -15,7 +15,6 @@ const THIN_CORE_TOOL_NAMES = [ "read_screen", "my_agents", "list_agents", - "broadcast", "close_surface", "dispatch_to_agent", "list_surfaces", diff --git a/tests/inbox-nudge.test.ts b/tests/inbox-nudge.test.ts index a4aadb8f..dd9239f7 100644 --- a/tests/inbox-nudge.test.ts +++ b/tests/inbox-nudge.test.ts @@ -28,6 +28,7 @@ import { tmpdir } from "node:os"; import { createServer } from "../src/server.js"; import { agentDir, + inboxPath, writeHeartbeat, readInbox, } from "../src/inbox.js"; @@ -287,8 +288,10 @@ describe("dispatch_to_agent nudge (state-independent inbox wake)", () => { const nudgeCall = after.at(-1)!; const nudgeText = String(nudgeCall.at(-1) ?? ""); expect(nudgeCall.join(" ")).toContain("surface:new"); - expect(nudgeCall.join(" ")).toContain("inbox"); - expect(nudgeText).not.toMatch(/[\n\r]/); + const message = readInbox(agentId, { baseDir: inboxDir }).at(-1)!; + expect(nudgeText).toBe( + `[inbox] ${message.id} — reply_to: ${message.reply_to} — read ${inboxPath(agentId, { baseDir: inboxDir })}`, + ); // And the message itself is durably in the inbox file. expect( readInbox(agentId, { baseDir: inboxDir }).map((m) => m.task), @@ -351,6 +354,120 @@ describe("dispatch_to_agent nudge (state-independent inbox wake)", () => { expect(sendCalls(exec).length).toBe(before); }); + it("wakes an idle live agent exactly once on enqueue even when its monitor is fresh", async () => { + const agentId = await spawnTestAgent(server); + const engine = server._registeredTools["interact"]._engine; + const idle = engine.stateMgr.updateRecord(agentId, { state: "idle" }); + engine.getRegistry().set(agentId, idle); + writeHeartbeat(agentId, { baseDir: inboxDir }); + + const before = sendCalls(exec).length; + const result = await server._registeredTools["dispatch_to_agent"].handler( + { + agent_id: agentId, + task: "GO", + from: "orc", + tag: "dispatch", + persist: false, + nudge: "auto", + }, + {} as any, + ); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + const message = readInbox(agentId, { baseDir: inboxDir }).at(-1)!; + const after = sendCalls(exec); + + expect(parsed.ok).toBe(true); + expect(parsed.monitor_state).toBe("alive"); + expect(parsed.nudge).toMatchObject({ attempted: true, sent: true }); + expect(after).toHaveLength(before + 1); + expect(String(after.at(-1)?.at(-1) ?? "")).toBe( + `[inbox] ${message.id} — reply_to: ${message.reply_to} — read ${inboxPath(agentId, { baseDir: inboxDir })}`, + ); + }); + + it("puts the resolved caller agent id in the envelope and ping reply address", async () => { + const agentId = await spawnTestAgent(server); + const engine = server._registeredTools["interact"]._engine; + const target = engine.getRegistry().get(agentId)!; + const idle = engine.stateMgr.updateRecord(agentId, { state: "idle" }); + engine.getRegistry().set(agentId, idle); + const caller = { + ...target, + agent_id: "golems-caller", + surface_id: "surface:golems", + surface_uuid: "22222222-2222-4222-8222-222222222222", + state: "ready", + }; + engine.stateMgr.writeState(caller); + engine.getRegistry().set(caller.agent_id, caller); + writeHeartbeat(agentId, { baseDir: inboxDir }); + + const before = sendCalls(exec).length; + const result = await runWithCallerContext( + { surfaceId: caller.surface_uuid }, + () => + server._registeredTools["dispatch_to_agent"].handler( + { + agent_id: agentId, + task: "Reply to the sender, not your own pane", + from: "ambiguous-human-label", + nudge: "auto", + }, + {} as any, + ), + ); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + const message = readInbox(agentId, { baseDir: inboxDir }).at(-1)!; + const after = sendCalls(exec); + + expect(parsed.ok).toBe(true); + expect(message).toMatchObject({ + from: "ambiguous-human-label", + reply_to: caller.agent_id, + via: caller.surface_id, + observed_at: expect.any(String), + }); + expect(after).toHaveLength(before + 1); + expect(String(after.at(-1)?.at(-1) ?? "")).toBe( + `[inbox] ${message.id} — reply_to: ${caller.agent_id} via:${caller.surface_id} observed_at:${message.observed_at} — read ${inboxPath(agentId, { baseDir: inboxDir })}`, + ); + expect(String(after.at(-1)?.at(-1) ?? "")).not.toContain("workspace:"); + }); + + it("durably appends with the supplied sender id when caller surface is unresolved", async () => { + const agentId = await spawnTestAgent(server); + + const result = await runWithCallerContext( + { surfaceId: "surface:missing-caller" }, + () => + server._registeredTools["dispatch_to_agent"].handler( + { + agent_id: agentId, + task: "Recovery message must survive stale caller state", + from: "cmuxlayerClaude-recovery", + nudge: "never", + }, + {} as any, + ), + ); + const parsed = + result.structuredContent ?? JSON.parse(result.content[0].text); + const message = readInbox(agentId, { baseDir: inboxDir }).at(-1)!; + + expect(parsed.ok).toBe(false); + expect(parsed.durable).toBe(true); + expect(parsed.error_code).toBe("inbox_monitor_never_armed"); + expect(message).toMatchObject({ + from: "cmuxlayerClaude-recovery", + reply_to: "cmuxlayerClaude-recovery", + task: "Recovery message must survive stale caller state", + }); + expect(message).not.toHaveProperty("via"); + }); + it("republishes a Claude idle-to-working transition without shrinking any lane", async () => { await server.close(); const idleScreen = [ diff --git a/tests/inbox.test.ts b/tests/inbox.test.ts index ed57b9b3..f4f57100 100644 --- a/tests/inbox.test.ts +++ b/tests/inbox.test.ts @@ -154,6 +154,7 @@ describe("inbox write-channel", () => { it("dispatch appends a message with defaults (to=agent, tag=dispatch) and id", () => { const m = dispatch("a1", { from: "orc", task: "do X" }, opts); expect(m.to).toBe("a1"); + expect(m.reply_to).toBe("orc"); expect(m.tag).toBe("dispatch"); expect(m.id).toBeTruthy(); expect(m.ts_ms).toBe(1_000_000); @@ -162,6 +163,28 @@ describe("inbox write-channel", () => { expect(all[0].task).toBe("do X"); }); + it("stores an optional stale-able surface hint only beside the durable reply id", () => { + const m = dispatch( + "coach", + { + from: "golems", + reply_to: "golems-agent-id", + via: "surface:golems", + observed_at: "2026-08-12T18:00:00.000Z", + task: "reply through the registry", + }, + opts, + ); + + expect(m).toMatchObject({ + reply_to: "golems-agent-id", + via: "surface:golems", + observed_at: "2026-08-12T18:00:00.000Z", + }); + expect(m).not.toHaveProperty("tab"); + expect(m).not.toHaveProperty("tab_name"); + }); + it("dispatchOnce keeps one durable message for a stable recovery id", () => { const first = dispatchOnce( "a1-once", diff --git a/tests/server-agent-tools.test.ts b/tests/server-agent-tools.test.ts index dc8274df..2f8ef09c 100644 --- a/tests/server-agent-tools.test.ts +++ b/tests/server-agent-tools.test.ts @@ -43,6 +43,7 @@ import { currentCallerContext, runWithCallerContext, } from "../src/caller-context.js"; +import { MODEL_OVERRIDE_ENV } from "../src/model-policy.js"; let TEST_DIR = join(tmpdir(), "cmux-agents-test-server-tools"); const serverContexts: CmuxServerContext[] = []; @@ -636,7 +637,11 @@ describe("lean spawn tool responses", () => { it("stores reviewer function independently and places Claude on the right", async () => { const exec = makeLifecycleExec(); - const server = createLifecycleServer(exec); + const { server } = createHermeticSpawnServer({ + exec, + disableSpawnPreflight: true, + sessionIdentityResolver: () => null, + }); const spawn = (server as any)._registeredTools["spawn_agent"]; const args = spawn.inputSchema.parse({ @@ -673,7 +678,11 @@ describe("lean spawn tool responses", () => { it("defaults implementor axes independently of harness", async () => { const spawnWith = async (cli: "claude" | "codex") => { - const server = createLifecycleServer(makeLifecycleExec()); + const { server } = createHermeticSpawnServer({ + exec: makeLifecycleExec(), + disableSpawnPreflight: true, + sessionIdentityResolver: () => null, + }); const spawn = (server as any)._registeredTools["spawn_agent"]; return spawn.handler( spawn.inputSchema.parse({ @@ -901,7 +910,11 @@ describe("lean spawn tool responses", () => { }); it("returns a stable identity triple for a worker-spawned reviewer", async () => { - const server = createLifecycleServer(makeLifecycleExec()); + const { server } = createHermeticSpawnServer({ + exec: makeLifecycleExec(), + disableSpawnPreflight: true, + sessionIdentityResolver: () => null, + }); const spawn = (server as any)._registeredTools["spawn_agent"]; const engine = (server as any)._registeredTools["interact"]._engine; const parent = makeServerAgentRecord({ @@ -1228,11 +1241,22 @@ describe("lean spawn tool responses", () => { const mockExec = makeLifecycleExec(); const server = createLifecycleServer(mockExec); const spawn = (server as any)._registeredTools["spawn_agent"]; + const previousOverride = process.env[MODEL_OVERRIDE_ENV]; + process.env[MODEL_OVERRIDE_ENV] = "1"; - const result = await spawn.handler( - { repo: "cmuxlayer", cli: "claude", model: "fable-5" }, - {} as any, - ); + let result: any; + try { + result = await spawn.handler( + { repo: "cmuxlayer", cli: "claude", model: "fable-5" }, + {} as any, + ); + } finally { + if (previousOverride === undefined) { + delete process.env[MODEL_OVERRIDE_ENV]; + } else { + process.env[MODEL_OVERRIDE_ENV] = previousOverride; + } + } expect(result.structuredContent).toMatchObject({ ok: false }); expect(result.structuredContent.error).toContain( @@ -1242,9 +1266,11 @@ describe("lean spawn tool responses", () => { 'would actually run "claude-opus-5[1m]"', ); expect(result.structuredContent.error).toContain("Accepted models:"); - expect(result.structuredContent.error).toMatch( - /Accepted models: [^.]*\bsonnet\b/, - ); + for (const alias of ["opus", "sonnet", "haiku"]) { + expect(result.structuredContent.error).toMatch( + new RegExp(`Accepted models: [^.]*\\b${alias}\\b`), + ); + } expect( mockExec.mock.calls.some(([, callArgs]) => callArgs.includes("new-split")), ).toBe(false); @@ -6889,6 +6915,477 @@ describe("agent lifecycle tool handlers", () => { ); }); + it("send_to resolves structured targeting by job function, workspace, ids, and exclude", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "reviewer-a", + surface_id: "surface:reviewer-a", + workspace_id: "workspace:one", + state: "ready", + function: "reviewer", + }), + makeServerAgentRecord({ + agent_id: "reviewer-excluded", + surface_id: "surface:reviewer-excluded", + workspace_id: "workspace:one", + state: "ready", + function: "reviewer", + }), + makeServerAgentRecord({ + agent_id: "reviewer-other-workspace", + surface_id: "surface:reviewer-other", + workspace_id: "workspace:two", + state: "ready", + function: "reviewer", + }), + makeServerAgentRecord({ + agent_id: "implementor-a", + surface_id: "surface:implementor-a", + workspace_id: "workspace:one", + state: "ready", + function: "implementor", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Review the P6 receipt set", + press_enter: false, + targeting: { + role: "reviewer", + workspace: "workspace:one", + agent_ids: ["reviewer-a", "reviewer-excluded", "implementor-a"], + exclude: ["reviewer-excluded"], + }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(Object.isFrozen(parsed.receipts)).toBe(true); + expect(parsed).toMatchObject({ + ok: true, + targeting: { + role: "reviewer", + workspace: "workspace:one", + agent_ids: ["reviewer-a", "reviewer-excluded", "implementor-a"], + exclude: ["reviewer-excluded"], + }, + target_count: 3, + resolved_target_count: 1, + delivered_count: 1, + failed_count: 0, + skipped_count: 2, + receipts: expect.arrayContaining([ + expect.objectContaining({ + requested_agent_id: "reviewer-a", + agent_id: "reviewer-a", + resolution: "resolved", + delivered: true, + }), + expect.objectContaining({ + requested_agent_id: "reviewer-excluded", + agent_id: "reviewer-excluded", + resolution: "filtered_out", + predicate: "exclude", + }), + expect.objectContaining({ + requested_agent_id: "implementor-a", + agent_id: "implementor-a", + resolution: "filtered_out", + predicate: "role", + }), + ]), + }); + expect(sendCalls.map((call) => call.surface)).toEqual([ + "surface:reviewer-a", + ]); + }); + + it("send_to targeting reports unknown named ids alongside resolved deliveries", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "reviewer-known", + surface_id: "surface:reviewer-known", + state: "ready", + function: "reviewer", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Review the named targets", + press_enter: false, + targeting: { agent_ids: ["reviewer-known", "reviewer-typo"] }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed).toMatchObject({ + ok: true, + target_count: 2, + resolved_target_count: 1, + delivered_count: 1, + skipped_count: 1, + receipts: expect.arrayContaining([ + expect.objectContaining({ + requested_agent_id: "reviewer-known", + agent_id: "reviewer-known", + resolution: "resolved", + delivered: true, + }), + expect.objectContaining({ + requested_agent_id: "reviewer-typo", + resolution: "unknown", + delivered: false, + }), + ]), + }); + expect(sendCalls.map((call) => call.surface)).toEqual([ + "surface:reviewer-known", + ]); + }); + + it("send_to targeting refuses a zero-target resolution", async () => { + const { server, sendCalls } = await createBroadcastServer([]); + + const result = await registeredTestTool(server, "send_to").handler( + { text: "Gather now", targeting: { role: "gatherer" } }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBe(true); + expect(parsed.ok).toBe(false); + expect(parsed.error).toContain("resolved zero targets"); + expect(sendCalls).toHaveLength(0); + }); + + it("send_to targeting resolves an unambiguous short agent-id prefix", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "cmuxlayerClaude-9c55eb04", + surface_id: "surface:lead", + state: "ready", + function: "reviewer", + }), + makeServerAgentRecord({ + agent_id: "otherClaude-12345678", + surface_id: "surface:other", + state: "ready", + function: "reviewer", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Reply with the verdict", + press_enter: false, + targeting: { agent_ids: ["cmuxlayerClaude-9c55"] }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed.receipts).toEqual([ + expect.objectContaining({ + requested_agent_id: "cmuxlayerClaude-9c55", + agent_id: "cmuxlayerClaude-9c55eb04", + resolution: "resolved", + delivered: true, + }), + ]); + expect(sendCalls.map((call) => call.surface)).toEqual(["surface:lead"]); + }); + + it("send_to targeting force-discovers a surface added after lifecycle startup", async () => { + const firstUuid = "51111111-2222-4333-8444-555555555555"; + const secondUuid = "61111111-2222-4333-8444-555555555555"; + const routeClient = makeUuidRouteClient([ + { + ref: "surface:first-target", + id: firstUuid, + workspace_ref: "workspace:1", + }, + ]); + routeClient.setScreenText("OpenAI Codex\nModel: gpt-5.5\n\ncodex> "); + const record = makeServerAgentRecord({ + agent_id: "first-target-agent", + surface_id: "surface:first-target", + surface_uuid: firstUuid, + workspace_id: "workspace:1", + state: "ready", + }); + const server = await createUuidRouteServer(routeClient, record); + + routeClient.setLiveSurfaces([ + { + ref: "surface:first-target", + id: firstUuid, + workspace_ref: "workspace:1", + }, + { + ref: "surface:second-target", + id: secondUuid, + workspace_ref: "workspace:1", + }, + ]); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Resolve against live reality", + press_enter: false, + targeting: { agent_ids: [`auto-codex-${secondUuid}`] }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed.receipts).toEqual([ + expect.objectContaining({ + requested_agent_id: `auto-codex-${secondUuid}`, + agent_id: `auto-codex-${secondUuid}`, + resolution: "resolved", + delivered: true, + }), + ]); + expect(routeClient.sendCalls.map((call) => call.surface)).toEqual([ + "surface:second-target", + ]); + }); + + it("send_to targeting refuses an ambiguous agent-id prefix with candidates", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "cmuxlayerClaude-11111111", + surface_id: "surface:one", + state: "ready", + }), + makeServerAgentRecord({ + agent_id: "cmuxlayerClaude-22222222", + surface_id: "surface:two", + state: "ready", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Must not guess", + targeting: { agent_ids: ["cmuxlayerClaude"] }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBe(true); + expect(parsed.error).toContain('Ambiguous agent_id prefix "cmuxlayerClaude"'); + expect(parsed.error).toContain("cmuxlayerClaude-11111111"); + expect(parsed.error).toContain("cmuxlayerClaude-22222222"); + expect(sendCalls).toHaveLength(0); + }); + + it("send_to targeting preserves queued as nonterminal", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "implementor-busy", + surface_id: "surface:implementor-busy", + state: "working", + function: "implementor", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Queue this instruction", + targeting: { agent_ids: ["implementor-busy"] }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed.receipts).toEqual([ + expect.objectContaining({ + agent_id: "implementor-busy", + delivery_state: "queued", + terminal: false, + accepted: true, + delivered: false, + }), + ]); + expect(sendCalls).toHaveLength(0); + }); + + it("send_to rejects targeting combined with a singular agent id", async () => { + const { server, sendCalls } = await createBroadcastServer([]); + + const result = await registeredTestTool(server, "send_to").handler( + { + agent_id: "one-agent", + text: "Do not choose a route", + targeting: { role: "reviewer" }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBe(true); + expect(parsed.error).toContain( + "send_to accepts either targeting or agent_id/target, not both", + ); + expect(sendCalls).toHaveLength(0); + }); + + it("send_to structured targeting returns one stable receipt set when one target fails", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "gatherer-ok", + surface_id: "surface:gatherer-ok", + state: "ready", + function: "gatherer", + }), + makeServerAgentRecord({ + agent_id: "gatherer-fail", + surface_id: "surface:gatherer-fail", + state: "ready", + function: "gatherer", + }), + ]; + const { server } = await createBroadcastServer(records, { + failSurface: "surface:gatherer-fail", + }); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Gather receipts", + targeting: { role: "gatherer" }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed).toMatchObject({ + target_count: 2, + delivered_count: 1, + failed_count: 1, + skipped_count: 0, + receipts: expect.arrayContaining([ + expect.objectContaining({ + agent_id: "gatherer-ok", + delivered: true, + }), + expect.objectContaining({ + agent_id: "gatherer-fail", + delivered: false, + error: expect.stringContaining("send failed for surface:gatherer-fail"), + }), + ]), + }); + }); + + it("send_to structured targeting skips non-deliverable targets with stable receipts", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "implementor-ready", + surface_id: "surface:implementor-ready", + state: "ready", + function: "implementor", + }), + makeServerAgentRecord({ + agent_id: "implementor-done", + surface_id: "surface:implementor-done", + state: "done", + function: "implementor", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { text: "Apply P6", targeting: { role: "implementor" } }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBeFalsy(); + expect(parsed).toMatchObject({ + target_count: 2, + delivered_count: 1, + failed_count: 0, + skipped_count: 1, + receipts: expect.arrayContaining([ + expect.objectContaining({ + agent_id: "implementor-done", + delivered: false, + skipped: "dead:done", + }), + ]), + }); + expect(sendCalls.map((call) => call.surface)).toEqual([ + "surface:implementor-ready", + ]); + }); + + it("send_to structured targeting preflights every composer before the first delivery", async () => { + const records = [ + makeServerAgentRecord({ + agent_id: "a-gatherer-gemini", + surface_id: "surface:gatherer-gemini", + state: "ready", + function: "gatherer", + cli: "gemini", + }), + makeServerAgentRecord({ + agent_id: "z-gatherer-claude", + surface_id: "surface:gatherer-claude", + state: "ready", + function: "gatherer", + cli: "claude", + }), + ]; + const { server, sendCalls } = await createBroadcastServer(records); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "paragraph one\n\nparagraph two", + targeting: { role: "gatherer" }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBe(true); + expect(parsed.error).toContain("refuses multi-paragraph inline text"); + expect(sendCalls).toHaveLength(0); + }); + + it("send_to targeting rejects authority and placement labels as roles", async () => { + const { server, sendCalls } = await createBroadcastServer([]); + + const result = await registeredTestTool(server, "send_to").handler( + { + text: "Must not target by authority", + targeting: { role: "worker" }, + }, + {}, + ); + const parsed = parseToolResult(result); + + expect(result.isError).toBe(true); + expect(parsed.error).toContain("targeting.role"); + expect(sendCalls).toHaveLength(0); + }); + it("list_agents state filter uses the reconciled screen-active state", async () => { const server = createLifecycleServer(mockExec); const spawn = (server as any)._registeredTools["spawn_agent"]; diff --git a/tests/server.test.ts b/tests/server.test.ts index 4421c960..cca36f9e 100644 --- a/tests/server.test.ts +++ b/tests/server.test.ts @@ -432,7 +432,7 @@ describe("createServer", () => { await client.close(); }); - it("advertises SpawnSpec functions and independent placement axes", async () => { + it("advertises SpawnSpec axes and send_to job-function targeting", async () => { const stateDir = processScopedTmpDir("cmuxlayer-role-schema-test"); rmSync(stateDir, { recursive: true, force: true }); const server = createServer({ @@ -477,6 +477,9 @@ describe("createServer", () => { schemaFor("spawn_in_workspace").properties?.agents.items.properties.role .enum, ).toEqual(publicRoles); + expect( + schemaFor("send_to").properties?.targeting.properties.role.enum, + ).toEqual(["implementor", "reviewer", "gatherer"]); await client.close(); rmSync(stateDir, { recursive: true, force: true }); diff --git a/tests/thin-core-tools.test.ts b/tests/thin-core-tools.test.ts index 05dfdeb2..3f6eec9b 100644 --- a/tests/thin-core-tools.test.ts +++ b/tests/thin-core-tools.test.ts @@ -11,7 +11,6 @@ const CORE_TOOL_NAMES = [ "read_screen", "my_agents", "list_agents", - "broadcast", "close_surface", "dispatch_to_agent", "list_surfaces", @@ -21,6 +20,7 @@ const CORE_TOOL_NAMES = [ // The signed-off prose says 9 legacy names, but its exhaustive mapping names 8. const LEGACY_TOOL_NAMES = [ + "broadcast", "send_to_agent", "send_input", "send_command", @@ -106,7 +106,7 @@ function parseResult(result: { } describe("thin-core tool palette", () => { - it("lists exactly 13 signed core tools, defers interact, and deletes reorder_surface", () => { + it("lists exactly 12 signed core tools, defers interact, and deletes reorder_surface", () => { const server = createServer({ exec: makeExec(), disableSpawnPreflight: true,