Skip to content
Merged
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
98 changes: 95 additions & 3 deletions packages/bridge/src/spawner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import { createTraceableError } from '@agent-relay/utils/error-tracking';
import { createLogger } from '@agent-relay/utils/logger';
import { mapModelToCli } from '@agent-relay/utils/model-mapping';
import { isModelSwitchSupported, buildModelSwitchCommand, validateModelForCli } from '@agent-relay/utils/model-commands';
import { findRelayPtyBinary as findRelayPtyBinaryUtil, getLastSearchPaths } from '@agent-relay/utils/relay-pty-path';
import { RelayPtyOrchestrator, type RelayPtyOrchestratorConfig } from '@agent-relay/wrapper';
import { OpenCodeWrapper, type OpenCodeWrapperConfig, OpenCodeApi } from '@agent-relay/wrapper';
Expand Down Expand Up @@ -92,6 +93,8 @@
spawnedAt: number;
pid?: number;
logFile?: string;
/** Current model if known */
model?: string;
}

/** Stored listener references for cleanup */
Expand Down Expand Up @@ -578,7 +581,7 @@
}

// Clear workers.json to start fresh
fs.writeFileSync(this.workersPath, JSON.stringify({ workers: [] }, null, 2));

Check failure

Code scanning / CodeQL

Insecure temporary file High

Insecure creation of file in
the os temp dir
.

if (orphansKilled > 0) {
log.info(`Cleaned up ${orphansKilled} orphaned worker(s) from previous run`);
Expand Down Expand Up @@ -826,7 +829,7 @@
* Spawn a new worker agent using relay-pty
*/
async spawn(request: SpawnRequest): Promise<SpawnResult> {
const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive } = request;
const { name, cli, task, team, spawnerName, userId, includeWorkflowConventions, interactive, model: modelOverride } = request;

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.
This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI 4 months ago

In general terms, the fix is to stop using Math.random() for generating agent names and replace it with a cryptographically secure pseudo-random source. On Node.js, this means using the crypto module (for example, crypto.randomInt, or deriving an index from crypto.randomBytes) instead of Math.random(). We must ensure that the selection of indices remains uniform and within array bounds, and that we keep the public API (generateAgentName, generateUniqueAgentName, isValidAgentName) unchanged.

Concretely, we will modify packages/utils/src/name-generator.ts:

  1. Add an import of Node’s built-in crypto module at the top of the file.
  2. Replace the use of Math.floor(Math.random() * ADJECTIVES.length) and the analogous call for NOUNS in generateAgentName with crypto.randomInt(ADJECTIVES.length) and crypto.randomInt(NOUNS.length), respectively. crypto.randomInt(max) returns an integer in [0, max), so this is a drop-in replacement for indexing arrays.
  3. Replace the fallback suffix generation in generateUniqueAgentName that currently uses Math.floor(Math.random() * 1000) with crypto.randomInt(1000).

These changes stay entirely within packages/utils/src/name-generator.ts, do not affect the external interface, and remove all insecure Math.random() usages that CodeQL traces into the spawner. No changes to src/cli/index.ts, packages/config/src/shadow-config.ts, or packages/bridge/src/spawner.ts are required because they simply consume the already-generated names.


Suggested changeset 1
packages/utils/src/name-generator.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/utils/src/name-generator.ts b/packages/utils/src/name-generator.ts
--- a/packages/utils/src/name-generator.ts
+++ b/packages/utils/src/name-generator.ts
@@ -3,6 +3,8 @@
  * Inspired by mcp_agent_mail's approach.
  */
 
+import crypto from 'node:crypto';
+
 const ADJECTIVES = [
   'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
   'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -29,8 +31,8 @@
  * Generate a random agent name (AdjectiveNoun format).
  */
 export function generateAgentName(): string {
-  const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
-  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
+  const adjective = ADJECTIVES[crypto.randomInt(ADJECTIVES.length)];
+  const noun = NOUNS[crypto.randomInt(NOUNS.length)];
   return `${adjective}${noun}`;
 }
 
@@ -45,7 +47,7 @@
     }
   }
   // Fallback: append random suffix
-  return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
+  return `${generateAgentName()}${crypto.randomInt(1000)}`;
 }
 
 /**
EOF
@@ -3,6 +3,8 @@
* Inspired by mcp_agent_mail's approach.
*/

import crypto from 'node:crypto';

const ADJECTIVES = [
'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -29,8 +31,8 @@
* Generate a random agent name (AdjectiveNoun format).
*/
export function generateAgentName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const adjective = ADJECTIVES[crypto.randomInt(ADJECTIVES.length)];
const noun = NOUNS[crypto.randomInt(NOUNS.length)];
return `${adjective}${noun}`;
}

@@ -45,7 +47,7 @@
}
}
// Fallback: append random suffix
return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
return `${generateAgentName()}${crypto.randomInt(1000)}`;
}

/**
Copilot is powered by AI and may make mistakes. Always verify output.

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.
This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI 4 months ago

General approach: Replace usage of Math.random() in name-generator.ts with a cryptographically secure random source. In Node.js we can use crypto.randomInt (or crypto.randomBytes), which provides unbiased, cryptographically strong integers. This keeps the API the same (still returns a deterministic string format) while strengthening the randomness. No changes are needed in src/cli/index.ts, packages/config/src/shadow-config.ts, or packages/bridge/src/spawner.ts; they can continue to consume generated names exactly as before.

Best concrete fix:

  • In packages/utils/src/name-generator.ts, import randomInt from Node’s crypto module.
  • Replace Math.floor(Math.random() * ADJECTIVES.length) and Math.floor(Math.random() * NOUNS.length) with randomInt(ADJECTIVES.length) and randomInt(NOUNS.length).
  • Replace the fallback suffix Math.floor(Math.random() * 1000) with randomInt(1000).

These are minimal edits that preserve all external behavior (same ranges, same string format) while using a cryptographically secure PRNG.

No changes are needed to spawner.ts, since it simply receives cli as a parameter; strengthening the randomness at source (name generation) handles all the variants CodeQL reported.


Suggested changeset 1
packages/utils/src/name-generator.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/utils/src/name-generator.ts b/packages/utils/src/name-generator.ts
--- a/packages/utils/src/name-generator.ts
+++ b/packages/utils/src/name-generator.ts
@@ -3,6 +3,8 @@
  * Inspired by mcp_agent_mail's approach.
  */
 
+import { randomInt } from 'node:crypto';
+
 const ADJECTIVES = [
   'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
   'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -29,8 +31,8 @@
  * Generate a random agent name (AdjectiveNoun format).
  */
 export function generateAgentName(): string {
-  const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
-  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
+  const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)];
+  const noun = NOUNS[randomInt(NOUNS.length)];
   return `${adjective}${noun}`;
 }
 
@@ -45,7 +47,7 @@
     }
   }
   // Fallback: append random suffix
-  return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
+  return `${generateAgentName()}${randomInt(1000)}`;
 }
 
 /**
EOF
@@ -3,6 +3,8 @@
* Inspired by mcp_agent_mail's approach.
*/

import { randomInt } from 'node:crypto';

const ADJECTIVES = [
'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -29,8 +31,8 @@
* Generate a random agent name (AdjectiveNoun format).
*/
export function generateAgentName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const adjective = ADJECTIVES[randomInt(ADJECTIVES.length)];
const noun = NOUNS[randomInt(NOUNS.length)];
return `${adjective}${noun}`;
}

@@ -45,7 +47,7 @@
}
}
// Fallback: append random suffix
return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
return `${generateAgentName()}${randomInt(1000)}`;
}

/**
Copilot is powered by AI and may make mistakes. Always verify output.

Check failure

Code scanning / CodeQL

Insecure randomness High

This uses a cryptographically insecure random number generated at
Math.random()
in a security context.
This uses a cryptographically insecure random number generated at
Math.random()
in a security context.

Copilot Autofix

AI 4 months ago

General fix: Replace all uses of Math.random() in packages/utils/src/name-generator.ts with a cryptographically secure random source. On Node, use crypto.randomInt for unbiased integer selection and suffix generation.

Best concrete fix:

  • In packages/utils/src/name-generator.ts, import Node’s crypto module.
  • Implement a small helper getRandomInt(max: number): number that calls crypto.randomInt(max).
  • Use getRandomInt(ADJECTIVES.length) and getRandomInt(NOUNS.length) instead of Math.floor(Math.random() * ...).
  • For the numeric suffix in generateUniqueAgentName, use getRandomInt(1000) instead of Math.floor(Math.random() * 1000).
  • Do not change any function signatures or exported names; the only behavioral change is that randomness becomes cryptographically secure.

Only packages/utils/src/name-generator.ts needs code changes; the spawner and CLI code can stay as is.


Suggested changeset 1
packages/utils/src/name-generator.ts
Outside changed files

Autofix patch

Autofix patch
Run the following command in your local git repository to apply this patch
cat << 'EOF' | git apply
diff --git a/packages/utils/src/name-generator.ts b/packages/utils/src/name-generator.ts
--- a/packages/utils/src/name-generator.ts
+++ b/packages/utils/src/name-generator.ts
@@ -3,6 +3,8 @@
  * Inspired by mcp_agent_mail's approach.
  */
 
+import crypto from 'node:crypto';
+
 const ADJECTIVES = [
   'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
   'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -25,12 +27,16 @@
   'Star', 'Moon', 'Sun', 'Comet', 'Cloud', 'Storm', 'Thunder', 'Lightning',
 ];
 
+function getRandomInt(max: number): number {
+  return crypto.randomInt(max);
+}
+
 /**
  * Generate a random agent name (AdjectiveNoun format).
  */
 export function generateAgentName(): string {
-  const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
-  const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
+  const adjective = ADJECTIVES[getRandomInt(ADJECTIVES.length)];
+  const noun = NOUNS[getRandomInt(NOUNS.length)];
   return `${adjective}${noun}`;
 }
 
@@ -45,7 +47,7 @@
     }
   }
   // Fallback: append random suffix
-  return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
+  return `${generateAgentName()}${getRandomInt(1000)}`;
 }
 
 /**
EOF
@@ -3,6 +3,8 @@
* Inspired by mcp_agent_mail's approach.
*/

import crypto from 'node:crypto';

const ADJECTIVES = [
'Blue', 'Green', 'Red', 'Purple', 'Golden', 'Silver', 'Crystal', 'Amber',
'Coral', 'Jade', 'Ruby', 'Sapphire', 'Emerald', 'Onyx', 'Pearl', 'Copper',
@@ -25,12 +27,16 @@
'Star', 'Moon', 'Sun', 'Comet', 'Cloud', 'Storm', 'Thunder', 'Lightning',
];

function getRandomInt(max: number): number {
return crypto.randomInt(max);
}

/**
* Generate a random agent name (AdjectiveNoun format).
*/
export function generateAgentName(): string {
const adjective = ADJECTIVES[Math.floor(Math.random() * ADJECTIVES.length)];
const noun = NOUNS[Math.floor(Math.random() * NOUNS.length)];
const adjective = ADJECTIVES[getRandomInt(ADJECTIVES.length)];
const noun = NOUNS[getRandomInt(NOUNS.length)];
return `${adjective}${noun}`;
}

@@ -45,7 +47,7 @@
}
}
// Fallback: append random suffix
return `${generateAgentName()}${Math.floor(Math.random() * 1000)}`;
return `${generateAgentName()}${getRandomInt(1000)}`;
}

/**
Copilot is powered by AI and may make mistakes. Always verify output.
const debug = process.env.DEBUG_SPAWN === '1';

// Validate agent name to prevent path traversal attacks
Expand Down Expand Up @@ -917,6 +920,11 @@
log.warn(`Could not resolve path for '${commandName}', spawn may fail`);
}

// Track the effective model for this spawn.
// Model override from spawn request applies to ALL CLIs.
// CLI-specific blocks below may refine this (e.g., Claude agent profile lookup).
let spawnModel: string | undefined = modelOverride;

// Pre-configure MCP permissions for all supported CLIs
// This creates/updates CLI-specific settings files with agent-relay permissions
ensureMcpPermissions(this.projectRoot, commandName, debug);
Expand Down Expand Up @@ -954,8 +962,15 @@
? mapModelToCli(modelFromProfile)
: mapModelToCli(); // defaults to claude:sonnet

// Extract effective model name for logging
const effectiveModel = modelFromProfile || 'opus';
// Extract effective model name for logging and tracking
// Model override from spawn request takes precedence over agent profile
const effectiveModel = modelOverride || modelFromProfile || 'opus';
spawnModel = effectiveModel;

// If model override provided, add --model before buildClaudeArgs so it takes precedence
if (modelOverride && !args.includes('--model')) {
args.push('--model', modelOverride);
}

const configuredArgs = buildClaudeArgs(name, args, this.projectRoot);
// Replace args with configured version (includes --model and --agent if found)
Expand Down Expand Up @@ -984,6 +999,12 @@
}
}

// Pass model override to non-Claude CLIs via --model flag
// Claude handles this separately in its agent config block above
if (modelOverride && !isClaudeCli && !args.includes('--model') && !args.includes('-m')) {
args.push('--model', modelOverride);
}

// Check if MCP tools are available
// Must verify BOTH conditions (matching inbox hook behavior from commit 18bab59):
// 1. MCP config exists (user or project scope)
Expand Down Expand Up @@ -1329,6 +1350,7 @@
pty: openCodeWrapper as unknown as AgentWrapper,
logFile: openCodeWrapper.logPath,
listeners,
model: spawnModel,
};
this.activeWorkers.set(name, workerInfo);
this.saveWorkersMetadata();
Expand Down Expand Up @@ -1538,6 +1560,7 @@
pty,
logFile: pty.logPath,
listeners, // Store for cleanup
model: spawnModel,
};
this.activeWorkers.set(name, workerInfo);
this.saveWorkersMetadata();
Expand Down Expand Up @@ -1766,6 +1789,7 @@
spawnerName: w.spawnerName,
spawnedAt: w.spawnedAt,
pid: w.pid,
model: w.model,
}));
}

Expand All @@ -1789,6 +1813,7 @@
team: worker.team,
spawnedAt: worker.spawnedAt,
pid: worker.pid,
model: worker.model,
};
}

Expand Down Expand Up @@ -1823,6 +1848,72 @@
return true;
}

/**
* Switch the model of a running worker agent.
* Waits for the agent to be idle, then sends the CLI-specific model switch command.
*
* @param name - Worker name
* @param model - Target model identifier (e.g., 'opus', 'sonnet', 'haiku')
* @param timeoutMs - Max time to wait for agent idle (default: 30000)
* @returns Result of the model switch attempt
*/
async setWorkerModel(
name: string,
model: string,
timeoutMs = 30000,
): Promise<{ success: boolean; previousModel?: string; normalizedModel?: string; error?: string }> {
const worker = this.activeWorkers.get(name);
if (!worker) {
return { success: false, error: `Agent "${name}" not found` };
}

// Validate CLI supports model switching
if (!isModelSwitchSupported(worker.cli)) {
return { success: false, error: `CLI "${worker.cli}" does not support mid-session model switching` };
}

// Validate and normalize model name
const validation = validateModelForCli(worker.cli, model);
if (!validation.valid) {
return { success: false, error: validation.error };
}
const normalizedModel = validation.normalizedModel ?? model;

// Build the command using normalized model
const command = buildModelSwitchCommand(worker.cli, normalizedModel);
if (!command) {
return { success: false, error: `Failed to build model switch command for "${worker.cli}"` };
}

// Wait for agent idle via readiness detection
const pty = worker.pty;
if ('waitUntilReadyForMessages' in pty && typeof pty.waitUntilReadyForMessages === 'function') {
const ready = await pty.waitUntilReadyForMessages(timeoutMs, 200);
if (!ready) {
return {
success: false,
error: `Agent "${name}" did not become idle within ${timeoutMs}ms. The agent may be processing a task. Try again later.`,
};
}
} else {
log.warn(`PTY for ${name} does not support idle detection; sending model switch without waiting`);
}

// Send the command via write() (raw stdin)
try {
const previousModel = worker.model;
await pty.write(command);
worker.model = normalizedModel;
this.saveWorkersMetadata();

log.info(`Model switched for ${name}: ${previousModel ?? 'unknown'} -> ${normalizedModel}`);
return { success: true, previousModel, normalizedModel };
} catch (err: unknown) {
const message = err instanceof Error ? err.message : String(err);
return { success: false, error: `Failed to send model switch command: ${message}` };
}
}

/**
* Wait for an agent to appear in the connected list and registry (connected-agents.json + agents.json).
*/
Expand Down Expand Up @@ -1929,6 +2020,7 @@
spawnedAt: w.spawnedAt,
pid: w.pid,
logFile: w.logFile,
model: w.model,
}));

fs.writeFileSync(this.workersPath, JSON.stringify({ workers }, null, 2));
Expand Down
4 changes: 4 additions & 0 deletions packages/bridge/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ export interface SpawnRequest {
cli: string;
/** Initial task to inject */
task: string;
/** Model override (e.g., 'opus', 'sonnet', 'haiku'). Takes precedence over agent profile. */
model?: string;
/** Optional team name to organize agents under */
team?: string;
/** Working directory for the agent (defaults to detected workspace) */
Expand Down Expand Up @@ -93,6 +95,8 @@ export interface WorkerInfo {
spawnedAt: number;
/** PID of the pty process */
pid?: number;
/** Current model if known (e.g., 'opus', 'sonnet', 'haiku') */
model?: string;
}

/** SpeakOn trigger types for shadow agents */
Expand Down
12 changes: 12 additions & 0 deletions packages/daemon/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ import {
type MetricsResponsePayload,
type AgentReadyPayload,
type SendInputPayload,
type SetModelPayload,
type ListWorkersPayload,
} from '@agent-relay/protocol/types';
import type { ChannelJoinPayload, ChannelLeavePayload, ChannelMessagePayload } from '@agent-relay/protocol/channels';
Expand Down Expand Up @@ -1345,6 +1346,17 @@ export class Daemon {
break;
}

case 'SET_MODEL': {
if (!this.spawnManager) {
this.sendErrorEnvelope(connection, 'SpawnManager not enabled. Configure spawnManager: true in daemon config.');
break;
}
const setModelEnvelope = envelope as Envelope<SetModelPayload>;
log.info(`SET_MODEL request: from=${connection.agentName} agent=${setModelEnvelope.payload.name} model=${setModelEnvelope.payload.model}`);
this.spawnManager.handleSetModel(connection, setModelEnvelope);
break;
}

case 'LIST_WORKERS': {
if (!this.spawnManager) {
this.sendErrorEnvelope(connection, 'SpawnManager not enabled. Configure spawnManager: true in daemon config.');
Expand Down
144 changes: 144 additions & 0 deletions packages/daemon/src/spawn-manager-set-model.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { SpawnManager } from './spawn-manager.js';
import type { Envelope, SetModelPayload } from '@agent-relay/protocol/types';

/**
* Mock connection that captures sent envelopes.
*/
function createMockConnection(agentName: string) {
return {
id: `conn-${agentName}`,
agentName,
sessionId: `session-${agentName}`,
send: vi.fn(),
close: vi.fn(),
};
}

function createSetModelEnvelope(
name: string,
model: string,
timeoutMs?: number,
): Envelope<SetModelPayload> {
return {
v: 1,
type: 'SET_MODEL',
id: `env-${Date.now()}`,
ts: Date.now(),
payload: { name, model, timeoutMs },
};
}

describe('SpawnManager.handleSetModel', () => {
let manager: SpawnManager;
let mockSetWorkerModel: ReturnType<typeof vi.fn>;

beforeEach(() => {
vi.clearAllMocks();

manager = new SpawnManager({
projectRoot: '/tmp/test-project',
});

// Mock the spawner's setWorkerModel method
mockSetWorkerModel = vi.fn();
(manager as any).spawner.setWorkerModel = mockSetWorkerModel;
});

it('should send success result when model switch succeeds', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('Worker1', 'haiku');

mockSetWorkerModel.mockResolvedValue({
success: true,
previousModel: 'sonnet',
});

await manager.handleSetModel(connection as any, envelope);

expect(mockSetWorkerModel).toHaveBeenCalledWith('Worker1', 'haiku', 30000);
expect(connection.send).toHaveBeenCalledTimes(1);

const result = connection.send.mock.calls[0][0];
expect(result.type).toBe('SET_MODEL_RESULT');
expect(result.payload.success).toBe(true);
expect(result.payload.name).toBe('Worker1');
expect(result.payload.model).toBe('haiku');
expect(result.payload.previousModel).toBe('sonnet');
expect(result.payload.error).toBeUndefined();
});

it('should send failure result when model switch fails', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('Worker1', 'haiku');

mockSetWorkerModel.mockResolvedValue({
success: false,
error: 'Agent "Worker1" did not become idle within 30000ms',
});

await manager.handleSetModel(connection as any, envelope);

const result = connection.send.mock.calls[0][0];
expect(result.type).toBe('SET_MODEL_RESULT');
expect(result.payload.success).toBe(false);
expect(result.payload.error).toContain('did not become idle');
});

it('should pass custom timeout from payload', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('Worker1', 'opus', 60000);

mockSetWorkerModel.mockResolvedValue({ success: true });

await manager.handleSetModel(connection as any, envelope);

expect(mockSetWorkerModel).toHaveBeenCalledWith('Worker1', 'opus', 60000);
});

it('should handle spawner throwing an error', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('Worker1', 'opus');

mockSetWorkerModel.mockRejectedValue(new Error('PTY process crashed'));

await manager.handleSetModel(connection as any, envelope);

const result = connection.send.mock.calls[0][0];
expect(result.type).toBe('SET_MODEL_RESULT');
expect(result.payload.success).toBe(false);
expect(result.payload.error).toBe('PTY process crashed');
});

it('should send failure when agent not found', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('NonExistent', 'haiku');

mockSetWorkerModel.mockResolvedValue({
success: false,
error: 'Agent "NonExistent" not found',
});

await manager.handleSetModel(connection as any, envelope);

const result = connection.send.mock.calls[0][0];
expect(result.payload.success).toBe(false);
expect(result.payload.error).toContain('not found');
});

it('should send failure for unsupported CLI', async () => {
const connection = createMockConnection('Lead');
const envelope = createSetModelEnvelope('CodexWorker', 'gpt-4o');

mockSetWorkerModel.mockResolvedValue({
success: false,
error: 'CLI "codex" does not support mid-session model switching',
});

await manager.handleSetModel(connection as any, envelope);

const result = connection.send.mock.calls[0][0];
expect(result.payload.success).toBe(false);
expect(result.payload.error).toContain('does not support');
});
});
Loading
Loading