diff --git a/packages/squad-cli/package.json b/packages/squad-cli/package.json index 57be67836..3d83504a6 100644 --- a/packages/squad-cli/package.json +++ b/packages/squad-cli/package.json @@ -20,14 +20,6 @@ "types": "./dist/cli/copilot-install.d.ts", "import": "./dist/cli/copilot-install.js" }, - "./shell/sessions": { - "types": "./dist/cli/shell/sessions.d.ts", - "import": "./dist/cli/shell/sessions.js" - }, - "./shell/spawn": { - "types": "./dist/cli/shell/spawn.d.ts", - "import": "./dist/cli/shell/spawn.js" - }, "./shell/coordinator": { "types": "./dist/cli/shell/coordinator.d.ts", "import": "./dist/cli/shell/coordinator.js" @@ -124,18 +116,6 @@ "types": "./dist/cli/commands/watch/index.d.ts", "import": "./dist/cli/commands/watch/index.js" }, - "./commands/start": { - "types": "./dist/cli/commands/start.d.ts", - "import": "./dist/cli/commands/start.js" - }, - "./commands/rc-tunnel": { - "types": "./dist/cli/commands/rc-tunnel.d.ts", - "import": "./dist/cli/commands/rc-tunnel.js" - }, - "./commands/rc": { - "types": "./dist/cli/commands/rc.d.ts", - "import": "./dist/cli/commands/rc.js" - }, "./commands/extract": { "types": "./dist/cli/commands/extract.d.ts", "import": "./dist/cli/commands/extract.js" @@ -148,10 +128,6 @@ "types": "./dist/cli/commands/cost.d.ts", "import": "./dist/cli/commands/cost.js" }, - "./commands/copilot-bridge": { - "types": "./dist/cli/commands/copilot-bridge.d.ts", - "import": "./dist/cli/commands/copilot-bridge.js" - }, "./commands/personal": { "types": "./dist/cli/commands/personal.d.ts", "import": "./dist/cli/commands/personal.js" @@ -168,26 +144,21 @@ "README.md" ], "scripts": { - "postinstall": "node scripts/patch-esm-imports.mjs && node scripts/patch-ink-rendering.mjs", + "postinstall": "node scripts/patch-esm-imports.mjs", "prepublishOnly": "npm run build", - "build": "tsc -p tsconfig.json && npm run postbuild", - "postbuild": "node -e \"require('fs').cpSync('src/remote-ui', 'dist/remote-ui', {recursive: true})\"" + "build": "tsc -p tsconfig.json" }, "engines": { "node": ">=22.5.0" }, "dependencies": { "@bradygaster/squad-sdk": ">=0.9.0", - "ink": "^6.8.0", - "react": "^19.2.4", "vscode-jsonrpc": "^8.2.1" }, "devDependencies": { "@types/node": "^22.0.0", - "@types/react": "^19.2.14", "esbuild": "^0.25.0", - "typescript": "^5.7.0", - "ink-testing-library": "^4.0.0" + "typescript": "^5.7.0" }, "keywords": [ "copilot", diff --git a/packages/squad-cli/scripts/patch-ink-rendering.mjs b/packages/squad-cli/scripts/patch-ink-rendering.mjs deleted file mode 100644 index a545a03e6..000000000 --- a/packages/squad-cli/scripts/patch-ink-rendering.mjs +++ /dev/null @@ -1,115 +0,0 @@ -#!/usr/bin/env node - -/** - * Ink Rendering Patcher for Squad CLI - * - * Patches ink/build/ink.js to fix scroll flicker on Windows Terminal. - * Three patches are applied: - * - * 1. Remove trailing newline — the extra '\n' appended to output causes - * logUpdate's previousLineCount to be off by one, pushing the bottom of - * the UI below the viewport. - * - * 2. Disable clearTerminal fullscreen path — when output fills the terminal, - * Ink clears the entire screen, causing violent scroll-to-top flicker. - * We force the condition to `false` so logUpdate's incremental - * erase-and-rewrite is always used instead. - * - * 3. Verify incrementalRendering passthrough — confirms that Ink forwards - * the incrementalRendering option to logUpdate.create(). No code change - * needed if already wired up. - * - * All patches are idempotent (safe to run multiple times). - */ - -import { readFileSync, writeFileSync, existsSync } from 'fs'; -import { join, dirname } from 'path'; -import { fileURLToPath } from 'url'; - -const __dirname = dirname(fileURLToPath(import.meta.url)); - -function patchInkRendering() { - // Try multiple possible locations (npm workspaces can hoist dependencies) - const possiblePaths = [ - // squad-cli package node_modules - join(__dirname, '..', 'node_modules', 'ink', 'build', 'ink.js'), - // Workspace root node_modules (common with npm workspaces) - join(__dirname, '..', '..', '..', 'node_modules', 'ink', 'build', 'ink.js'), - // Global install location (node_modules at parent of package) - join(__dirname, '..', '..', 'ink', 'build', 'ink.js'), - ]; - - const inkJsPath = possiblePaths.find(p => existsSync(p)) ?? null; - - if (!inkJsPath) { - // ink not installed yet — exit silently - return false; - } - - try { - let content = readFileSync(inkJsPath, 'utf8'); - let patchCount = 0; - - // --- Patch 1: Remove trailing newline --- - // Original: const outputToRender = output + '\n'; - // Patched: const outputToRender = output; - const trailingNewlineSearch = "const outputToRender = output + '\\n';"; - const trailingNewlineReplace = 'const outputToRender = output;'; - if (content.includes(trailingNewlineSearch)) { - content = content.replace(trailingNewlineSearch, trailingNewlineReplace); - console.log(' ✅ Patch 1/3: Removed trailing newline from outputToRender'); - patchCount++; - } else if (content.includes(trailingNewlineReplace)) { - console.log(' ⏭️ Patch 1/3: Trailing newline already removed'); - } else { - console.warn(' ⚠️ Patch 1/3: Could not find outputToRender pattern — Ink version may have changed'); - } - - // --- Patch 2: Disable clearTerminal fullscreen path --- - // Original: if (isFullscreen) { - // const sync = shouldSynchronize(this.options.stdout); - // ... - // this.options.stdout.write(ansiEscapes.clearTerminal + ... - // Patched: if (false) { - // - // We match `if (isFullscreen) {` only when followed by the clearTerminal - // usage to avoid replacing unrelated isFullscreen references. - const fullscreenSearch = /if \(isFullscreen\) \{\s*\n\s*const sync = shouldSynchronize/; - const fullscreenAlreadyPatched = /if \(false\) \{\s*\n\s*const sync = shouldSynchronize/; - if (fullscreenSearch.test(content)) { - content = content.replace( - /if \(isFullscreen\) (\{\s*\n\s*const sync = shouldSynchronize)/, - 'if (false) $1' - ); - console.log(' ✅ Patch 2/3: Disabled clearTerminal fullscreen path'); - patchCount++; - } else if (fullscreenAlreadyPatched.test(content)) { - console.log(' ⏭️ Patch 2/3: clearTerminal path already disabled'); - } else { - console.warn(' ⚠️ Patch 2/3: Could not find isFullscreen pattern — Ink version may have changed'); - } - - // --- Patch 3: Verify incrementalRendering passthrough --- - const incrementalPattern = 'incremental: options.incrementalRendering'; - if (content.includes(incrementalPattern)) { - console.log(' ✅ Patch 3/3: incrementalRendering passthrough verified (no change needed)'); - } else { - console.warn(' ⚠️ Patch 3/3: incrementalRendering passthrough not found — Ink version may have changed'); - } - - if (patchCount > 0) { - writeFileSync(inkJsPath, content, 'utf8'); - console.log(`✅ Patched ink.js with ${patchCount} rendering fix(es) for scroll flicker`); - return true; - } - - return false; - } catch (err) { - console.warn('⚠️ Failed to patch ink.js rendering:', err.message); - console.warn(' Scroll flicker may occur on Windows Terminal.'); - return false; - } -} - -// Run patch -patchInkRendering(); diff --git a/packages/squad-cli/src/cli-entry.ts b/packages/squad-cli/src/cli-entry.ts index c6101a71c..3bb7cd36f 100644 --- a/packages/squad-cli/src/cli-entry.ts +++ b/packages/squad-cli/src/cli-entry.ts @@ -73,7 +73,7 @@ Module._resolveFilename = function (request: string, parent: unknown, isMain: bo // --------------------------------------------------------------------------- // Top-level signal handlers — safety net for clean exit on Ctrl+C / SIGTERM. -// Individual commands (shell, watch, aspire, rc) register their own handlers +// Individual commands (watch, aspire) register their own handlers // that run first; these ensure the process never hangs if a command doesn't. // --------------------------------------------------------------------------- let _exitingOnSignal = false; @@ -101,7 +101,7 @@ import { getPackageVersion } from './cli/core/version.js'; // Lazy-load squad-sdk to avoid triggering @github/copilot-sdk import on Node 24+ // (Issue: copilot-sdk has broken ESM imports - vscode-jsonrpc/node without .js extension) const lazySquadSdk = () => import('@bradygaster/squad-sdk'); -const lazyRunShell = () => import('./cli/shell/index.js'); + // Use local version resolver instead of importing VERSION from squad-sdk const VERSION = getPackageVersion(); @@ -137,8 +137,7 @@ async function main(): Promise { console.log(`\n${BOLD}squad${RESET} v${VERSION} — Add an AI agent team to any project\n`); console.log(`Usage: squad [command] [options]\n`); console.log(`Commands:`); - console.log(` ${BOLD}(default)${RESET} Launch interactive shell (no args)`); - console.log(` Flags: --global (init in personal squad directory)`); + console.log(` ${BOLD}init${RESET} Initialize Squad (markdown-only, default)`); console.log(` Flags: --sdk (SDK builder syntax)`); console.log(` --roles (use base roles)`); @@ -192,12 +191,6 @@ async function main(): Promise { console.log(` Usage: import [--force]`); console.log(` ${BOLD}scrub-emails${RESET} Remove email addresses from Squad state files`); console.log(` Usage: scrub-emails [directory] (default: .ai-team/)`); - console.log(` ${BOLD}start${RESET} Start Copilot with remote access from phone/browser`); - console.log(` Usage: start [--tunnel] [--port ] [--command ]`); - console.log(` [copilot flags...]`); - console.log(` Examples: start --tunnel --yolo`); - console.log(` start --tunnel --model claude-sonnet-4`); - console.log(` start --tunnel --command "gh copilot"`); console.log(` ${BOLD}nap${RESET} Context hygiene (compress, prune, archive .squad/ state)`); console.log(` Usage: nap [--deep] [--dry-run]`); console.log(` Flags: --deep (thorough cleanup), --dry-run (preview only)`); @@ -222,12 +215,8 @@ async function main(): Promise { console.log(` Usage: personal init | list | add `); console.log(` --role | remove `); console.log(` ${BOLD}cast${RESET} Show current session cast (project + personal agents)`); - console.log(` ${BOLD}rc${RESET} Start Remote Control bridge (phone/browser → Copilot)`); - console.log(` Usage: rc [--tunnel] [--port ] [--path ]`); - console.log(` ${BOLD}copilot-bridge${RESET} Check Copilot ACP stdio compatibility`); console.log(` ${BOLD}init-remote${RESET} Link project to remote team root (shorthand)`); console.log(` Usage: init-remote `); - console.log(` ${BOLD}rc-tunnel${RESET} Check devtunnel CLI availability`); console.log(` ${BOLD}discover${RESET} List known squads and their capabilities`); console.log(` ${BOLD}delegate${RESET} Create work in another squad`); console.log(` Usage: delegate `); @@ -254,12 +243,19 @@ async function main(): Promise { return; } - // No args → launch interactive shell; whitespace-only arg → show help + // No args → show deprecation notice for removed interactive REPL if (rawCmd === undefined) { - // Fire-and-forget update check — non-blocking, never delays shell startup - import('./cli/self-update.js').then(m => m.notifyIfUpdateAvailable(VERSION)).catch(() => {}); - const { runShell } = await lazyRunShell(); - await runShell(); + console.log(`\n${YELLOW}${BOLD}⚠ The interactive REPL has been deprecated and removed.${RESET}\n`); + console.log(`${DIM}The built-in interactive shell had persistent rendering issues (scrollback`); + console.log(`corruption, Ink viewport collisions, terminal incompatibility) and has been`); + console.log(`removed in favour of a better experience.${RESET}\n`); + console.log(`${BOLD}Recommended replacement: GitHub Copilot CLI${RESET}`); + console.log(` Install: ${BOLD}gh extension install github/gh-copilot${RESET}`); + console.log(` Then run ${BOLD}gh copilot suggest${RESET} or ${BOLD}gh copilot explain${RESET} from your repo root.\n`); + console.log(`${DIM}Tip: running from a repo root that contains squad.agent.md means Copilot`); + console.log(`picks up your Squad context automatically.${RESET}\n`); + console.log(`${DIM}More info: https://github.com/bradygaster/squad/issues/665${RESET}\n`); + process.exitCode = 0; return; } if (!cmd) { @@ -544,20 +540,6 @@ async function main(): Promise { return; } - if (cmd === 'start') { - const { runStart } = await import('./cli/commands/start.js'); - const hasTunnel = args.includes('--tunnel'); - const portIdx = args.indexOf('--port'); - const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0; - // Collect all remaining args to pass through to copilot - const cmdIdx = args.indexOf('--command'); - const customCmd = (cmdIdx !== -1 && args[cmdIdx + 1]) ? args[cmdIdx + 1] : undefined; - const squadFlags = ['start', '--tunnel', '--port', port.toString(), '--command', customCmd || ''].filter(Boolean); - const copilotArgs = args.slice(1).filter(a => !squadFlags.includes(a)); - await runStart(process.cwd(), { tunnel: hasTunnel, port, copilotArgs, command: customCmd }); - return; - } - if (cmd === 'nap') { const { runNap, formatNapReport } = await import('./cli/core/nap.js'); const sdk = await lazySquadSdk(); @@ -610,28 +592,6 @@ async function main(): Promise { return; } - if (cmd === 'rc' || cmd === 'remote-control') { - const { runRC } = await import('./cli/commands/rc.js'); - const hasTunnel = args.includes('--tunnel'); - const portIdx = args.indexOf('--port'); - const port = (portIdx !== -1 && args[portIdx + 1]) ? parseInt(args[portIdx + 1]!, 10) : 0; - const pathIdx = args.indexOf('--path'); - const rcPath = (pathIdx !== -1 && args[pathIdx + 1]) ? args[pathIdx + 1] : undefined; - await runRC(rcPath || process.cwd(), { tunnel: hasTunnel, port }); - return; - } - - if (cmd === 'copilot-bridge') { - const { CopilotBridge } = await import('./cli/commands/copilot-bridge.js'); - const result = await CopilotBridge.checkCompatibility(); - if (result.compatible) { - console.log(`${GREEN}✓${RESET} ${result.message}`); - } else { - console.log(`${YELLOW}⚠${RESET} ${result.message}`); - } - return; - } - if (cmd === 'init-remote') { const { writeRemoteConfig } = await import('./cli/commands/init-remote.js'); const teamPath = args[1]; @@ -644,16 +604,6 @@ async function main(): Promise { return; } - if (cmd === 'rc-tunnel') { - const { isDevtunnelAvailable } = await import('./cli/commands/rc-tunnel.js'); - if (isDevtunnelAvailable()) { - console.log(`${GREEN}✓${RESET} devtunnel CLI is available`); - } else { - console.log(`${YELLOW}⚠${RESET} devtunnel CLI not found. Install with: winget install Microsoft.devtunnel`); - } - return; - } - if (cmd === 'schedule') { const { runSchedule } = await import('./cli/commands/schedule.js'); const subcommand = args[1] || 'list'; diff --git a/packages/squad-cli/src/cli/commands/copilot-bridge.ts b/packages/squad-cli/src/cli/commands/copilot-bridge.ts deleted file mode 100644 index 2eaf62934..000000000 --- a/packages/squad-cli/src/cli/commands/copilot-bridge.ts +++ /dev/null @@ -1,214 +0,0 @@ -/** - * Squad Remote Control — Copilot ACP Bridge - * - * Spawns `copilot --acp --stdio` and relays JSON-RPC messages - * between the WebSocket clients and the Copilot CLI process. - */ - -import { spawn, execSync, type ChildProcess } from 'node:child_process'; -import { createInterface } from 'node:readline'; - -export interface CopilotBridgeConfig { - cwd: string; - agent?: string; -} - -export class CopilotBridge { - private child: ChildProcess | null = null; - private messageCallback: ((line: string) => void) | null = null; - private requestId = 0; - private pendingRequests = new Map void; reject: (e: Error) => void }>(); - private sessionId: string | null = null; - private initialized = false; - - constructor(private config: CopilotBridgeConfig) {} - - /** Check if copilot CLI supports ACP stdio mode */ - static async checkCompatibility(): Promise<{ compatible: boolean; version: string; message: string }> { - try { - const version = execSync('copilot --version', { encoding: 'utf-8', timeout: 5000 }).trim(); - const versionMatch = version.match(/(\d+\.\d+\.\d+[-\w]*)/); - const ver = versionMatch?.[1] || 'unknown'; - - // Test if --acp --stdio actually produces output - const testResult = await new Promise((resolve) => { - const cp = spawn('copilot', ['--acp', '--stdio'], { stdio: ['pipe', 'pipe', 'pipe'] }); - let gotOutput = false; - - cp.stdout?.on('data', () => { gotOutput = true; }); - - // Send initialize and wait briefly - setTimeout(() => { - const msg = JSON.stringify({ - jsonrpc: '2.0', id: 1, method: 'initialize', - params: { protocolVersion: '2025-03-26', clientCapabilities: {}, clientInfo: { name: 'squad-rc-check', version: '1.0.0' } } - }); - try { cp.stdin?.write(msg + '\n'); } catch { /* ignore */ } - }, 2000); - - setTimeout(() => { - cp.kill(); - resolve(gotOutput); - }, 8000); - - cp.on('error', () => resolve(false)); - }); - - if (testResult) { - return { compatible: true, version: ver, message: `Copilot CLI ${ver} supports ACP stdio` }; - } else { - return { compatible: false, version: ver, message: `Copilot CLI ${ver} found but ACP stdio not responding. Version 0.0.420+ may be required.` }; - } - } catch { - return { compatible: false, version: 'not found', message: 'Copilot CLI not installed. Run: npm install -g @github/copilot' }; - } - } - - /** Set callback for messages from Copilot */ - onMessage(cb: (line: string) => void): void { - this.messageCallback = cb; - } - - /** Spawn copilot --acp --stdio */ - async start(): Promise { - const args = ['--acp', '--stdio']; - if (this.config.agent) { - args.push('--agent', this.config.agent); - } - - this.child = spawn('copilot', args, { - cwd: this.config.cwd, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - // Read NDJSON from stdout - const rl = createInterface({ - input: this.child.stdout!, - terminal: false, - }); - - rl.on('line', (line) => { - if (!line.trim()) return; - - // Check if it's a response to a pending request - try { - const msg = JSON.parse(line); - if (msg.id !== undefined && this.pendingRequests.has(msg.id)) { - const pending = this.pendingRequests.get(msg.id)!; - this.pendingRequests.delete(msg.id); - if (msg.error) { - pending.reject(new Error(msg.error.message || JSON.stringify(msg.error))); - } else { - pending.resolve(msg.result); - } - return; - } - } catch { - // not JSON, forward anyway - } - - // Forward to callback (notifications, session/update, etc.) - if (this.messageCallback) { - this.messageCallback(line); - } - }); - - this.child.stderr?.on('data', (data: Buffer) => { - // Log stderr but don't crash - const text = data.toString().trim(); - if (text) { - console.error(` [copilot stderr] ${text}`); - } - }); - - this.child.on('exit', (code) => { - console.log(` [copilot] exited with code ${code}`); - this.child = null; - this.initialized = false; - this.sessionId = null; - }); - - // Initialize ACP session - await this.initialize(); - } - - /** Send raw NDJSON line to copilot stdin */ - send(message: string): void { - if (!this.child?.stdin?.writable) return; - const payload = message.endsWith('\n') ? message : message + '\n'; - this.child.stdin.write(payload); - } - - /** Send JSON-RPC request and wait for response */ - private sendRequest(method: string, params: Record): Promise { - return new Promise((resolve, reject) => { - const id = ++this.requestId; - this.pendingRequests.set(id, { resolve, reject }); - - const msg = JSON.stringify({ jsonrpc: '2.0', id, method, params }); - this.send(msg); - - // Timeout after 30s - setTimeout(() => { - if (this.pendingRequests.has(id)) { - this.pendingRequests.delete(id); - reject(new Error(`Request ${method} timed out`)); - } - }, 30000); - }); - } - - /** Initialize ACP protocol + create session */ - private async initialize(): Promise { - // Step 1: initialize - await this.sendRequest('initialize', { - protocolVersion: '2025-01-01', - clientCapabilities: {}, - clientInfo: { name: 'squad-rc', version: '1.0.0' }, - }); - - // Step 2: session/new - const result = await this.sendRequest<{ sessionId: string }>('session/new', { - cwd: this.config.cwd, - mcpServers: [], - }); - - this.sessionId = result.sessionId; - this.initialized = true; - } - - /** Send a prompt to Copilot (response comes via notifications) */ - sendPrompt(text: string): void { - if (!this.initialized || !this.sessionId) { - console.error(' [copilot] Not initialized, cannot send prompt'); - return; - } - - const id = ++this.requestId; - // Don't await — response is streamed via notifications - const msg = JSON.stringify({ - jsonrpc: '2.0', - id, - method: 'session/prompt', - params: { - sessionId: this.sessionId, - prompt: text, - }, - }); - this.send(msg); - } - - /** Stop the copilot process */ - stop(): void { - if (this.child) { - this.child.kill(); - this.child = null; - } - this.initialized = false; - this.sessionId = null; - } - - isRunning(): boolean { - return this.child !== null && this.initialized; - } -} diff --git a/packages/squad-cli/src/cli/commands/rc-tunnel.ts b/packages/squad-cli/src/cli/commands/rc-tunnel.ts deleted file mode 100644 index c7290d72d..000000000 --- a/packages/squad-cli/src/cli/commands/rc-tunnel.ts +++ /dev/null @@ -1,139 +0,0 @@ -/** - * Squad Remote Control — Devtunnel lifecycle management - * - * Creates, hosts, and cleans up devtunnels with squad labels - * for discovery from the PWA dashboard. - */ - -import { execSync, execFileSync, spawn, type ChildProcess } from 'node:child_process'; -import os from 'node:os'; - -export interface TunnelInfo { - tunnelId: string; - url: string; - port: number; -} - -export interface TunnelLabels { - repo: string; - branch: string; - machine: string; -} - -let hostProcess: ChildProcess | null = null; -let currentTunnelId: string | null = null; - -/** Check if devtunnel CLI is available */ -export function isDevtunnelAvailable(): boolean { - try { - execFileSync('devtunnel', ['--version'], { stdio: 'pipe' }); - return true; - } catch { - return false; - } -} - -/** Create a devtunnel with squad labels and host it */ -export async function createTunnel(port: number, labels: TunnelLabels): Promise { - // Devtunnel labels only allow: letters, digits, underscore, hyphen, equals [a-zA-Z0-9_-=] - const sanitize = (l: string) => { - const clean = l.replace(/[^a-zA-Z0-9_\-=]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').substring(0, 50); - return clean || 'unknown'; - }; - const labelValues = ['squad', sanitize(labels.repo), sanitize(labels.branch), sanitize(labels.machine), `port-${port}`]; - const labelArgs = labelValues.flatMap((l) => ['--labels', l]); - - // Create tunnel with labels - const createOutput = execFileSync( - 'devtunnel', ['create', ...labelArgs, '--expiration', '1d', '--json'], - { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] } - ); - const createResult = JSON.parse(createOutput); - const tunnelId = createResult.tunnelId || createResult.tunnel?.tunnelId; - // Strip cluster suffix for commands (e.g., "abc.euw" → "abc") - const tunnelIdClean = tunnelId?.split('.')[0]; - - if (!tunnelIdClean) { - throw new Error('Failed to create devtunnel: no tunnelId returned'); - } - currentTunnelId = tunnelIdClean; - - // Add port - execFileSync( - 'devtunnel', ['port', 'create', tunnelIdClean, '-p', String(port), '--protocol', 'http'], - { stdio: 'pipe' } - ); - - // Host in background - hostProcess = spawn('devtunnel', ['host', tunnelIdClean], { - stdio: 'pipe', - detached: false, - }); - - // Wait for the URL to appear in stdout - const url = await new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Timeout waiting for devtunnel URL')), 15000); - let output = ''; - - hostProcess!.stdout?.on('data', (data: Buffer) => { - output += data.toString(); - const match = output.match(/https:\/\/[^\s]+/); - if (match) { - clearTimeout(timeout); - resolve(match[0]); - } - }); - - hostProcess!.stderr?.on('data', (data: Buffer) => { - output += data.toString(); - }); - - hostProcess!.on('error', (err) => { - clearTimeout(timeout); - reject(err); - }); - - hostProcess!.on('exit', (code) => { - if (code !== 0 && code !== null) { - clearTimeout(timeout); - reject(new Error(`devtunnel host exited with code ${code}`)); - } - }); - }); - - return { tunnelId: tunnelIdClean, url, port }; -} - -/** Clean up the tunnel */ -export function destroyTunnel(): void { - if (hostProcess) { - hostProcess.kill(); - hostProcess = null; - } - - if (currentTunnelId) { - try { - execFileSync('devtunnel', ['delete', currentTunnelId, '-y'], { stdio: 'pipe' }); - } catch { - // Best effort cleanup - } - currentTunnelId = null; - } -} - -/** Get machine hostname for labels */ -export function getMachineId(): string { - return os.hostname(); -} - -/** Get repo name and branch from git */ -export function getGitInfo(cwd: string): { repo: string; branch: string } { - try { - const remote = execSync('git remote get-url origin', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim(); - const repo = remote.split('/').pop()?.replace('.git', '') || 'unknown'; - const branch = execSync('git branch --show-current', { cwd, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim() || 'unknown'; - return { repo, branch }; - } catch { - return { repo: 'unknown', branch: 'unknown' }; - } -} diff --git a/packages/squad-cli/src/cli/commands/rc.ts b/packages/squad-cli/src/cli/commands/rc.ts deleted file mode 100644 index 9fafeff70..000000000 --- a/packages/squad-cli/src/cli/commands/rc.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Squad Remote Control — CLI Command - * - * `squad rc` or `squad remote-control` - * Starts the RemoteBridge, creates a devtunnel, shows QR code. - */ - -import path from 'node:path'; -// createReadStream retained — streaming not in StorageProvider scope -import { createReadStream } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { FSStorageProvider, RemoteBridge } from '@bradygaster/squad-sdk'; - -const storage = new FSStorageProvider(); -import type { RemoteBridgeConfig } from '@bradygaster/squad-sdk'; -import { - isDevtunnelAvailable, - createTunnel, - destroyTunnel, - getMachineId, - getGitInfo, -} from './rc-tunnel.js'; - -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; -const DIM = '\x1b[2m'; -const GREEN = '\x1b[32m'; -const CYAN = '\x1b[36m'; -const YELLOW = '\x1b[33m'; - -export interface RCOptions { - tunnel: boolean; - port: number; - path?: string; -} - -export async function runRC(cwd: string, options: RCOptions): Promise { - const { repo, branch } = getGitInfo(cwd); - const machine = getMachineId(); - - // Resolve squad directory - const squadDir = storage.existsSync(path.join(cwd, '.squad')) - ? path.join(cwd, '.squad') - : storage.existsSync(path.join(cwd, '.ai-team')) - ? path.join(cwd, '.ai-team') - : ''; - - console.log(`\n${BOLD}🎮 Squad Remote Control${RESET}\n`); - console.log(` ${DIM}Repo:${RESET} ${repo}`); - console.log(` ${DIM}Branch:${RESET} ${branch}`); - console.log(` ${DIM}Machine:${RESET} ${machine}`); - console.log(` ${DIM}Squad:${RESET} ${squadDir || 'not found'}\n`); - - // Load team roster if squad dir exists - const agents: Array<{name: string; role: string}> = []; - if (squadDir) { - try { - const teamMd = storage.readSync(path.join(squadDir, 'team.md')) ?? ''; - const memberLines = teamMd.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - for (const line of memberLines) { - const cols = line.split('|').map(c => c.trim()).filter(Boolean); - if (cols.length >= 2 && cols[0] !== 'Name') { - agents.push({ name: cols[0]!, role: cols[1]! }); - } - } - console.log(` ${GREEN}✓${RESET} Loaded ${agents.length} agents from team.md\n`); - } catch { - console.log(` ${YELLOW}⚠${RESET} Could not read team.md\n`); - } - } - - // Copilot passthrough will be set up after bridge starts - const { spawn: spawnChild } = await import('node:child_process'); - const { createInterface: createRL } = await import('node:readline'); - let copilotReady = false; - - // Create bridge config (fallback when passthrough is NOT active) - const config: RemoteBridgeConfig = { - port: options.port || 0, - maxHistory: 500, - repo, - branch, - machine, - squadDir, - onPrompt: async (text) => { - console.log(` ${CYAN}←${RESET} ${DIM}Remote prompt:${RESET} ${text}`); - bridge.addMessage('user', text); - const agent = agents.length > 0 ? agents[0]! : { name: 'Assistant', role: 'General' }; - bridge.addMessage('agent', `[Copilot passthrough not active] Echo: ${text}`, agent.name); - }, - onDirectMessage: async (agentName, text) => { - console.log(` ${CYAN}←${RESET} ${DIM}Remote @${agentName}:${RESET} ${text}`); - bridge.addMessage('user', `@${agentName} ${text}`); - bridge.addMessage('agent', `[Copilot passthrough not active] Echo: ${text}`, agentName); - }, - onCommand: (name) => { - console.log(` ${CYAN}←${RESET} ${DIM}Remote /${name}${RESET}`); - if (name === 'status') { - bridge.addMessage('system', `Squad RC | Repo: ${repo} | Branch: ${branch} | Agents: ${agents.length} | Copilot: ${copilotReady ? 'passthrough' : 'off'} | Connections: ${bridge.getConnectionCount()}`); - } else if (name === 'agents') { - const list = agents.map(a => `• ${a.name} (${a.role})`).join('\n'); - bridge.addMessage('system', `Team Roster:\n${list || 'No agents loaded'}`); - } else { - bridge.addMessage('system', `Unknown command: /${name}`); - } - }, - }; - - // Start bridge - const bridge = new RemoteBridge(config); - - // Serve PWA static files - bridge.setStaticHandler((req, res) => { - const uiDir = path.resolve( - path.dirname(fileURLToPath(import.meta.url)), - '../../remote-ui' - ); - - // #18: Guard against malformed URI encoding - let decodedUrl: string; - try { - const parsed = new URL(req.url || '/', `http://${req.headers.host}`); - decodedUrl = decodeURIComponent(parsed.pathname); - } catch { - res.writeHead(400); res.end(); return; - } - if (decodedUrl.includes('..')) { res.writeHead(400); res.end(); return; } - - let filePath = path.join(uiDir, decodedUrl === '/' ? 'index.html' : decodedUrl.replace(/^\//, '')); - - // Security: prevent directory traversal - if (!filePath.startsWith(uiDir)) { - res.writeHead(403); - res.end('Forbidden'); - return; - } - - // #2: EISDIR guard — check if path is a directory before createReadStream - try { - const stat = storage.statSync(filePath); - if (stat?.isDirectory) { - filePath = path.join(filePath, 'index.html'); - if (!storage.existsSync(filePath)) { res.writeHead(404); res.end(); return; } - } else if (!stat) { res.writeHead(404); res.end(); return; } - } catch { res.writeHead(404); res.end(); return; } - - const ext = path.extname(filePath); - const mimeTypes: Record = { - '.html': 'text/html', - '.js': 'application/javascript', - '.css': 'text/css', - '.json': 'application/json', - '.png': 'image/png', - '.svg': 'image/svg+xml', - '.ico': 'image/x-icon', - }; - - res.writeHead(200, { - 'Content-Type': mimeTypes[ext] || 'application/octet-stream', - 'X-Frame-Options': 'DENY', - 'X-Content-Type-Options': 'nosniff', - 'Referrer-Policy': 'no-referrer', - 'Cache-Control': 'no-store', - }); - // #8: Handle createReadStream errors - const stream = createReadStream(filePath); - stream.on('error', () => { if (!res.headersSent) { res.writeHead(500); } res.end(); }); - stream.pipe(res); - }); - - const actualPort = await bridge.start(); - const localUrl = `http://localhost:${actualPort}`; - - // Initialize agent roster in bridge - const allAgents = copilotReady - ? [{ name: 'Copilot', role: 'AI Assistant', status: 'idle' as const }, ...agents.map(a => ({ name: a.name, role: a.role, status: 'idle' as const }))] - : agents.map(a => ({ name: a.name, role: a.role, status: 'idle' as const })); - if (allAgents.length > 0) { - bridge.updateAgents(allAgents); - } - - console.log(` ${GREEN}✓${RESET} Bridge running on port ${BOLD}${actualPort}${RESET}`); - console.log(` ${DIM}Local:${RESET} ${localUrl}\n`); - - // Spawn copilot --acp as transparent relay (dumb pipe) - // Copilot needs ~20s to load MCP servers before accepting ACP requests - // Try to find copilot in common locations, fall back to PATH - let copilotCmd = 'copilot'; - - // On Windows, try the global npm location first - if (process.platform === 'win32') { - const winPath = path.join( - 'C:', 'ProgramData', 'global-npm', 'node_modules', '@github', 'copilot', - 'node_modules', '@github', 'copilot-win32-x64', 'copilot.exe' - ); - if (storage.existsSync(winPath)) { - copilotCmd = winPath; - } - } - - console.log(` ${DIM}Spawning copilot --acp (MCP servers loading ~15-20s)...${RESET}`); - let copilotProc: ReturnType | null = null; - try { - copilotProc = spawnChild(copilotCmd, ['--acp'], { - cwd, - stdio: ['pipe', 'pipe', 'pipe'], - }); - - copilotProc.on('error', (err) => { - console.log(` ${YELLOW}⚠${RESET} Copilot error: ${err.message}`); - }); - copilotProc.on('exit', (code) => { - console.log(` ${DIM}[copilot] exited with code ${code}${RESET}`); - copilotReady = false; - }); - copilotProc.stderr?.on('data', (d: Buffer) => { - const text = d.toString().trim(); - if (text && !text.includes('[mcp server') && !text.includes('npm ')) { - console.log(` ${DIM}[copilot] ${text}${RESET}`); - } - }); - - // copilot stdout → all WebSocket clients (raw JSON-RPC) - const rl = createRL({ input: copilotProc.stdout!, terminal: false }); - rl.on('line', (line) => { - if (line.trim()) { - console.log(` ${GREEN}→${RESET} ${DIM}ACP out: ${line.substring(0, 100)}${RESET}`); - bridge.passthroughFromAgent(line); - } - }); - - // WebSocket → copilot stdin (raw JSON-RPC) - bridge.setPassthrough((msg) => { - if (copilotProc?.stdin?.writable) { - console.log(` ${CYAN}←${RESET} ${DIM}ACP in: ${msg.substring(0, 100)}${RESET}`); - copilotProc.stdin.write(msg.endsWith('\n') ? msg : msg + '\n'); - } - }); - - copilotReady = true; - console.log(` ${GREEN}✓${RESET} Copilot ACP passthrough active\n`); - } catch (err) { - console.log(` ${YELLOW}⚠${RESET} Copilot not available: ${(err as Error).message}\n`); - } - - // Tunnel setup - if (options.tunnel) { - if (!isDevtunnelAvailable()) { - console.log(` ${YELLOW}⚠${RESET} devtunnel CLI not found. Install with:`); - console.log(` winget install Microsoft.devtunnel`); - console.log(` ${DIM}Then: devtunnel user login${RESET}\n`); - console.log(` ${DIM}Running in local-only mode.${RESET}\n`); - } else { - console.log(` ${DIM}Creating tunnel...${RESET}`); - try { - const tunnel = await createTunnel(actualPort, { repo, branch, machine }); - console.log(` ${GREEN}✓${RESET} Tunnel active: ${BOLD}${tunnel.url}${RESET}\n`); - - // Show QR code - try { - // @ts-ignore - no type declarations for qrcode-terminal - const qrcode = (await import('qrcode-terminal')) as any; - qrcode.default.generate(tunnel.url, { small: true }, (code: string) => { - console.log(code); - }); - } catch { - // qrcode-terminal not available, skip - } - - console.log(` ${DIM}Scan QR code or open URL on your phone.${RESET}`); - console.log(` ${DIM}Auth: private — only your MS/GitHub account can connect.${RESET}\n`); - } catch (err) { - console.log(` ${YELLOW}⚠${RESET} Tunnel failed: ${(err as Error).message}`); - console.log(` ${DIM}Running in local-only mode.${RESET}\n`); - } - } - } else { - console.log(` ${DIM}No tunnel (local only). Use --tunnel for remote access.${RESET}\n`); - } - - console.log(` ${DIM}Press Ctrl+C to stop.${RESET}\n`); - - // Clean shutdown - const cleanup = async () => { - console.log(`\n ${DIM}Shutting down...${RESET}`); - clearInterval(checkInterval); - copilotProc?.kill(); - destroyTunnel(); - await bridge.stop(); - console.log(` ${GREEN}✓${RESET} Stopped.\n`); - process.exit(0); - }; - - process.on('SIGINT', cleanup); - process.on('SIGTERM', cleanup); - - // Log connections - const checkInterval = setInterval(() => { - const count = bridge.getConnectionCount(); - if (count > 0) { - process.stdout.write(`\r ${GREEN}●${RESET} ${count} client(s) connected `); - } - }, 5000); - - // Keep process alive - await new Promise(() => {}); -} diff --git a/packages/squad-cli/src/cli/commands/start.ts b/packages/squad-cli/src/cli/commands/start.ts deleted file mode 100644 index 4c85cf835..000000000 --- a/packages/squad-cli/src/cli/commands/start.ts +++ /dev/null @@ -1,240 +0,0 @@ -/** - * Squad Start — PTY Mirror Mode - * - * `squad start [--tunnel]` - * Spawns copilot in a PTY (pseudo-terminal) — you see the EXACT same - * TUI as running copilot directly. The raw terminal output is mirrored - * to a remote PWA via WebSocket + devtunnel. - * - * Bidirectional: keyboard input from terminal AND phone both go to copilot. - */ - -import path from 'node:path'; -// createReadStream retained — streaming not in StorageProvider scope -import { createReadStream } from 'node:fs'; -import { fileURLToPath } from 'node:url'; -import { FSStorageProvider, RemoteBridge } from '@bradygaster/squad-sdk'; - -const storage = new FSStorageProvider(); -import type { RemoteBridgeConfig } from '@bradygaster/squad-sdk'; -import { - isDevtunnelAvailable, - createTunnel, - destroyTunnel, - getMachineId, - getGitInfo, -} from './rc-tunnel.js'; - -const BOLD = '\x1b[1m'; -const RESET = '\x1b[0m'; -const DIM = '\x1b[2m'; -const GREEN = '\x1b[32m'; -const YELLOW = '\x1b[33m'; - -export interface StartOptions { - tunnel: boolean; - port: number; - copilotArgs?: string[]; - command?: string; -} - -export async function runStart(cwd: string, options: StartOptions): Promise { - const { repo, branch } = getGitInfo(cwd); - const machine = getMachineId(); - const squadDir = storage.existsSync(path.join(cwd, '.squad')) - ? path.join(cwd, '.squad') - : storage.existsSync(path.join(cwd, '.ai-team')) - ? path.join(cwd, '.ai-team') - : ''; - - // ─── Setup remote bridge FIRST (before PTY takes over terminal) ─── - let bridge: RemoteBridge | null = null; - let tunnelUrl = ''; - - const config: RemoteBridgeConfig = { - port: options.port || 0, - maxHistory: 500, - repo, branch, machine, squadDir, - enableReplay: true, - }; - - bridge = new RemoteBridge(config); - - // PWA static files - bridge.setStaticHandler((req, res) => { - const uiDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../remote-ui'); - let decodedUrl: string; - try { - const parsed = new URL(req.url || '/', `http://${req.headers.host}`); - decodedUrl = decodeURIComponent(parsed.pathname); - } catch { res.writeHead(400); res.end(); return; } - if (decodedUrl.includes('..')) { res.writeHead(400); res.end(); return; } - let filePath = path.resolve(uiDir, decodedUrl === '/' ? 'index.html' : decodedUrl.replace(/^\//, '')); - if (!filePath.startsWith(uiDir)) { res.writeHead(403); res.end(); return; } - try { - const stat = storage.statSync(filePath); - if (stat?.isDirectory) { - filePath = path.join(filePath, 'index.html'); - if (!storage.existsSync(filePath)) { res.writeHead(404); res.end(); return; } - } else if (!stat) { res.writeHead(404); res.end(); return; } - } catch { res.writeHead(404); res.end(); return; } - const servePath = storage.existsSync(filePath) ? filePath : path.join(uiDir, 'index.html'); - const ext = path.extname(servePath); - const mimes: Record = { '.html': 'text/html', '.js': 'application/javascript', '.css': 'text/css', '.json': 'application/json' }; - const headers: Record = { - 'Content-Type': mimes[ext] || 'application/octet-stream', - 'X-Content-Type-Options': 'nosniff', - 'X-Frame-Options': 'DENY', - }; - if (ext === '.html') { - headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net; style-src 'self' https://cdn.jsdelivr.net 'unsafe-inline'; connect-src 'self' ws: wss:; img-src 'self' data:; font-src 'self' https://cdn.jsdelivr.net"; - } - res.writeHead(200, headers); - const stream = createReadStream(servePath); - stream.on('error', () => { if (!res.headersSent) { res.writeHead(500); } res.end(); }); - stream.pipe(res); - }); - - const actualPort = await bridge.start(); - - // Tunnel - if (options.tunnel && isDevtunnelAvailable()) { - try { - const tunnel = await createTunnel(actualPort, { repo, branch, machine }); - const tunnelUrlWithToken = `${tunnel.url}?token=${bridge.getSessionToken()}`; - tunnelUrl = tunnelUrlWithToken; - console.log(`${GREEN}✓${RESET} Remote: ${BOLD}${tunnelUrlWithToken}${RESET}`); - try { - // @ts-ignore - const qrcode = (await import('qrcode-terminal')) as any; - qrcode.default.generate(tunnelUrlWithToken, { small: true }, (code: string) => { console.log(code); }); - } catch {} - console.log(`${DIM}Scan QR or open URL on phone. Starting copilot...${RESET}\n`); - console.log(` ${DIM}Audit log:${RESET} ${bridge.getAuditLogPath()}`); - console.log(` ${DIM}Session expires:${RESET} ${new Date(bridge.getSessionExpiry()).toLocaleTimeString()}`); - } catch (err) { - console.log(`${YELLOW}⚠${RESET} Tunnel failed: ${(err as Error).message}`); - } - } else if (options.tunnel) { - console.log(`${YELLOW}⚠${RESET} devtunnel not installed. Local mirror on port ${actualPort}.`); - } - - // ─── Spawn copilot in PTY ───────────────────────────────── - // Dynamic import node-pty (native module) - // @ts-expect-error — node-pty is an optional native dependency - const nodePty = await import('node-pty'); - - const copilotExePath = path.join( - 'C:', 'ProgramData', 'global-npm', 'node_modules', '@github', 'copilot', - 'node_modules', '@github', 'copilot-win32-x64', 'copilot.exe' - ); - const defaultCmd = storage.existsSync(copilotExePath) ? copilotExePath : 'copilot'; - const copilotCmd = options.command || defaultCmd; - - const cols = process.stdout.columns || 120; - const rows = process.stdout.rows || 30; - - const copilotExtraArgs = options.copilotArgs || []; - if (copilotExtraArgs.length > 0) { - console.log(` ${DIM}Copilot flags:${RESET} ${copilotExtraArgs.join(' ')}\n`); - } - - // F-07: Security — blocklist dangerous environment variables for PTY - const DANGEROUS_VARS = new Set(['NODE_OPTIONS', 'NODE_REPL_HISTORY', 'NODE_EXTRA_CA_CERTS', - 'NODE_PATH', 'NODE_REDIRECT_WARNINGS', 'NODE_PENDING_DEPRECATION', - 'UV_THREADPOOL_SIZE', 'LD_PRELOAD', 'DYLD_INSERT_LIBRARIES', - 'SSH_AUTH_SOCK', 'GPG_TTY', - 'PYTHONPATH', 'BASH_ENV', 'BASH_FUNC', 'JAVA_TOOL_OPTIONS', 'JAVA_OPTIONS', '_JAVA_OPTIONS', - 'PROMPT_COMMAND', 'ENV', 'ZDOTDIR', 'PERL5OPT', 'RUBYOPT']); - const sensitivePattern = /token|secret|key|password|credential|authorization|api_key|private_key|access_key|connection_string|db_pass|signing|kubeconfig|docker_host|docker_config/i; - - const safeEnv: Record = {}; - for (const [k, v] of Object.entries(process.env)) { - if (v !== undefined && !DANGEROUS_VARS.has(k) && !sensitivePattern.test(k)) { - safeEnv[k] = v; - } - } - - const pty = nodePty.spawn(copilotCmd, copilotExtraArgs, { - name: 'xterm-256color', - cols, - rows, - cwd, - env: safeEnv, - }); - - // Terminal output buffer for remote clients - let outputBuffer = ''; - - // PTY output → local terminal + remote - pty.onData((data: string) => { - // Write to local terminal (exact copilot output) - process.stdout.write(data); - - // Buffer for remote clients - outputBuffer += data; - // Cap buffer at 100KB - if (outputBuffer.length > 100000) { - outputBuffer = outputBuffer.slice(-100000); - } - - // Send to remote clients as raw terminal data - bridge?.passthroughFromAgent(JSON.stringify({ type: 'pty', data })); - }); - - pty.onExit(({ exitCode }: { exitCode: number }) => { - console.log(`\n${DIM}Copilot exited (code ${exitCode}).${RESET}`); - destroyTunnel(); - bridge?.stop(); - process.exit(exitCode); - }); - - // Local keyboard → PTY (raw mode for full terminal experience) - if (process.stdin.isTTY) { - process.stdin.setRawMode(true); - } - process.stdin.resume(); - process.stdin.on('data', (data: Buffer) => { - pty.write(data.toString()); - }); - - // Handle terminal resize - process.stdout.on('resize', () => { - pty.resize(process.stdout.columns || 120, process.stdout.rows || 30); - }); - - // Remote input → PTY (phone sends keystrokes + resize) - bridge.setPassthrough((msg) => { - try { - const parsed = JSON.parse(msg); - if (parsed.type === 'pty_input') { - pty.write(parsed.data); - } - if (parsed.type === 'pty_resize') { - const cols = Number(parsed.cols); - const rows = Number(parsed.rows); - if (Number.isFinite(cols) && Number.isFinite(rows)) { - pty.resize(Math.max(1, Math.min(500, cols)), Math.max(1, Math.min(200, rows))); - } - } - } catch { - // Only log, do NOT write raw input to PTY - const auditPath = bridge?.getAuditLogPath(); - if (auditPath) { - storage.appendSync(auditPath, `${new Date().toISOString()} [remote] [RAW] ${JSON.stringify(msg)}\n`); - } - } - }); - - // When remote client connects, send the buffer (full terminal history) - // This is handled by the bridge's acpEventLog replay - - // Cleanup - process.on('SIGINT', () => { - pty.kill(); - destroyTunnel(); - bridge?.stop(); - process.exit(0); - }); -} - diff --git a/packages/squad-cli/src/cli/shell/agent-name-parser.ts b/packages/squad-cli/src/cli/shell/agent-name-parser.ts deleted file mode 100644 index ff38859b6..000000000 --- a/packages/squad-cli/src/cli/shell/agent-name-parser.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Extract an agent name from a task description string. - * Tries multiple patterns in order of specificity: - * 1. Emoji + name + colon at start (e.g. "🔧 EECOM: Fix auth module") - * 2. Name + colon anywhere (e.g. "EECOM: Fix auth module") - * 3. Fuzzy: any knownAgentName appears as a whole word (case-insensitive) - * - * @param description - The task description string - * @param knownAgentNames - Lowercase agent names to match against - * @returns Parsed agent name and task summary, or null if no match - */ -export function parseAgentFromDescription( - description: string, - knownAgentNames: string[], -): { agentName: string; taskSummary: string } | null { - if (!description || typeof description !== 'string') return null; - if (!knownAgentNames || knownAgentNames.length === 0) return null; - - // Pattern 1: optional leading non-whitespace (emoji) then whitespace then word + colon at start - const leadingMatch = description.match(/^\S*\s*(\w+):/); - const leadingName = leadingMatch?.[1]; - if (leadingName) { - const candidate = leadingName.toLowerCase(); - if (knownAgentNames.includes(candidate)) { - const taskSummary = description.replace(/^\S*\s*\w+:\s*/, '').slice(0, 60); - return { agentName: candidate, taskSummary }; - } - } - - // Pattern 2: word + colon anywhere in the string - const anyColonMatch = description.match(/(\w+):/); - const colonName = anyColonMatch?.[1]; - if (colonName) { - const candidate = colonName.toLowerCase(); - if (knownAgentNames.includes(candidate)) { - const afterColon = description.slice( - (anyColonMatch.index ?? 0) + anyColonMatch[0].length, - ).replace(/^\s*/, '').slice(0, 60); - return { agentName: candidate, taskSummary: afterColon || description.slice(0, 60) }; - } - } - - // Pattern 3: fuzzy — check if any known agent name appears as a word boundary match - const descLower = description.toLowerCase(); - for (const name of knownAgentNames) { - const idx = descLower.indexOf(name); - if (idx !== -1) { - // Verify word boundary: char before and after must be non-word or start/end - const charBefore = idx > 0 ? description.charAt(idx - 1) : ''; - const charAfter = idx + name.length < description.length ? description.charAt(idx + name.length) : ''; - const before = idx === 0 || !/\w/.test(charBefore); - const after = charAfter === '' || !/\w/.test(charAfter); - if (before && after) { - return { agentName: name, taskSummary: description.slice(0, 60) }; - } - } - } - - return null; -} diff --git a/packages/squad-cli/src/cli/shell/agent-status.ts b/packages/squad-cli/src/cli/shell/agent-status.ts deleted file mode 100644 index ecbe38a28..000000000 --- a/packages/squad-cli/src/cli/shell/agent-status.ts +++ /dev/null @@ -1,29 +0,0 @@ -/** - * Shared agent status rendering — single source of truth for /agents command and AgentPanel. - * - * Status enum: 'working' | 'streaming' | 'idle' | 'error' - */ - -import { getRoleEmoji } from './lifecycle.js'; -import type { AgentSession } from './types.js'; - -/** Canonical status tag for display in both TUI and text contexts. */ -export function getStatusTag(status: AgentSession['status']): string { - switch (status) { - case 'working': - return '[WORK]'; - case 'streaming': - return '[STREAM]'; - case 'error': - return '[ERR]'; - case 'idle': - return '[IDLE]'; - } -} - -/** Format a single agent line for plain-text output (used by /agents and /status commands). */ -export function formatAgentLine(agent: AgentSession): string { - const emoji = getRoleEmoji(agent.role); - const tag = getStatusTag(agent.status); - return ` ${emoji} ${agent.name} ${tag} (${agent.role})`; -} diff --git a/packages/squad-cli/src/cli/shell/autocomplete.ts b/packages/squad-cli/src/cli/shell/autocomplete.ts deleted file mode 100644 index bf6f5b5ae..000000000 --- a/packages/squad-cli/src/cli/shell/autocomplete.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Autocomplete for the Squad interactive shell. - * Provides @agent name completion and /slash command completion. - */ - -/** Known slash commands for autocomplete */ -const SLASH_COMMANDS = [ - '/status', - '/history', - '/agents', - '/sessions', - '/resume', - '/init', - '/nap', - '/version', - '/clear', - '/help', - '/quit', - '/exit', -]; - -export type CompleterResult = [string[], string]; -export type CompleterFunction = (line: string) => CompleterResult; - -/** - * Create a readline-compatible completer function. - * Completes @AgentName and /command prefixes. - */ -export function createCompleter(agentNames: string[]): CompleterFunction { - return (line: string): CompleterResult => { - const trimmed = line.trimStart(); - - // @Agent completion - if (trimmed.startsWith('@')) { - const partial = trimmed.slice(1).toLowerCase(); - const matches = agentNames - .filter(name => name.toLowerCase().startsWith(partial)) - .map(name => `@${name} `); - return [matches, trimmed]; - } - - // /command completion - if (trimmed.startsWith('/')) { - const partial = trimmed.toLowerCase(); - const matches = SLASH_COMMANDS.filter(cmd => cmd.startsWith(partial)); - return [matches, trimmed]; - } - - return [[], line]; - }; -} diff --git a/packages/squad-cli/src/cli/shell/commands.ts b/packages/squad-cli/src/cli/shell/commands.ts deleted file mode 100644 index 5a4958962..000000000 --- a/packages/squad-cli/src/cli/shell/commands.ts +++ /dev/null @@ -1,275 +0,0 @@ -import { SessionRegistry } from './sessions.js'; -import { ShellRenderer } from './render.js'; -import { getTerminalWidth } from './terminal.js'; -import { BOLD, DIM, RESET } from '../core/output.js'; -import { listSessions, loadSessionById, type SessionData } from './session-store.js'; -import { formatAgentLine, getStatusTag } from './agent-status.js'; -import type { ShellMessage } from './types.js'; -import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; -import { runNapSync, formatNapReport } from '../core/nap.js'; - -const storage = new FSStorageProvider(); - -export interface CommandContext { - registry: SessionRegistry; - renderer: ShellRenderer; - messageHistory: ShellMessage[]; - teamRoot: string; - version?: string; - /** Callback to restore a previous session's messages into the shell. */ - onRestoreSession?: (session: SessionData) => void; -} - -export interface CommandResult { - handled: boolean; - exit?: boolean; - output?: string; - /** When true, the shell should clear its message history. */ - clear?: boolean; - /** When true, the shell should trigger init casting with the provided prompt. */ - triggerInitCast?: { prompt: string }; - /** - * When true, the shell should enter "awaiting init prompt" mode: - * the next user message will be treated as a team-cast request. - * Set when `/init` is run with no inline prompt. - */ - awaitInitPrompt?: boolean; -} - -/** - * Execute a slash command. - */ -export function executeCommand( - command: string, - args: string[], - context: CommandContext -): CommandResult { - switch (command) { - case 'status': - return handleStatus(context); - case 'history': - return handleHistory(args, context); - case 'clear': - return handleClear(); - case 'help': - return handleHelp(args); - case 'quit': - case 'exit': - return { handled: true, exit: true }; - case 'agents': - return handleAgents(context); - case 'sessions': - return handleSessions(context); - case 'resume': - return handleResume(args, context); - case 'version': - return { handled: true, output: context.version ?? 'unknown' }; - case 'nap': - return handleNap(args, context); - case 'init': - return handleInit(args, context); - default: { - const known = ['status', 'history', 'clear', 'help', 'quit', 'exit', 'agents', 'sessions', 'resume', 'version', 'nap', 'init']; - const suggestion = known.find(k => k.startsWith(command.slice(0, 2))); - const hint = suggestion ? ` Did you mean /${suggestion}?` : ''; - return { handled: false, output: `Unknown command: /${command}.${hint} Type /help for commands.` }; - } - } -} - -function handleStatus(context: CommandContext): CommandResult { - const agents = context.registry.getAll(); - const active = context.registry.getActive(); - const lines = [ - `${BOLD}Squad Status${RESET}`, - '-----------', - `Team: ${agents.length} agent${agents.length !== 1 ? 's' : ''} (${active.length} active)`, - `Root: ${DIM}${context.teamRoot}${RESET}`, - `Messages: ${context.messageHistory.length} this session`, - ]; - if (active.length > 0) { - lines.push('', 'Working:'); - for (const a of active) { - const hint = a.activityHint ? ` - ${a.activityHint}` : ''; - lines.push(`${formatAgentLine(a)}${hint}`); - } - } - return { handled: true, output: lines.join('\n') }; -} - -function handleHistory(args: string[], context: CommandContext): CommandResult { - let limit = 10; - if (args[0]) { - const parsed = parseInt(args[0], 10); - if (isNaN(parsed) || parsed <= 0) { - return { handled: true, output: 'Usage: /history [count] — count must be a positive number.' }; - } - limit = parsed; - } - const recent = context.messageHistory.slice(-limit); - if (recent.length === 0) { - return { handled: true, output: 'No messages yet.' }; - } - const lines = recent.map(m => { - const prefix = m.agentName ?? m.role; - const time = m.timestamp.toLocaleTimeString(); - return ` [${time}] ${prefix}: ${m.content.slice(0, 100)}${m.content.length > 100 ? '...' : ''}`; - }); - return { handled: true, output: `Last ${recent.length} message${recent.length !== 1 ? 's' : ''}:\n${lines.join('\n')}` }; -} - -function handleClear(): CommandResult { - // Send ANSI escape code to actually clear the terminal screen - process.stdout.write('\x1B[2J\x1B[H'); - return { handled: true, clear: true }; -} - -function handleHelp(args: string[]): CommandResult { - const width = getTerminalWidth(); - - if (width < 80) { - // Single-column compact help for narrow terminals - return { - handled: true, - output: [ - 'How it works:', - ' Just type what you need — Squad routes your message to the right agent.', - ' @AgentName message — send directly to one agent (case-insensitive).', - ' @Agent1 @Agent2 message — send to multiple agents in parallel.', - ' AgentName, message — comma syntax also works for direct dispatch.', - '', - 'Commands:', - '/status — Check your team', - '/history [N] — Recent messages (default 10)', - '/agents — List team members', - '/sessions — Past sessions', - '/resume — Restore session by ID prefix', - '/init [--roles] [prompt] — Set up your team', - '/nap [--deep] [--dry-run] — Context hygiene', - '/version — Show version', - '/clear — Clear screen', - '/quit — Exit', - ].join('\n'), - }; - } - - return { - handled: true, - output: [ - 'How it works:', - ' Just type what you need — Squad routes your message to the right agent.', - ' @AgentName message — send directly to one agent (case-insensitive).', - ' @Agent1 @Agent2 message — send to multiple agents in parallel.', - ' AgentName, message — comma syntax also works for direct dispatch.', - '', - 'Commands:', - " /status — Check your team & what's happening", - ' /history [N] — See recent messages (default 10)', - ' /agents — List all team members', - ' /sessions — List saved sessions', - ' /resume — Restore a past session by ID prefix', - ' /init [--roles] [p] — Set up your team (add --roles for base role catalog)', - ' /nap [--deep] — Context hygiene (compress, prune, archive)', - ' /version — Show version', - ' /clear — Clear the screen', - ' /quit — Exit', - ].join('\n'), - }; -} - -function handleAgents(context: CommandContext): CommandResult { - const agents = context.registry.getAll(); - if (agents.length === 0) { - return { handled: true, output: 'No team members yet.' }; - } - const lines = agents.map(a => formatAgentLine(a)); - return { handled: true, output: `Team Members:\n${lines.join('\n')}` }; -} - -function handleSessions(context: CommandContext): CommandResult { - const sessions = listSessions(context.teamRoot); - if (sessions.length === 0) { - return { handled: true, output: 'No saved sessions.' }; - } - const lines = sessions.slice(0, 10).map((s, i) => { - const date = new Date(s.lastActiveAt).toLocaleString(); - return ` ${i + 1}. ${s.id.slice(0, 8)} ${date} (${s.messageCount} messages)`; - }); - return { - handled: true, - output: `${BOLD}Saved Sessions${RESET} (${sessions.length} total)\n${lines.join('\n')}\n\nUse ${DIM}/resume ${RESET} to restore a session.`, - }; -} - -function handleResume(args: string[], context: CommandContext): CommandResult { - if (!args[0]) { - return { handled: true, output: 'Usage: /resume ' }; - } - const prefix = args[0].toLowerCase(); - const sessions = listSessions(context.teamRoot); - const match = sessions.find(s => s.id.toLowerCase().startsWith(prefix)); - if (!match) { - return { handled: true, output: `No session found matching "${prefix}". Try /sessions to list.` }; - } - const session = loadSessionById(context.teamRoot, match.id); - if (!session) { - return { handled: true, output: `Failed to load session data for "${prefix}". The session file may be corrupted. Try /sessions to see available sessions.` }; - } - if (context.onRestoreSession) { - context.onRestoreSession(session); - return { handled: true, output: `✓ Restored session ${match.id.slice(0, 8)} (${session.messages.length} messages)` }; - } - return { handled: true, output: 'Session restore not available in this context. Try restarting the shell.' }; -} - -function handleNap(args: string[], context: CommandContext): CommandResult { - try { - const squadDir = path.join(context.teamRoot, '.squad'); - const deep = args.includes('--deep'); - const dryRun = args.includes('--dry-run'); - const result = runNapSync({ squadDir, deep, dryRun }); - return { handled: true, output: formatNapReport(result, !!process.env['NO_COLOR']) }; - } catch (err) { - const detail = err instanceof Error ? err.message : String(err); - return { handled: true, output: `Nap failed: ${detail}\nRun \`squad nap\` from the CLI for details.` }; - } -} - -function handleInit(args: string[], context: CommandContext): CommandResult { - // Check for --roles flag - const useBaseRoles = args.includes('--roles'); - const filteredArgs = args.filter(a => a !== '--roles'); - const prompt = filteredArgs.join(' ').trim(); - - if (useBaseRoles) { - // Write .init-roles marker for the casting flow to pick up - const rolesMarker = path.join(context.teamRoot, '.squad', '.init-roles'); - try { storage.mkdirSync(path.dirname(rolesMarker), { recursive: true }); } catch { /* ignore */ } - try { storage.writeSync(rolesMarker, '1'); } catch { /* ignore */ } - } - - if (prompt) { - return { - handled: true, - triggerInitCast: { prompt }, - }; - } - - // No prompt: guide the user and enter "awaiting init prompt" mode - return { - handled: true, - awaitInitPrompt: true, - output: [ - 'To cast your Squad team, just type what you want to build.', - '', - 'The coordinator will analyze your message, propose a team,', - 'create agent files, and route your work — all automatically.', - '', - 'Example: "Build a React app with a Node.js backend"', - useBaseRoles ? 'Mode: Using built-in base roles (--roles)' : 'Mode: Fictional universe casting (default)', - '', - `Team file: ${context.teamRoot}/.squad/team.md`, - ].join('\n'), - }; -} diff --git a/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx b/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx deleted file mode 100644 index 8681b9019..000000000 --- a/packages/squad-cli/src/cli/shell/components/AgentPanel.tsx +++ /dev/null @@ -1,230 +0,0 @@ -import React, { useState, useEffect, useRef } from 'react'; -import { Box, Text } from 'ink'; -import { getRoleEmoji } from '../lifecycle.js'; -import { isNoColor, useLayoutTier } from '../terminal.js'; -import { Separator } from './Separator.js'; -import { useCompletionFlash } from '../useAnimation.js'; -import { getStatusTag } from '../agent-status.js'; -import type { AgentSession } from '../types.js'; - -interface AgentPanelProps { - agents: AgentSession[]; - streamingContent?: Map; -} - -const PULSE_FRAMES = ['●', '◉', '○', '◉']; - -/** Pulsing dot for active agents — draws the eye. Static in NO_COLOR mode. */ -const PulsingDot: React.FC = () => { - const noColor = isNoColor(); - const [frame, setFrame] = useState(0); - - useEffect(() => { - if (noColor) return; - // 800ms interval reduces re-renders vs 500ms (fix-cli-scroll-rerender-storm) - const timer = setInterval(() => { - setFrame(f => (f + 1) % PULSE_FRAMES.length); - }, 800); - return () => clearInterval(timer); - }, [noColor]); - - if (noColor) return ; - return {PULSE_FRAMES[frame]}; -}; - -/** Elapsed seconds since agent started working. */ -function agentElapsedSec(agent: AgentSession): number { - const active = agent.status === 'streaming' || agent.status === 'working'; - if (!active) return 0; - return Math.floor((Date.now() - agent.startedAt.getTime()) / 1000); -} - -/** Format elapsed time for display. */ -function formatElapsed(seconds: number): string { - if (seconds < 1) return ''; - return `${seconds}s`; -} - -export const AgentPanel: React.FC = ({ agents, streamingContent }) => { - const noColor = isNoColor(); - const tier = useLayoutTier(); - - // Re-render gate: store elapsed strings in a ref so the timer only triggers - // a React re-render (via the tick counter) when a visible value changes. - const elapsedRef = useRef(new Map()); - const [, setElapsedTick] = useState(0); - - useEffect(() => { - const hasActive = agents.some(a => a.status === 'working' || a.status === 'streaming'); - if (!hasActive) return; - const timer = setInterval(() => { - let changed = false; - for (const a of agents) { - if (a.status === 'working' || a.status === 'streaming') { - const display = formatElapsed(agentElapsedSec(a)); - if (elapsedRef.current.get(a.name) !== display) { - elapsedRef.current.set(a.name, display); - changed = true; - } - } - } - if (changed) setElapsedTick(t => t + 1); - }, 1000); - return () => clearInterval(timer); - }, [agents]); - - // Completion flash: brief "✓ Done" when agent finishes work - const completionFlash = useCompletionFlash(agents); - - if (agents.length === 0) { - return ( - - No agents active. - Send a message to start. /help for commands. - - ); - } - - const activeAgents = agents.filter(a => a.status === 'streaming' || a.status === 'working'); - - // Narrow layout: minimal single-line per agent, no hints - if (tier === 'narrow') { - return ( - - {agents.map((agent) => { - const active = agent.status === 'streaming' || agent.status === 'working'; - const errored = agent.status === 'error'; - const statusLabel = getStatusTag(agent.status); - return ( - - - {getRoleEmoji(agent.role)} {agent.name} - - {active && <> } - {errored && ERR} - {completionFlash.has(agent.name) && ( - noColor - ? ✓ Done - : ✓ Done - )} - {!active && !errored && !completionFlash.has(agent.name) && {statusLabel}} - - ); - })} - - - ); - } - - // Normal layout: compact, abbreviated hints - if (tier === 'normal') { - return ( - - {agents.map((agent) => { - const active = agent.status === 'streaming' || agent.status === 'working'; - const errored = agent.status === 'error'; - const statusLabel = getStatusTag(agent.status); - return ( - - - {getRoleEmoji(agent.role)} {agent.name} - - {active && <> {agent.activityHint && {agent.activityHint.slice(0, 30)}}} - {errored && {statusLabel}} - {completionFlash.has(agent.name) && ✓ Done} - {!active && !errored && !completionFlash.has(agent.name) && {statusLabel}} - - ); - })} - {/* Show simple status line for active agents at normal width */} - {activeAgents.length > 0 && ( - - {activeAgents.map(a => { - const sec = agentElapsedSec(a); - const elapsed = formatElapsed(sec); - const hint = a.activityHint ?? 'working'; - return ( - - {' '}{hint.slice(0, 40)}{elapsed ? ` (${elapsed})` : ''} - - ); - })} - - )} - - - ); - } - - // Wide layout: full detail with models, full hints - return ( - - {/* Agent roster */} - - {agents.map((agent) => { - const active = agent.status === 'streaming' || agent.status === 'working'; - const errored = agent.status === 'error'; - return ( - - - {getRoleEmoji(agent.role)} {agent.name} - - {active && ( - - - - {agent.activityHint && {agent.activityHint}} - {agent.model && ({agent.model})} - - )} - {errored && ( - [ERR] - )} - {completionFlash.has(agent.name) && ( - noColor - ? ✓ Done - : ✓ Done - )} - - ); - })} - - - {/* Status line — rich progress for active agents */} - {activeAgents.length > 0 ? ( - - {activeAgents.length > 1 && ( - {activeAgents.length} agents working in parallel - )} - {activeAgents.map(a => { - const sec = agentElapsedSec(a); - const elapsed = formatElapsed(sec); - const hint = a.activityHint ?? 'working'; - return ( - - {' '}{getRoleEmoji(a.role)} {a.name} — {hint}{elapsed ? ` (${elapsed})` : ''}{a.model ? ` [${a.model}]` : ''} - - ); - })} - - ) : ( - {' '}{agents.length} agent{agents.length !== 1 ? 's' : ''} ready - )} - - {/* Separator between panel and message stream */} - - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/App.tsx b/packages/squad-cli/src/cli/shell/components/App.tsx deleted file mode 100644 index 56ad6a310..000000000 --- a/packages/squad-cli/src/cli/shell/components/App.tsx +++ /dev/null @@ -1,485 +0,0 @@ -import React, { useState, useCallback, useEffect, useRef, useMemo } from 'react'; -import { Box, Text, Static, useApp, useInput, useStdout } from 'ink'; -import { AgentPanel } from './AgentPanel.js'; -import { MessageStream, renderMarkdownInline, formatDuration } from './MessageStream.js'; -import { InputPrompt } from './InputPrompt.js'; -import { parseInput, type ParsedInput } from '../router.js'; -import { executeCommand } from '../commands.js'; -import { loadWelcomeData, getRoleEmoji } from '../lifecycle.js'; -import { isNoColor, useTerminalWidth, useTerminalHeight, useLayoutTier } from '../terminal.js'; -import { Separator } from './Separator.js'; -import type { WelcomeData } from '../lifecycle.js'; -import type { SessionRegistry } from '../sessions.js'; -import type { ShellRenderer } from '../render.js'; -import type { ShellMessage, AgentSession } from '../types.js'; -import { MemoryManager } from '../memory.js'; -import type { SessionData } from '../session-store.js'; -import type { ThinkingPhase } from './ThinkingIndicator.js'; - -/** Methods exposed to the host so StreamBridge can push data into React state. */ -export interface ShellApi { - addMessage: (msg: ShellMessage) => void; - clearMessages: () => void; - setStreamingContent: (content: { agentName: string; content: string } | null) => void; - clearAgentStream: (agentName: string) => void; - setActivityHint: (hint: string | undefined) => void; - setAgentActivity: (agentName: string, activity: string | undefined) => void; - setProcessing: (processing: boolean) => void; - refreshAgents: () => void; - refreshWelcome: () => void; -} - -export interface AppProps { - registry: SessionRegistry; - renderer: ShellRenderer; - teamRoot: string; - version: string; - /** Max messages to keep in visible history (default: 200). Older messages are archived. */ - maxMessages?: number; - onReady?: (api: ShellApi) => void; - onDispatch?: (parsed: ParsedInput) => Promise; - onCancel?: () => void; - onRestoreSession?: (session: SessionData) => void; -} - -const EXIT_WORDS = new Set(['exit', 'quit', 'q']); - -export const App: React.FC = ({ registry, renderer, teamRoot, version, maxMessages, onReady, onDispatch, onCancel, onRestoreSession }) => { - const { exit } = useApp(); - // Session-scoped ID ensures Static keys are unique across session boundaries, - // preventing Ink from confusing items when sessions are restored. - const sessionId = useMemo(() => Date.now().toString(36), []); - const memoryManager = useMemo(() => new MemoryManager(maxMessages != null ? { maxMessages } : undefined), [maxMessages]); - const [messages, setMessages] = useState([]); - const [archivedMessages, setArchivedMessages] = useState([]); - const [agents, setAgents] = useState(registry.getAll()); - const [streamingContent, setStreamingContent] = useState>(new Map()); - const [processing, setProcessing] = useState(false); - const [activityHint, setActivityHint] = useState(undefined); - const [agentActivities, setAgentActivities] = useState>(new Map()); - const [welcome, setWelcome] = useState(() => loadWelcomeData(teamRoot)); - /** - * True after a no-args `/init` so the next user message is treated as a - * team-cast request (equivalent to `/init `). - */ - const [awaitingInitPrompt, setAwaitingInitPrompt] = useState(false); - const messagesRef = useRef([]); - const ctrlCRef = useRef(0); - const ctrlCTimerRef = useRef | null>(null); - - // Append messages and enforce the history cap, archiving overflow - const appendMessages = useCallback((updater: (prev: ShellMessage[]) => ShellMessage[]) => { - setMessages(prev => { - const next = updater(prev); - const { kept, archived } = memoryManager.trimWithArchival(next); - if (archived.length > 0) { - setArchivedMessages(old => [...old, ...archived]); - } - return kept; - }); - }, [memoryManager]); - - // Keep ref in sync so command handlers see latest history - useEffect(() => { messagesRef.current = messages; }, [messages]); - - // Expose API for external callers (StreamBridge, coordinator) - useEffect(() => { - onReady?.({ - addMessage: (msg: ShellMessage) => { - appendMessages(prev => [...prev, msg]); - if (msg.agentName) { - setStreamingContent(prev => { - const next = new Map(prev); - next.delete(msg.agentName!); - return next; - }); - } - setActivityHint(undefined); - }, - clearMessages: () => { - setMessages([]); - setArchivedMessages([]); - }, - setStreamingContent: (content) => { - if (content === null) { - setStreamingContent(new Map()); - } else { - setStreamingContent(prev => { - const next = new Map(prev); - next.set(content.agentName, content.content); - return next; - }); - } - }, - clearAgentStream: (agentName: string) => { - setStreamingContent(prev => { - const next = new Map(prev); - next.delete(agentName); - return next; - }); - }, - setActivityHint, - setAgentActivity: (agentName: string, activity: string | undefined) => { - setAgentActivities(prev => { - const next = new Map(prev); - if (activity) { - next.set(agentName, activity); - } else { - next.delete(agentName); - } - return next; - }); - }, - setProcessing, - refreshAgents: () => { - setAgents([...registry.getAll()]); - }, - refreshWelcome: () => { - const data = loadWelcomeData(teamRoot); - if (data) setWelcome(data); - }, - }); - }, [onReady, registry, appendMessages]); - - // Ctrl+C: cancel operation when processing, double-tap to exit when idle - useInput((_input, key) => { - if (key.ctrl && _input === 'c') { - if (processing && onCancel) { - // First Ctrl+C while processing → cancel operation and return to prompt - onCancel(); - setProcessing(false); - return; - } - // Not processing, or no cancel handler → increment double-tap counter - ctrlCRef.current++; - if (ctrlCTimerRef.current) clearTimeout(ctrlCTimerRef.current); - if (ctrlCRef.current >= 2) { - exit(); - return; - } - // Single Ctrl+C when idle — show hint, reset after 1s - ctrlCTimerRef.current = setTimeout(() => { ctrlCRef.current = 0; }, 1000); - if (!processing) { - appendMessages(prev => [...prev, { - role: 'system' as const, - content: 'Press Ctrl+C again to exit.', - timestamp: new Date(), - }]); - } - } - }); - - const handleSubmit = useCallback((input: string) => { - // Bare "exit" exits the shell - if (EXIT_WORDS.has(input.toLowerCase())) { - exit(); - return; - } - - const userMsg: ShellMessage = { role: 'user', content: input, timestamp: new Date() }; - appendMessages(prev => [...prev, userMsg]); - - const knownAgents = registry.getAll().map(a => a.name); - const parsed = parseInput(input, knownAgents); - - // If we're awaiting an init prompt and the user sent a non-slash message, - // treat it as an inline /init cast request. - if (awaitingInitPrompt && parsed.type !== 'slash_command') { - setAwaitingInitPrompt(false); - if (!onDispatch) { - appendMessages(prev => [...prev, { - role: 'system' as const, - content: 'SDK not connected. Try: (1) squad doctor to check setup, (2) check your internet connection, (3) restart the shell to reconnect.', - timestamp: new Date(), - }]); - return; - } - const castParsed: ParsedInput = { - type: 'coordinator', - raw: input, - content: input, - skipCastConfirmation: false, // show confirmation, same as freeform cast - }; - setProcessing(true); - onDispatch(castParsed).finally(() => { - setProcessing(false); - setAgents([...registry.getAll()]); - }); - return; - } - - if (parsed.type === 'slash_command') { - const result = executeCommand(parsed.command!, parsed.args ?? [], { - registry, - renderer, - messageHistory: [...messagesRef.current, userMsg], - teamRoot, - version, - onRestoreSession, - }); - - if (result.exit) { - exit(); - return; - } - - if (result.clear) { - setMessages([]); - setArchivedMessages([]); - return; - } - - if (result.triggerInitCast && onDispatch) { - // /init command returned a cast trigger — dispatch it as a coordinator message - const castParsed: ParsedInput = { - type: 'coordinator', - raw: result.triggerInitCast.prompt, - content: result.triggerInitCast.prompt, - skipCastConfirmation: true, - }; - setProcessing(true); - onDispatch(castParsed).finally(() => { - setProcessing(false); - setAgents([...registry.getAll()]); - }); - return; - } - - if (result.awaitInitPrompt) { - // No-args /init: show the guidance and wait for the user's next message - setAwaitingInitPrompt(true); - } - - if (result.output) { - appendMessages(prev => [...prev, { - role: 'system' as const, - content: result.output!, - timestamp: new Date(), - }]); - } - } else if (parsed.type === 'direct_agent' || parsed.type === 'coordinator') { - if (!onDispatch) { - appendMessages(prev => [...prev, { - role: 'system' as const, - content: 'SDK not connected. Try: (1) squad doctor to check setup, (2) check your internet connection, (3) restart the shell to reconnect.', - timestamp: new Date(), - }]); - return; - } - setProcessing(true); - onDispatch(parsed).finally(() => { - setProcessing(false); - setAgents([...registry.getAll()]); - }); - } - - setAgents([...registry.getAll()]); - }, [registry, renderer, teamRoot, exit, onDispatch, appendMessages, awaitingInitPrompt]); - - const rosterAgents = welcome?.agents ?? []; - - const noColor = isNoColor(); - const width = useTerminalWidth(); - const tier = useLayoutTier(); - const terminalHeight = useTerminalHeight(); - // Cap contentWidth at Ink's stdout columns to prevent text overflow/clipping. - // In tests, Ink renders at 100 columns while process.stdout.columns may differ. - const { stdout: inkStdout } = useStdout(); - const renderWidth = inkStdout && 'columns' in inkStdout - ? (inkStdout as { columns?: number }).columns ?? width - : width; - const contentWidth = Math.min( - tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width, - renderWidth, - ); - - // Prefer lead/coordinator for first-run hint, fall back to first agent - const leadAgent = welcome?.agents.find(a => - a.role?.toLowerCase().includes('lead') || - a.role?.toLowerCase().includes('coordinator') || - a.role?.toLowerCase().includes('architect') - )?.name ?? welcome?.agents[0]?.name; - - // Determine ThinkingIndicator phase based on SDK connection state - const thinkingPhase: ThinkingPhase = !onDispatch ? 'connecting' : 'routing'; - - // Derive @mention hint from last user message. - const mentionHint = useMemo(() => { - if (!processing) return undefined; - const lastUser = [...messages].reverse().find(m => m.role === 'user'); - if (lastUser) { - const atMatch = lastUser.content.match(/^@(\w+)/); - if (atMatch?.[1]) return `${atMatch[1]} is thinking...`; - } - return undefined; - }, [messages, processing]); - - // True when there is prior conversation history (at least one agent response). - const hasConversation = useMemo( - () => messages.some(m => m.role === 'agent'), - [messages], - ); - - // Only archived (overflow) messages go to Static scrollback. - // Current messages stay in the live region so the user can always see - // the recent conversation without scrolling. This prevents the - // "conversation vanishes" problem where every re-render forced the - // viewport to the bottom, hiding Static scrollback content. - const staticMessages = archivedMessages; - const roleMap = useMemo(() => new Map((agents ?? []).map(a => [a.name, a.role])), [agents]); - - // Memoize the header box — rendered once into Static scroll buffer at the top. - const headerElement = useMemo(() => { - // Narrow: minimal header, no border - if (tier === 'narrow') { - return ( - - SQUAD - v{version} - ⚠️ Experimental - - ); - } - - // Normal: abbreviated header - if (tier === 'normal') { - return ( - - SQUAD v{version} - Type naturally · @Agent · /help - ⚠️ Experimental preview - - ); - } - - // Wide: full ASCII art header - return ( - - {' ___ ___ _ _ _ ___\n / __|/ _ \\| | | |/_\\ | \\\n \\__ \\ (_) | |_| / _ \\| |) |\n |___/\\__\\_\\\\___/_/ \\_\\___/'} - {' '} - v{version} · Type naturally · @Agent to direct · /help - ⚠️ Experimental preview — file issues at github.com/bradygaster/squad - - ); - }, [noColor, version, tier]); - - const firstRunElement = useMemo(() => { - if (!welcome?.isFirstRun) return null; - return ( - - {rosterAgents.length > 0 ? ( - <> - Your squad is assembled. - - Try: What should we build first? - Squad automatically routes your message to the best agent. - Or use @{leadAgent} to message an agent directly. - - ) : null} - - ); - }, [welcome?.isFirstRun, rosterAgents, noColor, leadAgent]); - - // Static items: header rendered once at top of scroll buffer, then messages below. - // Ink's Static renders each keyed item exactly once — header stays at the top. - type StaticItem = - | { kind: 'header'; key: string } - | { kind: 'msg'; key: string; msg: ShellMessage; idx: number }; - - const allStaticItems = useMemo((): StaticItem[] => { - const items: StaticItem[] = [{ kind: 'header', key: 'welcome-header' }]; - for (let i = 0; i < staticMessages.length; i++) { - // Use timestamp + index-at-creation for stable keys that don't shift - // when new messages are added (array only grows via append) - const msg = staticMessages[i]!; - const stableKey = `${sessionId}-${msg.timestamp.getTime()}-${i}`; - items.push({ kind: 'msg', key: stableKey, msg, idx: i }); - } - return items; - }, [staticMessages, sessionId]); - - // Fill the entire viewport. Ink's fullscreen clearTerminal path and - // trailing-newline behavior have been patched out of ink.js, so we can - // safely use the full terminal height without triggering scroll-to-top. - // logUpdate tracks exactly rootHeight lines and erases/rewrites them - // on each render cycle without cursor drift. - const rootHeight = Math.max(terminalHeight, 8); - - // Derive maxVisible from terminal height so taller terminals show more - // conversation context. Reserve ~8 rows for header/input/agent-panel chrome. - const maxVisible = Math.max(Math.floor((terminalHeight - 8) / 3), 3); - - return ( - - {/* Static block: header first (stays at top of scroll buffer), then messages */} - - {(item) => { - if (item.kind === 'header') { - return ( - - {headerElement} - {firstRunElement} - - ); - } - const { msg, idx: i } = item; - const isNewTurn = msg.role === 'user' && i > 0; - const agentRole = msg.agentName ? roleMap.get(msg.agentName) : undefined; - const emoji = agentRole ? getRoleEmoji(agentRole) : ''; - let duration: string | null = null; - if (msg.role === 'agent') { - for (let j = i - 1; j >= 0; j--) { - if (staticMessages[j]?.role === 'user') { - duration = formatDuration(staticMessages[j]!.timestamp, msg.timestamp); - break; - } - } - } - return ( - - {isNewTurn && tier !== 'narrow' && } - - {msg.role === 'user' ? ( - - - - {msg.content.split('\n')[0] ?? ''} - - {msg.content.split('\n').slice(1).map((line, li) => ( - - {line} - - ))} - - ) : msg.role === 'system' ? ( - {msg.content} - ) : ( - <> - {emoji ? `${emoji} ` : ''}{(msg.agentName === 'coordinator' ? 'Squad' : msg.agentName) ?? 'agent'}: - {renderMarkdownInline(msg.content)} - {duration && ({duration})} - - )} - - - ); - }} - - - {/* Live region: always height-constrained to prevent layout shift flicker - when processing state toggles. InputPrompt stays pinned at bottom. - Messages are kept here (not in Static) so the user can always see the - recent conversation without scrolling. maxVisible caps the message - count to prevent overflow into the InputPrompt area. */} - - - - - {/* Fixed input box at bottom — Copilot/Claude style */} - - a.name)} messageCount={messages.length} /> - - {/* version is shown in the Static header — no footer duplicate needed */} - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/ErrorBoundary.tsx b/packages/squad-cli/src/cli/shell/components/ErrorBoundary.tsx deleted file mode 100644 index 61ad019b4..000000000 --- a/packages/squad-cli/src/cli/shell/components/ErrorBoundary.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * React ErrorBoundary for the Ink shell. - * - * Catches unhandled errors in the component tree and shows a friendly - * message instead of a raw stack trace. Logs the error to stderr for debugging. - */ - -import React from 'react'; -import { Box, Text } from 'ink'; - -interface ErrorBoundaryProps { - children: React.ReactNode; -} - -interface ErrorBoundaryState { - hasError: boolean; - error: Error | null; -} - -export class ErrorBoundary extends React.Component { - constructor(props: ErrorBoundaryProps) { - super(props); - this.state = { hasError: false, error: null }; - } - - static getDerivedStateFromError(error: Error): ErrorBoundaryState { - return { hasError: true, error }; - } - - componentDidCatch(error: Error, info: React.ErrorInfo): void { - console.error('[squad] Unhandled UI error:', error); - if (info.componentStack) { - console.error('[squad] Component stack:', info.componentStack); - } - } - - render(): React.ReactNode { - if (this.state.hasError) { - return ( - - Something went wrong. Press Ctrl+C to exit. - The error has been logged to stderr for debugging. - - ); - } - return this.props.children; - } -} diff --git a/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx b/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx deleted file mode 100644 index 7c926ba22..000000000 --- a/packages/squad-cli/src/cli/shell/components/InputPrompt.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Box, Text, useInput } from 'ink'; -import { isNoColor, useTerminalWidth } from '../terminal.js'; -import { createCompleter } from '../autocomplete.js'; - -interface InputPromptProps { - onSubmit: (value: string) => void; - prompt?: string; - disabled?: boolean; - agentNames?: string[]; - /** Number of messages exchanged so far — drives progressive hint text. */ - messageCount?: number; -} - -/** Return context-appropriate placeholder hint based on session progress. - * The header banner already shows @agent / /help guidance, so the prompt - * placeholder provides complementary tips instead of duplicating it. */ -function getHintText(messageCount: number, narrow: boolean): string { - if (messageCount < 10) { - return narrow ? ' Tab · ↑↓ history' : ' Tab completes · ↑↓ history'; - } - return narrow ? ' /status · /clear · /export' : ' /status · /clear · /export'; -} - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -export const InputPrompt: React.FC = ({ - onSubmit, - prompt = '> ', - disabled = false, - agentNames = [], - messageCount = 0, -}) => { - const noColor = isNoColor(); - const width = useTerminalWidth(); - const narrow = width < 60; - const [value, setValue] = useState(''); - const [history, setHistory] = useState([]); - const [historyIndex, setHistoryIndex] = useState(-1); - const [spinFrame, setSpinFrame] = useState(0); - const [bufferDisplay, setBufferDisplay] = useState(''); - const bufferRef = useRef(''); - const wasDisabledRef = useRef(disabled); - const pendingInputRef = useRef([]); - const pasteTimerRef = useRef | null>(null); - const valueRef = useRef(''); - - // When transitioning from disabled → enabled, restore buffered input - useEffect(() => { - if (wasDisabledRef.current && !disabled) { - // Clear any pending paste timer from before disable - if (pasteTimerRef.current) { - clearTimeout(pasteTimerRef.current); - pasteTimerRef.current = null; - } - // Drain pending input queue first (fast typing during transition) - const pending = pendingInputRef.current.join(''); - pendingInputRef.current = []; - - const combined = bufferRef.current + pending; - if (combined) { - valueRef.current = combined; - setValue(combined); - bufferRef.current = ''; - setBufferDisplay(''); - } else { - valueRef.current = ''; - } - } - wasDisabledRef.current = disabled; - }, [disabled]); - - const completer = useMemo(() => createCompleter(agentNames), [agentNames]); - - // Tab-cycling state - const tabMatchesRef = useRef([]); - const tabIndexRef = useRef(0); - const tabPrefixRef = useRef(''); - - // Animate spinner when disabled (processing) — static in NO_COLOR mode - useEffect(() => { - if (!disabled || noColor) return; - const timer = setInterval(() => { - setSpinFrame(f => (f + 1) % SPINNER_FRAMES.length); - }, 150); - return () => clearInterval(timer); - }, [disabled, noColor]); - - // Clean up paste detection timer on unmount - useEffect(() => { - return () => { - if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current); - }; - }, []); - - useInput((input, key) => { - if (disabled) { - // Allow slash commands through while processing (read-only, no dispatch) - if (key.return && bufferRef.current.trimStart().startsWith('/')) { - const cmd = bufferRef.current.trim(); - bufferRef.current = ''; - setBufferDisplay(''); - pendingInputRef.current = []; - onSubmit(cmd); - return; - } - // Preserve newlines from pasted text in disabled buffer - if (key.return) { - bufferRef.current += '\n'; - setBufferDisplay(bufferRef.current); - return; - } - if (key.upArrow || key.downArrow || key.ctrl || key.meta) return; - if (key.backspace || key.delete) { - bufferRef.current = bufferRef.current.slice(0, -1); - setBufferDisplay(bufferRef.current); - return; - } - if (input) { - // Queue input to catch race during disabled→enabled transition - pendingInputRef.current.push(input); - bufferRef.current += input; - setBufferDisplay(bufferRef.current); - } - return; - } - - // Race guard: if we just re-enabled but haven't drained queue yet, queue this too - if (wasDisabledRef.current && pendingInputRef.current.length > 0) { - pendingInputRef.current.push(input || ''); - return; - } - - if (key.return) { - // Debounce to detect multi-line paste: if more input arrives - // within 10ms this is a paste and the newline should be preserved. - if (pasteTimerRef.current) clearTimeout(pasteTimerRef.current); - valueRef.current += '\n'; - pasteTimerRef.current = setTimeout(() => { - pasteTimerRef.current = null; - const submitVal = valueRef.current.trim(); - if (submitVal) { - onSubmit(submitVal); - setHistory(prev => [...prev, submitVal]); - setHistoryIndex(-1); - } - valueRef.current = ''; - setValue(''); - }, 10); - return; - } - - if (key.backspace || key.delete) { - valueRef.current = valueRef.current.slice(0, -1); - setValue(valueRef.current); - return; - } - - if (key.upArrow && history.length > 0) { - const newIndex = historyIndex === -1 ? history.length - 1 : Math.max(0, historyIndex - 1); - setHistoryIndex(newIndex); - valueRef.current = history[newIndex]!; - setValue(history[newIndex]!); - return; - } - - if (key.downArrow) { - if (historyIndex >= 0) { - const newIndex = historyIndex + 1; - if (newIndex >= history.length) { - setHistoryIndex(-1); - valueRef.current = ''; - setValue(''); - } else { - setHistoryIndex(newIndex); - valueRef.current = history[newIndex]!; - setValue(history[newIndex]!); - } - } - return; - } - - if (key.tab) { - if (tabPrefixRef.current !== value) { - // New prefix — compute matches - tabPrefixRef.current = value; - tabIndexRef.current = 0; - const [matches] = completer(value); - tabMatchesRef.current = matches; - } else { - // Same prefix — cycle to next match - if (tabMatchesRef.current.length > 0) { - tabIndexRef.current = (tabIndexRef.current + 1) % tabMatchesRef.current.length; - } - } - if (tabMatchesRef.current.length > 0) { - valueRef.current = tabMatchesRef.current[tabIndexRef.current]!; - setValue(tabMatchesRef.current[tabIndexRef.current]!); - } - return; - } - // Reset tab state on any other key - tabMatchesRef.current = []; - tabPrefixRef.current = ''; - - if (input && !key.ctrl && !key.meta) { - valueRef.current += input; - setValue(valueRef.current); - } - }); - - if (disabled) { - return ( - - - {noColor ? ( - <> - {narrow ? 'sq ' : '◆ squad '} - [working...] - {bufferDisplay ? {bufferDisplay} : null} - - ) : ( - <> - {narrow ? 'sq ' : '◆ squad '} - {SPINNER_FRAMES[spinFrame]} - {'> '} - {bufferDisplay ? {bufferDisplay} : null} - - )} - - {!bufferDisplay && [working...]} - - ); - } - - return ( - - - {narrow ? 'sq> ' : '◆ squad> '} - {value} - - - {!value && ( - {getHintText(messageCount, narrow)} - )} - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/MessageStream.tsx b/packages/squad-cli/src/cli/shell/components/MessageStream.tsx deleted file mode 100644 index 23f95df10..000000000 --- a/packages/squad-cli/src/cli/shell/components/MessageStream.tsx +++ /dev/null @@ -1,347 +0,0 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { Box, Text } from 'ink'; -import { getRoleEmoji } from '../lifecycle.js'; -import { isNoColor, useTerminalWidth, useLayoutTier, type LayoutTier } from '../terminal.js'; -import { Separator } from './Separator.js'; -import { useMessageFade } from '../useAnimation.js'; -import { ThinkingIndicator } from './ThinkingIndicator.js'; -import type { ThinkingPhase } from './ThinkingIndicator.js'; -import type { ShellMessage, AgentSession } from '../types.js'; - -/** Convert basic inline markdown to Ink elements. */ -export function renderMarkdownInline(text: string): React.ReactNode { - // Split on bold (**text**), italic (*text*), and code (`text`) patterns - const parts: React.ReactNode[] = []; - // Regex: bold first (greedy **), then code (`), then italic (single *) - const re = /(\*\*(.+?)\*\*)|(`([^`]+?)`)|(\*(.+?)\*)/g; - let lastIndex = 0; - let match: RegExpExecArray | null; - let key = 0; - - while ((match = re.exec(text)) !== null) { - // Add plain text before this match - if (match.index > lastIndex) { - parts.push(text.slice(lastIndex, match.index)); - } - if (match[1]) { - // Bold: **text** - parts.push({match[2]}); - } else if (match[3]) { - // Code: `text` - parts.push({match[4]}); - } else if (match[5]) { - // Italic: *text* - parts.push({match[6]}); - } - lastIndex = match.index + match[0].length; - } - - // Remaining plain text - if (lastIndex < text.length) { - parts.push(text.slice(lastIndex)); - } - - return parts.length === 0 ? text : parts; -} - -interface MessageStreamProps { - messages: ShellMessage[]; - agents?: AgentSession[]; - streamingContent?: Map; - processing?: boolean; - activityHint?: string; - agentActivities?: Map; - thinkingPhase?: ThinkingPhase; - maxVisible?: number; - /** When true, thinking indicator shows conversation-aware phrases. */ - hasConversation?: boolean; -} - -/** Format elapsed seconds for response timestamps. */ -export function formatDuration(start: Date, end: Date): string { - const ms = end.getTime() - start.getTime(); - if (ms < 1000) return `${ms}ms`; - return `${(ms / 1000).toFixed(1)}s`; -} - -/** Convert table to card layout for narrow terminals. */ -function tableToCardLayout(tableLines: string[]): string { - const parsed = tableLines.map(line => { - const trimmed = line.trim(); - const inner = trimmed.slice(1, -1); - return inner.split('|').map(c => c.trim()); - }); - - // Find separator row to split header from data rows - const sepIndex = parsed.findIndex(row => - row.length > 0 && row.every(cell => /^[-:]+$/.test(cell)) - ); - - if (sepIndex <= 0 || sepIndex >= parsed.length - 1) { - // No valid separator or no data rows — return as-is - return tableLines.join('\n'); - } - - const headers = parsed[sepIndex - 1]; - const dataRows = parsed.slice(sepIndex + 1); - - if (!headers || headers.length === 0) return tableLines.join('\n'); - - // Render each row as a card with "Header: value" pairs - const cards = dataRows.map(row => { - const pairs = headers.map((h, i) => { - const val = row[i] ?? ''; - return `**${h}:** ${val}`; - }); - return pairs.join('\n'); - }); - - return cards.join('\n---\n'); -} - -/** Truncate table columns to fit within maxWidth. */ -function truncateTableColumns(tableLines: string[], maxWidth: number): string[] { - const parsed = tableLines.map(line => { - const trimmed = line.trim(); - const inner = trimmed.slice(1, -1); - return inner.split('|').map(c => c.trim()); - }); - const numCols = Math.max(...parsed.map(r => r.length)); - if (numCols === 0) return tableLines; - - const overhead = numCols + 1 + numCols * 2; - const available = Math.max(maxWidth - overhead, numCols * 3); - const colWidth = Math.max(3, Math.floor(available / numCols)); - - return parsed.map(cells => { - const truncated = cells.map(cell => { - if (/^[-:]+$/.test(cell)) return '-'.repeat(colWidth); - if (cell.length <= colWidth) return cell.padEnd(colWidth); - return cell.slice(0, colWidth - 1) + '\u2026'; - }); - while (truncated.length < numCols) truncated.push(' '.repeat(colWidth)); - return '| ' + truncated.join(' | ') + ' |'; - }); -} - -/** Bold the header row of a markdown table (the row above the separator). */ -function boldTableHeader(tableLines: string[]): string[] { - const sepIndex = tableLines.findIndex(line => { - const trimmed = line.trim(); - if (!trimmed.startsWith('|') || !trimmed.endsWith('|')) return false; - const inner = trimmed.slice(1, -1); - const cells = inner.split('|').map(c => c.trim()); - return cells.length > 0 && cells.every(cell => /^[-:]+$/.test(cell)); - }); - - if (sepIndex <= 0) return tableLines; - - const headerIndex = sepIndex - 1; - const headerLine = tableLines[headerIndex]!; - const leadingWS = headerLine.match(/^(\s*)/)?.[1] ?? ''; - const trimmed = headerLine.trim(); - const inner = trimmed.slice(1, -1); - const cells = inner.split('|'); - const boldCells = cells.map(cell => { - const content = cell.trim(); - if (content.length === 0) return cell; - return cell.replace(content, `**${content}**`); - }); - - const result = [...tableLines]; - result[headerIndex] = leadingWS + '|' + boldCells.join('|') + '|'; - return result; -} - -/** - * Reformat markdown tables based on layout tier. - * - **narrow**: Card layout (key-value pairs) - * - **normal**: Truncate columns to fit maxWidth - * - **wide**: Preserve full table - */ -export function wrapTableContent(content: string, maxWidth: number, tier: LayoutTier): string { - const lines = content.split('\n'); - const result: string[] = []; - let i = 0; - - while (i < lines.length) { - const line = lines[i]!; - if (line.trimStart().startsWith('|') && line.trimEnd().endsWith('|')) { - const tableLines: string[] = []; - while (i < lines.length && lines[i]!.trimStart().startsWith('|') && lines[i]!.trimEnd().endsWith('|')) { - tableLines.push(lines[i]!); - i++; - } - - if (tier === 'narrow') { - // Card layout for narrow terminals - result.push(tableToCardLayout(tableLines)); - } else { - const maxLineLen = Math.max(...tableLines.map(l => l.length)); - if (maxLineLen <= maxWidth) { - result.push(...boldTableHeader(tableLines)); - } else { - result.push(...boldTableHeader(truncateTableColumns(tableLines, maxWidth))); - } - } - } else { - result.push(line); - i++; - } - } - return result.join('\n'); -} - -export const MessageStream: React.FC = ({ - messages, - agents, - streamingContent, - processing = false, - activityHint, - agentActivities, - thinkingPhase, - maxVisible = 50, - hasConversation = false, -}) => { - const visible = messages.slice(-maxVisible); - const visibleOffset = Math.max(0, messages.length - maxVisible); - const roleMap = useMemo(() => new Map((agents ?? []).map(a => [a.name, a.role])), [agents]); - - // Message fade-in: new messages start dim for 200ms - const fadingCount = useMessageFade(messages.length); - - // Elapsed time tracking for the ThinkingIndicator. - // Only update state when the rounded seconds value changes to avoid - // unnecessary re-renders that cause terminal scroll flicker. - const [elapsedMs, setElapsedMs] = useState(0); - const processingStartRef = useRef(Date.now()); - const lastElapsedSecRef = useRef(0); - - useEffect(() => { - if (processing) { - processingStartRef.current = Date.now(); - lastElapsedSecRef.current = 0; - setElapsedMs(0); - const timer = setInterval(() => { - const now = Date.now() - processingStartRef.current; - const sec = Math.floor(now / 1000); - if (sec !== lastElapsedSecRef.current) { - lastElapsedSecRef.current = sec; - setElapsedMs(now); - } - }, 1000); - return () => clearInterval(timer); - } else { - setElapsedMs(0); - lastElapsedSecRef.current = 0; - } - }, [processing]); - - // Activity hint comes from the parent (App.tsx derives @mention hints - // via `mentionHint` and passes them through `activityHint`). - - // Compute response duration: time from previous user message to this agent message - const getResponseDuration = (index: number): string | null => { - const msg = visible[index]; - if (!msg || msg.role !== 'agent') return null; - // Walk backward to find the preceding user message - for (let j = index - 1; j >= 0; j--) { - if (visible[j]?.role === 'user') { - return formatDuration(visible[j]!.timestamp, msg.timestamp); - } - } - return null; - }; - - const noColor = isNoColor(); - const width = useTerminalWidth(); - const tier = useLayoutTier(); - const contentWidth = tier === 'wide' ? Math.min(width, 120) : tier === 'normal' ? Math.min(width, 80) : width; - - return ( - - {visible.map((msg, i) => { - const isNewTurn = msg.role === 'user' && i > 0; - const agentRole = msg.agentName ? roleMap.get(msg.agentName) : undefined; - const emoji = agentRole ? getRoleEmoji(agentRole) : ''; - const duration = getResponseDuration(i); - const isFading = fadingCount > 0 && i >= visible.length - fadingCount; - - return ( - - {isNewTurn && } - {msg.role === 'system' ? ( - - {msg.content} - - ) : ( - - {msg.role === 'user' ? ( - <> - - {msg.content} - - ) : ( - <> - {emoji ? `${emoji} ` : ''}{(msg.agentName === 'coordinator' ? 'Squad' : msg.agentName) ?? 'agent'}: - {renderMarkdownInline(wrapTableContent(msg.content, contentWidth, tier))} - {duration && ({duration})} - - )} - - )} - - ); - })} - - {/* Streaming content with live cursor */} - {streamingContent && streamingContent.size > 0 && ( - <> - {Array.from(streamingContent.entries()).map(([agentName, content]) => ( - content ? ( - - - {roleMap.has(agentName) - ? `${getRoleEmoji(roleMap.get(agentName)!)} ` - : ''} - {agentName === 'coordinator' ? 'Squad' : agentName}: - - {renderMarkdownInline(wrapTableContent(content, contentWidth, tier))} - - - ) : null - ))} - - )} - - {/* Agent activity feed — real-time lines showing what agents are doing */} - {agentActivities && agentActivities.size > 0 && ( - - {Array.from(agentActivities.entries()).map(([name, activity]) => ( - ▸ {name} is {activity} - ))} - - )} - - {/* Thinking indicator — shown when processing but no content yet */} - {processing && (!streamingContent || streamingContent.size === 0) && ( - - )} - - {/* Streaming status — shows elapsed while content flows */} - {processing && streamingContent && streamingContent.size > 0 && ( - - )} - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/Separator.tsx b/packages/squad-cli/src/cli/shell/components/Separator.tsx deleted file mode 100644 index b588b0a15..000000000 --- a/packages/squad-cli/src/cli/shell/components/Separator.tsx +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Separator — shared horizontal rule component. - * - * Consolidates all inline separator rendering (AgentPanel, MessageStream, App.tsx) - * into a single reusable component. Uses box-drawing chars that degrade to ASCII. - * - * Owned by Cheritto (TUI Engineer). - */ - -import React from 'react'; -import { Box, Text } from 'ink'; -import { detectTerminal, boxChars, getTerminalWidth } from '../terminal.js'; - -export interface SeparatorProps { - /** Explicit character width. Defaults to min(terminalWidth, 80) - 2. */ - width?: number; - marginTop?: number; - marginBottom?: number; -} - -export const Separator: React.FC = ({ width, marginTop = 0, marginBottom = 0 }) => { - const caps = detectTerminal(); - const box = boxChars(caps); - const w = width ?? Math.min(getTerminalWidth(), 80) - 2; - return ( - - {box.h.repeat(Math.max(w, 0))} - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/ThinkingIndicator.tsx b/packages/squad-cli/src/cli/shell/components/ThinkingIndicator.tsx deleted file mode 100644 index f1b44dbec..000000000 --- a/packages/squad-cli/src/cli/shell/components/ThinkingIndicator.tsx +++ /dev/null @@ -1,158 +0,0 @@ -/** - * ThinkingIndicator — clean feedback during agent operations. - * - * Shows spinner + activity context + elapsed time. - * Default label: "Routing to agent..." (covers SDK connection, initial routing). - * Activity hints from SDK events or @Agent mentions override the default. - * - * Owned by Cheritto (TUI Engineer). Design approved by Marquez. - */ - -import React, { useState, useEffect } from 'react'; -import { Box, Text } from 'ink'; -import { isNoColor } from '../terminal.js'; - -export type ThinkingPhase = 'connecting' | 'routing' | 'thinking'; - -export interface ThinkingIndicatorProps { - isThinking: boolean; - elapsedMs: number; - activityHint?: string; - phase?: ThinkingPhase; - /** When true, cycles conversation-aware phrases instead of generic ones. */ - hasConversation?: boolean; -} - -/** Rotating thinking phrases — cycled every few seconds to keep the UI alive. */ -export const THINKING_PHRASES = [ - 'Routing to agent', - 'Analyzing your request', - 'Reviewing project context', - 'Consulting the team', - 'Evaluating options', - 'Gathering context', - 'Synthesizing a response', - 'Reading the codebase', - 'Considering approaches', - 'Mapping dependencies', - 'Checking project structure', - 'Weighing trade-offs', - 'Crafting a plan', - 'Connecting the dots', - 'Exploring possibilities', -]; - -/** Context-aware phrases shown when conversation history exists. */ -export const CONVERSATION_PHRASES = [ - 'Reviewing conversation context', - 'Connecting to previous work', - 'Analyzing how this relates', - 'Checking conversation thread', - 'Considering prior context', - 'Building on earlier discussion', - 'Mapping to your session', - 'Evaluating options', - 'Consulting the team', - 'Synthesizing a response', - 'Weighing trade-offs', - 'Gathering context', - 'Crafting a plan', - 'Connecting the dots', - 'Reading the codebase', -]; - -/** Map phase to its default label. */ -function phaseLabel(phase: ThinkingPhase): string { - switch (phase) { - case 'connecting': return 'Connecting to GitHub Copilot...'; - case 'routing': return 'Routing to agent...'; - case 'thinking': return 'Thinking...'; - } -} - -const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏']; - -/** Color cycles through as time passes — feels alive. */ -function indicatorColor(elapsedSec: number): string { - if (elapsedSec < 5) return 'cyan'; - if (elapsedSec < 15) return 'yellow'; - return 'magenta'; -} - -function formatElapsed(ms: number): string { - const sec = Math.floor(ms / 1000); - if (sec < 1) return ''; - return `${sec}s`; -} - -/** Static dots for NO_COLOR mode (no animation). */ -const STATIC_SPINNER = '...'; - -export const ThinkingIndicator: React.FC = ({ - isThinking, - elapsedMs, - activityHint, - phase = 'routing', - hasConversation = false, -}) => { - const noColor = isNoColor(); - const [frame, setFrame] = useState(0); - const [phraseIndex, setPhraseIndex] = useState(0); - - // Spinner animation — 120ms per frame to reduce re-renders (#206) - useEffect(() => { - if (!isThinking || noColor) return; - const timer = setInterval(() => { - setFrame(f => (f + 1) % SPINNER_FRAMES.length); - }, 120); - return () => clearInterval(timer); - }, [isThinking, noColor]); - - const phrases = hasConversation ? CONVERSATION_PHRASES : THINKING_PHRASES; - - // Rotate thinking phrases every 3 seconds - useEffect(() => { - if (!isThinking) { setPhraseIndex(0); return; } - const timer = setInterval(() => { - setPhraseIndex(i => (i + 1) % phrases.length); - }, 3000); - return () => clearInterval(timer); - }, [isThinking, phrases]); - - // Reset frame when thinking starts - useEffect(() => { - if (isThinking) { setFrame(0); setPhraseIndex(0); } - }, [isThinking]); - - if (!isThinking) return null; - - const elapsedSec = Math.floor(elapsedMs / 1000); - const elapsedStr = formatElapsed(elapsedMs); - const spinnerChar = noColor ? STATIC_SPINNER : (SPINNER_FRAMES[frame] ?? '⠋'); - - // Resolve the display label: activity hint > rotating phrase > phase label - const displayLabel = activityHint ?? ( - phase === 'connecting' ? phaseLabel(phase) : `${phrases[phraseIndex]}...` - ); - - // NO_COLOR: no color props, use text labels - if (noColor) { - return ( - - {spinnerChar} - {displayLabel} - {elapsedStr ? ({elapsedStr}) : null} - - ); - } - - const color = indicatorColor(elapsedSec); - - return ( - - {spinnerChar} - {displayLabel} - {elapsedStr ? ({elapsedStr}) : null} - - ); -}; diff --git a/packages/squad-cli/src/cli/shell/components/index.ts b/packages/squad-cli/src/cli/shell/components/index.ts deleted file mode 100644 index a3cda9b4d..000000000 --- a/packages/squad-cli/src/cli/shell/components/index.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { AgentPanel } from './AgentPanel.js'; -export { MessageStream } from './MessageStream.js'; -export { InputPrompt } from './InputPrompt.js'; -export { ThinkingIndicator } from './ThinkingIndicator.js'; -export type { ThinkingIndicatorProps } from './ThinkingIndicator.js'; -export { ErrorBoundary } from './ErrorBoundary.js'; -export { App } from './App.js'; -export type { ShellApi, AppProps } from './App.js'; diff --git a/packages/squad-cli/src/cli/shell/coordinator.ts b/packages/squad-cli/src/cli/shell/coordinator.ts deleted file mode 100644 index 675ad7662..000000000 --- a/packages/squad-cli/src/cli/shell/coordinator.ts +++ /dev/null @@ -1,368 +0,0 @@ -import { join } from 'node:path'; -import { listRoles, searchRoles, FSStorageProvider } from '@bradygaster/squad-sdk'; - -import type { ShellMessage } from './types.js'; - -/** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */ -function debugLog(...args: unknown[]): void { - if (process.env['SQUAD_DEBUG'] === '1') { - console.error('[SQUAD_DEBUG]', ...args); - } -} - -/** - * Check if team.md has actual roster entries in the ## Members section. - * Returns true if there is at least one table data row. - */ -export function hasRosterEntries(teamContent: string): boolean { - const membersMatch = teamContent.match(/## Members\s*\n([\s\S]*?)(?=\n## |\n*$)/); - if (!membersMatch) return false; - const membersSection = membersMatch[1] ?? ''; - const rows = membersSection.split('\n').filter(line => { - const trimmed = line.trim(); - return trimmed.startsWith('|') && - !trimmed.match(/^\|\s*Name\s*\|/) && - !trimmed.match(/^\|\s*-+\s*\|/); - }); - return rows.length > 0; -} - -export interface CoordinatorConfig { - teamRoot: string; - /** Path to routing.md */ - routingPath?: string; - /** Path to team.md */ - teamPath?: string; - /** When true, include the base roles catalog in the init prompt. Default: false (fictional universe casting). */ - useBaseRoles?: boolean; -} - -/** Fallback text when team.md is missing or has no roster entries. */ -const noTeamFallback = `⚠️ NO TEAM CONFIGURED - -This project doesn't have a Squad team yet. - -**You MUST NOT do any project work.** Instead, tell the user: -1. "This project doesn't have a Squad team yet." -2. Suggest running \`squad init\` or the \`/init\` command to set one up. -3. Politely refuse any work requests until init is done. - -Do not answer coding questions, route to agents, or perform any project tasks.`; - -/** - * Build an Init Mode system prompt for team casting. - * Used when team.md exists but has no roster entries. - * - * When `config.useBaseRoles` is true (opt-in via `--roles`), the prompt - * includes the built-in base roles catalog so the LLM maps agents to - * curated role IDs. Otherwise (default), the LLM casts from a fictional - * universe with free-form role names — the beloved casting experience. - */ -export function buildInitModePrompt(config: CoordinatorConfig): string { - if (config.useBaseRoles) { - return buildBaseRolesInitPrompt(); - } - return buildUniverseCastingInitPrompt(); -} - -/** - * Default init prompt — fictional universe casting (no base roles catalog). - */ -function buildUniverseCastingInitPrompt(): string { - return `You are the Squad Coordinator in Init Mode. - -This project has a Squad scaffold (.squad/ directory) but no team has been cast yet. -The user's message describes what they want to build or work on. - -Your job: Propose a team of 4-5 AI agents based on what the user wants to do. - -## Rules -1. Analyze the user's message to understand the project (language, stack, scope) -2. Pick a fictional universe for character names. **Strongly prefer:** - - **The Usual Suspects** (8 characters: Keyser, McManus, Fenster, Verbal, Hockney, Redfoot, Edie, Kobayashi) - - **Ocean's Eleven** (10 characters: Danny, Rusty, Linus, Basher, Livingston, Saul, Yen, Virgil, Turk, Reuben) - - You may also choose other film universes (Alien, The Matrix, Heat, Star Wars, Blade Runner, etc.) but the two above are preferred. -3. Propose 4-5 agents with roles that match the project needs -4. Scribe and Ralph are always included automatically — do NOT include them in your proposal - -## Response Format — you MUST use this EXACT format: - -INIT_TEAM: -- {Name} | {Role} | {scope: 2-4 words describing expertise} -- {Name} | {Role} | {scope} -- {Name} | {Role} | {scope} -- {Name} | {Role} | {scope} -UNIVERSE: {universe name} -PROJECT: {1-sentence project description} - -## Example - -If user says "Build a React app with a Node backend": - -INIT_TEAM: -- Ripley | Lead | Architecture, code review, decisions -- Dallas | Frontend Dev | React, components, styling -- Kane | Backend Dev | Node.js, APIs, database -- Lambert | Tester | Tests, quality, edge cases -UNIVERSE: Alien -PROJECT: A React and Node.js web application - -## Important -- Use character names that feel natural, not forced -- Roles should match project needs (don't always use the same 4 roles) -- For CLI projects: maybe skip Frontend, add DevOps or SDK Expert -- For data projects: add Data Engineer, skip Frontend -- Keep scope descriptions short (2-4 words each) -- Respond ONLY with the INIT_TEAM block — no other text -`; -} - -/** - * Opt-in base roles init prompt — includes the curated role catalog. - * Activated by `squad init --roles` or `/init --roles`. - */ -function buildBaseRolesInitPrompt(): string { - const catalog = new Map( - listRoles().map((role: { id: string; title: string }) => [role.id, role]), - ); - const resolveRole = (id: string) => catalog.get(id) ?? searchRoles(id)[0]; - const formatRole = (id: string, label: string): string => { - const role = resolveRole(id); - const title = role?.title ?? label; - return ` ${id.padEnd(22, ' ')} — ${title}`; - }; - - const softwareRoles = [ - ['lead', 'Lead / Architect'], - ['frontend', 'Frontend Developer'], - ['backend', 'Backend Developer'], - ['fullstack', 'Full-Stack Developer'], - ['reviewer', 'Code Reviewer'], - ['tester', 'Test Engineer'], - ['devops', 'DevOps Engineer'], - ['security', 'Security Engineer'], - ['data', 'Data Engineer'], - ['docs', 'Technical Writer'], - ['ai', 'AI / ML Engineer'], - ['designer', 'UI/UX Designer'], - ] as const; - const businessRoles = [ - ['marketing-strategist', 'Marketing Strategist'], - ['sales-strategist', 'Sales Strategist'], - ['product-manager', 'Product Manager'], - ['project-manager', 'Project Manager'], - ['support-specialist', 'Support Specialist'], - ['game-developer', 'Game Developer'], - ['media-buyer', 'Media Buyer'], - ['compliance-legal', 'Compliance & Legal'], - ] as const; - const softwareRoleLines = softwareRoles.map(([id, label]) => formatRole(id, label)).join('\n'); - const businessRoleLines = businessRoles.map(([id, label]) => formatRole(id, label)).join('\n'); - - return `You are the Squad Coordinator in Init Mode. - -This project has a Squad scaffold (.squad/ directory) but no team has been cast yet. -The user's message describes what they want to build or work on. - -Your job: Propose a team of 4-5 AI agents based on what the user wants to do. - -## Rules -1. Analyze the user's message to understand the project (language, stack, scope) -2. Pick a fictional universe for character names (e.g., Alien, The Usual Suspects, Blade Runner, The Matrix, Heat, Star Wars). Pick ONE universe and use it consistently. -3. Propose 4-5 agents with roles that match the project needs -4. Scribe and Ralph are always included automatically — do NOT include them in your proposal - -## Built-in Base Roles (use these as starting points) - -The following base roles are available. Prefer these over inventing new roles — they have deep, curated charter content. -When proposing a team, match the user's project needs to these roles first. -Only propose a custom role if none of the base roles fit. - -Software Development: -${softwareRoleLines} - -Business & Operations: -${businessRoleLines} - -When proposing a team member, use the role ID from above in the Role field. -Example: "- Ripley | lead | Architecture, code review, decisions" -This tells the system to use the pre-built Lead/Architect charter content. -If you use a role not in this list, the system will generate a generic charter instead. - -## Response Format — you MUST use this EXACT format: - -INIT_TEAM: -- {Name} | {Role} | {scope: 2-4 words describing expertise} -- {Name} | {Role} | {scope} -- {Name} | {Role} | {scope} -- {Name} | {Role} | {scope} -UNIVERSE: {universe name} -PROJECT: {1-sentence project description} - -## Example - -If user says "Build a React app with a Node backend": - -INIT_TEAM: -- Ripley | lead | Architecture, code review, decisions -- Dallas | frontend | React, components, styling -- Kane | backend | Node.js, APIs, database -- Lambert | tester | Tests, quality, edge cases -UNIVERSE: Alien -PROJECT: A React and Node.js web application - -## Important -- Use character names that feel natural, not forced -- Roles should match project needs (don't always use the same 4 roles) -- For CLI projects: maybe skip Frontend, add DevOps or SDK Expert -- For data projects: add Data Engineer, skip Frontend -- Keep scope descriptions short (2-4 words each) -- Respond ONLY with the INIT_TEAM block — no other text -`; -} - -/** - * Build the coordinator system prompt from team.md + routing.md. - * This prompt tells the LLM how to route user requests to agents. - * - * Reads via FSStorageProvider so all file access is routed through the - * StorageProvider abstraction (Phase 3 migration). - */ -export async function buildCoordinatorPrompt(config: CoordinatorConfig): Promise { - const squadRoot = config.teamRoot; - const storage = new FSStorageProvider(); - - // Load team.md for roster - const teamPath = config.teamPath ?? join(squadRoot, '.squad', 'team.md'); - let teamContent = ''; - try { - const raw = await storage.read(teamPath); - if (raw === undefined) { - teamContent = noTeamFallback; - } else { - teamContent = raw; - if (!hasRosterEntries(teamContent)) { - teamContent = noTeamFallback; - } - } - } catch (err) { - debugLog('buildCoordinatorPrompt: failed to read team.md at', teamPath, err); - teamContent = noTeamFallback; - } - - // Load routing.md for routing rules - const routingPath = config.routingPath ?? join(squadRoot, '.squad', 'routing.md'); - let routingContent = ''; - try { - const raw = await storage.read(routingPath); - routingContent = raw ?? '(No routing.md found — run `squad init` to create one)'; - } catch (err) { - debugLog('buildCoordinatorPrompt: failed to read routing.md at', routingPath, err); - routingContent = '(No routing.md found — run `squad init` to create one)'; - } - - return `You are the Squad Coordinator — you route work to the right agent. - -## Team Roster -${teamContent} - -## Routing Rules -${routingContent} - -## Your Job -1. Read the user's message -2. Decide which agent(s) should handle it based on routing rules -3. If naming a specific agent ("Fenster, fix the bug"), route directly -4. If ambiguous, pick the best match and explain your choice -5. For status/factual questions, answer directly without spawning - -## Response Format -When routing to an agent, respond with: -ROUTE: {agent_name} -TASK: {what the agent should do} -CONTEXT: {any relevant context} - -When answering directly: -DIRECT: {your answer} - -When routing to multiple agents: -MULTI: -- {agent1}: {task1} -- {agent2}: {task2} -`; -} - -/** - * Parse coordinator response to extract routing decisions. - */ -export interface RoutingDecision { - type: 'direct' | 'route' | 'multi'; - directAnswer?: string; - routes?: Array<{ agent: string; task: string; context?: string }>; -} - -export function parseCoordinatorResponse(response: string): RoutingDecision { - const trimmed = response.trim(); - - // Direct answer - if (trimmed.startsWith('DIRECT:')) { - return { - type: 'direct', - directAnswer: trimmed.slice('DIRECT:'.length).trim(), - }; - } - - // Multi-agent routing - if (trimmed.startsWith('MULTI:')) { - const lines = trimmed.split('\n').slice(1); - const routes = lines - .filter(l => l.trim().startsWith('-')) - .map(l => { - const match = l.match(/^-\s*(\w+):\s*(.+)$/); - if (match) { - return { agent: match[1], task: match[2] }; - } - return null; - }) - .filter((r): r is { agent: string; task: string } => r !== null); - return { type: 'multi', routes }; - } - - // Single agent routing - if (trimmed.startsWith('ROUTE:')) { - const agentMatch = trimmed.match(/ROUTE:\s*(\w+)/); - const taskMatch = trimmed.match(/TASK:\s*(.+)/); - const contextMatch = trimmed.match(/CONTEXT:\s*(.+)/); - if (agentMatch) { - return { - type: 'route', - routes: [{ - agent: agentMatch[1]!, - task: taskMatch?.[1] ?? '', - context: contextMatch?.[1], - }], - }; - } - } - - // Fallback — treat as direct answer - return { type: 'direct', directAnswer: trimmed }; -} - -/** - * Format conversation history for the coordinator context window. - * Keeps recent messages, summarizes older ones. - */ -export function formatConversationContext( - messages: ShellMessage[], - maxMessages: number = 20, -): string { - const recent = messages.slice(-maxMessages); - return recent - .map(m => { - const prefix = m.agentName ? `[${m.agentName}]` : `[${m.role}]`; - return `${prefix}: ${m.content}`; - }) - .join('\n'); -} diff --git a/packages/squad-cli/src/cli/shell/error-messages.ts b/packages/squad-cli/src/cli/shell/error-messages.ts deleted file mode 100644 index bee06d31d..000000000 --- a/packages/squad-cli/src/cli/shell/error-messages.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * User-friendly error message templates with recovery guidance. - * All messages are conversational and action-oriented. - * - * @module cli/shell/error-messages - */ - -export interface ErrorGuidance { - message: string; - recovery: string[]; -} - -/** SDK disconnect / connection errors */ -export function sdkDisconnectGuidance(detail?: string): ErrorGuidance { - return { - message: detail ? `SDK disconnected: ${detail}` : 'SDK disconnected.', - recovery: [ - "Run 'squad doctor' to check your setup", - 'Check your internet connection', - 'Restart the shell to reconnect', - ], - }; -} - -/** team.md missing or invalid */ -export function teamConfigGuidance(issue: string): ErrorGuidance { - return { - message: `Team configuration issue: ${issue}`, - recovery: [ - "Run 'squad doctor' to diagnose", - "Run 'squad init' to regenerate team.md", - 'Check .squad/team.md exists and has valid YAML', - ], - }; -} - -/** Agent session failure */ -export function agentSessionGuidance(agentName: string, detail?: string): ErrorGuidance { - return { - message: `${agentName} session failed${detail ? `: ${detail}` : ''}.`, - recovery: [ - 'Try your message again (session will auto-reconnect)', - "Run 'squad doctor' to check setup", - `Use @${agentName} to retry directly`, - ], - }; -} - -/** Format seconds into a human-readable duration string */ -function formatDuration(seconds: number): string { - const hours = Math.ceil(seconds / 3600); - const minutes = Math.ceil(seconds / 60); - if (seconds >= 3600) return `${hours} hour${hours === 1 ? '' : 's'}`; - if (seconds >= 60) return `${minutes} minute${minutes === 1 ? '' : 's'}`; - return `${seconds} second${seconds === 1 ? '' : 's'}`; -} - -/** - * Extract retry-after duration (in seconds) from an error message string. - * Handles patterns like "retry after 120 seconds", "try again in 2 hours", etc. - */ -export function extractRetryAfter(message: string): number | undefined { - const secMatch = message.match(/retry.{0,15}after\s+(\d+)\s*second/i); - if (secMatch) return parseInt(secMatch[1]!, 10); - const hrMatch = message.match(/(?:try again|retry).{0,20}in\s+(\d+)\s*hour/i); - if (hrMatch) return parseInt(hrMatch[1]!, 10) * 3600; - const minMatch = message.match(/(?:try again|retry).{0,20}in\s+(\d+)\s*minute/i); - if (minMatch) return parseInt(minMatch[1]!, 10) * 60; - return undefined; -} - -/** Rate limit hit — model or endpoint temporarily throttled */ -export function rateLimitGuidance(opts?: { retryAfter?: number; model?: string }): ErrorGuidance { - const modelStr = opts?.model ? ` for ${opts.model}` : ''; - const retryStr = opts?.retryAfter - ? `Try again in ${formatDuration(opts.retryAfter)}` - : 'Try again later when the limit resets'; - return { - message: `Rate limit reached${modelStr}. Copilot has temporarily throttled your requests.`, - recovery: [ - retryStr, - 'Enable economy mode to switch to cheaper models: `squad economy on`', - 'Or set a different model: add `"defaultModel": "gpt-4.1"` to .squad/config.json', - ], - }; -} - -/** Generic error with context */ -export function genericGuidance(detail: string): ErrorGuidance { - return { - message: detail, - recovery: [ - 'Try your message again', - "Run 'squad doctor' for diagnostics", - 'Check your internet connection', - ], - }; -} - -/** Request timeout */ -export function timeoutGuidance(agentName?: string): ErrorGuidance { - const who = agentName ? `${agentName} timed out` : 'Request timed out'; - return { - message: `${who}. The model may be under load.`, - recovery: [ - 'Try again — the issue is often transient', - 'Set SQUAD_REPL_TIMEOUT=120 for a longer timeout (seconds)', - "Run 'squad doctor' to verify connectivity", - ], - }; -} - -/** Unknown slash command */ -export function unknownCommandGuidance(command: string): ErrorGuidance { - return { - message: `Unknown command: /${command}`, - recovery: [ - 'Type /help to see available commands', - 'Check for typos in the command name', - ], - }; -} - -/** Format an ErrorGuidance into a user-facing string */ -export function formatGuidance(g: ErrorGuidance): string { - const lines = [`❌ ${g.message}`]; - if (g.recovery.length > 0) { - lines.push(' Try:'); - for (const r of g.recovery) { - lines.push(` • ${r}`); - } - } - return lines.join('\n'); -} diff --git a/packages/squad-cli/src/cli/shell/index.ts b/packages/squad-cli/src/cli/shell/index.ts deleted file mode 100644 index ddff37d91..000000000 --- a/packages/squad-cli/src/cli/shell/index.ts +++ /dev/null @@ -1,1353 +0,0 @@ -/** - * Squad Interactive Shell — entry point - * - * Renders the Ink-based shell UI with AgentPanel, MessageStream, and InputPrompt. - * Manages CopilotSDK sessions and routes messages to agents/coordinator. - */ - -import { createRequire } from 'node:module'; -import { join, resolve as pathResolve } from 'node:path'; -import React from 'react'; -import { render } from 'ink'; -import { App } from './components/App.js'; -import type { ShellApi } from './components/App.js'; -import { ErrorBoundary } from './components/ErrorBoundary.js'; -import { SessionRegistry } from './sessions.js'; -import { ShellRenderer } from './render.js'; -import { StreamBridge } from './stream-bridge.js'; -import { ShellLifecycle, loadWelcomeData } from './lifecycle.js'; -import { SquadClient } from '@bradygaster/squad-sdk/client'; -import type { SquadSession } from '@bradygaster/squad-sdk/client'; -import type { SquadPermissionHandler } from '@bradygaster/squad-sdk/client'; -import { RateLimitError } from '@bradygaster/squad-sdk/adapter/errors'; -import type { ShellMessage } from './types.js'; -import { FSStorageProvider, initSquadTelemetry, TIMEOUTS, StreamingPipeline, recordAgentSpawn, recordAgentDuration, recordAgentError, recordAgentDestroy, RuntimeEventBus, resolveSquad, resolveGlobalSquadPath } from '@bradygaster/squad-sdk'; -import type { UsageEvent } from '@bradygaster/squad-sdk'; -import { enableShellMetrics, recordShellSessionDuration, recordAgentResponseLatency, recordShellError } from './shell-metrics.js'; -import { parseAgentFromDescription } from './agent-name-parser.js'; -import { buildCoordinatorPrompt, buildInitModePrompt, parseCoordinatorResponse, hasRosterEntries } from './coordinator.js'; -import { loadAgentCharter, buildAgentPrompt } from './spawn.js'; -import { createSession, saveSession, loadLatestSession, type SessionData } from './session-store.js'; -import { parseDispatchTargets, type ParsedInput } from './router.js'; -import { agentSessionGuidance, genericGuidance, rateLimitGuidance, extractRetryAfter, formatGuidance } from './error-messages.js'; -import { parseCastResponse, createTeam, formatCastSummary, augmentWithCastingEngine, type CastProposal } from '../core/cast.js'; - -export { SessionRegistry } from './sessions.js'; -export { StreamBridge } from './stream-bridge.js'; -export type { StreamBridgeOptions } from './stream-bridge.js'; -export { ShellRenderer } from './render.js'; -export { ShellLifecycle } from './lifecycle.js'; -export type { LifecycleOptions, DiscoveredAgent } from './lifecycle.js'; -export { spawnAgent, loadAgentCharter, buildAgentPrompt } from './spawn.js'; -export type { SpawnOptions, SpawnResult, ToolDefinition } from './spawn.js'; -export { buildCoordinatorPrompt, buildInitModePrompt, parseCoordinatorResponse, formatConversationContext, hasRosterEntries } from './coordinator.js'; -export type { CoordinatorConfig, RoutingDecision } from './coordinator.js'; -export { parseInput, parseDispatchTargets } from './router.js'; -export type { MessageType, ParsedInput, DispatchTargets } from './router.js'; -export { executeCommand } from './commands.js'; -export type { CommandContext, CommandResult } from './commands.js'; -export { MemoryManager, DEFAULT_LIMITS } from './memory.js'; -export type { MemoryLimits } from './memory.js'; -export { detectTerminal, safeChar, boxChars } from './terminal.js'; -export type { TerminalCapabilities } from './terminal.js'; -export { createCompleter } from './autocomplete.js'; -export type { CompleterFunction, CompleterResult } from './autocomplete.js'; -export { createSession, saveSession, loadLatestSession, listSessions, loadSessionById } from './session-store.js'; -export type { SessionData, SessionSummary } from './session-store.js'; -export { App } from './components/App.js'; -export type { ShellApi, AppProps } from './components/App.js'; -export { ErrorBoundary } from './components/ErrorBoundary.js'; -export { - sdkDisconnectGuidance, - teamConfigGuidance, - agentSessionGuidance, - genericGuidance, - rateLimitGuidance, - extractRetryAfter, - timeoutGuidance, - unknownCommandGuidance, - formatGuidance, -} from './error-messages.js'; -export type { ErrorGuidance } from './error-messages.js'; -export { - enableShellMetrics, - recordShellSessionDuration, - recordAgentResponseLatency, - recordShellError, - isShellTelemetryEnabled, - _resetShellMetrics, -} from './shell-metrics.js'; - -const require = createRequire(import.meta.url); -const pkg = require('../../../package.json') as { version: string }; - -const storage = new FSStorageProvider(); - -/** - * Approve all permission requests. CLI runs locally with user trust, - * so no interactive confirmation is needed. - */ -const approveAllPermissions: SquadPermissionHandler = () => ({ kind: 'approved' }); - -/** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */ -function debugLog(...args: unknown[]): void { - if (process.env['SQUAD_DEBUG'] === '1') { - console.error('[SQUAD_DEBUG]', ...args); - } -} - -/** Options for ghost response retry. */ -export interface GhostRetryOptions { - maxRetries?: number; - backoffMs?: readonly number[]; - onRetry?: (attempt: number, maxRetries: number) => void; - onExhausted?: (maxRetries: number) => void; - debugLog?: (...args: unknown[]) => void; - promptPreview?: string; -} - -/** - * Retry a send function when the response is empty (ghost response). - * Ghost responses occur when session.idle fires before assistant.message, - * causing sendAndWait() to return undefined or empty content. - */ -export async function withGhostRetry( - sendFn: () => Promise, - options: GhostRetryOptions = {}, -): Promise { - const maxRetries = options.maxRetries ?? 3; - const backoffMs = options.backoffMs ?? [1000, 2000, 4000]; - const log = options.debugLog ?? (() => {}); - const preview = options.promptPreview ?? ''; - - for (let attempt = 0; attempt <= maxRetries; attempt++) { - if (attempt > 0) { - log('ghost response detected', { - timestamp: new Date().toISOString(), - attempt, - promptPreview: preview.slice(0, 80), - }); - options.onRetry?.(attempt, maxRetries); - const delay = backoffMs[attempt - 1] ?? backoffMs[backoffMs.length - 1] ?? 4000; - await new Promise(r => setTimeout(r, delay)); - } - const result = await sendFn(); - if (result) return result; - } - - log('ghost response: all retries exhausted', { - timestamp: new Date().toISOString(), - promptPreview: preview.slice(0, 80), - }); - options.onExhausted?.(maxRetries); - return ''; -} - -export async function runShell(): Promise { - // First-run check: before requiring a TTY, detect if no .squad/ exists locally. - // In that case, output a plain-text welcome and init hint so non-interactive - // contexts (pipes, tests, CI) see useful guidance rather than a TTY error. - const cwd = process.cwd(); - const localSquad = resolveSquad(cwd); - const globalSquadDir = join(resolveGlobalSquadPath(), '.squad'); - const hasAnySquad = !!localSquad || storage.existsSync(globalSquadDir); - - if (!hasAnySquad && !process.stdin.isTTY) { - console.log('Welcome to Squad\n'); - console.log('Get started by initializing your squad:'); - console.log(' squad init "describe what you want to build"\n'); - console.log('Or run: squad help'); - process.exit(0); - } - - // Ink requires a TTY for raw mode input — bail out early when piped (#576) - if (!process.stdin.isTTY) { - console.error('✗ Squad shell requires an interactive terminal (TTY).'); - console.error(' Piped or redirected stdin is not supported.'); - console.error(" Tip: Run 'squad --preview' for non-interactive usage."); - process.exit(1); - } - - // Show immediate feedback — users need to see something within 100ms - console.error('◆ Loading Squad shell...'); - - // Configurable REPL timeout: SQUAD_REPL_TIMEOUT (seconds) > TIMEOUTS.SESSION_RESPONSE_MS (ms) - const replTimeoutMs = (() => { - const envSeconds = process.env['SQUAD_REPL_TIMEOUT']; - if (envSeconds) { - const parsed = parseInt(envSeconds, 10); - if (!isNaN(parsed) && parsed > 0) return parsed * 1000; - } - return TIMEOUTS.SESSION_RESPONSE_MS; - })(); - debugLog('REPL timeout:', replTimeoutMs, 'ms'); - - const sessionStart = Date.now(); - let messageCount = 0; - - const registry = new SessionRegistry(); - const renderer = new ShellRenderer(); - - // Resolve teamRoot: local .squad/ → global squad → cwd (init mode) - const teamRoot = (() => { - const cwd = process.cwd(); - // 1. Walk up from cwd looking for a local .squad/ - const localSquad = resolveSquad(cwd); - if (localSquad) { - return pathResolve(localSquad, '..'); - } - // 2. Fall back to global (personal) squad path - const globalPath = resolveGlobalSquadPath(); - const globalSquadDir = join(globalPath, '.squad'); - if (storage.existsSync(globalSquadDir)) { - return globalPath; - } - // 3. No squad found — use cwd (triggers init mode) - return cwd; - })(); - - // Session persistence — create or resume a previous session - // Skip resume on first run (no team.md or .first-run marker present) - const hasTeam = storage.existsSync(join(teamRoot, '.squad', 'team.md')); - const isFirstRun = storage.existsSync(join(teamRoot, '.squad', '.first-run')); - let persistedSession: SessionData = createSession(); - const recentSession = (hasTeam && !isFirstRun) ? loadLatestSession(teamRoot) : null; - if (recentSession) { - persistedSession = recentSession; - debugLog('resuming recent session', persistedSession.id); - } - - // Initialize OpenTelemetry if endpoint is configured (e.g. Aspire dashboard) - const eventBus = new RuntimeEventBus(); - const telemetry = initSquadTelemetry({ serviceName: 'squad-cli', mode: 'cli', eventBus }); - if (telemetry.tracing || telemetry.metrics) { - debugLog('🔭 Telemetry active — exporting to ' + process.env['OTEL_EXPORTER_OTLP_ENDPOINT']); - } - - // Streaming pipeline for token usage and response latency metrics - const streamingPipeline = new StreamingPipeline(); - - // Shell-level observability metrics (auto-enabled when OTel is configured) - const shellMetricsActive = enableShellMetrics(); - if (shellMetricsActive) { - debugLog('shell observability metrics enabled'); - } - - // Initialize lifecycle — discover team agents - const lifecycle = new ShellLifecycle({ teamRoot, renderer, registry }); - try { - await lifecycle.initialize(); - } catch (err) { - debugLog('lifecycle.initialize() failed:', err); - // Non-fatal: shell works without discovered agents - } - - // Create SDK client (auto-connects on first session creation) - const client = new SquadClient({ cwd: teamRoot }); - - let shellApi: ShellApi | undefined; - let origAddMessage: ((msg: ShellMessage) => void) | undefined; - const agentSessions = new Map(); - let coordinatorSession: SquadSession | null = null; - let activeInitSession: SquadSession | null = null; - let pendingCastConfirmation: { proposal: CastProposal; parsed: ParsedInput } | null = null; - - // Eager SDK warm-up — start coordinator session before user's first message - // This runs in background so UI renders immediately - (async () => { - try { - debugLog('eager warm-up: creating coordinator session'); - const systemPrompt = await buildCoordinatorPrompt({ teamRoot }); - coordinatorSession = await client.createSession({ - streaming: true, - systemMessage: { mode: 'append', content: systemPrompt }, - workingDirectory: teamRoot, - onPermissionRequest: approveAllPermissions, - }); - debugLog('eager warm-up: coordinator session ready'); - } catch (err) { - debugLog('eager warm-up failed (non-fatal, will retry on first dispatch):', err); - // Non-fatal — first dispatch will create the session as before - } - })(); - - const streamBuffers = new Map(); - - // StreamBridge wires streaming pipeline events into Ink component state. - const _bridge = new StreamBridge(registry, { - onContent: (agentName: string, delta: string) => { - const existing = streamBuffers.get(agentName) ?? ''; - const accumulated = existing + delta; - streamBuffers.set(agentName, accumulated); - shellApi?.setStreamingContent({ agentName, content: accumulated }); - shellApi?.refreshAgents(); - }, - onComplete: (message) => { - if (message.agentName) streamBuffers.delete(message.agentName); - shellApi?.addMessage(message); - shellApi?.refreshAgents(); - }, - onError: (agentName: string, error: Error) => { - debugLog(`StreamBridge error for ${agentName}:`, error); - streamBuffers.delete(agentName); - const friendly = error.message.replace(/^Error:\s*/i, ''); - const guidance = agentSessionGuidance(agentName, friendly); - shellApi?.addMessage({ - role: 'system', - content: formatGuidance(guidance), - timestamp: new Date(), - }); - }, - }); - - /** Extract text delta from an SDK session event. */ - function extractDelta(event: { type: string; [key: string]: unknown }): string { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const result = typeof val === 'string' ? val : ''; - debugLog('extractDelta', { type: event['type'], keys: Object.keys(event), hasDeltaContent: 'deltaContent' in event, result: result.slice(0, 80) }); - return result; - } - - /** - * Send a prompt and wait for the full streamed response. - * Prefers sendAndWait (blocks until idle); falls back to sendMessage + turn_end event. - * Returns the full response content from sendAndWait as a fallback string. - */ - async function awaitStreamedResponse(session: SquadSession, prompt: string): Promise { - if (session.sendAndWait) { - debugLog('awaitStreamedResponse: using sendAndWait'); - - // ThinkingIndicator already shows elapsed time via its own timer; - // no need to override the current activity hint with generic text. - const result = await session.sendAndWait({ prompt }, replTimeoutMs); - debugLog('awaitStreamedResponse: sendAndWait returned', { - type: typeof result, - keys: result ? Object.keys(result as Record) : [], - hasData: !!(result as Record | undefined)?.['data'], - }); - // Return full response content as fallback for when deltas weren't captured - const data = (result as Record | undefined)?.['data'] as Record | undefined; - const content = typeof data?.['content'] === 'string' ? data['content'] as string : ''; - debugLog('awaitStreamedResponse: fallback content length', content.length); - return content; - } else { - const done = new Promise((resolve) => { - const onEnd = (): void => { - try { session.off('turn_end', onEnd); } catch { /* ignore */ } - try { session.off('idle', onEnd); } catch { /* ignore */ } - resolve(); - }; - session.on('turn_end', onEnd); - session.on('idle', onEnd); - }); - await session.sendMessage({ prompt }); - await done; - return ''; - } - } - - /** Convenience wrapper for withGhostRetry with shell UI integration. */ - function ghostRetry( - sendFn: () => Promise, - promptPreview: string, - ): Promise { - return withGhostRetry(sendFn, { - debugLog, - promptPreview, - onRetry: (attempt, max) => { - const totalAttempts = max + 1; // max is retry count, +1 for initial attempt - const currentAttempt = attempt + 1; // attempt is retry number, +1 for total attempt number - shellApi?.addMessage({ - role: 'system', - content: `⚠ Empty response detected. Retrying... (attempt ${currentAttempt}/${totalAttempts})`, - timestamp: new Date(), - }); - }, - onExhausted: (max) => { - const totalAttempts = max + 1; - shellApi?.addMessage({ - role: 'system', - content: `❌ Agent did not respond after ${totalAttempts} attempts. Try again or run \`squad doctor\`.`, - timestamp: new Date(), - }); - }, - }); - } - - /** - * Send a message to an agent session and stream the response. - * - * **Streaming architecture:** - * 1. Register `message_delta` listener BEFORE sending message (ensures we catch all deltas) - * 2. Call `awaitStreamedResponse` which uses `sendAndWait` (blocks until session idle) - * 3. Accumulate deltas into `accumulated` via the `onDelta` handler - * 4. Fallback to `sendAndWait` result if no deltas were captured (ghost response handling) - * 5. Remove listener in finally block to prevent memory leaks - * - * Both agent and coordinator dispatch use identical event wiring patterns. - */ - async function dispatchToAgent(agentName: string, message: string): Promise { - debugLog('dispatchToAgent:', agentName, message.slice(0, 120)); - const dispatchStartMs = Date.now(); - let firstTokenRecorded = false; - let dispatchError = false; - let session = agentSessions.get(agentName); - if (!session) { - shellApi?.setActivityHint(`Connecting to ${agentName}...`); - shellApi?.setAgentActivity(agentName, 'connecting...'); - // Give React a tick to render the connection hint before blocking on SDK - await new Promise(resolve => setImmediate(resolve)); - const charter = await loadAgentCharter(agentName, teamRoot); - const systemPrompt = buildAgentPrompt(charter); - - if (!registry.get(agentName)) { - const roleMatch = charter.match(/^#\s+\w+\s+—\s+(.+)$/m); - registry.register(agentName, roleMatch?.[1] ?? 'Agent'); - } - - session = await client.createSession({ - streaming: true, - systemMessage: { mode: 'append', content: systemPrompt }, - workingDirectory: teamRoot, - onPermissionRequest: approveAllPermissions, - }); - agentSessions.set(agentName, session); - } - - // Record agent spawn metric - recordAgentSpawn(agentName, 'direct'); - // Attach streaming pipeline for token/latency metrics - const sid = session.sessionId ?? `agent-${agentName}-${Date.now()}`; - if (!streamingPipeline.isAttached(sid)) streamingPipeline.attachToSession(sid); - streamingPipeline.markMessageStart(sid); - - registry.updateStatus(agentName, 'streaming'); - shellApi?.refreshAgents(); - shellApi?.setActivityHint(`${agentName} is thinking...`); - shellApi?.setAgentActivity(agentName, 'thinking...'); - - let accumulated = ''; - let deltaIndex = 0; - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - debugLog('agent onDelta fired', agentName, { eventType: event['type'] }); - const delta = extractDelta(event); - if (!delta) return; - if (!firstTokenRecorded) { - firstTokenRecorded = true; - recordAgentResponseLatency(agentName, Date.now() - dispatchStartMs, 'direct'); - } - // Feed delta to streaming pipeline for TTFT/latency metrics - streamingPipeline.processEvent({ - type: 'message_delta', - sessionId: sid, - agentName, - content: delta, - index: deltaIndex++, - timestamp: new Date(), - }); - accumulated += delta; - shellApi?.setStreamingContent({ agentName, content: accumulated }); - shellApi?.setActivityHint(undefined); // Clear hint once content is flowing - }; - - // Listen for usage events to record token metrics and capture model name - const onUsage = (event: { type: string; [key: string]: unknown }): void => { - const inputTokens = typeof event['inputTokens'] === 'number' ? event['inputTokens'] : 0; - const outputTokens = typeof event['outputTokens'] === 'number' ? event['outputTokens'] : 0; - const model = typeof event['model'] === 'string' ? event['model'] : 'unknown'; - const estimatedCost = typeof event['estimatedCost'] === 'number' ? event['estimatedCost'] : 0; - // Update model display in agent panel - registry.updateModel(agentName, model); - shellApi?.refreshAgents(); - // Feed usage to streaming pipeline for token/duration metrics - streamingPipeline.processEvent({ - type: 'usage', - sessionId: sid, - agentName, - model, - inputTokens, - outputTokens, - estimatedCost, - timestamp: new Date(), - } as UsageEvent); - }; - - session.on('message_delta', onDelta); - try { session.on('usage', onUsage); } catch { /* event may not exist */ } - // Listen for tool/activity events to show Copilot-style hints - const onToolCall = (event: { type: string; [key: string]: unknown }): void => { - const toolName = event['toolName'] ?? event['name'] ?? event['tool']; - if (typeof toolName === 'string') { - const hintMap: Record = { - 'read_file': 'Reading file...', - 'write_file': 'Writing file...', - 'edit_file': 'Editing file...', - 'run_command': 'Running command...', - 'search': 'Searching codebase...', - 'spawn_agent': `Spawning specialist...`, - 'analyze': 'Analyzing dependencies...', - }; - const hint = hintMap[toolName] ?? `Using ${toolName}...`; - shellApi?.setActivityHint(hint); - registry.updateActivityHint(agentName, hint.replace(/\.\.\.$/, '')); - shellApi?.setAgentActivity(agentName, hint.replace(/\.\.\.$/, '').toLowerCase()); - shellApi?.refreshAgents(); - } - }; - try { session.on('tool_call', onToolCall); } catch { /* event may not exist */ } - try { - accumulated = await ghostRetry(async () => { - accumulated = ''; - deltaIndex = 0; - const fallback = await awaitStreamedResponse(session, message); - debugLog('agent dispatch:', agentName, 'accumulated length', accumulated.length, 'fallback length', fallback.length); - if (!accumulated && fallback) accumulated = fallback; - return accumulated; - }, message); - } catch (err) { - dispatchError = true; - // Evict dead session so next attempt creates a fresh one - debugLog('dispatchToAgent: evicting dead session for', agentName, err); - recordShellError('agent_dispatch', agentName); - recordAgentError(agentName, 'dispatch_failure'); - agentSessions.delete(agentName); - streamBuffers.delete(agentName); - throw err; - } finally { - try { session.off('message_delta', onDelta); } catch { /* session may not support off */ } - try { session.off('usage', onUsage); } catch { /* ignore */ } - try { session.off('tool_call', onToolCall); } catch { /* ignore */ } - // Record agent duration and destroy metrics - const durationMs = Date.now() - dispatchStartMs; - recordAgentDuration(agentName, durationMs, dispatchError ? 'error' : 'success'); - recordAgentDestroy(agentName); - streamingPipeline.detachFromSession(sid); - shellApi?.clearAgentStream(agentName); - shellApi?.setActivityHint(undefined); - shellApi?.setAgentActivity(agentName, undefined); - if (accumulated) { - shellApi?.addMessage({ - role: 'agent', - agentName, - content: accumulated, - timestamp: new Date(), - }); - } - registry.updateStatus(agentName, 'idle'); - shellApi?.refreshAgents(); - } - } - - /** - * Send a message through the coordinator and route based on response. - * - * **Streaming architecture:** - * 1. Create coordinator session with `streaming: true` config - * 2. Register `message_delta` listener BEFORE sending message (ensures we catch all deltas) - * 3. Call `awaitStreamedResponse` which uses `sendAndWait` (blocks until session idle) - * 4. Accumulate deltas into `accumulated` via the `onDelta` handler - * 5. Fallback to `sendAndWait` result if no deltas were captured (ghost response handling) - * 6. Remove listener in finally block to prevent memory leaks - * 7. Parse accumulated response and route to agents or show direct answer - * - * Event wiring is identical to `dispatchToAgent` — both use the same `message_delta` pattern. - */ - - /** Extract a meaningful activity description from coordinator text near an agent name mention. */ - function extractAgentHint(text: string, agentName: string): string { - const lower = text.toLowerCase(); - const nameIdx = lower.lastIndexOf(agentName.toLowerCase()); - if (nameIdx === -1) return 'working...'; - const afterName = text.slice(nameIdx + agentName.length, nameIdx + agentName.length + 120); - const patterns = [ - /^\s*(?:is|will|should|can)\s+(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i, - /^\s*[:\-→—]+\s*(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i, - /^\s+(?:to|for)\s+(\w[\w\s,'-]{3,50}?)(?:[.\n;]|$)/i, - ]; - for (const pattern of patterns) { - const match = afterName.match(pattern); - if (match?.[1]) { - let hint = match[1].trim().replace(/[.…,;:\-]+$/, '').trim(); - if (hint.length > 45) hint = hint.slice(0, 42) + '...'; - return hint.charAt(0).toUpperCase() + hint.slice(1); - } - } - return 'working...'; - } - - async function dispatchToCoordinator(message: string): Promise { - debugLog('dispatchToCoordinator: sending message', message.slice(0, 120)); - const coordStartMs = Date.now(); - let coordFirstToken = false; - let coordError = false; - if (!coordinatorSession) { - shellApi?.setActivityHint('Connecting to SDK...'); - // Give React a tick to render the connection hint before blocking on SDK - await new Promise(resolve => setImmediate(resolve)); - const systemPrompt = await buildCoordinatorPrompt({ teamRoot }); - coordinatorSession = await client.createSession({ - streaming: true, - systemMessage: { mode: 'append', content: systemPrompt }, - workingDirectory: teamRoot, - onPermissionRequest: approveAllPermissions, - }); - debugLog('coordinator session created:', { - sessionId: coordinatorSession.sessionId, - hasOn: typeof coordinatorSession.on === 'function', - hasSendAndWait: typeof coordinatorSession.sendAndWait === 'function', - }); - } - shellApi?.setActivityHint('Coordinator is thinking...'); - - // Record coordinator spawn metric - recordAgentSpawn('coordinator', 'coordinator'); - const coordSid = coordinatorSession.sessionId ?? `coordinator-${Date.now()}`; - if (!streamingPipeline.isAttached(coordSid)) streamingPipeline.attachToSession(coordSid); - streamingPipeline.markMessageStart(coordSid); - - // Build a set of known agent names for detecting mentions in coordinator text - const knownAgentNames = registry.getAll().map(a => a.name.toLowerCase()); - - let accumulated = ''; - let coordDeltaIndex = 0; - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - debugLog('coordinator onDelta fired', { eventType: event['type'] }); - const delta = extractDelta(event); - if (!delta) return; - if (!coordFirstToken) { - coordFirstToken = true; - recordAgentResponseLatency('coordinator', Date.now() - coordStartMs, 'coordinator'); - } - // Feed delta to streaming pipeline for TTFT/latency metrics - streamingPipeline.processEvent({ - type: 'message_delta', - sessionId: coordSid, - agentName: 'coordinator', - content: delta, - index: coordDeltaIndex++, - timestamp: new Date(), - }); - accumulated += delta; - // Don't push coordinator routing text to streamingContent — it's internal - // routing instructions, not user-facing content. Keeping streamingContent - // empty lets the ThinkingIndicator stay visible with the "Routing..." hint. - - // Parse streaming text for agent name mentions → update AgentPanel - for (const name of knownAgentNames) { - if (delta.toLowerCase().includes(name)) { - const displayName = registry.get(name)?.name ?? name; - registry.updateStatus(name, 'working'); - // Extract task description from accumulated coordinator text - const hint = extractAgentHint(accumulated, name); - registry.updateActivityHint(name, hint); - shellApi?.setActivityHint(`${displayName} — ${hint}`); - shellApi?.setAgentActivity(name, hint); - shellApi?.refreshAgents(); - } - } - }; - - // Listen for usage events to record token metrics - const onCoordUsage = (event: { type: string; [key: string]: unknown }): void => { - const inputTokens = typeof event['inputTokens'] === 'number' ? event['inputTokens'] : 0; - const outputTokens = typeof event['outputTokens'] === 'number' ? event['outputTokens'] : 0; - const model = typeof event['model'] === 'string' ? event['model'] : 'unknown'; - const estimatedCost = typeof event['estimatedCost'] === 'number' ? event['estimatedCost'] : 0; - streamingPipeline.processEvent({ - type: 'usage', - sessionId: coordSid, - agentName: 'coordinator', - model, - inputTokens, - outputTokens, - estimatedCost, - timestamp: new Date(), - } as UsageEvent); - }; - - // Listen for tool/activity events (same pattern as dispatchToAgent) - const onToolCall = (event: { type: string; [key: string]: unknown }): void => { - const toolName = event['toolName'] ?? event['name'] ?? event['tool']; - if (typeof toolName === 'string') { - const hintMap: Record = { - 'read_file': 'Reading file...', - 'write_file': 'Writing file...', - 'edit_file': 'Editing file...', - 'run_command': 'Running command...', - 'search': 'Searching codebase...', - 'spawn_agent': 'Spawning agent...', - 'task': 'Dispatching to agent...', - 'analyze': 'Analyzing dependencies...', - }; - // Try to extract agent name from task description (e.g., "🔧 Morpheus: Building effects") - const desc = typeof event['description'] === 'string' ? event['description'] as string : ''; - const parsed = parseAgentFromDescription(desc, knownAgentNames); - if (parsed) { - const { agentName: matchedAgent, taskSummary } = parsed; - registry.updateStatus(matchedAgent, 'working'); - registry.updateActivityHint(matchedAgent, taskSummary || 'working...'); - shellApi?.setActivityHint(`${registry.get(matchedAgent)?.name ?? matchedAgent} — ${taskSummary || 'working'}...`); - shellApi?.setAgentActivity(matchedAgent, taskSummary || 'working...'); - shellApi?.refreshAgents(); - } else { - const trimmedDesc = desc.trim().slice(0, 80); - const hint = trimmedDesc || (hintMap[toolName] ?? `Using ${toolName}...`); - shellApi?.setActivityHint(hint); - } - } - }; - - const activeCoordSession = coordinatorSession; - // Wire event listeners BEFORE sending the message to ensure we catch all events - activeCoordSession.on('message_delta', onDelta); - try { activeCoordSession.on('usage', onCoordUsage); } catch { /* event may not exist */ } - try { activeCoordSession.on('tool_call', onToolCall); } catch { /* event may not exist */ } - debugLog('coordinator message_delta + usage + tool_call listeners registered'); - try { - accumulated = await ghostRetry(async () => { - accumulated = ''; - coordDeltaIndex = 0; - debugLog('coordinator: starting awaitStreamedResponse'); - const fallback = await awaitStreamedResponse(activeCoordSession, message); - debugLog('coordinator dispatch: accumulated length', accumulated.length, 'fallback length', fallback.length); - if (!accumulated && fallback) { - debugLog('coordinator: using sendAndWait fallback content'); - accumulated = fallback; - } - return accumulated; - }, message); - debugLog('coordinator: final accumulated length', accumulated.length); - } catch (err) { - coordError = true; - // Evict dead coordinator session so next attempt creates a fresh one - debugLog('dispatchToCoordinator: evicting dead coordinator session', err); - recordShellError('coordinator_dispatch'); - recordAgentError('coordinator', 'dispatch_failure'); - coordinatorSession = null; - streamBuffers.delete('coordinator'); - throw err; - } finally { - try { - activeCoordSession.off('message_delta', onDelta); - debugLog('coordinator message_delta listener removed'); - } catch { /* session may not support off */ } - try { activeCoordSession.off('usage', onCoordUsage); } catch { /* ignore */ } - try { activeCoordSession.off('tool_call', onToolCall); } catch { /* ignore */ } - // Record coordinator duration and destroy metrics - const coordDurationMs = Date.now() - coordStartMs; - recordAgentDuration('coordinator', coordDurationMs, coordError ? 'error' : 'success'); - recordAgentDestroy('coordinator'); - streamingPipeline.detachFromSession(coordSid); - shellApi?.clearAgentStream('coordinator'); - // Reset any agents that were marked working during coordinator dispatch - for (const name of knownAgentNames) { - const agent = registry.get(name); - if (agent && (agent.status === 'working' || agent.status === 'streaming')) { - registry.updateStatus(name, 'idle'); - shellApi?.setAgentActivity(name, undefined); - } - } - // Re-sync registry from team.md for any new agents added by coordinator - const freshRoster = loadWelcomeData(teamRoot); - if (freshRoster) { - for (const agent of freshRoster.agents) { - const lname = agent.name.toLowerCase(); - if (!registry.get(lname)) { - registry.register(agent.name, agent.role); - } - } - } - shellApi?.refreshWelcome(); - shellApi?.refreshAgents(); - } - - // Parse routing decision from coordinator response - debugLog('coordinator accumulated (first 200 chars)', accumulated.slice(0, 200)); - const decision = parseCoordinatorResponse(accumulated); - debugLog('coordinator decision', { type: decision.type, hasRoutes: !!(decision.routes?.length), hasDirectAnswer: !!decision.directAnswer }); - - if (decision.type === 'route' && decision.routes?.length) { - for (const route of decision.routes) { - shellApi?.addMessage({ - role: 'system', - content: `📌 Routing to ${route.agent}: ${route.task}`, - timestamp: new Date(), - }); - const taskMsg = route.context ? `${route.task}\n\nContext: ${route.context}` : route.task; - await dispatchToAgent(route.agent, taskMsg); - } - } else if (decision.type === 'multi' && decision.routes?.length) { - for (const route of decision.routes) { - shellApi?.addMessage({ - role: 'system', - content: `📌 Routing to ${route.agent}: ${route.task}`, - timestamp: new Date(), - }); - } - await Promise.allSettled( - decision.routes.map(r => dispatchToAgent(r.agent, r.task)) - ); - } else { - // Direct answer or fallback — show coordinator response - shellApi?.addMessage({ - role: 'agent', - agentName: 'coordinator', - content: decision.directAnswer ?? accumulated, - timestamp: new Date(), - }); - } - } - - /** Cancel all active operations (called on Ctrl+C during processing). */ - async function handleCancel(): Promise { - debugLog('handleCancel: aborting active sessions'); - - // Abort init session if active - if (activeInitSession) { - try { await activeInitSession.abort?.(); debugLog('aborted init session'); } catch (err) { debugLog('abort init failed:', err); } - activeInitSession = null; - } - - // Clear pending cast confirmation - pendingCastConfirmation = null; - - // Abort coordinator session - if (coordinatorSession) { - try { await coordinatorSession.abort?.(); } catch (err) { debugLog('abort coordinator failed:', err); } - } - - // Abort all agent sessions - for (const [name, session] of agentSessions) { - try { await session.abort?.(); debugLog(`aborted session: ${name}`); } catch (err) { debugLog(`abort ${name} failed:`, err); } - } - - // Clear streaming state - streamBuffers.clear(); - shellApi?.setStreamingContent(null); - shellApi?.setActivityHint(undefined); - shellApi?.addMessage({ - role: 'system', - content: 'Operation cancelled.', - timestamp: new Date(), - }); - } - - /** - * Init Mode — cast a team when the roster is empty. - * Creates a temporary coordinator session with Init Mode instructions, - * sends the user's message, parses the team proposal, creates files, - * and then re-dispatches the original message to the now-populated team. - */ - async function handleInitCast(parsed: ParsedInput, skipConfirmation?: boolean): Promise { - debugLog('handleInitCast: entering Init Mode'); - shellApi?.setProcessing(true); - - // Check for a stored init prompt (from `squad init "prompt"`) - const initPromptFile = join(teamRoot, '.squad', '.init-prompt'); - let castPrompt = parsed.raw; - if (storage.existsSync(initPromptFile)) { - const storedPrompt = (storage.readSync(initPromptFile) ?? '').trim(); - if (storedPrompt) { - debugLog('handleInitCast: using stored init prompt', storedPrompt.slice(0, 100)); - castPrompt = storedPrompt; - } - } - - shellApi?.addMessage({ - role: 'system', - content: '🏗️ No team yet — casting one based on your project...', - timestamp: new Date(), - }); - shellApi?.setActivityHint('Casting your team...'); - - // Create a temporary Init Mode coordinator session - let initSession: SquadSession | null = null; - try { - // Check for .init-roles marker (set by `squad init --roles` or `/init --roles`) - const initRolesMarker = join(teamRoot, '.squad', '.init-roles'); - const useBaseRoles = storage.existsSync(initRolesMarker); - // Consume the marker immediately — it's been read, no need to persist - if (useBaseRoles) { - try { await storage.delete(initRolesMarker); } catch { /* ignore */ } - } - const initSysPrompt = buildInitModePrompt({ teamRoot, useBaseRoles }); - initSession = await client.createSession({ - streaming: true, - systemMessage: { mode: 'append', content: initSysPrompt }, - workingDirectory: teamRoot, - onPermissionRequest: approveAllPermissions, - }); - activeInitSession = initSession; - debugLog('handleInitCast: init session created'); - - // Send the prompt and collect the response - let accumulated = ''; - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const delta = extractDelta(event); - if (delta) accumulated += delta; - }; - - initSession.on('message_delta', onDelta); - try { - accumulated = await ghostRetry(async () => { - accumulated = ''; - const fallback = await awaitStreamedResponse(initSession!, castPrompt); - if (!accumulated && fallback) accumulated = fallback; - return accumulated; - }, castPrompt); - } finally { - try { initSession.off('message_delta', onDelta); } catch { /* ignore */ } - } - - debugLog('handleInitCast: response length', accumulated.length); - debugLog('handleInitCast: response preview', accumulated.slice(0, 500)); - - // Parse the team proposal - let proposal = parseCastResponse(accumulated); - if (!proposal) { - debugLog('handleInitCast: failed to parse team from response'); - debugLog('handleInitCast: full response:', accumulated); - shellApi?.addMessage({ - role: 'system', - content: [ - '⚠ Could not parse a team proposal from the model response.', - '', - 'Try again, or run: squad init "describe your project"', - ].join('\n'), - timestamp: new Date(), - }); - return; - } - - // Augment with CastingEngine if universe is recognized - proposal = augmentWithCastingEngine(proposal); - debugLog('handleInitCast: augmented proposal', { - universe: proposal.universe, - members: proposal.members.map(m => m.name), - }); - - // Show the proposed team - shellApi?.addMessage({ - role: 'agent', - agentName: 'coordinator', - content: `Team proposed:\n\n${formatCastSummary(proposal)}\n\nUniverse: ${proposal.universe}`, - timestamp: new Date(), - }); - - // Close the init session — it's no longer needed after parsing the proposal - try { await initSession.close?.(); } catch { /* ignore */ } - initSession = null; - activeInitSession = null; - - // P2: Cast confirmation — require user approval for freeform REPL casts - if (!skipConfirmation) { - shellApi?.addMessage({ - role: 'system', - content: 'Look good? Type **y** to confirm or **n** to cancel.', - timestamp: new Date(), - }); - pendingCastConfirmation = { proposal, parsed }; - shellApi?.setActivityHint(undefined); - shellApi?.setProcessing(false); - return; - } - - // Auto-confirmed path (auto-cast or /init command) — create team immediately - await finalizeCast(proposal, parsed); - - } catch (err) { - debugLog('handleInitCast error:', err); - recordShellError('init_cast', err instanceof Error ? err.constructor.name : 'unknown'); - shellApi?.addMessage({ - role: 'system', - content: `⚠ Team casting failed: ${err instanceof Error ? err.message : String(err)}\nTry again or edit .squad/team.md directly.`, - timestamp: new Date(), - }); - } finally { - if (initSession) { - try { await initSession.close?.(); } catch { /* ignore */ } - } - activeInitSession = null; - shellApi?.setActivityHint(undefined); - shellApi?.setProcessing(false); - } - } - - /** - * Finalize a confirmed cast — create team files, register agents, re-dispatch. - * Shared by the auto-confirmed path and the pending-confirmation accept path. - */ - async function finalizeCast(proposal: CastProposal, parsed: ParsedInput): Promise { - shellApi?.setActivityHint('Creating team files...'); - - const result = await createTeam(teamRoot, proposal); - debugLog('finalizeCast: team created', { - members: result.membersCreated.length, - files: result.filesCreated.length, - }); - - shellApi?.addMessage({ - role: 'system', - content: `✅ Team hired! ${result.membersCreated.length} members created.`, - timestamp: new Date(), - }); - - // Clean up stored init prompt (it's been consumed) - const initPromptFile = join(teamRoot, '.squad', '.init-prompt'); - if (storage.existsSync(initPromptFile)) { - try { await storage.delete(initPromptFile); } catch { /* ignore */ } - } - - // Note: .init-roles marker is already cleaned up in handleInitCast (consumed on read) - - // Invalidate the old coordinator session so the next dispatch builds one - // with the real team roster - if (coordinatorSession) { - try { await coordinatorSession.abort?.(); } catch { /* ignore */ } - coordinatorSession = null; - streamBuffers.delete('coordinator'); - } - - // Register the new agents in the session registry - for (const member of proposal.members) { - const roleName = member.role || 'Agent'; - registry.register(member.name, roleName); - } - - // Refresh the header box to show new team roster - shellApi?.refreshWelcome(); - shellApi?.setActivityHint('Routing your message to the team...'); - - // Re-dispatch the original message — now with a populated roster - shellApi?.addMessage({ - role: 'system', - content: '📌 Routing your message to the team now...', - timestamp: new Date(), - }); - await dispatchToCoordinator(parsed.content ?? parsed.raw); - shellApi?.setActivityHint(undefined); - } - - /** Handle dispatching parsed input to agents or coordinator. */ - async function handleDispatch(parsed: ParsedInput): Promise { - // P2: Handle pending cast confirmation before any other dispatch - if (pendingCastConfirmation) { - const input = parsed.raw.trim().toLowerCase(); - const { proposal, parsed: originalParsed } = pendingCastConfirmation; - pendingCastConfirmation = null; - if (input === 'y' || input === 'yes') { - try { - await finalizeCast(proposal, originalParsed); - } catch (err) { - debugLog('finalizeCast error:', err); - recordShellError('init_cast', err instanceof Error ? err.constructor.name : 'unknown'); - shellApi?.addMessage({ - role: 'system', - content: `⚠ Team casting failed: ${err instanceof Error ? err.message : String(err)}\nTry again or edit .squad/team.md directly.`, - timestamp: new Date(), - }); - } - } else { - shellApi?.addMessage({ - role: 'system', - content: 'Cast cancelled. Describe what you\'re building to try again.', - timestamp: new Date(), - }); - } - return; - } - - // Guard: require a Squad team before processing work requests - const teamFile = join(teamRoot, '.squad', 'team.md'); - if (!storage.existsSync(teamFile)) { - // When skipCastConfirmation is explicitly set (true or false), the message - // was routed from an /init flow (inline or follow-up), so bypass the guard - // and go straight to Init Mode casting even without a team.md. - if (parsed.skipCastConfirmation !== undefined) { - await handleInitCast(parsed, parsed.skipCastConfirmation); - return; - } - shellApi?.addMessage({ - role: 'system', - content: '\u26A0 No Squad team found. Run /init to create your team first.', - timestamp: new Date(), - }); - return; - } - - // Check if roster is actually populated — if not, enter Init Mode (cast a team) - const teamContent = storage.readSync(teamFile) ?? ''; - if (!hasRosterEntries(teamContent)) { - await handleInitCast(parsed, parsed.skipCastConfirmation); - return; - } - - messageCount++; - try { - // Check for multiple @agent mentions for parallel dispatch - const knownAgents = registry.getAll().map(s => s.name); - const targets = parseDispatchTargets(parsed.raw, knownAgents); - - if (targets.agents.length > 1) { - debugLog('handleDispatch: multi-agent dispatch detected', { - agents: targets.agents, - contentPreview: targets.content.slice(0, 80), - }); - for (const agent of targets.agents) { - shellApi?.addMessage({ - role: 'system', - content: `📌 Dispatching to ${agent} (parallel)`, - timestamp: new Date(), - }); - } - const results = await Promise.allSettled( - targets.agents.map(agent => dispatchToAgent(agent, targets.content || parsed.raw)) - ); - for (let i = 0; i < results.length; i++) { - const r = results[i]!; - if (r.status === 'rejected') { - debugLog('handleDispatch: parallel agent failed', targets.agents[i], r.reason); - shellApi?.addMessage({ - role: 'system', - content: `⚠ ${targets.agents[i]} failed: ${r.reason instanceof Error ? r.reason.message : String(r.reason)}`, - timestamp: new Date(), - }); - } - } - } else if (parsed.type === 'direct_agent' && parsed.agentName) { - debugLog('handleDispatch: single agent dispatch', { agent: parsed.agentName }); - await dispatchToAgent(parsed.agentName, parsed.content ?? parsed.raw); - } else if (parsed.type === 'coordinator') { - debugLog('handleDispatch: routing through coordinator'); - await dispatchToCoordinator(parsed.content ?? parsed.raw); - } - } catch (err) { - debugLog('handleDispatch error:', err); - recordShellError('dispatch', err instanceof Error ? err.constructor.name : 'unknown'); - const errorMsg = err instanceof Error ? err.message : String(err); - if (shellApi) { - const isRateLimit = - err instanceof RateLimitError || - /rate.?limit|quota.*exceed|429/i.test(errorMsg); - let guidance; - if (isRateLimit) { - const retryAfter = - err instanceof RateLimitError - ? err.retryAfter - : extractRetryAfter(errorMsg); - const model = - err instanceof RateLimitError ? err.context.model : undefined; - guidance = rateLimitGuidance({ retryAfter, model }); - // Persist rate limit status so `squad doctor` can surface it. - try { - const squadDir = join(teamRoot, '.squad'); - storage.writeSync( - join(squadDir, 'rate-limit-status.json'), - JSON.stringify({ - timestamp: new Date().toISOString(), - retryAfter, - model, - message: errorMsg, - }), - ); - } catch { /* non-fatal */ } - } else if (process.env['SQUAD_DEBUG'] === '1') { - const friendly = errorMsg.replace(/^Error:\s*/i, ''); - guidance = genericGuidance(friendly); - } else { - guidance = genericGuidance('Something went wrong processing your message.'); - } - shellApi.addMessage({ - role: 'system', - content: formatGuidance(guidance), - timestamp: new Date(), - }); - } - } - } - - /** Auto-save session when messages change. */ - let shellMessages: ShellMessage[] = []; - function autoSave(): void { - persistedSession.messages = shellMessages; - try { saveSession(teamRoot, persistedSession); } catch (err) { debugLog('autoSave failed:', err); } - } - - /** Callback for /resume command — replaces current messages with restored session. */ - function onRestoreSession(session: SessionData): void { - persistedSession = session; - // Clear old messages and terminal to prevent content bleed-through - shellApi?.clearMessages(); - process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); - // Use unwrapped addMessage to avoid per-message autoSave and duplicate pushes - for (const msg of session.messages) { - origAddMessage?.(msg); - } - shellMessages = [...session.messages]; - autoSave(); - } - - // Clear terminal and scrollback — prevents old scaffold output from - // bleeding through above the header box in extended sessions. - // Also ensures we start from a clean viewport before Ink renders. - process.stdout.write('\x1b[2J\x1b[3J\x1b[H'); - - const { waitUntilExit, unmount } = render( - React.createElement(ErrorBoundary, null, - React.createElement(App, { - registry, - renderer, - teamRoot, - version: pkg.version, - onReady: (api: ShellApi) => { - // Wrap addMessage to auto-save on every message - const origAdd = api.addMessage; - origAddMessage = origAdd; - api.addMessage = (msg: ShellMessage) => { - origAdd(msg); - shellMessages.push(msg); - autoSave(); - }; - shellApi = api; - - // Restore messages from resumed session - if (recentSession && recentSession.messages.length > 0) { - for (const msg of recentSession.messages) { - origAdd(msg); - } - shellMessages = [...recentSession.messages]; - origAdd({ - role: 'system', - content: `✓ Resumed session ${recentSession.id.slice(0, 8)} (${recentSession.messages.length} messages)`, - timestamp: new Date(), - }); - } - - // Bug fix #3: Clean up orphan .init-prompt if team already exists - const initPromptPath = join(teamRoot, '.squad', '.init-prompt'); - const teamFilePath = join(teamRoot, '.squad', 'team.md'); - if (storage.existsSync(teamFilePath)) { - const tc = storage.readSync(teamFilePath) ?? ''; - if (hasRosterEntries(tc) && storage.existsSync(initPromptPath)) { - debugLog('Cleaning up orphan .init-prompt (team already exists)'); - void storage.delete(initPromptPath).catch(() => { /* ignore */ }); - } - } - - // Bug fix #1: Auto-cast after shellApi is guaranteed to be set (no race condition) - if (storage.existsSync(initPromptPath) && storage.existsSync(teamFilePath)) { - const tc = storage.readSync(teamFilePath) ?? ''; - if (!hasRosterEntries(tc)) { - const storedPrompt = (storage.readSync(initPromptPath) ?? '').trim(); - if (storedPrompt) { - debugLog('Auto-cast: .init-prompt found with empty roster, triggering cast'); - // Trigger cast after Ink settles, but now shellApi is guaranteed to be set - setTimeout(() => { - handleInitCast({ type: 'coordinator', raw: storedPrompt, content: storedPrompt }, true).catch(err => { - debugLog('Auto-cast error:', err); - }); - }, 100); - } - } - } - }, - onDispatch: handleDispatch, - onCancel: handleCancel, - onRestoreSession, - }), - ), - // NOTE: Both incrementalRendering AND Ink's trailing-newline have been - // patched via scripts/patch-ink-rendering.mjs (runs on postinstall). - // This means: (a) logUpdate uses standard erase-and-rewrite, (b) no - // trailing '\n' is appended to output, (c) no clearTerminal scroll-to-top. - // patchConsole: false ensures console.log doesn't corrupt Ink's rendering. - { exitOnCtrlC: false, patchConsole: false }, - ); - - // Clear the loading message now that Ink is rendering - process.stderr.write('\r\x1b[K'); - - // Signal handlers for graceful exit — prevents orphaned child processes on Ctrl+C. - // Calling unmount() causes waitUntilExit() to resolve, triggering the normal - // cleanup path below (session close, client disconnect, telemetry shutdown). - let _shellSignalCode: number | undefined; - let _shellExiting = false; - const handleShellSignal = (signal: 'SIGINT' | 'SIGTERM'): void => { - const code = signal === 'SIGINT' ? 130 : 143; - if (_shellExiting) { - // Second signal — force exit immediately - process.exit(code); - } - _shellExiting = true; - _shellSignalCode = code; - debugLog(`Received ${signal}, unmounting shell...`); - unmount(); - }; - process.on('SIGINT', () => handleShellSignal('SIGINT')); - process.on('SIGTERM', () => handleShellSignal('SIGTERM')); - - await waitUntilExit(); - - // Record shell session duration before cleanup - recordShellSessionDuration(Date.now() - sessionStart); - - // Final session save before cleanup - autoSave(); - - // Consult mode reminder: prompt user to extract learnings before exiting - try { - // Use the resolved teamRoot instead of cwd so this works from subdirectories (#207) - const squadDir = join(teamRoot, '.squad'); - const configPath = join(squadDir, 'config.json'); - if (storage.existsSync(configPath)) { - const raw = storage.readSync(configPath) ?? ''; - const parsed = JSON.parse(raw) as unknown; - if (parsed && typeof parsed === 'object' && (parsed as { consult?: boolean }).consult === true) { - const nc = process.env['NO_COLOR'] != null && process.env['NO_COLOR'] !== ''; - const highlight = nc ? '' : '\x1b[33m'; - const reset = nc ? '' : '\x1b[0m'; - console.log(''); - console.log(`${highlight}📤 You're in consult mode.${reset}`); - console.log(` Run ${highlight}squad extract${reset} to bring learnings home.`); - console.log(` Run ${highlight}squad extract --clean${reset} to extract and remove project .squad/`); - console.log(''); - } - } - } catch { - // Silently ignore — consult mode check is optional - } - - // Cleanup: close all sessions and disconnect - for (const [name, session] of agentSessions) { - try { await session.close(); } catch (err) { debugLog(`Failed to close session for ${name}:`, err); } - } - // coordinatorSession is assigned inside dispatchToCoordinator closure; - // TS control flow can't see the mutation, so assert the type. - const coordSession = coordinatorSession as SquadSession | null; - if (coordSession) { - try { await coordSession.close(); } catch (err) { debugLog('Failed to close coordinator session:', err); } - } - try { await client.disconnect(); } catch (err) { debugLog('Failed to disconnect client:', err); } - try { await lifecycle.shutdown(); } catch (err) { debugLog('Failed to shutdown lifecycle:', err); } - try { await telemetry.shutdown(); } catch (err) { debugLog('Failed to shutdown telemetry:', err); } - - // NO_COLOR-aware exit message with session summary - const nc = process.env['NO_COLOR'] != null && process.env['NO_COLOR'] !== ''; - const prefix = nc ? '-- ' : '\x1b[36m--\x1b[0m '; - - if (messageCount > 0) { - const elapsedMs = Date.now() - sessionStart; - const mins = Math.round(elapsedMs / 60000); - const durationStr = mins >= 1 ? `${mins} min` : '<1 min'; - const agentNames = [...agentSessions.keys()]; - const agentStr = agentNames.length > 0 ? ` with ${agentNames.join(', ')}.` : ''; - console.log(`${prefix}Squad out. ${durationStr}${agentStr} ${messageCount} message${messageCount === 1 ? '' : 's'}.`); - } else { - console.log(`${prefix}Squad out.`); - } - - // If we exited due to a signal, propagate the conventional exit code - if (_shellSignalCode !== undefined) { - process.exit(_shellSignalCode); - } -} diff --git a/packages/squad-cli/src/cli/shell/lifecycle.ts b/packages/squad-cli/src/cli/shell/lifecycle.ts deleted file mode 100644 index f2c5e119a..000000000 --- a/packages/squad-cli/src/cli/shell/lifecycle.ts +++ /dev/null @@ -1,332 +0,0 @@ -/** - * Shell session lifecycle management. - * - * Manages initialization (team discovery, path resolution), - * message history tracking, state transitions, and graceful shutdown. - * - * @module cli/shell/lifecycle - */ - -import path from 'node:path'; -import { FSStorageProvider } from '@bradygaster/squad-sdk'; -import { SessionRegistry } from './sessions.js'; -import { ShellRenderer } from './render.js'; -import type { ShellState, ShellMessage } from './types.js'; - -/** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */ -function debugLog(...args: unknown[]): void { - if (process.env['SQUAD_DEBUG'] === '1') { - console.error('[SQUAD_DEBUG]', ...args); - } -} - -export interface LifecycleOptions { - teamRoot: string; - renderer: ShellRenderer; - registry: SessionRegistry; -} - -export interface DiscoveredAgent { - name: string; - role: string; - charter: string | undefined; - status: string; -} - -/** - * Manages the shell session lifecycle: - * - Initialization (load team, resolve squad path, populate registry) - * - Message handling (route user input, track responses) - * - Cleanup (graceful shutdown, session cleanup) - */ -export class ShellLifecycle { - private state: ShellState; - private options: LifecycleOptions; - private messageHistory: ShellMessage[] = []; - private discoveredAgents: DiscoveredAgent[] = []; - - constructor(options: LifecycleOptions) { - this.options = options; - this.state = { - status: 'initializing', - activeAgents: new Map(), - messageHistory: [], - }; - } - - /** - * Initialize the shell — verify .squad/, load team.md, discover agents. - * - * Reads via FSStorageProvider so all file access is routed through the - * StorageProvider abstraction (Phase 3 migration). - */ - async initialize(): Promise { - this.state.status = 'initializing'; - const storage = new FSStorageProvider(); - - const squadDir = path.resolve(this.options.teamRoot, '.squad'); - if (!await storage.exists(squadDir) || !await storage.isDirectory(squadDir)) { - this.state.status = 'error'; - const err = new Error( - `No team found. Run \`squad init\` to create one.` - ); - debugLog('initialize: .squad/ directory not found at', squadDir); - throw err; - } - - const teamPath = path.join(squadDir, 'team.md'); - const teamContent = await storage.read(teamPath); - if (teamContent === undefined) { - this.state.status = 'error'; - const err = new Error( - `No team manifest found. The .squad/ directory exists but has no team.md. Run \`squad init\` to fix.` - ); - debugLog('initialize: team.md not found at', teamPath); - throw err; - } - - this.discoveredAgents = parseTeamManifest(teamContent); - - if (this.discoveredAgents.length === 0) { - const initPromptPath = path.join(squadDir, '.init-prompt'); - if (!await storage.exists(initPromptPath)) { - console.warn('⚠ No agents found in team.md. Run `squad init "describe your project"` to cast a team.'); - } - // Auto-cast message is shown inside the Ink UI (index.ts handleInitCast) - } - - // Register discovered agents in the session registry - for (const agent of this.discoveredAgents) { - if (agent.status === 'Active') { - this.options.registry.register(agent.name, agent.role); - } - } - - this.state.status = 'ready'; - } - - /** Get current shell state. */ - getState(): ShellState { - return { ...this.state }; - } - - /** Get agents discovered during initialization. */ - getDiscoveredAgents(): readonly DiscoveredAgent[] { - return this.discoveredAgents; - } - - /** Add a user message to history. */ - addUserMessage(content: string): ShellMessage { - const msg: ShellMessage = { - role: 'user', - content, - timestamp: new Date(), - }; - this.messageHistory.push(msg); - this.state.messageHistory = [...this.messageHistory]; - return msg; - } - - /** Add an agent response to history. */ - addAgentMessage(agentName: string, content: string): ShellMessage { - const msg: ShellMessage = { - role: 'agent', - agentName, - content, - timestamp: new Date(), - }; - this.messageHistory.push(msg); - this.state.messageHistory = [...this.messageHistory]; - return msg; - } - - /** Add a system message. */ - addSystemMessage(content: string): ShellMessage { - const msg: ShellMessage = { - role: 'system', - content, - timestamp: new Date(), - }; - this.messageHistory.push(msg); - this.state.messageHistory = [...this.messageHistory]; - return msg; - } - - /** Get message history (optionally filtered by agent). */ - getHistory(agentName?: string): ShellMessage[] { - if (agentName) { - return this.messageHistory.filter(m => m.agentName === agentName); - } - return [...this.messageHistory]; - } - - /** Clean shutdown — close all sessions, clear state. */ - async shutdown(): Promise { - this.state.status = 'initializing'; // transitioning - this.options.registry.clear(); - this.messageHistory = []; - this.state.messageHistory = []; - this.state.activeAgents.clear(); - this.discoveredAgents = []; - } -} - -/** - * Parse the Members table from team.md and extract agent metadata. - * - * Expected markdown table format: - * ``` - * | Name | Role | Charter | Status | - * |------|------|---------|--------| - * | Keaton | Lead | `.squad/agents/keaton/charter.md` | ✅ Active | - * ``` - */ -function parseTeamManifest(content: string): DiscoveredAgent[] { - const agents: DiscoveredAgent[] = []; - const lines = content.split('\n'); - - let inMembersTable = false; - let headerParsed = false; - - for (const line of lines) { - const trimmed = line.trim(); - - // Detect the "Members" section header - if (/^#+\s*Members/i.test(trimmed)) { - inMembersTable = true; - headerParsed = false; - continue; - } - - // Stop at the next section header - if (inMembersTable && /^#+\s/.test(trimmed) && !/^#+\s*Members/i.test(trimmed)) { - inMembersTable = false; - continue; - } - - if (!inMembersTable) continue; - - // Skip non-table lines - if (!trimmed.startsWith('|')) continue; - - // Skip the header row (contains "Name") and separator row (contains "---") - if (trimmed.includes('---') || /\|\s*Name\s*\|/i.test(trimmed)) { - headerParsed = true; - continue; - } - - if (!headerParsed) continue; - - const cells = trimmed - .split('|') - .map(c => c.trim()) - .filter(c => c.length > 0); - - if (cells.length < 4) continue; - - const name = cells[0]!; - const role = cells[1]!; - const charter = cells[2]?.startsWith('`') ? cells[2].replace(/`/g, '') : undefined; - - // Extract status text from emoji-prefixed status (e.g. "✅ Active" → "Active") - const rawStatus = cells[3]!; - const status = rawStatus.replace(/^[^\w]*/, '').trim(); - - agents.push({ name, role, charter, status }); - } - - return agents; -} - -/** Role → emoji mapping for rich terminal display. */ -export function getRoleEmoji(role: string): string { - const normalized = role.toLowerCase(); - const exactMap: Record = { - 'lead': '🏗️', - 'prompt engineer': '💬', - 'core dev': '🔧', - 'tester': '🧪', - 'devrel': '📢', - 'sdk expert': '📦', - 'typescript engineer': '⌨️', - 'git & release': '🏷️', - 'node.js runtime': '⚡', - 'distribution': '📤', - 'security': '🔒', - 'graphic designer': '🎨', - 'vs code extension': '🧩', - 'session logger': '📋', - 'work monitor': '🔄', - 'coordinator': '🎯', - 'coding agent': '🤖', - }; - if (exactMap[normalized]) return exactMap[normalized]!; - // Keyword-based fallbacks for custom roles - if (normalized.includes('lead') || normalized.includes('architect')) return '🏗️'; - if (normalized.includes('frontend') || normalized.includes('ui')) return '⚛️'; - if (normalized.includes('backend') || normalized.includes('api') || normalized.includes('server')) return '🔧'; - if (normalized.includes('test') || normalized.includes('qa') || normalized.includes('quality')) return '🧪'; - if (normalized.includes('game') || normalized.includes('logic')) return '🎮'; - if (normalized.includes('devops') || normalized.includes('infra') || normalized.includes('platform')) return '⚙️'; - if (normalized.includes('security') || normalized.includes('auth')) return '🔒'; - if (normalized.includes('doc') || normalized.includes('writer') || normalized.includes('devrel')) return '📝'; - if (normalized.includes('data') || normalized.includes('database') || normalized.includes('analytics')) return '📊'; - if (normalized.includes('design') || normalized.includes('visual') || normalized.includes('graphic')) return '🎨'; - if (normalized.includes('dev') || normalized.includes('engineer')) return '🔧'; - return '🔹'; -} - -export interface WelcomeData { - projectName: string; - description: string; - agents: Array<{ name: string; role: string; emoji: string }>; - focus: string | null; - /** True on the very first launch after `squad init`. */ - isFirstRun: boolean; -} - -/** - * Load welcome screen data from .squad/ directory. - * - * Uses FSStorageProvider (sync) so all reads are routed through the - * StorageProvider abstraction. Kept synchronous to preserve the React - * useState initializer contract in App.tsx (Phase 3 migration). - */ -export function loadWelcomeData(teamRoot: string): WelcomeData | null { - try { - const storage = new FSStorageProvider(); - const teamPath = path.join(teamRoot, '.squad', 'team.md'); - const content = storage.readSync(teamPath); - if (content === undefined) return null; - - const titleMatch = content.match(/^#\s+Squad Team\s+—\s+(.+)$/m); - const projectName = titleMatch?.[1] ?? 'Squad'; - const descMatch = content.match(/^>\s+(.+)$/m); - const description = descMatch?.[1] ?? ''; - - const agents = parseTeamManifest(content) - .filter(a => a.status === 'Active') - .map(a => ({ name: a.name, role: a.role, emoji: getRoleEmoji(a.role) })); - - let focus: string | null = null; - const nowPath = path.join(teamRoot, '.squad', 'identity', 'now.md'); - const nowContent = storage.readSync(nowPath); - if (nowContent !== undefined) { - const focusMatch = nowContent.match(/focus_area:\s*(.+)/); - focus = focusMatch?.[1]?.trim() ?? null; - } - - // Detect and consume first-run marker from `squad init` - const firstRunPath = path.join(teamRoot, '.squad', '.first-run'); - let isFirstRun = false; - if (storage.existsSync(firstRunPath)) { - isFirstRun = true; - try { storage.deleteSync(firstRunPath); } catch { /* non-fatal */ } - } - - return { projectName, description, agents, focus, isFirstRun }; - } catch (err) { - debugLog('loadWelcomeData failed:', err); - return null; - } -} diff --git a/packages/squad-cli/src/cli/shell/memory.ts b/packages/squad-cli/src/cli/shell/memory.ts deleted file mode 100644 index 6aa0f0d08..000000000 --- a/packages/squad-cli/src/cli/shell/memory.ts +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Memory management for shell sessions. - * Enforces buffer limits and handles cleanup. - */ - -export interface MemoryLimits { - /** Max messages to keep in history (default: 200) */ - maxMessages: number; - /** Max buffer size per stream in bytes (default: 1MB) */ - maxStreamBuffer: number; - /** Max concurrent sessions (default: 10) */ - maxSessions: number; - /** Session idle timeout in ms (default: 5 minutes) */ - sessionIdleTimeout: number; -} - -export const DEFAULT_LIMITS: MemoryLimits = { - maxMessages: 200, - maxStreamBuffer: 1024 * 1024, // 1MB - maxSessions: 10, - sessionIdleTimeout: 5 * 60 * 1000, // 5 minutes -}; - -export class MemoryManager { - private limits: MemoryLimits; - private bufferSizes = new Map(); - - constructor(limits: Partial = {}) { - this.limits = { ...DEFAULT_LIMITS, ...limits }; - } - - /** Check if a new session can be created */ - canCreateSession(currentCount: number): boolean { - return currentCount < this.limits.maxSessions; - } - - /** Track buffer growth, return true if within limits */ - trackBuffer(sessionId: string, additionalBytes: number): boolean { - const current = this.bufferSizes.get(sessionId) ?? 0; - const newSize = current + additionalBytes; - if (newSize > this.limits.maxStreamBuffer) { - return false; // would exceed limit - } - this.bufferSizes.set(sessionId, newSize); - return true; - } - - /** Trim message history to limit */ - trimMessages(messages: T[]): T[] { - if (messages.length <= this.limits.maxMessages) return messages; - return messages.slice(-this.limits.maxMessages); - } - - /** Trim messages and return both kept and archived portions */ - trimWithArchival(messages: T[]): { kept: T[]; archived: T[] } { - if (messages.length <= this.limits.maxMessages) { - return { kept: messages, archived: [] }; - } - const cutoff = messages.length - this.limits.maxMessages; - return { - kept: messages.slice(cutoff), - archived: messages.slice(0, cutoff), - }; - } - - /** Clear buffer tracking for a session */ - clearBuffer(sessionId: string): void { - this.bufferSizes.delete(sessionId); - } - - /** Get current memory stats */ - getStats(): { sessions: number; totalBufferBytes: number } { - let total = 0; - for (const size of this.bufferSizes.values()) total += size; - return { sessions: this.bufferSizes.size, totalBufferBytes: total }; - } - - /** Get configured limits */ - getLimits(): Readonly { - return { ...this.limits }; - } -} diff --git a/packages/squad-cli/src/cli/shell/render.ts b/packages/squad-cli/src/cli/shell/render.ts deleted file mode 100644 index f191a7df3..000000000 --- a/packages/squad-cli/src/cli/shell/render.ts +++ /dev/null @@ -1,48 +0,0 @@ -/** - * Console-based shell renderer. - * - * Renders agent output to the terminal using plain stdout writes. - * This is the pre-ink renderer — will be replaced with ink components later. - * - * @module cli/shell/render - */ - -export class ShellRenderer { - private currentAgent: string | null = null; - - /** Print a content delta (streaming chunk). */ - renderDelta(agentName: string, content: string): void { - if (this.currentAgent !== agentName) { - if (this.currentAgent) process.stdout.write('\n'); - process.stdout.write(`\n${agentName}: `); - this.currentAgent = agentName; - } - process.stdout.write(content); - } - - /** Print a complete message. */ - renderMessage(role: string, name: string | undefined, content: string): void { - const prefix = name ? `${name}` : role; - console.log(`\n${prefix}: ${content}`); - this.currentAgent = null; - } - - /** Print a system message. */ - renderSystem(message: string): void { - console.log(`\n💡 ${message}`); - this.currentAgent = null; - } - - /** Print an error. */ - renderError(agentName: string, error: string): void { - console.error(`\n❌ ${agentName}: ${error}`); - this.currentAgent = null; - } - - /** Print usage stats. */ - renderUsage(model: string, inputTokens: number, outputTokens: number, cost: number): void { - if (cost > 0) { - console.log(` 📊 ${model}: ${inputTokens}+${outputTokens} tokens ($${cost.toFixed(4)})`); - } - } -} diff --git a/packages/squad-cli/src/cli/shell/router.ts b/packages/squad-cli/src/cli/shell/router.ts deleted file mode 100644 index 0a0e8e060..000000000 --- a/packages/squad-cli/src/cli/shell/router.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { parseCoordinatorResponse, type RoutingDecision } from './coordinator.js'; -import { SessionRegistry } from './sessions.js'; - -export type MessageType = 'slash_command' | 'direct_agent' | 'coordinator'; - -export interface ParsedInput { - type: MessageType; - raw: string; - command?: string; // for slash commands: 'status', 'history', etc. - args?: string[]; // command arguments - agentName?: string; // for @Agent direct messages - content?: string; // the actual message content - skipCastConfirmation?: boolean; // skip cast confirmation (explicit /init or auto-cast) -} - -/** - * Parse user input to determine routing. - * - /command → slash command - * - @AgentName message → direct to agent - * - anything else → coordinator - */ -export function parseInput(input: string, knownAgents: string[]): ParsedInput { - const trimmed = input.trim(); - - // Slash commands - if (trimmed.startsWith('/')) { - const parts = trimmed.slice(1).split(/\s+/); - return { - type: 'slash_command', - raw: trimmed, - command: parts[0]!.toLowerCase(), - args: parts.slice(1), - }; - } - - // @Agent direct addressing - const atMatch = trimmed.match(/^@(\w+)\s*(.*)/s); - if (atMatch) { - const name = atMatch[1]!; - // Case-insensitive match against known agents - const match = knownAgents.find(a => a.toLowerCase() === name.toLowerCase()); - if (match) { - const body = atMatch[2]!.trim(); - if (!body) { - // @Agent with no message — route to coordinator with context - return { - type: 'coordinator', - raw: trimmed, - content: trimmed, - }; - } - return { - type: 'direct_agent', - raw: trimmed, - agentName: match, - content: body, - }; - } - // Unknown @mention — fall through to coordinator with the full text - // (coordinator can still route or answer) - } - - // Also support "AgentName, do something" syntax - const commaMatch = trimmed.match(/^(\w+),\s*(.*)/s); - if (commaMatch) { - const name = commaMatch[1]!; - const match = knownAgents.find(a => a.toLowerCase() === name.toLowerCase()); - if (match) { - const body = commaMatch[2]!.trim(); - if (!body) { - return { - type: 'coordinator', - raw: trimmed, - content: trimmed, - }; - } - return { - type: 'direct_agent', - raw: trimmed, - agentName: match, - content: body, - }; - } - } - - // Default: route to coordinator - return { - type: 'coordinator', - raw: trimmed, - content: trimmed, - }; -} - -/** Result of extracting multiple @agent mentions from a message. */ -export interface DispatchTargets { - agents: string[]; - content: string; -} - -/** - * Extract multiple @agent mentions from a message for parallel dispatch. - * Returns all matched agent names (de-duplicated, case-insensitive) and - * the remaining message content with mentions stripped out. - * - * Examples: - * "@Fenster @Hockney fix and test" → { agents: ['Fenster','Hockney'], content: 'fix and test' } - * "plain message" → { agents: [], content: 'plain message' } - */ -export function parseDispatchTargets(input: string, knownAgents: string[]): DispatchTargets { - const trimmed = input.trim(); - const mentionRegex = /@(\w+)/g; - const matched: string[] = []; - const seen = new Set(); - let m: RegExpExecArray | null; - - while ((m = mentionRegex.exec(trimmed)) !== null) { - const name = m[1]!; - const agent = knownAgents.find(a => a.toLowerCase() === name.toLowerCase()); - if (agent && !seen.has(agent.toLowerCase())) { - seen.add(agent.toLowerCase()); - matched.push(agent); - } - } - - // Strip all @mentions (known or unknown) from the content - const content = trimmed.replace(/@\w+/g, '').replace(/\s+/g, ' ').trim(); - - return { agents: matched, content }; -} diff --git a/packages/squad-cli/src/cli/shell/session-store.ts b/packages/squad-cli/src/cli/shell/session-store.ts deleted file mode 100644 index 4b4cdab8d..000000000 --- a/packages/squad-cli/src/cli/shell/session-store.ts +++ /dev/null @@ -1,159 +0,0 @@ -/** - * Session persistence — save and restore shell message history across restarts. - * - * Sessions are stored as JSON files in `.squad/sessions/`. - * Each file is named `{safeTimestamp}_{sessionId}.json`. - */ - -import { randomUUID } from 'node:crypto'; -import { join } from 'node:path'; -import { FSStorageProvider, safeTimestamp } from '@bradygaster/squad-sdk'; -import type { ShellMessage } from './types.js'; - -const storage = new FSStorageProvider(); - -/** Serialisable session envelope persisted to disk. */ -export interface SessionData { - id: string; - createdAt: string; - lastActiveAt: string; - messages: ShellMessage[]; -} - -/** Lightweight summary returned by {@link listSessions}. */ -export interface SessionSummary { - id: string; - createdAt: string; - lastActiveAt: string; - messageCount: number; - filePath: string; -} - -/** 24 hours in milliseconds — sessions older than this are not offered for resume. */ -const RECENT_THRESHOLD_MS = 24 * 60 * 60 * 1000; - -function sessionsDir(teamRoot: string): string { - return join(teamRoot, '.squad', 'sessions'); -} - -function ensureDir(dir: string): void { - if (!storage.existsSync(dir)) { - storage.mkdirSync(dir, { recursive: true }); - } -} - -/** - * Create a new, empty session and return its data envelope. - */ -export function createSession(): SessionData { - const now = new Date().toISOString(); - return { - id: randomUUID(), - createdAt: now, - lastActiveAt: now, - messages: [], - }; -} - -/** - * Persist a session to disk. - * - * The file is named `{safeTimestamp}_{id}.json` so that lexicographic sorting - * equals chronological ordering while remaining Windows-safe. - */ -export function saveSession(teamRoot: string, session: SessionData): string { - const dir = sessionsDir(teamRoot); - ensureDir(dir); - - session.lastActiveAt = new Date().toISOString(); - - // Determine file path — reuse existing file for this session ID if present - const existing = findSessionFile(dir, session.id); - const filePath = existing ?? join(dir, `${safeTimestamp()}_${session.id}.json`); - - storage.writeSync(filePath, JSON.stringify(session, null, 2)); - return filePath; -} - -/** - * List all persisted sessions, most recent first. - */ -export function listSessions(teamRoot: string): SessionSummary[] { - const dir = sessionsDir(teamRoot); - if (!storage.existsSync(dir)) return []; - - const files = storage.listSync(dir).filter(f => f.endsWith('.json')); - const summaries: SessionSummary[] = []; - - for (const file of files) { - try { - const filePath = join(dir, file); - const raw = storage.readSync(filePath); - if (raw === undefined) continue; - const data = JSON.parse(raw) as SessionData; - summaries.push({ - id: data.id, - createdAt: data.createdAt, - lastActiveAt: data.lastActiveAt, - messageCount: data.messages.length, - filePath, - }); - } catch { - // Skip malformed files - } - } - - // Most recent first - summaries.sort((a, b) => b.lastActiveAt.localeCompare(a.lastActiveAt)); - return summaries; -} - -/** - * Load the most recent session if it was active within the last 24 hours. - * Returns `null` when no recent session exists. - */ -export function loadLatestSession(teamRoot: string): SessionData | null { - const sessions = listSessions(teamRoot); - if (sessions.length === 0) return null; - - const latest = sessions[0]!; - const age = Date.now() - new Date(latest.lastActiveAt).getTime(); - if (age > RECENT_THRESHOLD_MS) return null; - - return loadSessionById(teamRoot, latest.id); -} - -/** - * Load a specific session by ID. - */ -export function loadSessionById(teamRoot: string, sessionId: string): SessionData | null { - const dir = sessionsDir(teamRoot); - if (!storage.existsSync(dir)) return null; - - const filePath = findSessionFile(dir, sessionId); - if (!filePath) return null; - - try { - const raw = storage.readSync(filePath); - if (raw === undefined) return null; - const data = JSON.parse(raw) as SessionData; - // Rehydrate Date objects on messages - data.messages = data.messages.map(m => ({ - ...m, - timestamp: new Date(m.timestamp), - })); - return data; - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function findSessionFile(dir: string, sessionId: string): string | null { - const files = storage.listSync(dir); - const match = files.find(f => f.includes(sessionId) && f.endsWith('.json')); - return match ? join(dir, match) : null; -} diff --git a/packages/squad-cli/src/cli/shell/sessions.ts b/packages/squad-cli/src/cli/shell/sessions.ts deleted file mode 100644 index 3ebeb2f9c..000000000 --- a/packages/squad-cli/src/cli/shell/sessions.ts +++ /dev/null @@ -1,59 +0,0 @@ -/** - * Session registry — tracks active agent sessions within the interactive shell. - */ - -import { AgentSession } from './types.js'; - -export class SessionRegistry { - private sessions = new Map(); - - register(name: string, role: string): AgentSession { - const session: AgentSession = { - name, - role, - status: 'idle', - startedAt: new Date(), - }; - this.sessions.set(name.toLowerCase(), session); - return session; - } - - get(name: string): AgentSession | undefined { - return this.sessions.get(name.toLowerCase()); - } - - getAll(): AgentSession[] { - return Array.from(this.sessions.values()); - } - - getActive(): AgentSession[] { - return this.getAll().filter(s => s.status === 'working' || s.status === 'streaming'); - } - - updateStatus(name: string, status: AgentSession['status']): void { - const session = this.sessions.get(name.toLowerCase()); - if (session) { - session.status = status; - // Clear activity hint when agent goes idle or errors - if (status === 'idle' || status === 'error') session.activityHint = undefined; - } - } - - updateActivityHint(name: string, hint: string | undefined): void { - const session = this.sessions.get(name.toLowerCase()); - if (session) session.activityHint = hint; - } - - updateModel(name: string, model: string | undefined): void { - const session = this.sessions.get(name.toLowerCase()); - if (session) session.model = model; - } - - remove(name: string): boolean { - return this.sessions.delete(name.toLowerCase()); - } - - clear(): void { - this.sessions.clear(); - } -} diff --git a/packages/squad-cli/src/cli/shell/shell-metrics.ts b/packages/squad-cli/src/cli/shell/shell-metrics.ts deleted file mode 100644 index 044c2dd35..000000000 --- a/packages/squad-cli/src/cli/shell/shell-metrics.ts +++ /dev/null @@ -1,123 +0,0 @@ -/** - * Shell Observability Metrics (Issues #508, #520, #526, #530, #531) - * - * Provides user-facing shell metrics: session lifetime, agent response - * latency (time to first visible token), error rate, and session count. - * No-op when SQUAD_TELEMETRY !== '1' or OTel is not configured. - * - * Privacy-first: opt-in via SQUAD_TELEMETRY=1 env var. No PII collected. - * - * @module shell/shell-metrics - */ - -import { getMeter } from '@bradygaster/squad-sdk'; - -// ============================================================================ -// Types -// ============================================================================ - -interface ShellMetrics { - sessionDuration: ReturnType['createHistogram']>; - agentLatency: ReturnType['createHistogram']>; - errorCount: ReturnType['createCounter']>; - sessionCount: ReturnType['createCounter']>; -} - -// ============================================================================ -// Internal state -// ============================================================================ - -let _metrics: ShellMetrics | undefined; -let _enabled = false; - -// ============================================================================ -// Opt-in gate -// ============================================================================ - -/** Check if shell telemetry is opt-in enabled via SQUAD_TELEMETRY=1. */ -export function isShellTelemetryEnabled(): boolean { - return process.env['SQUAD_TELEMETRY'] === '1'; -} - -function ensureMetrics(): ShellMetrics | undefined { - if (!_enabled) return undefined; - if (!_metrics) { - const meter = getMeter('squad-shell'); - _metrics = { - sessionDuration: meter.createHistogram('squad.shell.session_duration_ms', { - description: 'Shell session duration in milliseconds', - unit: 'ms', - }), - agentLatency: meter.createHistogram('squad.shell.agent_response_latency_ms', { - description: 'Time from message send to first response token in milliseconds', - unit: 'ms', - }), - errorCount: meter.createCounter('squad.shell.error_count', { - description: 'Total errors during shell session', - }), - sessionCount: meter.createCounter('squad.shell.session_count', { - description: 'Total shell sessions started', - }), - }; - } - return _metrics; -} - -// ============================================================================ -// Public API -// ============================================================================ - -/** - * Enable shell metrics collection. Call once at shell startup. - * Always enabled when OTel is configured; SQUAD_TELEMETRY=1 also enables. - * Returns true if metrics were enabled. - */ -export function enableShellMetrics(): boolean { - const hasOTel = !!process.env['OTEL_EXPORTER_OTLP_ENDPOINT']; - if (!hasOTel && !isShellTelemetryEnabled()) return false; - _enabled = true; - const m = ensureMetrics(); - m?.sessionCount.add(1); - return true; -} - -/** Record the final session duration when the shell exits. */ -export function recordShellSessionDuration(durationMs: number): void { - ensureMetrics()?.sessionDuration.record(durationMs); -} - -/** - * Record agent response latency — time from message dispatch to first - * visible response token. Attributes include agent name and dispatch type. - */ -export function recordAgentResponseLatency( - agentName: string, - latencyMs: number, - dispatchType: 'direct' | 'coordinator' = 'direct', -): void { - ensureMetrics()?.agentLatency.record(latencyMs, { - 'agent.name': agentName, - 'dispatch.type': dispatchType, - }); -} - -/** - * Record an error encountered during the shell session. - * Attributes include error source context. - */ -export function recordShellError(source: string, errorType?: string): void { - ensureMetrics()?.errorCount.add(1, { - 'error.source': source, - ...(errorType ? { 'error.type': errorType } : {}), - }); -} - -// ============================================================================ -// Reset (for testing) -// ============================================================================ - -/** Reset all cached metric instances and state. Used in tests only. */ -export function _resetShellMetrics(): void { - _metrics = undefined; - _enabled = false; -} diff --git a/packages/squad-cli/src/cli/shell/spawn.ts b/packages/squad-cli/src/cli/shell/spawn.ts deleted file mode 100644 index 4712fcfb4..000000000 --- a/packages/squad-cli/src/cli/shell/spawn.ts +++ /dev/null @@ -1,167 +0,0 @@ -/** - * Agent spawning — loads charters, builds prompts, and manages spawn lifecycle. - * - * Creates SDK sessions via SquadClient, sends the task, and streams the response. - */ - -import { resolveSquad } from '@bradygaster/squad-sdk/resolution'; -import { SquadClient } from '@bradygaster/squad-sdk/client'; -import type { SquadSession } from '@bradygaster/squad-sdk/client'; -import { SquadState, FSStorageProvider } from '@bradygaster/squad-sdk'; -import { SessionRegistry } from './sessions.js'; -import { dirname } from 'node:path'; - -/** Debug logger — writes to stderr only when SQUAD_DEBUG=1. */ -function debugLog(...args: unknown[]): void { - if (process.env['SQUAD_DEBUG'] === '1') { - console.error('[SQUAD_DEBUG]', ...args); - } -} - -export interface SpawnOptions { - /** Wait for completion (sync) or fire-and-track (background) */ - mode: 'sync' | 'background'; - /** Additional system prompt context */ - systemContext?: string; - /** Tool definitions to register */ - tools?: ToolDefinition[]; - /** SquadClient instance for SDK session creation */ - client?: SquadClient; - /** Working directory for the session */ - teamRoot?: string; -} - -export interface ToolDefinition { - name: string; - description: string; - parameters: Record; -} - -export interface SpawnResult { - agentName: string; - status: 'completed' | 'streaming' | 'error'; - response?: string; - error?: string; -} - -/** - * Load agent charter from .squad/agents/{name}/charter.md - * - * Reads via SquadState → AgentHandle.charter() so all file access is - * routed through the StorageProvider abstraction. - */ -export async function loadAgentCharter(agentName: string, teamRoot?: string): Promise { - let rootDir: string; - if (teamRoot) { - rootDir = teamRoot; - } else { - const squadDir = resolveSquad(); - if (!squadDir) { - debugLog('loadAgentCharter: no .squad/ directory found'); - throw new Error('No team found. Run `squad init` to set up your project.'); - } - rootDir = dirname(squadDir); - } - - const storage = new FSStorageProvider(); - let state: SquadState; - try { - state = await SquadState.create(storage, rootDir); - } catch { - debugLog('loadAgentCharter: no .squad/ directory at', rootDir); - throw new Error('No team found. Run `squad init` to set up your project.'); - } - - try { - return await state.agents.get(agentName.toLowerCase()).charter(); - } catch (err) { - debugLog('loadAgentCharter: failed to read charter for', agentName, err); - throw new Error(`No charter found for "${agentName}". Check that .squad/agents/${agentName.toLowerCase()}/charter.md exists.`); - } -} - -/** - * Build system prompt for an agent from their charter + optional context - */ -export function buildAgentPrompt(charter: string, options?: { systemContext?: string }): string { - let prompt = `You are an AI agent on a software development team.\n\nYOUR CHARTER:\n${charter}`; - if (options?.systemContext) { - prompt += `\n\nADDITIONAL CONTEXT:\n${options.systemContext}`; - } - return prompt; -} - -/** - * Spawn an agent session. - * - * When a SquadClient is provided via options.client, creates a real SDK session, - * sends the task, streams the response, and returns the accumulated result. - * Without a client, returns a stub result for backward compatibility. - */ -export async function spawnAgent( - name: string, - task: string, - registry: SessionRegistry, - options: SpawnOptions = { mode: 'sync' } -): Promise { - const teamRoot = options.teamRoot ?? process.cwd(); - const charter = await loadAgentCharter(name, teamRoot); - - const roleMatch = charter.match(/^#\s+\w+\s+—\s+(.+)$/m); - const role = roleMatch?.[1] ?? 'Agent'; - - registry.register(name, role); - registry.updateStatus(name, 'working'); - - try { - const systemPrompt = buildAgentPrompt(charter, { systemContext: options.systemContext }); - - if (!options.client) { - // No client provided — return stub for backward compatibility - registry.updateStatus(name, 'idle'); - return { - agentName: name, - status: 'completed', - response: `[Agent ${name} spawn ready — no client provided]`, - }; - } - - const session: SquadSession = await options.client.createSession({ - streaming: true, - systemMessage: { mode: 'append', content: systemPrompt }, - workingDirectory: teamRoot, - }); - - // Accumulate streamed response - let accumulated = ''; - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['delta'] ?? event['content']; - if (typeof val === 'string') accumulated += val; - }; - - session.on('message_delta', onDelta); - try { - await session.sendMessage({ prompt: task }); - } finally { - try { session.off('message_delta', onDelta); } catch (err) { debugLog('spawnAgent: failed to remove delta listener:', err); } - } - - try { await session.close(); } catch (err) { debugLog('spawnAgent: failed to close session for', name, err); } - - registry.updateStatus(name, 'idle'); - return { - agentName: name, - status: 'completed', - response: accumulated || undefined, - }; - } catch (error) { - debugLog('spawnAgent: spawn failed for', name, error); - registry.updateStatus(name, 'error'); - const msg = error instanceof Error ? error.message : String(error); - return { - agentName: name, - status: 'error', - error: `Failed to spawn ${name}: ${msg.replace(/^Error:\s*/i, '')}. Try again or run \`squad doctor\`.`, - }; - } -} diff --git a/packages/squad-cli/src/cli/shell/stream-bridge.ts b/packages/squad-cli/src/cli/shell/stream-bridge.ts deleted file mode 100644 index 3df932be7..000000000 --- a/packages/squad-cli/src/cli/shell/stream-bridge.ts +++ /dev/null @@ -1,152 +0,0 @@ -/** - * Stream Bridge — connects StreamingPipeline events to shell rendering callbacks. - * - * Accumulates content deltas into complete messages and dispatches - * to the shell's render loop via simple callbacks. - * - * @module cli/shell/stream-bridge - */ - -import type { - StreamingEvent, - StreamDelta, - UsageEvent, - ReasoningDelta, -} from '@bradygaster/squad-sdk/runtime/streaming'; -import type { SessionRegistry } from './sessions.js'; -import type { ShellMessage } from './types.js'; - -export interface StreamBridgeOptions { - /** Callback when new content arrives (for render updates) */ - onContent: (agentName: string, content: string) => void; - /** Callback when a message is complete */ - onComplete: (message: ShellMessage) => void; - /** Callback for usage/cost data */ - onUsage?: (usage: { - model: string; - inputTokens: number; - outputTokens: number; - cost: number; - }) => void; - /** Callback for reasoning content */ - onReasoning?: (agentName: string, content: string) => void; - /** Callback for errors */ - onError?: (agentName: string, error: Error) => void; -} - -/** - * Bridges the StreamingPipeline events to shell rendering callbacks. - * Accumulates content deltas into complete messages. - */ -export class StreamBridge { - private buffers = new Map(); - private readonly options: StreamBridgeOptions; - private readonly registry: SessionRegistry; - - /** Maximum buffer size per session (1 MB). Prevents unbounded memory growth. */ - static readonly MAX_BUFFER_SIZE = 1024 * 1024; - - constructor(registry: SessionRegistry, options: StreamBridgeOptions) { - this.registry = registry; - this.options = options; - } - - /** - * Process a streaming event from the pipeline. - * Dispatches to the correct callback based on event type. - */ - handleEvent(event: StreamingEvent): void { - switch (event.type) { - case 'message_delta': - this.handleDelta(event); - break; - case 'usage': - this.handleUsage(event); - break; - case 'reasoning_delta': - this.handleReasoning(event); - break; - } - } - - /** - * Finalize the buffer for a session, emitting a complete ShellMessage. - * Call this when a stream ends (e.g. after the SDK signals completion). - */ - flush(sessionId: string): void { - const content = this.buffers.get(sessionId); - if (content === undefined || content.length === 0) return; - - const session = this.registry.get(sessionId); - const agentName = session?.name ?? sessionId; - - const message: ShellMessage = { - role: 'agent', - agentName, - content, - timestamp: new Date(), - }; - - this.options.onComplete(message); - this.buffers.delete(sessionId); - - this.registry.updateStatus(sessionId, 'idle'); - } - - /** - * Get the current buffer content for a session (for partial renders). - */ - getBuffer(sessionId: string): string { - return this.buffers.get(sessionId) ?? ''; - } - - /** - * Clear all buffers (on session end). - */ - clear(): void { - this.buffers.clear(); - } - - // ---------- Private ---------- - - private handleDelta(event: StreamDelta): void { - const { sessionId, content } = event; - const agentName = event.agentName ?? sessionId; - - // Accumulate content in the session buffer (with size limit) - const existing = this.buffers.get(sessionId) ?? ''; - const updated = existing + content; - if (updated.length <= StreamBridge.MAX_BUFFER_SIZE) { - this.buffers.set(sessionId, updated); - } else { - // Truncate from the front to keep the most recent content - this.buffers.set(sessionId, updated.slice(-StreamBridge.MAX_BUFFER_SIZE)); - } - - // Mark session as streaming - this.registry.updateStatus(agentName, 'streaming'); - - // Notify the render loop - this.options.onContent(agentName, content); - } - - private handleUsage(event: UsageEvent): void { - const agentName = event.agentName ?? event.sessionId; - - this.options.onUsage?.({ - model: event.model, - inputTokens: event.inputTokens, - outputTokens: event.outputTokens, - cost: event.estimatedCost, - }); - - // Usage event typically signals end of a turn — mark idle - this.registry.updateStatus(agentName, 'idle'); - } - - private handleReasoning(event: ReasoningDelta): void { - const agentName = event.agentName ?? event.sessionId; - - this.options.onReasoning?.(agentName, event.content); - } -} diff --git a/packages/squad-cli/src/cli/shell/terminal.ts b/packages/squad-cli/src/cli/shell/terminal.ts deleted file mode 100644 index 0d40042ab..000000000 --- a/packages/squad-cli/src/cli/shell/terminal.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { platform } from 'node:os'; -import { useState, useEffect } from 'react'; - -export interface TerminalCapabilities { - supportsColor: boolean; - supportsUnicode: boolean; - columns: number; - rows: number; - platform: NodeJS.Platform; - isWindows: boolean; - isTTY: boolean; - /** True when NO_COLOR=1, TERM=dumb, or color is otherwise suppressed. */ - noColor: boolean; -} - -/** Current terminal width, clamped to a minimum of 40. */ -export function getTerminalWidth(): number { - return Math.max(process.stdout.columns || 80, 40); -} - -/** - * Default row count used when `process.stdout.rows` is undefined - * (e.g. piped output, test harnesses). 50 rows ensures the live - * viewport has enough room for content like /help. - */ -const DEFAULT_TERMINAL_ROWS = 50; - -/** Current terminal height, clamped to a minimum of 10. - * Fallback of DEFAULT_TERMINAL_ROWS when rows is undefined (test/pipe environments) - * ensures the live viewport has enough room for content like /help. */ -export function getTerminalHeight(): number { - return Math.max(process.stdout.rows || DEFAULT_TERMINAL_ROWS, 10); -} - -/** - * Shared hook that subscribes to `process.stdout` resize events and - * returns the current value of `getter()`, debounced at 150 ms. - * Extracted from the formerly-duplicated useTerminalWidth / useTerminalHeight hooks. - */ -function useTerminalDimension(getter: () => number): number { - const [value, setValue] = useState(getter()); - useEffect(() => { - let timer: ReturnType | null = null; - const onResize = () => { - if (timer) clearTimeout(timer); - timer = setTimeout(() => setValue(getter()), 150); - }; - const prev = process.stdout.getMaxListeners?.() ?? 10; - if (prev <= 20) process.stdout.setMaxListeners?.(prev + 10); - process.stdout.on('resize', onResize); - return () => { - process.stdout.off('resize', onResize); - if (timer) clearTimeout(timer); - }; - }, []); - return value; -} - -/** React hook — returns live terminal width, updates on resize. */ -export function useTerminalWidth(): number { return useTerminalDimension(getTerminalWidth); } - -/** React hook — returns live terminal height, updates on resize. */ -export function useTerminalHeight(): number { return useTerminalDimension(getTerminalHeight); } - -/** - * Returns true when the environment requests no color output. - * Respects the NO_COLOR standard (https://no-color.org/) and TERM=dumb. - */ -export function isNoColor(): boolean { - return ( - process.env['NO_COLOR'] != null && process.env['NO_COLOR'] !== '' || - process.env['TERM'] === 'dumb' - ); -} - -/** Detect terminal capabilities for cross-platform compatibility. */ -export function detectTerminal(): TerminalCapabilities { - const plat = platform(); - const isTTY = Boolean(process.stdout.isTTY); - const noColor = isNoColor(); - - return { - supportsColor: !noColor && isTTY && (process.env['FORCE_COLOR'] !== '0'), - supportsUnicode: plat !== 'win32' || Boolean(process.env['WT_SESSION']), - columns: process.stdout.columns || 80, - // detectTerminal uses 24 (standard VT100 default) rather than - // DEFAULT_TERMINAL_ROWS because this is a capability snapshot — not - // a live viewport sizing decision — and 24 is the safer assumption - // when advertising rows to callers that need a conservative baseline. - rows: process.stdout.rows || 24, - platform: plat, - isWindows: plat === 'win32', - isTTY, - noColor, - }; -} - -/** - * Get a safe character for the platform. - * Falls back to ASCII on terminals that don't support unicode. - */ -export function safeChar(unicode: string, ascii: string, caps: TerminalCapabilities): string { - return caps.supportsUnicode ? unicode : ascii; -} - -/** - * Box-drawing characters that degrade gracefully. - */ -export function boxChars(caps: TerminalCapabilities) { - if (caps.supportsUnicode) { - return { tl: '╭', tr: '╮', bl: '╰', br: '╯', h: '─', v: '│' }; - } - return { tl: '+', tr: '+', bl: '+', br: '+', h: '-', v: '|' }; -} - -/** - * Terminal layout tier based on width. - * - **wide** (120+ cols): Full layout — complete tables, full separators, all chrome - * - **normal** (80-119 cols): Compact tables, shorter separators, abbreviated labels - * - **narrow** (<80 cols): Card/stacked layout for tables, minimal chrome, no borders - */ -export type LayoutTier = 'wide' | 'normal' | 'narrow'; - -/** Determine layout tier from terminal width. */ -export function getLayoutTier(width: number): LayoutTier { - if (width >= 120) return 'wide'; - if (width >= 80) return 'normal'; - return 'narrow'; -} - -/** React hook — returns current layout tier, updates on resize. */ -export function useLayoutTier(): LayoutTier { - const width = useTerminalWidth(); - return getLayoutTier(width); -} diff --git a/packages/squad-cli/src/cli/shell/types.ts b/packages/squad-cli/src/cli/shell/types.ts deleted file mode 100644 index 73c0a080c..000000000 --- a/packages/squad-cli/src/cli/shell/types.ts +++ /dev/null @@ -1,25 +0,0 @@ -/** - * Shell-specific type definitions for the Squad interactive shell. - */ - -export interface ShellState { - status: 'initializing' | 'ready' | 'processing' | 'error'; - activeAgents: Map; - messageHistory: ShellMessage[]; -} - -export interface ShellMessage { - role: 'user' | 'agent' | 'system'; - agentName?: string; - content: string; - timestamp: Date; -} - -export interface AgentSession { - name: string; - role: string; - status: 'idle' | 'working' | 'streaming' | 'error'; - startedAt: Date; - activityHint?: string; - model?: string; -} diff --git a/packages/squad-cli/src/cli/shell/useAnimation.ts b/packages/squad-cli/src/cli/shell/useAnimation.ts deleted file mode 100644 index 7cb7cff4b..000000000 --- a/packages/squad-cli/src/cli/shell/useAnimation.ts +++ /dev/null @@ -1,160 +0,0 @@ -/** - * Animation hooks for tasteful CLI transitions. - * - * All hooks respect NO_COLOR — when isNoColor() is true, animations are - * skipped and static content is returned immediately. - * - * Frame rate capped at ~15fps (67ms intervals) to stay GPU-friendly in Ink. - * - * Owned by Cheritto (TUI Engineer). - */ - -import { useState, useEffect, useRef } from 'react'; -import { isNoColor } from './terminal.js'; - -/** ~15fps frame interval */ -const FRAME_MS = 67; - -/** - * Typewriter: reveals text character by character over durationMs. - * NO_COLOR: returns full text immediately. - */ -export function useTypewriter(text: string, durationMs: number = 500): string { - const noColor = isNoColor(); - const [count, setCount] = useState(noColor ? text.length : 0); - - useEffect(() => { - if (noColor || !text) { - setCount(text.length); - return; - } - setCount(0); - const charsPerFrame = Math.max(1, Math.ceil(text.length / (durationMs / FRAME_MS))); - const timer = setInterval(() => { - setCount(c => { - const next = Math.min(c + charsPerFrame, text.length); - if (next >= text.length) clearInterval(timer); - return next; - }); - }, FRAME_MS); - return () => clearInterval(timer); - }, [text, durationMs, noColor]); - - return text.slice(0, count); -} - -/** - * Fade-in: starts dim, becomes normal after durationMs. - * Returns true while still fading (content should be dim). - * Triggers when `active` becomes true. - * NO_COLOR: always returns false (no fade). - */ -export function useFadeIn(active: boolean, durationMs: number = 300): boolean { - const noColor = isNoColor(); - const [dim, setDim] = useState(false); - - useEffect(() => { - if (noColor || !active) return; - setDim(true); - const timer = setTimeout(() => setDim(false), durationMs); - return () => clearTimeout(timer); - }, [active, durationMs, noColor]); - - return dim; -} - -/** - * Completion flash: detects when agents transition working/streaming → idle. - * Returns Set of agent names currently showing "✓ Done" flash. - * Flash lasts flashMs (default 1500ms). - * NO_COLOR: returns empty set. - * - * Uses React's setState-during-render pattern for synchronous detection, - * so the flash is visible on the same render that triggers the transition. - */ -export function useCompletionFlash( - agents: Array<{ name: string; status: string }>, - flashMs: number = 1500, -): Set { - const noColor = isNoColor(); - const prevRef = useRef(new Map()); - const [flashing, setFlashing] = useState>(new Set()); - const timersRef = useRef(new Map>()); - - // Detect transitions during render (synchronous) - const prev = prevRef.current; - - if (!noColor) { - let changed = false; - const next = new Set(flashing); - - for (const agent of agents) { - const prevStatus = prev.get(agent.name); - const wasActive = prevStatus === 'working' || prevStatus === 'streaming'; - const isNowIdle = agent.status === 'idle'; - - if (wasActive && isNowIdle && !flashing.has(agent.name)) { - next.add(agent.name); - changed = true; - } - } - - if (changed) { - setFlashing(next); - } - } - - // Update prev status map after detection - const newMap = new Map(); - for (const a of agents) newMap.set(a.name, a.status); - prevRef.current = newMap; - - // Timer cleanup: remove flash after flashMs - useEffect(() => { - for (const name of flashing) { - if (!timersRef.current.has(name)) { - const timer = setTimeout(() => { - setFlashing(s => { const n = new Set(s); n.delete(name); return n; }); - timersRef.current.delete(name); - }, flashMs); - timersRef.current.set(name, timer); - } - } - }, [flashing, flashMs]); - - // Cleanup all timers on unmount - useEffect(() => { - return () => { timersRef.current.forEach(t => clearTimeout(t)); }; - }, []); - - return flashing; -} - -/** - * Message fade: tracks new messages and returns count of "fading" messages - * from the end of the visible list. - * NO_COLOR: always returns 0. - */ -export function useMessageFade(totalCount: number, fadeMs: number = 200): number { - const noColor = isNoColor(); - const prevRef = useRef(totalCount); - const [fadingCount, setFadingCount] = useState(0); - - useEffect(() => { - if (noColor) { - prevRef.current = totalCount; - return; - } - - const diff = totalCount - prevRef.current; - if (diff > 0) { - setFadingCount(diff); - const timer = setTimeout(() => setFadingCount(0), fadeMs); - prevRef.current = totalCount; - return () => clearTimeout(timer); - } - prevRef.current = totalCount; - }, [totalCount, fadeMs, noColor]); - - return fadingCount; -} diff --git a/packages/squad-cli/src/remote-ui/app.js b/packages/squad-cli/src/remote-ui/app.js deleted file mode 100644 index 5000d0c6d..000000000 --- a/packages/squad-cli/src/remote-ui/app.js +++ /dev/null @@ -1,334 +0,0 @@ -/** - * Squad Remote Control — PTY Terminal PWA - * Raw terminal rendering via xterm.js + WebSocket - */ -(function () { - 'use strict'; - - let ws = null; - let connected = false; - let replaying = false; - let reconnectDelay = 1000; - - const $ = (sel) => document.querySelector(sel); - const terminal = $('#terminal'); - const inputEl = $('#input'); - const formEl = $('#input-form'); - const statusEl = $('#status-indicator'); - const statusText = $('#status-text'); - const permOverlay = $('#permission-overlay'); - const dashboard = $('#dashboard'); - const termContainer = $('#terminal-container'); - let currentView = 'terminal'; // 'dashboard' or 'terminal' - - // ─── xterm.js Terminal ─────────────────────────────────── - let xterm = null; - let fitAddon = null; - - function initXterm() { - if (xterm) return; - xterm = new Terminal({ - theme: { - background: '#0d1117', - foreground: '#c9d1d9', - cursor: '#3fb950', - selectionBackground: '#264f78', - black: '#0d1117', - red: '#f85149', - green: '#3fb950', - yellow: '#d29922', - blue: '#58a6ff', - magenta: '#bc8cff', - cyan: '#39c5cf', - white: '#c9d1d9', - brightBlack: '#6e7681', - brightRed: '#f85149', - brightGreen: '#3fb950', - brightYellow: '#d29922', - brightBlue: '#58a6ff', - brightMagenta: '#bc8cff', - brightCyan: '#39c5cf', - brightWhite: '#f0f6fc', - }, - fontFamily: "'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', monospace", - fontSize: 13, - scrollback: 5000, - cursorBlink: true, - }); - - fitAddon = new FitAddon.FitAddon(); - xterm.loadAddon(fitAddon); - xterm.open(termContainer); - fitAddon.fit(); - - // Send terminal size to PTY so copilot renders correctly - function sendResize() { - if (ws && ws.readyState === WebSocket.OPEN && xterm) { - ws.send(JSON.stringify({ type: 'pty_resize', cols: xterm.cols, rows: xterm.rows })); - } - } - - // Handle resize - window.addEventListener('resize', () => { - if (fitAddon) { fitAddon.fit(); sendResize(); } - }); - - // Send initial size - setTimeout(sendResize, 500); - - // Keyboard input → send to bridge → PTY - xterm.onData((data) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'pty_input', data })); - } - }); - } - - // ─── Dashboard ─────────────────────────────────────────── - let showOffline = false; - - async function loadSessions() { - try { - const resp = await fetch('/api/sessions'); - const data = await resp.json(); - renderDashboard(data.sessions || []); - } catch (err) { - dashboard.innerHTML = '
' + escapeHtml('Failed to load sessions: ' + err.message) + '
'; - } - } - - function renderDashboard(sessions) { - const filtered = showOffline ? sessions : sessions.filter(s => s.online); - const offlineCount = sessions.filter(s => !s.online).length; - const onlineCount = sessions.filter(s => s.online).length; - - let html = `
- ${onlineCount} online${offlineCount > 0 ? ', ' + offlineCount + ' offline' : ''} - - - ${offlineCount > 0 ? '' : ''} - -
`; - - if (filtered.length === 0) { - html += '
' + - (sessions.length === 0 ? 'No Squad RC sessions found.' : 'No online sessions. Tap "Show offline" to see stale ones.') + - '
'; - } else { - html += filtered.map(s => ` -
- -
-
📦 ${escapeHtml(s.repo)}
-
🌿 ${escapeHtml(s.branch)}
-
💻 ${escapeHtml(s.machine)}
-
- ${s.online ? '' : - ''} -
- `).join(''); - } - dashboard.innerHTML = html; - // #16: XSS fix — use event delegation instead of inline onclick - dashboard.querySelectorAll('.session-card[data-session-url]').forEach(function(card) { - card.addEventListener('click', function() { openSession(card.dataset.sessionUrl); }); - }); - dashboard.querySelectorAll('[data-delete-id]').forEach(function(btn) { - btn.addEventListener('click', function(e) { e.stopPropagation(); deleteSession(btn.dataset.deleteId); }); - }); - } - - window.openSession = (url) => { - window.location.href = url; - }; - - window.toggleOffline = () => { - showOffline = !showOffline; - loadSessions(); - }; - - window.cleanOffline = async () => { - const resp = await fetch('/api/sessions'); - const data = await resp.json(); - const offline = (data.sessions || []).filter(s => !s.online); - for (const s of offline) { - await fetch('/api/sessions/' + s.id, { method: 'DELETE' }); - } - loadSessions(); - }; - - window.deleteSession = async (id) => { - await fetch('/api/sessions/' + id, { method: 'DELETE' }); - loadSessions(); - }; - - window.toggleView = () => { - if (currentView === 'terminal') { - currentView = 'dashboard'; - terminal.classList.add('hidden'); - termContainer.classList.add('hidden'); - $('#input-area').classList.add('hidden'); - dashboard.classList.remove('hidden'); - $('#btn-sessions').textContent = 'Terminal'; - loadSessions(); - } else { - currentView = 'terminal'; - dashboard.classList.add('hidden'); - $('#input-area').classList.remove('hidden'); - if (ptyMode) { - termContainer.classList.remove('hidden'); - $('#input-form').classList.add('hidden'); - if (fitAddon) fitAddon.fit(); - if (xterm) xterm.focus(); - } else { - terminal.classList.remove('hidden'); - } - $('#btn-sessions').textContent = 'Sessions'; - } - }; - - // ─── Terminal Output ───────────────────────────────────── - function writeSys(text) { - const div = document.createElement('div'); - div.className = 'sys'; - div.textContent = text; - terminal.appendChild(div); - if (!replaying) scrollToBottom(); - } - - // ─── WebSocket ─────────────────────────────────────────── - async function connect() { - const tokenParam = new URLSearchParams(window.location.search).get('token'); - if (!tokenParam) { setStatus('offline', 'No credentials'); return; } - - const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; - - // F-02: Try ticket-based auth first - try { - const resp = await fetch('/api/auth/ticket', { - method: 'POST', - headers: { 'Authorization': 'Bearer ' + tokenParam } - }); - if (resp.ok) { - const { ticket } = await resp.json(); - ws = new WebSocket(`${proto}//${location.host}?ticket=${encodeURIComponent(ticket)}`); - } else { - // Fallback to token-in-URL (backward compat) - ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`); - } - } catch { - // Fallback to token-in-URL - ws = new WebSocket(`${proto}//${location.host}?token=${encodeURIComponent(tokenParam)}`); - } - setStatus('connecting', 'Connecting...'); - - ws.onopen = () => { - connected = true; - reconnectDelay = 1000; - setStatus('online', 'Connected — waiting for terminal...'); - }; - ws.onclose = () => { - connected = false; - setStatus('offline', 'Disconnected'); - reconnectDelay = Math.min(reconnectDelay * 2, 30000); - setTimeout(connect, reconnectDelay); - }; - ws.onerror = () => setStatus('offline', 'Error'); - ws.onmessage = (e) => { - try { - const msg = JSON.parse(e.data); - handleMessage(msg); - } catch {} - }; - } - - // ─── Message Handler ───────────────────────────────────── - function handleMessage(msg) { - // Replay events from bridge recording - if (msg.type === '_replay') { - replaying = true; - try { handleMessage(JSON.parse(msg.data)); } catch {} - return; - } - if (msg.type === '_replay_done') { - replaying = false; - scrollToBottom(); - return; - } - - // PTY data — raw terminal output → xterm.js - if (msg.type === 'pty') { - if (!ptyMode) { - ptyMode = true; - setStatus('online', 'PTY Mirror'); - terminal.classList.add('hidden'); - // Hide text input form but keep key bar visible - $('#input-form').classList.add('hidden'); - termContainer.classList.remove('hidden'); - initXterm(); - } - xterm.write(msg.data); - return; - } - - // Clear screen detection - if (msg.type === 'clear') { - if (xterm) xterm.clear(); - return; - } - } - - // ─── Mobile Key Bar ─────────────────────────────────────── - let ptyMode = false; - - window.sendKey = (key) => { - if (ws && ws.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ type: 'pty_input', data: key })); - } - if (xterm) xterm.focus(); - }; - - // Event delegation for key-bar buttons (no inline onclick) - var keyBar = document.getElementById('key-bar'); - if (keyBar) { - var keyMap = { - '\\x1b[A': '\x1b[A', '\\x1b[B': '\x1b[B', '\\x1b[C': '\x1b[C', '\\x1b[D': '\x1b[D', - '\\t': '\t', '\\r': '\r', '\\x1b': '\x1b', '\\x03': '\x03', ' ': ' ', '\\x7f': '\x7f', - }; - keyBar.addEventListener('click', function(e) { - var btn = e.target; - if (btn && btn.tagName === 'BUTTON' && btn.dataset.key) { - var key = keyMap[btn.dataset.key] || btn.dataset.key; - window.sendKey(key); - } - }); - } - - // Event listener for btn-sessions (no inline onclick) - var btnSessions = document.getElementById('btn-sessions'); - if (btnSessions) { - btnSessions.addEventListener('click', function() { window.toggleView(); }); - } - - // Form submit — in PTY mode, just focus xterm - formEl.addEventListener('submit', function(e) { - e.preventDefault(); - if (xterm) xterm.focus(); - }); - - // ─── Helpers ───────────────────────────────────────────── - function setStatus(state, text) { - statusEl.className = state; - statusText.textContent = text; - } - function scrollToBottom() { - requestAnimationFrame(() => { terminal.scrollTop = terminal.scrollHeight; }); - } - function escapeHtml(s) { - var d = document.createElement('div'); d.textContent = s || ''; return d.innerHTML.replace(/'/g, ''').replace(/"/g, '"'); - } - - // ─── Start ─────────────────────────────────────────────── - writeSys('Squad Remote Control'); - connect(); -})(); diff --git a/packages/squad-cli/src/remote-ui/index.html b/packages/squad-cli/src/remote-ui/index.html deleted file mode 100644 index 34c33cf2d..000000000 --- a/packages/squad-cli/src/remote-ui/index.html +++ /dev/null @@ -1,57 +0,0 @@ - - - - - - - - - Squad RC - - - - -
- - - - - - -
- - -
- -
-
- - - - - - - - - - -
-
- > - -
-
-
- - - - - - diff --git a/packages/squad-cli/src/remote-ui/manifest.json b/packages/squad-cli/src/remote-ui/manifest.json deleted file mode 100644 index 81a511d40..000000000 --- a/packages/squad-cli/src/remote-ui/manifest.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "name": "Squad RC", - "short_name": "Squad", - "description": "Remote control for your Squad AI team", - "start_url": "/", - "display": "standalone", - "background_color": "#1a1a2e", - "theme_color": "#1a1a2e", - "icons": [] -} diff --git a/packages/squad-cli/src/remote-ui/styles.css b/packages/squad-cli/src/remote-ui/styles.css deleted file mode 100644 index 6454f8797..000000000 --- a/packages/squad-cli/src/remote-ui/styles.css +++ /dev/null @@ -1,249 +0,0 @@ -:root { - --bg: #0d1117; - --bg-tool: #161b22; - --text: #c9d1d9; - --text-dim: #6e7681; - --text-bright: #f0f6fc; - --green: #3fb950; - --red: #f85149; - --yellow: #d29922; - --blue: #58a6ff; - --purple: #bc8cff; - --cyan: #39c5cf; - --border: #30363d; - --font: 'Cascadia Code', 'SF Mono', 'Fira Code', 'Menlo', 'Consolas', monospace; -} - -* { margin: 0; padding: 0; box-sizing: border-box; } - -body { - font-family: var(--font); - font-size: 13px; - background: var(--bg); - color: var(--text); - height: 100dvh; - overflow: hidden; - -webkit-font-smoothing: antialiased; -} - -#app { - display: flex; - flex-direction: column; - height: 100dvh; -} - -/* Header — minimal status bar */ -header { - display: flex; - align-items: center; - gap: 8px; - padding: 6px 12px; - background: var(--bg-tool); - border-bottom: 1px solid var(--border); - flex-shrink: 0; - font-size: 12px; -} -#status-indicator { font-size: 10px; } -#status-indicator.online { color: var(--green); } -#status-indicator.offline { color: var(--red); } -#status-indicator.connecting { color: var(--yellow); } -#status-text { color: var(--text-dim); } - -/* Terminal area (legacy) */ -#terminal { - flex: 1; - overflow-y: auto; - padding: 8px 12px; - white-space: pre-wrap; - word-wrap: break-word; - line-height: 1.5; - scroll-behavior: smooth; -} - -/* xterm.js container */ -#terminal-container { - flex: 1; - overflow: hidden; -} -#terminal-container .xterm { - height: 100%; - padding: 4px; -} - -/* System messages */ -.sys { color: var(--text-dim); font-style: italic; } - -/* User input echo */ -.user-input { color: var(--blue); } -.user-input::before { content: '❯ '; color: var(--green); } - -/* Agent text */ -.agent-text { color: var(--text); } - -/* Streaming cursor */ -.cursor { - display: inline-block; - width: 7px; height: 14px; - background: var(--text); - animation: blink 0.7s infinite; - vertical-align: text-bottom; - margin-left: 1px; -} -@keyframes blink { 50% { opacity: 0; } } - -/* Tool calls */ -.tool-call { - margin: 4px 0; - border-left: 2px solid var(--blue); - padding: 2px 0 2px 8px; - color: var(--text-dim); - font-size: 12px; -} -.tool-call.completed { border-left-color: var(--green); } -.tool-call.failed { border-left-color: var(--red); } -.tool-call .tool-icon { margin-right: 4px; } -.tool-call .tool-name { color: var(--cyan); } -.tool-call .tool-status { margin-left: 8px; } -.tool-call .tool-status.completed { color: var(--green); } -.tool-call .tool-status.failed { color: var(--red); } -.tool-call .tool-status.in_progress { color: var(--yellow); } - -/* Tool call content (expandable) */ -.tool-body { display: none; margin-top: 4px; } -.tool-call.expanded .tool-body { display: block; } - -/* Diff blocks */ -.diff { margin: 4px 0; font-size: 12px; } -.diff-header { color: var(--text-dim); } -.diff-add { color: var(--green); } -.diff-add::before { content: '+ '; } -.diff-del { color: var(--red); } -.diff-del::before { content: '- '; } - -/* Code blocks */ -.code-block { - background: var(--bg-tool); - border: 1px solid var(--border); - border-radius: 4px; - padding: 6px 8px; - margin: 4px 0; - overflow-x: auto; - font-size: 12px; -} -.code-header { color: var(--text-dim); font-size: 11px; margin-bottom: 2px; } - -/* Permission dialog */ -#permission-overlay { - position: fixed; - top: 0; left: 0; right: 0; bottom: 0; - background: rgba(0,0,0,0.7); - display: flex; - align-items: flex-end; - justify-content: center; - padding-bottom: 60px; - z-index: 100; -} -#permission-overlay.hidden { display: none; } -.perm-dialog { - background: var(--bg-tool); - border: 1px solid var(--yellow); - border-radius: 8px; - padding: 12px; - width: calc(100% - 24px); - max-width: 400px; - max-height: 40vh; - display: flex; - flex-direction: column; -} -.perm-dialog h3 { color: var(--yellow); font-size: 14px; margin-bottom: 6px; flex-shrink: 0; } -.perm-dialog p { font-size: 12px; color: var(--text); margin-bottom: 10px; overflow-y: auto; flex: 1; min-height: 0; } -.perm-actions { display: flex; gap: 8px; justify-content: flex-end; flex-shrink: 0; padding-top: 4px; } -.perm-actions { display: flex; gap: 8px; justify-content: flex-end; } -.perm-actions button { - padding: 6px 16px; border: none; border-radius: 4px; - cursor: pointer; font-size: 13px; font-family: var(--font); -} -.btn-approve { background: var(--green); color: #000; } -.btn-deny { background: var(--red); color: #fff; } - -/* Input area */ -#input-area { - padding: 4px 8px 6px; - background: var(--bg-tool); - border-top: 1px solid var(--border); - flex-shrink: 0; -} -#key-bar { - display: flex; - gap: 4px; - padding: 4px 0; - overflow-x: auto; - -webkit-overflow-scrolling: touch; -} -#key-bar button { - background: var(--bg); - border: 1px solid var(--border); - color: var(--text); - font-family: var(--font); - font-size: 13px; - padding: 6px 10px; - border-radius: 4px; - cursor: pointer; - flex-shrink: 0; - min-width: 36px; - -webkit-tap-highlight-color: transparent; -} -#key-bar button:active { background: var(--blue); color: #000; } -#input-form { - display: flex; - align-items: center; - gap: 4px; -} -.prompt { color: var(--green); font-weight: bold; } -#input { - flex: 1; - background: transparent; - border: none; - color: var(--text-bright); - font-size: 14px; - font-family: var(--font); - outline: none; - caret-color: var(--green); -} -#input::placeholder { color: var(--text-dim); } - -.hidden { display: none !important; } - -/* Dashboard */ -#dashboard { - flex: 1; - overflow-y: auto; - padding: 8px; -} -.session-card { - background: var(--bg-tool); - border: 1px solid var(--border); - border-radius: 6px; - padding: 10px 12px; - margin-bottom: 6px; - cursor: pointer; - display: flex; - align-items: center; - gap: 10px; -} -.session-card:hover { border-color: var(--blue); } -.session-card .status-dot { - width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; -} -.session-card .status-dot.online { background: var(--green); } -.session-card .status-dot.offline { background: var(--text-dim); } -.session-card .info { flex: 1; min-width: 0; } -.session-card .repo { color: var(--blue); font-weight: bold; font-size: 13px; } -.session-card .branch { color: var(--text-dim); font-size: 11px; } -.session-card .machine { color: var(--text-dim); font-size: 11px; } -.session-card .arrow { color: var(--text-dim); } - -/* Scrollbar */ -::-webkit-scrollbar { width: 6px; } -::-webkit-scrollbar-track { background: transparent; } -::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; } diff --git a/test/agent-name-extraction.test.ts b/test/agent-name-extraction.test.ts deleted file mode 100644 index 13316a933..000000000 --- a/test/agent-name-extraction.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -/** - * Tests for agent name extraction from task descriptions. - * - * Validates the parseAgentFromDescription helper that extracts agent identity - * from free-form task description strings used in the shell UI. - * - * @module test/agent-name-extraction - */ - -import { describe, it, expect } from 'vitest'; -import { parseAgentFromDescription } from '@bradygaster/squad-cli/shell/agent-name-parser'; - -const KNOWN = ['eecom', 'flight', 'scribe', 'fido', 'vox', 'dsky', 'pao']; - -// ============================================================================ -// Happy-path: standard "emoji NAME: summary" format -// ============================================================================ -describe('parseAgentFromDescription — happy path', () => { - it('parses emoji + uppercase name + colon', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); - - it('parses Flight with building emoji', () => { - const result = parseAgentFromDescription('🏗️ Flight: Reviewing architecture', KNOWN); - expect(result).toEqual({ agentName: 'flight', taskSummary: 'Reviewing architecture' }); - }); - - it('parses Scribe with clipboard emoji', () => { - const result = parseAgentFromDescription('📋 Scribe: Log session & merge decisions', KNOWN); - expect(result).toEqual({ agentName: 'scribe', taskSummary: 'Log session & merge decisions' }); - }); - - it('parses FIDO with test tube emoji', () => { - const result = parseAgentFromDescription('🧪 FIDO: Writing test cases', KNOWN); - expect(result).toEqual({ agentName: 'fido', taskSummary: 'Writing test cases' }); - }); -}); - -// ============================================================================ -// Emoji variations -// ============================================================================ -describe('parseAgentFromDescription — emoji variations', () => { - it('handles multi-byte emoji (⚛️)', () => { - const result = parseAgentFromDescription('⚛️ DSKY: Building TUI', KNOWN); - expect(result).toEqual({ agentName: 'dsky', taskSummary: 'Building TUI' }); - }); - - it('handles no emoji prefix', () => { - const result = parseAgentFromDescription('EECOM: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); - - it('handles multiple spaces after emoji', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); -}); - -// ============================================================================ -// Case insensitivity -// ============================================================================ -describe('parseAgentFromDescription — case insensitivity', () => { - it('matches lowercase input against lowercase known', () => { - const result = parseAgentFromDescription('🔧 eecom: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); - - it('matches UPPERCASE input against lowercase known', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); - - it('matches Mixed case input against lowercase known', () => { - const result = parseAgentFromDescription('🔧 Eecom: Fix auth module', KNOWN); - expect(result).toEqual({ agentName: 'eecom', taskSummary: 'Fix auth module' }); - }); -}); - -// ============================================================================ -// Fuzzy fallback (name present but format differs) -// ============================================================================ -describe('parseAgentFromDescription — fuzzy fallback', () => { - it('finds agent name mentioned without colon pattern', () => { - const result = parseAgentFromDescription('general-purpose task for EECOM', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - expect(result!.taskSummary).toBe('general-purpose task for EECOM'); - }); - - it('finds VOX in a differently structured sentence', () => { - const result = parseAgentFromDescription('Working on shell — VOX task', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('vox'); - }); -}); - -// ============================================================================ -// No match → null -// ============================================================================ -describe('parseAgentFromDescription — no match', () => { - it('returns null for generic description with no known name', () => { - expect( - parseAgentFromDescription('general-purpose agent working on task', ['eecom', 'flight']), - ).toBeNull(); - }); - - it('returns null for empty string', () => { - expect(parseAgentFromDescription('', KNOWN)).toBeNull(); - }); - - it('returns null for unrelated text', () => { - expect(parseAgentFromDescription('Dispatching to agent...', ['eecom', 'flight'])).toBeNull(); - }); -}); - -// ============================================================================ -// Edge cases -// ============================================================================ -describe('parseAgentFromDescription — edge cases', () => { - it('picks first agent when multiple known names appear', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix bug found by FIDO', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - }); - - it('matches agent name that is substring-safe (vox vs invoice)', () => { - const result = parseAgentFromDescription('🔧 VOX: Fixed invoice rendering', KNOWN); - expect(result!.agentName).toBe('vox'); - }); - - it('handles description that is just the agent name', () => { - const result = parseAgentFromDescription('EECOM', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - expect(result!.taskSummary).toBeDefined(); - }); - - it('truncates very long descriptions in taskSummary', () => { - const longDesc = '🔧 EECOM: ' + 'A'.repeat(500); - const result = parseAgentFromDescription(longDesc, KNOWN); - expect(result).not.toBeNull(); - expect(result!.taskSummary.length).toBeLessThanOrEqual(60); - }); - - it('handles special characters in description', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix auth (OAuth 2.0) — urgent!', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - expect(result!.taskSummary).toContain('OAuth 2.0'); - }); - - it('matches agent name embedded in kebab-case value (fuzzy)', () => { - const result = parseAgentFromDescription('eecom-fix-auth', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - }); - - it('returns null for empty knownAgentNames array', () => { - expect(parseAgentFromDescription('🔧 EECOM: Fix auth', [])).toBeNull(); - }); - - it('returns null when description is only emoji', () => { - expect(parseAgentFromDescription('🔧', KNOWN)).toBeNull(); - }); - - it('handles agent name with numbers', () => { - const result = parseAgentFromDescription('🔧 agent1: checking build', ['agent1']); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('agent1'); - }); - - it('handles unicode characters in description but not in name', () => { - const result = parseAgentFromDescription('🔧 EECOM: Fix für Überprüfung', KNOWN); - expect(result).not.toBeNull(); - expect(result!.agentName).toBe('eecom'); - }); -}); - -// ============================================================================ -// Adversarial inputs -// ============================================================================ -describe('parseAgentFromDescription — adversarial inputs', () => { - it('returns null for null input', () => { - expect(parseAgentFromDescription(null as unknown as string, KNOWN)).toBeNull(); - }); - - it('returns null for undefined input', () => { - expect(parseAgentFromDescription(undefined as unknown as string, KNOWN)).toBeNull(); - }); - - it('returns null for numeric input', () => { - expect(parseAgentFromDescription(42 as unknown as string, KNOWN)).toBeNull(); - }); - - it('returns null for null knownAgentNames', () => { - expect(parseAgentFromDescription('🔧 EECOM: Fix auth', null as unknown as string[])).toBeNull(); - }); - - it('returns null for undefined knownAgentNames', () => { - expect( - parseAgentFromDescription('🔧 EECOM: Fix auth', undefined as unknown as string[]), - ).toBeNull(); - }); -}); diff --git a/test/cast-parser.test.ts b/test/cast-parser.test.ts deleted file mode 100644 index b900c0402..000000000 --- a/test/cast-parser.test.ts +++ /dev/null @@ -1,286 +0,0 @@ -/** - * Tests for parseCastResponse and createTeam — the REPL casting engine. - * Ensures robust parsing of various model response formats and correct - * file scaffolding for both fresh and pre-initialised projects. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, readFile, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { parseCastResponse, createTeam, type CastProposal } from '../packages/squad-cli/src/cli/core/cast.js'; - -describe('parseCastResponse', () => { - it('parses strict INIT_TEAM format', () => { - const response = `INIT_TEAM: -- Ripley | Lead | Architecture, code review, decisions -- Dallas | Frontend Dev | React, UI, components -- Kane | Backend Dev | Node.js, APIs, database -- Lambert | Tester | Tests, quality, edge cases -UNIVERSE: Alien -PROJECT: A React and Node.js web application`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(4); - expect(result!.members[0]!.name).toBe('Ripley'); - expect(result!.members[0]!.role).toBe('Lead'); - expect(result!.universe).toBe('Alien'); - expect(result!.projectDescription).toBe('A React and Node.js web application'); - }); - - it('parses INIT_TEAM wrapped in markdown code block', () => { - const response = `Here's the team I'd suggest: - -\`\`\` -INIT_TEAM: -- Neo | Lead | Architecture, decisions -- Trinity | Frontend Dev | React, UI -- Morpheus | Backend Dev | APIs, database -- Tank | Tester | Tests, quality -UNIVERSE: The Matrix -PROJECT: A web dashboard -\`\`\` - -Let me know if this works!`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(4); - expect(result!.members[0]!.name).toBe('Neo'); - expect(result!.universe).toBe('The Matrix'); - }); - - it('parses response with preamble text before INIT_TEAM', () => { - const response = `Based on your project, I'd suggest the following team: - -INIT_TEAM: -- Vincent | Lead | Architecture, code review -- Jules | Backend Dev | APIs, services -- Mia | Frontend Dev | UI, components -- Butch | Tester | Tests, quality -UNIVERSE: Pulp Fiction -PROJECT: A snake game in HTML and JavaScript`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(4); - expect(result!.members[0]!.name).toBe('Vincent'); - }); - - it('parses pipe-delimited lines without INIT_TEAM header', () => { - const response = `Here's the team for your project: - -- Deckard | Lead | Architecture, code review -- Rachel | Frontend Dev | HTML, CSS, JavaScript -- Roy | Backend Dev | Game logic, state management -- Pris | Tester | Tests, quality assurance - -Universe: Blade Runner -Project: A snake game in HTML and JavaScript`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(4); - expect(result!.members[0]!.name).toBe('Deckard'); - expect(result!.universe).toBe('Blade Runner'); - expect(result!.projectDescription).toBe('A snake game in HTML and JavaScript'); - }); - - it('parses pipe lines with * bullets', () => { - const response = `INIT_TEAM: -* Solo | Lead | Architecture, decisions -* Leia | Frontend Dev | UI, components -* Chewie | Backend Dev | APIs, services -* Lando | Tester | Tests, quality -UNIVERSE: Star Wars -PROJECT: A CLI tool`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(4); - expect(result!.members[0]!.name).toBe('Solo'); - }); - - it('handles bold markdown in UNIVERSE/PROJECT labels', () => { - const response = `INIT_TEAM: -- Ripley | Lead | Architecture -- Dallas | Frontend Dev | UI -**UNIVERSE:** Alien -**PROJECT:** A web app`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.universe).toBe('Alien'); - expect(result!.projectDescription).toBe('A web app'); - }); - - it('handles case-insensitive INIT_TEAM', () => { - const response = `init_team: -- Neo | Lead | Architecture -- Trinity | Dev | Code -UNIVERSE: The Matrix -PROJECT: Game`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.members).toHaveLength(2); - }); - - it('returns null for empty string', () => { - expect(parseCastResponse('')).toBeNull(); - }); - - it('returns null for completely unrelated response', () => { - const response = `I'd be happy to help you build a snake game! -Let me start by creating the HTML file with a canvas element.`; - expect(parseCastResponse(response)).toBeNull(); - }); - - it('returns null when no members could be extracted', () => { - const response = `INIT_TEAM: -UNIVERSE: Alien -PROJECT: Something`; - expect(parseCastResponse(response)).toBeNull(); - }); - - it('provides default universe when missing', () => { - const response = `- Neo | Lead | Architecture -- Trinity | Dev | Code`; - - const result = parseCastResponse(response); - expect(result).not.toBeNull(); - expect(result!.universe).toBe('Unknown'); - }); - - it('strips trailing pipes from scope (markdown table format)', () => { - const response = `| Ripley | Lead | Architecture | -| Dallas | Frontend Dev | UI |`; - - const result = parseCastResponse(response); - // Should extract something even from table format - if (result) { - expect(result.members.length).toBeGreaterThan(0); - } - }); -}); - -// ── createTeam ───────────────────────────────────────────────────── - -const minimalProposal: CastProposal = { - universe: 'Alien', - projectDescription: 'A React and Node.js web application', - members: [ - { name: 'Ripley', role: 'Lead', scope: 'Architecture, code review', emoji: '🏗️' }, - { name: 'Dallas', role: 'Frontend Dev', scope: 'React, UI, components', emoji: '⚛️' }, - { name: 'Kane', role: 'Backend Dev', scope: 'Node.js, APIs, database', emoji: '🔧' }, - ], -}; - -describe('createTeam', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await mkdtemp(join(tmpdir(), 'squad-test-')); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - describe('fresh project — no .squad/ directory', () => { - it('creates team.md with ## Members section and data rows', async () => { - await createTeam(tempDir, minimalProposal); - - const teamPath = join(tempDir, '.squad', 'team.md'); - expect(existsSync(teamPath)).toBe(true); - - const content = await readFile(teamPath, 'utf-8'); - expect(content).toContain('## Members'); - expect(content).toContain('| Ripley |'); - expect(content).toContain('| Dallas |'); - expect(content).toContain('| Kane |'); - }); - - it('creates routing.md from scratch', async () => { - await createTeam(tempDir, minimalProposal); - - const routingPath = join(tempDir, '.squad', 'routing.md'); - expect(existsSync(routingPath)).toBe(true); - - const content = await readFile(routingPath, 'utf-8'); - expect(content).toContain('# Squad Routing'); - }); - - it('includes project description in team.md header', async () => { - await createTeam(tempDir, minimalProposal); - - const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); - expect(content).toContain('A React and Node.js web application'); - }); - - it('team.md passes hasRosterEntries check (coordinator can read it)', async () => { - // Import hasRosterEntries to verify the coordinator will recognise the team - const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js'); - - await createTeam(tempDir, minimalProposal); - const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); - expect(hasRosterEntries(content)).toBe(true); - }); - - it('adds built-in Scribe and Ralph when not in proposal', async () => { - const result = await createTeam(tempDir, minimalProposal); - expect(result.membersCreated).toContain('Scribe'); - expect(result.membersCreated).toContain('Ralph'); - }); - - it('creates agent charter and history files for each member', async () => { - const result = await createTeam(tempDir, minimalProposal); - for (const name of result.membersCreated) { - const base = join(tempDir, '.squad', 'agents', name.toLowerCase()); - expect(existsSync(join(base, 'charter.md'))).toBe(true); - expect(existsSync(join(base, 'history.md'))).toBe(true); - } - }); - }); - - describe('existing project — .squad/ with empty team.md', () => { - beforeEach(async () => { - const squadDir = join(tempDir, '.squad'); - await mkdir(squadDir, { recursive: true }); - await writeFile(join(squadDir, 'team.md'), [ - '# Squad Team', - '', - '> Pre-existing project', - '', - '## Members', - '', - '| Name | Role | Charter | Status |', - '|------|------|---------|--------|', - '', - '## Project Context', - '', - '- **Project:** Pre-existing', - '', - ].join('\n')); - }); - - it('updates the Members section without clobbering surrounding content', async () => { - await createTeam(tempDir, minimalProposal); - - const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); - expect(content).toContain('Pre-existing project'); - expect(content).toContain('## Project Context'); - expect(content).toContain('| Ripley |'); - }); - - it('team.md passes hasRosterEntries after update', async () => { - const { hasRosterEntries } = await import('../packages/squad-cli/src/cli/shell/coordinator.js'); - - await createTeam(tempDir, minimalProposal); - const content = await readFile(join(tempDir, '.squad', 'team.md'), 'utf-8'); - expect(hasRosterEntries(content)).toBe(true); - }); - }); -}); diff --git a/test/cli-shell-comprehensive.test.ts b/test/cli-shell-comprehensive.test.ts deleted file mode 100644 index de8ac02b4..000000000 --- a/test/cli-shell-comprehensive.test.ts +++ /dev/null @@ -1,1297 +0,0 @@ -/** - * Comprehensive CLI shell tests - * - * Covers all shell modules with deep edge case coverage: - * - index.ts: runShell(), dispatchToAgent(), dispatchToCoordinator(), handleDispatch() - * - coordinator.ts: buildCoordinatorPrompt(), parseCoordinatorResponse(), formatConversationContext() - * - spawn.ts: spawnAgent(), loadAgentCharter(), buildAgentPrompt() - * - lifecycle.ts: ShellLifecycle initialization, agent discovery, shutdown - * - router.ts: parseInput() for all message types - * - sessions.ts: SessionRegistry operations - * - commands.ts: executeCommand() for all slash commands - * - memory.ts: MemoryManager limits and pruning - * - autocomplete.ts: createCompleter() for agents and commands - * - * Critical bug test: verifies coordinatorSession.sendMessage() exists after createSession() - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { join } from 'node:path'; -import fs from 'node:fs'; -import os from 'node:os'; -import path from 'node:path'; - -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { loadAgentCharter, buildAgentPrompt } from '../packages/squad-cli/src/cli/shell/spawn.js'; -import { - buildCoordinatorPrompt, - parseCoordinatorResponse, - formatConversationContext, -} from '../packages/squad-cli/src/cli/shell/coordinator.js'; -import { ShellLifecycle } from '../packages/squad-cli/src/cli/shell/lifecycle.js'; -import { parseInput, parseDispatchTargets } from '../packages/squad-cli/src/cli/shell/router.js'; -import { executeCommand } from '../packages/squad-cli/src/cli/shell/commands.js'; -import { MemoryManager, DEFAULT_LIMITS } from '../packages/squad-cli/src/cli/shell/memory.js'; -import { createCompleter } from '../packages/squad-cli/src/cli/shell/autocomplete.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -const FIXTURES = join(process.cwd(), 'test-fixtures'); - -// ============================================================================ -// Mock SquadClient and SquadSession -// ============================================================================ - -interface MockSquadSession { - sendMessage: ReturnType; - on: ReturnType; - off: ReturnType; - close: ReturnType; -} - -function createMockSession(): MockSquadSession { - return { - sendMessage: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - off: vi.fn(), - close: vi.fn().mockResolvedValue(undefined), - }; -} - -function createMockClient() { - return { - createSession: vi.fn(), - disconnect: vi.fn().mockResolvedValue(undefined), - }; -} - -// ============================================================================ -// Test Helpers -// ============================================================================ - -function makeTempDir(prefix: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function cleanDir(dir: string): void { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } -} - -function makeTeamMd(agents: Array<{ name: string; role: string; status?: string }>): string { - const rows = agents - .map(a => `| ${a.name} | ${a.role} | \`.squad/agents/${a.name.toLowerCase()}/charter.md\` | ✅ ${a.status ?? 'Active'} |`) - .join('\n'); - return `# Team Manifest - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -${rows} -`; -} - -// ============================================================================ -// 1. coordinator.ts — buildCoordinatorPrompt() edge cases -// ============================================================================ - -describe('coordinator.ts — buildCoordinatorPrompt', () => { - it('uses custom teamPath when provided', async () => { - const customPath = join(FIXTURES, '.squad', 'team.md'); - const prompt = await buildCoordinatorPrompt({ teamRoot: '/fake', teamPath: customPath }); - expect(prompt).toContain('Hockney'); - expect(prompt).toContain('Fenster'); - }); - - it('uses custom routingPath when provided', async () => { - const customPath = join(FIXTURES, '.squad', 'routing.md'); - const prompt = await buildCoordinatorPrompt({ teamRoot: '/fake', routingPath: customPath }); - expect(prompt).toContain('Tests → Hockney'); - }); - - it('handles missing team.md gracefully', async () => { - const prompt = await buildCoordinatorPrompt({ teamRoot: '/nonexistent' }); - expect(prompt).toContain('NO TEAM CONFIGURED'); - }); - - it('handles missing routing.md gracefully', async () => { - const prompt = await buildCoordinatorPrompt({ teamRoot: '/nonexistent' }); - expect(prompt).toContain('No routing.md found'); - }); - - it('includes all required prompt sections', async () => { - const prompt = await buildCoordinatorPrompt({ teamRoot: FIXTURES }); - expect(prompt).toContain('Squad Coordinator'); - expect(prompt).toContain('Team Roster'); - expect(prompt).toContain('Routing Rules'); - expect(prompt).toContain('Response Format'); - }); -}); - -// ============================================================================ -// 2. coordinator.ts — parseCoordinatorResponse() edge cases -// ============================================================================ - -describe('coordinator.ts — parseCoordinatorResponse', () => { - describe('ROUTE format', () => { - it('parses ROUTE with CONTEXT', () => { - const response = 'ROUTE: Fenster\nTASK: Fix bug\nCONTEXT: Issue #123'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('route'); - expect(result.routes).toHaveLength(1); - expect(result.routes![0]).toEqual({ - agent: 'Fenster', - task: 'Fix bug', - context: 'Issue #123', - }); - }); - - it('parses ROUTE without CONTEXT', () => { - const response = 'ROUTE: Hockney\nTASK: Write tests'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('route'); - expect(result.routes![0]!.context).toBeUndefined(); - }); - - it('handles ROUTE without TASK (empty task)', () => { - const response = 'ROUTE: Edie'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('route'); - expect(result.routes![0]!.task).toBe(''); - }); - - it('handles ROUTE with multiline TASK', () => { - const response = 'ROUTE: Baer\nTASK: First line\nSecond line\nCONTEXT: Extra'; - const result = parseCoordinatorResponse(response); - // Only captures first line after TASK: - expect(result.routes![0]!.task).toBe('First line'); - }); - - it('handles empty ROUTE: (no agent name captures TASK as agent)', () => { - const response = 'ROUTE: \nTASK: Do something'; - const result = parseCoordinatorResponse(response); - // ROUTE regex \w+ doesn't match whitespace, so it matches "TASK" on next line - expect(result.type).toBe('route'); - // This is a quirk: empty agent name causes "TASK" to be captured as agent - expect(result.routes![0]!.agent).toBe('TASK'); - }); - }); - - describe('DIRECT format', () => { - it('parses simple DIRECT response', () => { - const response = 'DIRECT: All tests are passing.'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('All tests are passing.'); - }); - - it('parses DIRECT with multiline content', () => { - const response = 'DIRECT: Line 1\nLine 2\nLine 3'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toContain('Line 1'); - expect(result.directAnswer).toContain('Line 2'); - }); - - it('handles DIRECT with no content after colon', () => { - const response = 'DIRECT:'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe(''); - }); - }); - - describe('MULTI format', () => { - it('parses MULTI with multiple valid lines', () => { - const response = 'MULTI:\n- Fenster: Fix parser\n- Hockney: Write tests'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(2); - expect(result.routes![0]).toEqual({ agent: 'Fenster', task: 'Fix parser' }); - expect(result.routes![1]).toEqual({ agent: 'Hockney', task: 'Write tests' }); - }); - - it('parses MULTI with mixed valid and invalid lines', () => { - const response = 'MULTI:\n- Edie: Code review\nInvalid line\n- Baer: Security audit'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(2); - expect(result.routes![0]!.agent).toBe('Edie'); - expect(result.routes![1]!.agent).toBe('Baer'); - }); - - it('handles MULTI with no valid routes', () => { - const response = 'MULTI:\nInvalid line\nAnother bad line'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(0); - }); - - it('handles MULTI with extra whitespace', () => { - const response = 'MULTI:\n- Fortier: Build system'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(1); - expect(result.routes![0]!.agent).toBe('Fortier'); - expect(result.routes![0]!.task).toBe('Build system'); - }); - }); - - describe('Fallback behavior', () => { - it('treats unknown format as direct answer', () => { - const response = 'This is just a plain response.'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('This is just a plain response.'); - }); - - it('handles empty response', () => { - const response = ''; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe(''); - }); - - it('handles whitespace-only response', () => { - const response = ' \n \t '; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe(''); - }); - }); -}); - -// ============================================================================ -// 3. coordinator.ts — formatConversationContext() -// ============================================================================ - -describe('coordinator.ts — formatConversationContext', () => { - it('formats messages with agentName prefix', () => { - const messages: ShellMessage[] = [ - { role: 'agent', agentName: 'Hockney', content: 'Test complete', timestamp: new Date() }, - ]; - const formatted = formatConversationContext(messages); - expect(formatted).toBe('[Hockney]: Test complete'); - }); - - it('formats messages with role prefix when no agentName', () => { - const messages: ShellMessage[] = [ - { role: 'user', content: 'Hello', timestamp: new Date() }, - ]; - const formatted = formatConversationContext(messages); - expect(formatted).toBe('[user]: Hello'); - }); - - it('respects maxMessages limit', () => { - const messages: ShellMessage[] = Array.from({ length: 50 }, (_, i) => ({ - role: 'user' as const, - content: `Message ${i}`, - timestamp: new Date(), - })); - const formatted = formatConversationContext(messages, 10); - const lines = formatted.split('\n'); - expect(lines).toHaveLength(10); - expect(lines[0]).toContain('Message 40'); // Last 10 messages - }); - - it('handles empty message array', () => { - const formatted = formatConversationContext([]); - expect(formatted).toBe(''); - }); - - it('handles single message', () => { - const messages: ShellMessage[] = [ - { role: 'system', content: 'Init', timestamp: new Date() }, - ]; - const formatted = formatConversationContext(messages); - expect(formatted).toBe('[system]: Init'); - }); - - it('uses default maxMessages of 20', () => { - const messages: ShellMessage[] = Array.from({ length: 30 }, (_, i) => ({ - role: 'user' as const, - content: `Msg ${i}`, - timestamp: new Date(), - })); - const formatted = formatConversationContext(messages); - const lines = formatted.split('\n'); - expect(lines).toHaveLength(20); - }); -}); - -// ============================================================================ -// 4. spawn.ts — loadAgentCharter() edge cases -// ============================================================================ - -describe('spawn.ts — loadAgentCharter', () => { - it('loads charter with teamRoot provided', async () => { - const charter = await loadAgentCharter('hockney', FIXTURES); - expect(charter).toContain('Hockney'); - }); - - it('lowercases agent name for path resolution', async () => { - const charter = await loadAgentCharter('HOCKNEY', FIXTURES); - expect(charter).toContain('Hockney'); - }); - - it('throws descriptive error when charter not found', async () => { - await expect(loadAgentCharter('nobody', FIXTURES)).rejects.toThrow( - /No charter found for "nobody"/ - ); - }); - - it('throws when .squad/ does not exist and teamRoot not provided', async () => { - const originalCwd = process.cwd(); - try { - const tmpDir = makeTempDir('no-squad-'); - process.chdir(tmpDir); - await expect(loadAgentCharter('test')).rejects.toThrow(/No (team|charter) found/); - cleanDir(tmpDir); - } finally { - process.chdir(originalCwd); - } - }); -}); - -// ============================================================================ -// 5. spawn.ts — buildAgentPrompt() -// ============================================================================ - -describe('spawn.ts — buildAgentPrompt', () => { - it('includes charter in prompt', () => { - const prompt = buildAgentPrompt('# Charter Content'); - expect(prompt).toContain('YOUR CHARTER'); - expect(prompt).toContain('# Charter Content'); - }); - - it('includes systemContext when provided', () => { - const prompt = buildAgentPrompt('charter', { systemContext: 'Extra context' }); - expect(prompt).toContain('ADDITIONAL CONTEXT'); - expect(prompt).toContain('Extra context'); - }); - - it('omits ADDITIONAL CONTEXT when not provided', () => { - const prompt = buildAgentPrompt('charter'); - expect(prompt).not.toContain('ADDITIONAL CONTEXT'); - }); - - it('handles empty charter', () => { - const prompt = buildAgentPrompt(''); - expect(prompt).toContain('YOUR CHARTER'); - }); -}); - -// ============================================================================ -// 6. lifecycle.ts — ShellLifecycle initialization -// ============================================================================ - -describe('lifecycle.ts — ShellLifecycle', () => { - let tmpDir: string; - let registry: SessionRegistry; - let renderer: ShellRenderer; - - beforeEach(() => { - tmpDir = makeTempDir('lifecycle-'); - registry = new SessionRegistry(); - renderer = new ShellRenderer(); - }); - - afterEach(() => { - cleanDir(tmpDir); - }); - - function makeLifecycle(teamRoot: string): ShellLifecycle { - return new ShellLifecycle({ teamRoot, renderer, registry }); - } - - it('throws when .squad/ does not exist', async () => { - const lc = makeLifecycle(tmpDir); - await expect(lc.initialize()).rejects.toThrow(/No team found/); - }); - - it('throws when team.md is missing', async () => { - fs.mkdirSync(join(tmpDir, '.squad'), { recursive: true }); - const lc = makeLifecycle(tmpDir); - await expect(lc.initialize()).rejects.toThrow(/No team manifest found/); - }); - - it('sets state to error on initialization failure', async () => { - const lc = makeLifecycle(tmpDir); - try { - await lc.initialize(); - } catch { - // Expected - } - expect(lc.getState().status).toBe('error'); - }); - - it('discovers agents from team.md', async () => { - const squadDir = join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Tester' }, - ])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - expect(lc.getState().status).toBe('ready'); - expect(lc.getDiscoveredAgents()).toHaveLength(2); - }); - - it('registers discovered agents in the registry', async () => { - const squadDir = join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Edie', role: 'TypeScript' }, - ])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - expect(registry.get('Edie')).toBeDefined(); - expect(registry.get('Edie')?.role).toBe('TypeScript'); - }); - - it('handles team.md with no active agents', async () => { - const squadDir = join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(join(squadDir, 'team.md'), makeTeamMd([])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - expect(lc.getDiscoveredAgents()).toHaveLength(0); - }); -}); - -// ============================================================================ -// 7. router.ts — parseInput() for all message types -// ============================================================================ - -describe('router.ts — parseInput', () => { - const knownAgents = ['Fenster', 'Hockney', 'Edie']; - - describe('slash commands', () => { - it('parses /status command', () => { - const parsed = parseInput('/status', knownAgents); - expect(parsed.type).toBe('slash_command'); - expect(parsed.command).toBe('status'); - expect(parsed.args).toEqual([]); - }); - - it('parses command with args', () => { - const parsed = parseInput('/history 50', knownAgents); - expect(parsed.type).toBe('slash_command'); - expect(parsed.command).toBe('history'); - expect(parsed.args).toEqual(['50']); - }); - - it('lowercases command name', () => { - const parsed = parseInput('/QUIT', knownAgents); - expect(parsed.command).toBe('quit'); - }); - - it('handles multiple args', () => { - const parsed = parseInput('/cmd arg1 arg2 arg3', knownAgents); - expect(parsed.args).toEqual(['arg1', 'arg2', 'arg3']); - }); - }); - - describe('direct agent addressing', () => { - it('parses @Agent syntax', () => { - const parsed = parseInput('@Fenster fix the bug', knownAgents); - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Fenster'); - expect(parsed.content).toBe('fix the bug'); - }); - - it('parses comma syntax', () => { - const parsed = parseInput('Hockney, write tests', knownAgents); - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Hockney'); - expect(parsed.content).toBe('write tests'); - }); - - it('matches agent names case-insensitively', () => { - const parsed = parseInput('@fenster help', knownAgents); - expect(parsed.agentName).toBe('Fenster'); - }); - - it('handles @Agent with no message — routes to coordinator', () => { - const parsed = parseInput('@Edie', knownAgents); - expect(parsed.type).toBe('coordinator'); - expect(parsed.raw).toBe('@Edie'); - }); - - it('routes to coordinator when @Unknown agent', () => { - const parsed = parseInput('@Nobody help', knownAgents); - expect(parsed.type).toBe('coordinator'); - }); - - it('routes to coordinator when unknown agent with comma', () => { - const parsed = parseInput('Nobody, help', knownAgents); - expect(parsed.type).toBe('coordinator'); - }); - }); - - describe('coordinator routing', () => { - it('routes plain text to coordinator', () => { - const parsed = parseInput('What is the status?', knownAgents); - expect(parsed.type).toBe('coordinator'); - expect(parsed.content).toBe('What is the status?'); - }); - - it('routes empty input to coordinator', () => { - const parsed = parseInput('', knownAgents); - expect(parsed.type).toBe('coordinator'); - }); - - it('routes whitespace-only input to coordinator', () => { - const parsed = parseInput(' ', knownAgents); - expect(parsed.type).toBe('coordinator'); - }); - }); - - describe('edge cases', () => { - it('handles input with leading/trailing whitespace', () => { - const parsed = parseInput(' @Fenster test ', knownAgents); - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Fenster'); - }); - - it('handles multiline content in @Agent message', () => { - const parsed = parseInput('@Hockney line1\nline2', knownAgents); - expect(parsed.content).toContain('line1'); - expect(parsed.content).toContain('line2'); - }); - }); -}); - -// ============================================================================ -// 7b. router.ts — parseDispatchTargets() for multi-agent mentions -// ============================================================================ - -describe('router.ts — parseDispatchTargets', () => { - const knownAgents = ['Fenster', 'Hockney', 'Edie']; - - it('extracts multiple @agent mentions', () => { - const result = parseDispatchTargets('@Fenster @Hockney fix and test', knownAgents); - expect(result.agents).toEqual(['Fenster', 'Hockney']); - expect(result.content).toBe('fix and test'); - }); - - it('returns empty agents for plain text', () => { - const result = parseDispatchTargets('just a plain message', knownAgents); - expect(result.agents).toEqual([]); - expect(result.content).toBe('just a plain message'); - }); - - it('deduplicates repeated mentions', () => { - const result = parseDispatchTargets('@Fenster @fenster do it', knownAgents); - expect(result.agents).toEqual(['Fenster']); - }); - - it('ignores unknown agent mentions', () => { - const result = parseDispatchTargets('@Fenster @Nobody test', knownAgents); - expect(result.agents).toEqual(['Fenster']); - }); - - it('handles single mention', () => { - const result = parseDispatchTargets('@Edie write docs', knownAgents); - expect(result.agents).toEqual(['Edie']); - expect(result.content).toBe('write docs'); - }); - - it('is case-insensitive for mentions', () => { - const result = parseDispatchTargets('@FENSTER @hockney go', knownAgents); - expect(result.agents).toEqual(['Fenster', 'Hockney']); - }); - - it('extracts mentions from mid-sentence', () => { - const result = parseDispatchTargets('ask @Fenster and @Hockney to collaborate', knownAgents); - expect(result.agents).toEqual(['Fenster', 'Hockney']); - expect(result.content).toBe('ask and to collaborate'); - }); - - it('handles all three agents', () => { - const result = parseDispatchTargets('@Fenster @Hockney @Edie full team task', knownAgents); - expect(result.agents).toEqual(['Fenster', 'Hockney', 'Edie']); - expect(result.content).toBe('full team task'); - }); - - it('handles empty input', () => { - const result = parseDispatchTargets('', knownAgents); - expect(result.agents).toEqual([]); - expect(result.content).toBe(''); - }); -}); - -// ============================================================================ -// 8. sessions.ts — SessionRegistry operations -// ============================================================================ - -describe('sessions.ts — SessionRegistry', () => { - let registry: SessionRegistry; - - beforeEach(() => { - registry = new SessionRegistry(); - }); - - it('register creates session with idle status', () => { - const session = registry.register('test', 'Role'); - expect(session.name).toBe('test'); - expect(session.role).toBe('Role'); - expect(session.status).toBe('idle'); - expect(session.startedAt).toBeInstanceOf(Date); - }); - - it('get retrieves registered session', () => { - registry.register('agent1', 'role1'); - expect(registry.get('agent1')?.role).toBe('role1'); - }); - - it('get returns undefined for unknown name', () => { - expect(registry.get('nobody')).toBeUndefined(); - }); - - it('getAll returns all sessions', () => { - registry.register('a', 'r1'); - registry.register('b', 'r2'); - expect(registry.getAll()).toHaveLength(2); - }); - - it('getActive filters to working/streaming status', () => { - registry.register('idle1', 'r'); - registry.register('working1', 'r'); - registry.register('streaming1', 'r'); - registry.register('error1', 'r'); - registry.updateStatus('working1', 'working'); - registry.updateStatus('streaming1', 'streaming'); - registry.updateStatus('error1', 'error'); - const active = registry.getActive(); - expect(active).toHaveLength(2); - expect(active.map(s => s.name).sort()).toEqual(['streaming1', 'working1']); - }); - - it('updateStatus changes session status', () => { - registry.register('agent', 'role'); - registry.updateStatus('agent', 'working'); - expect(registry.get('agent')?.status).toBe('working'); - }); - - it('updateStatus is no-op for unknown session', () => { - expect(() => registry.updateStatus('nobody', 'working')).not.toThrow(); - }); - - it('remove deletes session and returns true', () => { - registry.register('agent', 'role'); - expect(registry.remove('agent')).toBe(true); - expect(registry.get('agent')).toBeUndefined(); - }); - - it('remove returns false for unknown session', () => { - expect(registry.remove('nobody')).toBe(false); - }); - - it('clear removes all sessions', () => { - registry.register('a', 'r'); - registry.register('b', 'r'); - registry.clear(); - expect(registry.getAll()).toHaveLength(0); - }); -}); - -// ============================================================================ -// 9. commands.ts — executeCommand() for all slash commands -// ============================================================================ - -describe('commands.ts — executeCommand', () => { - let registry: SessionRegistry; - let renderer: ShellRenderer; - let messageHistory: ShellMessage[]; - let context: any; - - beforeEach(() => { - registry = new SessionRegistry(); - renderer = new ShellRenderer(); - messageHistory = []; - context = { registry, renderer, messageHistory, teamRoot: '/test' }; - }); - - describe('/help', () => { - it('returns help text', () => { - const result = executeCommand('help', [], context); - expect(result.handled).toBe(true); - expect(result.output).toContain('Commands:'); - expect(result.output).toContain('/status'); - expect(result.output).toContain('/agents'); - }); - }); - - describe('/status', () => { - it('shows status with no agents', () => { - const result = executeCommand('status', [], context); - expect(result.handled).toBe(true); - expect(result.output).toContain('Squad Status'); - expect(result.output).toContain('Team: 0'); - }); - - it('shows registered agents count', () => { - registry.register('a', 'r1'); - registry.register('b', 'r2'); - const result = executeCommand('status', [], context); - expect(result.output).toContain('Team: 2 agents'); - }); - - it('shows active agents details', () => { - registry.register('worker', 'role'); - registry.updateStatus('worker', 'working'); - const result = executeCommand('status', [], context); - expect(result.output).toContain('(1 active)'); - expect(result.output).toContain('worker'); - }); - }); - - describe('/agents', () => { - it('shows "No agents registered" when empty', () => { - const result = executeCommand('agents', [], context); - expect(result.handled).toBe(true); - expect(result.output).toContain('No team members yet'); - }); - - it('lists all agents with status icons', () => { - registry.register('idle1', 'r'); - registry.register('worker', 'r'); - registry.updateStatus('worker', 'working'); - const result = executeCommand('agents', [], context); - expect(result.output).toContain('idle1'); - expect(result.output).toContain('worker'); - }); - }); - - describe('/history', () => { - it('shows "No message history" when empty', () => { - const result = executeCommand('history', [], context); - expect(result.handled).toBe(true); - expect(result.output).toContain('No messages yet'); - }); - - it('shows recent messages with default limit 10', () => { - for (let i = 0; i < 20; i++) { - messageHistory.push({ - role: 'user', - content: `Message ${i}`, - timestamp: new Date(), - }); - } - const result = executeCommand('history', [], context); - expect(result.output).toContain('Last 10 messages:'); - expect(result.output).toContain('Message 19'); // Last message - }); - - it('respects custom limit arg', () => { - for (let i = 0; i < 50; i++) { - messageHistory.push({ role: 'user', content: `Msg ${i}`, timestamp: new Date() }); - } - const result = executeCommand('history', ['5'], context); - expect(result.output).toContain('Last 5 messages:'); - }); - - it('truncates long messages at 100 chars', () => { - messageHistory.push({ - role: 'user', - content: 'x'.repeat(150), - timestamp: new Date(), - }); - const result = executeCommand('history', [], context); - expect(result.output).toContain('...'); - }); - }); - - describe('/clear', () => { - it('returns clear flag to reset message history', () => { - const result = executeCommand('clear', [], context); - expect(result.handled).toBe(true); - expect(result.clear).toBe(true); - }); - }); - - describe('/quit and /exit', () => { - it('/quit sets exit flag', () => { - const result = executeCommand('quit', [], context); - expect(result.handled).toBe(true); - expect(result.exit).toBe(true); - }); - - it('/exit sets exit flag', () => { - const result = executeCommand('exit', [], context); - expect(result.handled).toBe(true); - expect(result.exit).toBe(true); - }); - }); - - describe('unknown command', () => { - it('returns handled: false with error message', () => { - const result = executeCommand('foobar', [], context); - expect(result.handled).toBe(false); - expect(result.output).toContain('Unknown command: /foobar'); - expect(result.output).toContain('Type /help'); - }); - }); -}); - -// ============================================================================ -// 10. memory.ts — MemoryManager -// ============================================================================ - -describe('memory.ts — MemoryManager', () => { - it('uses DEFAULT_LIMITS when no config provided', () => { - const manager = new MemoryManager(); - const limits = manager.getLimits(); - expect(limits.maxMessages).toBe(DEFAULT_LIMITS.maxMessages); - expect(limits.maxStreamBuffer).toBe(DEFAULT_LIMITS.maxStreamBuffer); - }); - - it('allows partial limit overrides', () => { - const manager = new MemoryManager({ maxMessages: 500 }); - expect(manager.getLimits().maxMessages).toBe(500); - expect(manager.getLimits().maxSessions).toBe(DEFAULT_LIMITS.maxSessions); - }); - - describe('canCreateSession', () => { - it('returns true when under limit', () => { - const manager = new MemoryManager({ maxSessions: 5 }); - expect(manager.canCreateSession(3)).toBe(true); - }); - - it('returns false when at limit', () => { - const manager = new MemoryManager({ maxSessions: 5 }); - expect(manager.canCreateSession(5)).toBe(false); - }); - }); - - describe('trackBuffer', () => { - it('tracks buffer growth within limits', () => { - const manager = new MemoryManager({ maxStreamBuffer: 100 }); - expect(manager.trackBuffer('s1', 50)).toBe(true); - expect(manager.trackBuffer('s1', 30)).toBe(true); - }); - - it('rejects buffer growth exceeding limit', () => { - const manager = new MemoryManager({ maxStreamBuffer: 100 }); - manager.trackBuffer('s1', 80); - expect(manager.trackBuffer('s1', 30)).toBe(false); - }); - - it('tracks multiple sessions independently', () => { - const manager = new MemoryManager({ maxStreamBuffer: 100 }); - manager.trackBuffer('s1', 50); - manager.trackBuffer('s2', 50); - expect(manager.getStats().sessions).toBe(2); - }); - }); - - describe('trimMessages', () => { - it('returns same array when under limit', () => { - const manager = new MemoryManager({ maxMessages: 10 }); - const msgs = Array(5).fill('x'); - expect(manager.trimMessages(msgs)).toHaveLength(5); - }); - - it('trims to maxMessages when over limit', () => { - const manager = new MemoryManager({ maxMessages: 10 }); - const msgs = Array(20).fill(null).map((_, i) => i); - const trimmed = manager.trimMessages(msgs); - expect(trimmed).toHaveLength(10); - expect(trimmed[0]).toBe(10); // Last 10 messages - }); - }); - - describe('clearBuffer', () => { - it('removes buffer tracking for session', () => { - const manager = new MemoryManager(); - manager.trackBuffer('s1', 100); - manager.clearBuffer('s1'); - expect(manager.getStats().sessions).toBe(0); - }); - - it('is safe to call for non-existent session', () => { - const manager = new MemoryManager(); - expect(() => manager.clearBuffer('nobody')).not.toThrow(); - }); - }); - - describe('getStats', () => { - it('returns accurate session count and buffer size', () => { - const manager = new MemoryManager(); - manager.trackBuffer('s1', 100); - manager.trackBuffer('s2', 200); - const stats = manager.getStats(); - expect(stats.sessions).toBe(2); - expect(stats.totalBufferBytes).toBe(300); - }); - }); -}); - -// ============================================================================ -// 11. autocomplete.ts — createCompleter() -// ============================================================================ - -describe('autocomplete.ts — createCompleter', () => { - const agents = ['Fenster', 'Hockney', 'Edie']; - let completer: ReturnType; - - beforeEach(() => { - completer = createCompleter(agents); - }); - - describe('agent name completion', () => { - it('completes @Agent prefix', () => { - const [matches, partial] = completer('@Fe'); - expect(matches).toEqual(['@Fenster ']); - expect(partial).toBe('@Fe'); - }); - - it('returns all agents for bare @', () => { - const [matches] = completer('@'); - expect(matches).toHaveLength(3); - expect(matches).toContain('@Fenster '); - }); - - it('matches case-insensitively', () => { - const [matches] = completer('@hock'); - expect(matches).toEqual(['@Hockney ']); - }); - - it('returns no matches for non-matching prefix', () => { - const [matches] = completer('@Nobody'); - expect(matches).toHaveLength(0); - }); - }); - - describe('slash command completion', () => { - it('completes /status', () => { - const [matches] = completer('/sta'); - expect(matches).toEqual(['/status']); - }); - - it('returns all commands for bare /', () => { - const [matches] = completer('/'); - expect(matches.length).toBeGreaterThan(5); - expect(matches).toContain('/help'); - expect(matches).toContain('/quit'); - }); - - it('matches case-insensitively', () => { - const [matches] = completer('/HELP'); - expect(matches).toEqual(['/help']); - }); - - it('returns no matches for non-existent command', () => { - const [matches] = completer('/foobar'); - expect(matches).toHaveLength(0); - }); - }); - - describe('no completion', () => { - it('returns empty array for plain text', () => { - const [matches] = completer('hello world'); - expect(matches).toHaveLength(0); - }); - - it('returns empty array for empty input', () => { - const [matches] = completer(''); - expect(matches).toHaveLength(0); - }); - }); -}); - -// ============================================================================ -// 12. CRITICAL BUG TEST: coordinatorSession.sendMessage() exists after createSession() -// ============================================================================ - -describe('CRITICAL BUG: session.sendMessage() exists after createSession()', () => { - it('mock session has sendMessage as callable function', async () => { - const mockSession = createMockSession(); - expect(typeof mockSession.sendMessage).toBe('function'); - await expect(mockSession.sendMessage({ prompt: 'test' })).resolves.toBeUndefined(); - expect(mockSession.sendMessage).toHaveBeenCalledWith({ prompt: 'test' }); - }); - - it('createSession returns object with sendMessage method', async () => { - const mockClient = createMockClient(); - const mockSession = createMockSession(); - mockClient.createSession.mockResolvedValue(mockSession); - - const session = await mockClient.createSession({ streaming: true }); - expect(session).toBeDefined(); - expect(typeof session.sendMessage).toBe('function'); - }); - - it('throws clear error when sendMessage is missing', async () => { - const mockClient = createMockClient(); - const brokenSession = { on: vi.fn(), off: vi.fn(), close: vi.fn() }; // Missing sendMessage - mockClient.createSession.mockResolvedValue(brokenSession); - - const session = await mockClient.createSession({ streaming: true }); - expect(session.sendMessage).toBeUndefined(); - // In real code, calling session.sendMessage() would throw "is not a function" - }); - - it('verifies sendMessage is present before calling', async () => { - const mockClient = createMockClient(); - const mockSession = createMockSession(); - mockClient.createSession.mockResolvedValue(mockSession); - - const session = await mockClient.createSession({ streaming: true }); - - // Best practice: check before calling - if (typeof session.sendMessage !== 'function') { - throw new Error('Session object missing sendMessage method'); - } - - await session.sendMessage({ prompt: 'test' }); - expect(mockSession.sendMessage).toHaveBeenCalled(); - }); - - it('handles sendMessage rejection gracefully', async () => { - const mockSession = createMockSession(); - mockSession.sendMessage.mockRejectedValue(new Error('Network error')); - - await expect(mockSession.sendMessage({ prompt: 'test' })).rejects.toThrow('Network error'); - }); -}); - -// ============================================================================ -// 13. Error handling tests -// ============================================================================ - -describe('Error handling in shell operations', () => { - it('dispatchToAgent handles session creation failure', async () => { - const mockClient = createMockClient(); - mockClient.createSession.mockRejectedValue(new Error('Connection failed')); - - await expect(mockClient.createSession({})).rejects.toThrow('Connection failed'); - }); - - it('dispatchToAgent handles sendMessage failure', async () => { - const mockSession = createMockSession(); - mockSession.sendMessage.mockRejectedValue(new Error('Timeout')); - - await expect(mockSession.sendMessage({ prompt: 'test' })).rejects.toThrow('Timeout'); - }); - - it('session.close() is safe to call on cleanup', async () => { - const mockSession = createMockSession(); - mockSession.close.mockResolvedValue(undefined); - - await expect(mockSession.close()).resolves.toBeUndefined(); - }); - - it('session.close() handles rejection gracefully', async () => { - const mockSession = createMockSession(); - mockSession.close.mockRejectedValue(new Error('Already closed')); - - await expect(mockSession.close()).rejects.toThrow('Already closed'); - }); -}); - -// ============================================================================ -// 14. Error hardening tests (Issue #334) -// ============================================================================ - -describe('Error hardening — user-friendly messages with remediation hints', () => { - // --- lifecycle.ts --- - - it('lifecycle init error for missing .squad/ includes remediation hint', async () => { - const tmpDir = makeTempDir('no-squad-'); - const lc = new ShellLifecycle({ - teamRoot: tmpDir, - renderer: new ShellRenderer(), - registry: new SessionRegistry(), - }); - try { - await lc.initialize(); - } catch (err: unknown) { - expect((err as Error).message).toContain('squad init'); - expect((err as Error).message).not.toContain('Error:'); - } - cleanDir(tmpDir); - }); - - it('lifecycle init error for missing team.md includes remediation hint', async () => { - const tmpDir = makeTempDir('no-team-'); - fs.mkdirSync(join(tmpDir, '.squad'), { recursive: true }); - const lc = new ShellLifecycle({ - teamRoot: tmpDir, - renderer: new ShellRenderer(), - registry: new SessionRegistry(), - }); - try { - await lc.initialize(); - } catch (err: unknown) { - expect((err as Error).message).toContain('squad init'); - expect((err as Error).message).toContain('No team manifest found'); - } - cleanDir(tmpDir); - }); - - // --- spawn.ts --- - - it('loadAgentCharter error for missing charter includes agent name', async () => { - try { - await loadAgentCharter('nonexistent-agent', FIXTURES); - } catch (err: unknown) { - expect((err as Error).message).toContain('nonexistent-agent'); - expect((err as Error).message).toContain('charter.md exists'); - } - }); - - it('loadAgentCharter error for no .squad/ includes actionable hint', async () => { - const tmpDir = makeTempDir('no-squad-spawn-'); - const originalCwd = process.cwd(); - try { - process.chdir(tmpDir); - await loadAgentCharter('test'); - } catch (err: unknown) { - // Error may say "squad init" OR "charter.md exists" depending on resolveSquad() - expect((err as Error).message).toMatch(/squad init|charter\.md exists/); - expect((err as Error).message).not.toMatch(/^Error:/); - } finally { - process.chdir(originalCwd); - cleanDir(tmpDir); - } - }); - - // --- coordinator.ts --- - - it('buildCoordinatorPrompt includes squad init hint in fallback text', async () => { - const prompt = await buildCoordinatorPrompt({ teamRoot: '/nonexistent' }); - expect(prompt).toContain('squad init'); - }); - - // --- commands.ts --- - - it('unknown command returns helpful suggestion', () => { - const result = executeCommand('foobar', [], { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [], - teamRoot: '/tmp', - }); - expect(result.handled).toBe(false); - expect(result.output).toContain('/help'); - expect(result.output).toContain('foobar'); - }); - - // --- Error message sanitization --- - - it('error messages do not expose raw stack traces', () => { - const rawError = new Error('Connection reset by peer'); - rawError.stack = 'Error: Connection reset by peer\n at Socket.emit (node:events:513:28)'; - const errorMsg = rawError.message; - const friendly = errorMsg.replace(/^Error:\s*/i, ''); - expect(friendly).not.toContain('at Socket.emit'); - expect(friendly).toBe('Connection reset by peer'); - }); - - it('Error: prefix is stripped from user-facing messages', () => { - const msg = 'Error: something broke'; - const friendly = msg.replace(/^Error:\s*/i, ''); - expect(friendly).toBe('something broke'); - }); - - it('messages without Error: prefix pass through unchanged', () => { - const msg = 'Connection timed out'; - const friendly = msg.replace(/^Error:\s*/i, ''); - expect(friendly).toBe('Connection timed out'); - }); -}); - -// ============================================================================ -// 14. Dead session eviction (Issue #366) -// ============================================================================ - -describe('Dead session eviction', () => { - it('agentSessions Map evicts entry on delete', () => { - const agentSessions = new Map(); - const session = createMockSession(); - agentSessions.set('TestAgent', session); - expect(agentSessions.has('TestAgent')).toBe(true); - - // Simulate eviction after error - agentSessions.delete('TestAgent'); - expect(agentSessions.has('TestAgent')).toBe(false); - expect(agentSessions.size).toBe(0); - }); - - it('next dispatch creates fresh session after eviction', () => { - const agentSessions = new Map(); - const deadSession = createMockSession(); - agentSessions.set('TestAgent', deadSession); - - // Evict the dead session - agentSessions.delete('TestAgent'); - - // Simulate next dispatch — no existing session - const existingSession = agentSessions.get('TestAgent'); - expect(existingSession).toBeUndefined(); - - // Would create a new session here in real code - const freshSession = createMockSession(); - agentSessions.set('TestAgent', freshSession); - expect(agentSessions.get('TestAgent')).toBe(freshSession); - expect(agentSessions.get('TestAgent')).not.toBe(deadSession); - }); - - it('coordinator session evicts on null assignment', () => { - let coordinatorSession: MockSquadSession | null = createMockSession(); - expect(coordinatorSession).not.toBeNull(); - - // Simulate eviction - coordinatorSession = null; - expect(coordinatorSession).toBeNull(); - }); -}); - -// ============================================================================ -// 15. Stub command removal (Issue #371) -// ============================================================================ - -describe('Stub command removal', () => { - it('commands.ts executeCommand does not have loop command', () => { - const reg = new SessionRegistry(); - const renderer = new ShellRenderer(); - const result = executeCommand('loop', [], { - registry: reg, - renderer, - messageHistory: [], - teamRoot: '/test', - }); - // Unknown commands return handled: false - expect(result.handled).toBe(false); - }); - - it('commands.ts executeCommand does not have hire command', () => { - const reg = new SessionRegistry(); - const renderer = new ShellRenderer(); - const result = executeCommand('hire', [], { - registry: reg, - renderer, - messageHistory: [], - teamRoot: '/test', - }); - expect(result.handled).toBe(false); - }); - - it('all known commands are functional (not stubs)', () => { - const reg = new SessionRegistry(); - const renderer = new ShellRenderer(); - const knownCommands = ['status', 'history', 'clear', 'help', 'quit', 'exit', 'agents']; - for (const cmd of knownCommands) { - const result = executeCommand(cmd, [], { - registry: reg, - renderer, - messageHistory: [], - teamRoot: '/test', - }); - expect(result.handled).toBe(true); - } - }); -}); diff --git a/test/cli/copilot-bridge.test.ts b/test/cli/copilot-bridge.test.ts deleted file mode 100644 index 80bc70a33..000000000 --- a/test/cli/copilot-bridge.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -/** - * Copilot-Bridge Command Tests — Copilot ACP Bridge - * - * Tests the CopilotBridge class exports, instantiation, and state management. - * Does NOT spawn real copilot processes (requires copilot CLI). - */ - -import { describe, it, expect } from 'vitest'; - -describe('CLI: copilot-bridge command', () => { - it('module exports CopilotBridge class', async () => { - const mod = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - expect(typeof mod.CopilotBridge).toBe('function'); - expect(typeof mod.CopilotBridge.checkCompatibility).toBe('function'); - }); - - it('CopilotBridge can be instantiated with config', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd() }); - expect(bridge).toBeDefined(); - expect(typeof bridge.start).toBe('function'); - expect(typeof bridge.stop).toBe('function'); - expect(typeof bridge.send).toBe('function'); - expect(typeof bridge.sendPrompt).toBe('function'); - expect(typeof bridge.onMessage).toBe('function'); - expect(typeof bridge.isRunning).toBe('function'); - }); - - it('isRunning returns false before start', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd() }); - expect(bridge.isRunning()).toBe(false); - }); - - it('accepts optional agent in config', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd(), agent: 'test-agent' }); - expect(bridge).toBeDefined(); - expect(bridge.isRunning()).toBe(false); - }); - - it('stop is safe to call when not running', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd() }); - expect(() => bridge.stop()).not.toThrow(); - }); - - it('sendPrompt is safe to call when not initialized', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd() }); - // Should log error but not throw - expect(() => bridge.sendPrompt('test')).not.toThrow(); - }); - - it('onMessage accepts a callback', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - const bridge = new CopilotBridge({ cwd: process.cwd() }); - const cb = (_line: string) => {}; - expect(() => bridge.onMessage(cb)).not.toThrow(); - }); - - it('checkCompatibility is a static async method', async () => { - const { CopilotBridge } = await import('@bradygaster/squad-cli/commands/copilot-bridge'); - expect(typeof CopilotBridge.checkCompatibility).toBe('function'); - // Verify it returns a promise (don't await — spawns real processes) - expect(CopilotBridge.checkCompatibility.constructor.name).toBe('AsyncFunction'); - }); -}); diff --git a/test/cli/no-args-deprecation.test.ts b/test/cli/no-args-deprecation.test.ts new file mode 100644 index 000000000..8aeb1a4d3 --- /dev/null +++ b/test/cli/no-args-deprecation.test.ts @@ -0,0 +1,68 @@ +/** + * No-args deprecation message test (#665) + * + * Verifies that running `squad` with no arguments prints the REPL deprecation + * notice, exits 0, and includes the expected guidance text. + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { join } from 'node:path'; +import { existsSync } from 'node:fs'; + +const CLI_ENTRY = join(process.cwd(), 'packages', 'squad-cli', 'dist', 'cli-entry.js'); + +describe('CLI: no-args deprecation message (#665)', () => { + it('dist/cli-entry.js exists (built before test run)', () => { + expect(existsSync(CLI_ENTRY)).toBe(true); + }); + + it('exits 0 when run with no arguments', () => { + let code = 0; + try { + execFileSync(process.execPath, [CLI_ENTRY], { encoding: 'utf8', env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' } }); + } catch (err: any) { + code = err.status ?? 1; + } + expect(code).toBe(0); + }); + + it('output mentions the REPL has been deprecated', () => { + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [CLI_ENTRY], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + } catch (err: any) { + stdout = (err.stdout ?? '') + (err.stderr ?? ''); + } + expect(stdout.toLowerCase()).toMatch(/deprecated|repl/); + }); + + it('output contains the issue link', () => { + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [CLI_ENTRY], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + } catch (err: any) { + stdout = (err.stdout ?? '') + (err.stderr ?? ''); + } + expect(stdout).toContain('github.com/bradygaster/squad/issues/665'); + }); + + it('output contains the Copilot CLI install command', () => { + let stdout = ''; + try { + stdout = execFileSync(process.execPath, [CLI_ENTRY], { + encoding: 'utf8', + env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + }); + } catch (err: any) { + stdout = (err.stdout ?? '') + (err.stderr ?? ''); + } + expect(stdout).toContain('gh extension install github/gh-copilot'); + }); +}); diff --git a/test/cli/rc-tunnel.test.ts b/test/cli/rc-tunnel.test.ts deleted file mode 100644 index 333816448..000000000 --- a/test/cli/rc-tunnel.test.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * RC-Tunnel Command Tests — Devtunnel lifecycle management - * - * Tests module exports and pure utility functions. - * Does NOT create real devtunnels (requires devtunnel CLI). - */ - -import { describe, it, expect } from 'vitest'; -import os from 'node:os'; - -describe('CLI: rc-tunnel command', () => { - it('module exports isDevtunnelAvailable function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(typeof mod.isDevtunnelAvailable).toBe('function'); - }); - - it('module exports createTunnel function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(typeof mod.createTunnel).toBe('function'); - }); - - it('module exports destroyTunnel function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(typeof mod.destroyTunnel).toBe('function'); - }); - - it('module exports getMachineId function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(typeof mod.getMachineId).toBe('function'); - }); - - it('module exports getGitInfo function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(typeof mod.getGitInfo).toBe('function'); - }); - - it('isDevtunnelAvailable returns a boolean', async () => { - const { isDevtunnelAvailable } = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - const result = isDevtunnelAvailable(); - expect(typeof result).toBe('boolean'); - }); - - it('getMachineId returns the system hostname', async () => { - const { getMachineId } = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - const id = getMachineId(); - expect(id).toBe(os.hostname()); - }); - - it('getGitInfo returns repo and branch from cwd', async () => { - const { getGitInfo } = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - const info = getGitInfo(process.cwd()); - expect(info).toHaveProperty('repo'); - expect(info).toHaveProperty('branch'); - expect(typeof info.repo).toBe('string'); - expect(typeof info.branch).toBe('string'); - }); - - it('getGitInfo returns "unknown" for non-git directories', async () => { - const { getGitInfo } = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - const info = getGitInfo(os.tmpdir()); - expect(info.repo).toBe('unknown'); - expect(info.branch).toBe('unknown'); - }); - - it('destroyTunnel does not throw when no tunnel active', async () => { - const { destroyTunnel } = await import('@bradygaster/squad-cli/commands/rc-tunnel'); - expect(() => destroyTunnel()).not.toThrow(); - }); -}); diff --git a/test/cli/rc.test.ts b/test/cli/rc.test.ts deleted file mode 100644 index dc2cb9ab1..000000000 --- a/test/cli/rc.test.ts +++ /dev/null @@ -1,461 +0,0 @@ -/** - * RC Command Tests — squad rc / squad remote-control - * - * Tests module exports, option handling, and error paths. - * Does NOT create real WebSocket servers or spawn copilot (requires network + native deps). - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -describe('CLI: rc command', () => { - describe('Module exports', () => { - it('module exports runRC function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc'); - expect(typeof mod.runRC).toBe('function'); - }); - - it('module exports RCOptions interface (verifiable via function arity)', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc'); - // runRC(cwd, options) — should accept 2 parameters - expect(mod.runRC.length).toBe(2); - }); - - it('module has no unexpected default export', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc'); - // ESM module should have named exports, no default - expect(mod.default).toBeUndefined(); - }); - }); - - describe('RCOptions interface validation', () => { - it('accepts tunnel option', async () => { - const { RCOptions } = await import('@bradygaster/squad-cli/commands/rc') as any; - // TypeScript interface — verify shape through runRC signature - // This is a compile-time check, but we can verify runtime behavior - const mod = await import('@bradygaster/squad-cli/commands/rc'); - expect(mod.runRC).toBeDefined(); - }); - - it('accepts port option', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc'); - expect(mod.runRC).toBeDefined(); - }); - - it('accepts optional path option', async () => { - const mod = await import('@bradygaster/squad-cli/commands/rc'); - expect(mod.runRC).toBeDefined(); - }); - }); - - describe('Squad directory detection', () => { - let tempDir: string; - - beforeEach(async () => { - tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'squad-rc-test-')); - }); - - afterEach(async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }); - - it('detects .squad directory', async () => { - const squadDir = path.join(tempDir, '.squad'); - await fs.promises.mkdir(squadDir); - - // Verify directory exists - const exists = fs.existsSync(squadDir); - expect(exists).toBe(true); - }); - - it('falls back to .ai-team directory', async () => { - const aiTeamDir = path.join(tempDir, '.ai-team'); - await fs.promises.mkdir(aiTeamDir); - - // Verify directory exists - const exists = fs.existsSync(aiTeamDir); - expect(exists).toBe(true); - }); - - it('handles missing squad directory gracefully', () => { - const squadDir = path.join(tempDir, '.squad'); - const aiTeamDir = path.join(tempDir, '.ai-team'); - - // Verify both don't exist - expect(fs.existsSync(squadDir)).toBe(false); - expect(fs.existsSync(aiTeamDir)).toBe(false); - }); - }); - - describe('Team roster parsing', () => { - let tempDir: string; - let squadDir: string; - - beforeEach(async () => { - tempDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'squad-rc-test-')); - squadDir = path.join(tempDir, '.squad'); - await fs.promises.mkdir(squadDir); - }); - - afterEach(async () => { - await fs.promises.rm(tempDir, { recursive: true, force: true }); - }); - - it('parses valid team.md with agents', async () => { - const teamMd = `# Team Roster\n\n| Name | Role | Status |\n|------|------|--------|\n| Keaton | Architect | Active |\n| Fenster | DevOps | Active |\n`; - await fs.promises.writeFile(path.join(squadDir, 'team.md'), teamMd); - - const content = await fs.promises.readFile(path.join(squadDir, 'team.md'), 'utf-8'); - const lines = content.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - - expect(lines.length).toBeGreaterThan(0); - }); - - it('handles empty team.md', async () => { - await fs.promises.writeFile(path.join(squadDir, 'team.md'), ''); - - const content = await fs.promises.readFile(path.join(squadDir, 'team.md'), 'utf-8'); - const lines = content.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - - expect(lines).toEqual([]); - }); - - it('handles malformed team.md table', async () => { - const teamMd = `# Team\n\nNot a table\n`; - await fs.promises.writeFile(path.join(squadDir, 'team.md'), teamMd); - - const content = await fs.promises.readFile(path.join(squadDir, 'team.md'), 'utf-8'); - const lines = content.split('\n').filter(l => l.startsWith('|') && l.includes('Active')); - - expect(lines).toEqual([]); - }); - - it('handles missing team.md gracefully', () => { - const teamPath = path.join(squadDir, 'team.md'); - expect(fs.existsSync(teamPath)).toBe(false); - }); - }); - - describe('Static file handler security', () => { - it('validates directory traversal prevention pattern', () => { - // This is a security regression test — verifies the pattern exists in source - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify security checks are present - expect(rcSource).toContain('!filePath.startsWith(uiDir)'); - expect(rcSource).toContain('decodedUrl.includes(\'..\')'); - }); - - it('validates security headers are set', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify security headers - expect(rcSource).toContain('X-Frame-Options'); - expect(rcSource).toContain('X-Content-Type-Options'); - expect(rcSource).toContain('Referrer-Policy'); - expect(rcSource).toContain('Cache-Control'); - }); - - it('validates EISDIR guard exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify EISDIR guard (issue #2 fix) - expect(rcSource).toContain('stat?.isDirectory'); - }); - - it('validates malformed URI handling', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify malformed URI protection (issue #18) - expect(rcSource).toContain('decodeURIComponent'); - expect(rcSource).toContain('try'); - }); - }); - - describe('Copilot ACP path resolution', () => { - it('validates Windows path pattern exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify Windows-specific path - expect(rcSource).toContain('ProgramData'); - expect(rcSource).toContain('copilot.exe'); - }); - - it('validates fallback to PATH exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - // Verify fallback to 'copilot' command (updated implementation uses conditional) - expect(rcSource).toContain('copilotCmd = \'copilot\''); - expect(rcSource).toContain('if (storage.existsSync(winPath))'); - }); - }); - - describe('RemoteBridge callbacks', () => { - it('validates onPrompt callback signature in source', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('onPrompt:'); - expect(rcSource).toContain('async (text)'); - }); - - it('validates onDirectMessage callback signature in source', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('onDirectMessage:'); - expect(rcSource).toContain('async (agentName, text)'); - }); - - it('validates onCommand callback signature in source', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('onCommand:'); - expect(rcSource).toContain('(name)'); - }); - - it('validates /status command implementation', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('if (name === \'status\')'); - }); - - it('validates /agents command implementation', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('if (name === \'agents\')'); - }); - }); - - describe('Connection monitoring', () => { - it('validates 5-second interval exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('setInterval'); - expect(rcSource).toContain('5000'); - }); - - it('validates connection count tracking', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('getConnectionCount()'); - }); - }); - - describe('Cleanup and signal handling', () => { - it('validates SIGINT handler exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('process.on(\'SIGINT\''); - }); - - it('validates SIGTERM handler exists', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('process.on(\'SIGTERM\''); - }); - - it('validates cleanup function calls bridge.stop()', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('await bridge.stop()'); - }); - - it('validates cleanup function calls destroyTunnel()', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('destroyTunnel()'); - }); - - it('validates copilot process is killed on cleanup', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('copilotProc?.kill()'); - }); - }); - - describe('Copilot passthrough integration', () => { - it('validates copilot spawn with --acp flag', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('--acp'); - expect(rcSource).toContain('spawnChild(copilotCmd'); - }); - - it('validates stdio piping configuration', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('stdio: [\'pipe\', \'pipe\', \'pipe\']'); - }); - - it('validates readline interface for copilot stdout', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('createInterface'); - expect(rcSource).toContain('copilotProc.stdout'); - }); - - it('validates passthrough bidirectional flow', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('passthroughFromAgent'); - expect(rcSource).toContain('setPassthrough'); - }); - - it('validates copilot error handling', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('copilotProc.on(\'error\''); - expect(rcSource).toContain('copilotProc.on(\'exit\''); - }); - }); - - describe('Tunnel integration', () => { - it('validates tunnel flag check', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('if (options.tunnel)'); - }); - - it('validates devtunnel availability check', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('isDevtunnelAvailable()'); - }); - - it('validates tunnel creation with metadata', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('createTunnel(actualPort, { repo, branch, machine })'); - }); - - it('validates QR code generation attempt', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('qrcode-terminal'); - }); - }); - - describe('Color constants', () => { - it('validates ANSI color codes are defined', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('const BOLD ='); - expect(rcSource).toContain('const RESET ='); - expect(rcSource).toContain('const DIM ='); - expect(rcSource).toContain('const GREEN ='); - expect(rcSource).toContain('const CYAN ='); - expect(rcSource).toContain('const YELLOW ='); - }); - }); - - describe('Import statements', () => { - it('imports RemoteBridge from SDK', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('import { FSStorageProvider, RemoteBridge }'); - expect(rcSource).toContain('@bradygaster/squad-sdk'); - }); - - it('imports tunnel utilities', () => { - const rcSource = fs.readFileSync( - path.join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'commands', 'rc.ts'), - 'utf-8' - ); - - expect(rcSource).toContain('import {'); - expect(rcSource).toContain('isDevtunnelAvailable'); - expect(rcSource).toContain('createTunnel'); - expect(rcSource).toContain('destroyTunnel'); - expect(rcSource).toContain('getMachineId'); - expect(rcSource).toContain('getGitInfo'); - }); - }); -}); diff --git a/test/cli/signal-handling.test.ts b/test/cli/signal-handling.test.ts deleted file mode 100644 index 19d53af71..000000000 --- a/test/cli/signal-handling.test.ts +++ /dev/null @@ -1,335 +0,0 @@ -/** - * Signal Handling Tests — SIGINT / SIGTERM handlers in squad-cli - * - * Tests the top-level signal handler in cli-entry.ts and the shell-specific - * signal handler in cli/shell/index.ts. Verifies correct exit codes, double-signal - * force-exit behavior, and cleanup timeout. - * - * Issue: squad/cli-docs-sigint branch — clean exit on Ctrl+C / SIGTERM. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { readFileSync } from 'node:fs'; -import { join } from 'node:path'; - -// --------------------------------------------------------------------------- -// Source paths (for static source analysis, following rc.test.ts pattern) -// --------------------------------------------------------------------------- -const CLI_ENTRY_PATH = join( - process.cwd(), - 'packages', - 'squad-cli', - 'src', - 'cli-entry.ts', -); -const SHELL_INDEX_PATH = join( - process.cwd(), - 'packages', - 'squad-cli', - 'src', - 'cli', - 'shell', - 'index.ts', -); - -// Read sources once for all static-analysis tests -const cliEntrySource = readFileSync(CLI_ENTRY_PATH, 'utf-8'); -const shellIndexSource = readFileSync(SHELL_INDEX_PATH, 'utf-8'); - -// ============================================================================ -// 1. Static analysis — signal handlers are registered (source-level checks) -// ============================================================================ - -describe('Signal handler registration (source analysis)', () => { - describe('cli-entry.ts — top-level handlers', () => { - it('registers a SIGINT handler via process.on', () => { - expect(cliEntrySource).toContain("process.on('SIGINT'"); - }); - - it('registers a SIGTERM handler via process.on', () => { - expect(cliEntrySource).toContain("process.on('SIGTERM'"); - }); - - it('defines _handleTopLevelSignal function', () => { - expect(cliEntrySource).toContain('function _handleTopLevelSignal'); - }); - - it('uses exit code 130 for SIGINT', () => { - // The pattern: signal === 'SIGINT' ? 130 : 143 - expect(cliEntrySource).toMatch(/SIGINT.*130/); - }); - - it('uses exit code 143 for SIGTERM', () => { - expect(cliEntrySource).toMatch(/143/); - }); - }); - - describe('shell/index.ts — shell-specific handlers', () => { - it('registers a SIGINT handler via process.on', () => { - expect(shellIndexSource).toContain("process.on('SIGINT'"); - }); - - it('registers a SIGTERM handler via process.on', () => { - expect(shellIndexSource).toContain("process.on('SIGTERM'"); - }); - - it('defines handleShellSignal function', () => { - expect(shellIndexSource).toContain('handleShellSignal'); - }); - - it('calls unmount() on first signal', () => { - // Shell handler calls unmount() to trigger graceful Ink teardown - expect(shellIndexSource).toMatch(/unmount\(\)/); - }); - }); -}); - -// ============================================================================ -// 2. Behavioral tests — exercise the _handleTopLevelSignal logic via mock -// ============================================================================ - -describe('Top-level signal handler behavior (_handleTopLevelSignal)', () => { - let exitMock: ReturnType; - let setTimeoutSpy: ReturnType; - let processOnSpy: ReturnType; - - // Captured signal handler callbacks - let capturedHandlers: Record void)[]>; - - beforeEach(() => { - capturedHandlers = {}; - - // Mock process.exit to prevent actually exiting - exitMock = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); - - // Spy on setTimeout to verify cleanup timeout - setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => { - // Return an object with unref() to mimic Node timer - return { unref: vi.fn() } as unknown as ReturnType; - }) as typeof setTimeout); - - // Spy on process.on to capture registered handlers - processOnSpy = vi.spyOn(process, 'on').mockImplementation(((event: string, handler: (...args: unknown[]) => void) => { - if (!capturedHandlers[event]) capturedHandlers[event] = []; - capturedHandlers[event].push(handler); - return process; - }) as typeof process.on); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Recreate the _handleTopLevelSignal logic from cli-entry.ts (lines 82-94). - * We test the extracted logic rather than importing the module (which has - * heavy side-effects including ESM patching and node:sqlite probing). - */ - function createTopLevelHandler() { - let _exitingOnSignal = false; - - function _handleTopLevelSignal(signal: 'SIGINT' | 'SIGTERM'): void { - const code = signal === 'SIGINT' ? 130 : 143; - if (_exitingOnSignal) { - process.exit(code); - return; - } - _exitingOnSignal = true; - setTimeout(() => process.exit(code), 3_000).unref(); - } - - return _handleTopLevelSignal; - } - - it('SIGINT exits with code 130', () => { - const handler = createTopLevelHandler(); - handler('SIGINT'); - - // First signal should NOT call process.exit immediately - expect(exitMock).not.toHaveBeenCalled(); - // But should set up a timeout - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3_000); - }); - - it('SIGTERM exits with code 143', () => { - const handler = createTopLevelHandler(); - handler('SIGTERM'); - - expect(exitMock).not.toHaveBeenCalled(); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3_000); - }); - - it('double SIGINT force-exits immediately', () => { - const handler = createTopLevelHandler(); - - // First signal — sets up graceful shutdown - handler('SIGINT'); - expect(exitMock).not.toHaveBeenCalled(); - - // Second signal — forces immediate exit - handler('SIGINT'); - expect(exitMock).toHaveBeenCalledWith(130); - }); - - it('double SIGTERM force-exits immediately', () => { - const handler = createTopLevelHandler(); - - handler('SIGTERM'); - expect(exitMock).not.toHaveBeenCalled(); - - handler('SIGTERM'); - expect(exitMock).toHaveBeenCalledWith(143); - }); - - it('mixed signals: SIGINT then SIGTERM force-exits with SIGTERM code', () => { - const handler = createTopLevelHandler(); - - handler('SIGINT'); - expect(exitMock).not.toHaveBeenCalled(); - - handler('SIGTERM'); - expect(exitMock).toHaveBeenCalledWith(143); - }); - - it('cleanup timeout is 3 seconds', () => { - const handler = createTopLevelHandler(); - handler('SIGINT'); - - expect(setTimeoutSpy).toHaveBeenCalledTimes(1); - expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 3_000); - }); - - it('cleanup timeout calls process.exit with correct code', () => { - // Use real setTimeout capturing instead of spy - vi.restoreAllMocks(); - - let capturedFn: (() => void) | undefined; - let capturedMs: number | undefined; - const realExitMock = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); - vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => { - capturedFn = fn; - capturedMs = ms; - return { unref: vi.fn() } as unknown as ReturnType; - }) as typeof setTimeout); - - const handler = createTopLevelHandler(); - handler('SIGINT'); - - // Execute the timeout callback - expect(capturedFn).toBeDefined(); - expect(capturedMs).toBe(3_000); - capturedFn!(); - expect(realExitMock).toHaveBeenCalledWith(130); - }); - - it('cleanup timeout callback uses SIGTERM code 143', () => { - vi.restoreAllMocks(); - - let capturedFn: (() => void) | undefined; - const realExitMock = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); - vi.spyOn(globalThis, 'setTimeout').mockImplementation(((fn: () => void, ms?: number) => { - capturedFn = fn; - return { unref: vi.fn() } as unknown as ReturnType; - }) as typeof setTimeout); - - const handler = createTopLevelHandler(); - handler('SIGTERM'); - - capturedFn!(); - expect(realExitMock).toHaveBeenCalledWith(143); - }); - - it('timeout timer is unref()d to not keep process alive', () => { - const unrefMock = vi.fn(); - vi.restoreAllMocks(); - vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); - vi.spyOn(globalThis, 'setTimeout').mockImplementation((() => { - return { unref: unrefMock }; - }) as unknown as typeof setTimeout); - - const handler = createTopLevelHandler(); - handler('SIGINT'); - - expect(unrefMock).toHaveBeenCalledOnce(); - }); -}); - -// ============================================================================ -// 3. Shell signal handler behavior (handleShellSignal logic) -// ============================================================================ - -describe('Shell signal handler behavior (handleShellSignal)', () => { - let exitMock: ReturnType; - - beforeEach(() => { - exitMock = vi.spyOn(process, 'exit').mockImplementation((() => {}) as never); - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - /** - * Recreate shell handleShellSignal from shell/index.ts (lines 1239-1253). - * The shell handler calls unmount() on first signal instead of setTimeout. - */ - function createShellHandler() { - let _shellExiting = false; - let _shellSignalCode: number | undefined; - const unmount = vi.fn(); - - const handleShellSignal = (signal: 'SIGINT' | 'SIGTERM'): void => { - const code = signal === 'SIGINT' ? 130 : 143; - if (_shellExiting) { - process.exit(code); - return; - } - _shellExiting = true; - _shellSignalCode = code; - unmount(); - }; - - return { handleShellSignal, unmount, getSignalCode: () => _shellSignalCode }; - } - - it('SIGINT calls unmount() and stores code 130', () => { - const { handleShellSignal, unmount, getSignalCode } = createShellHandler(); - handleShellSignal('SIGINT'); - - expect(unmount).toHaveBeenCalledOnce(); - expect(getSignalCode()).toBe(130); - expect(exitMock).not.toHaveBeenCalled(); - }); - - it('SIGTERM calls unmount() and stores code 143', () => { - const { handleShellSignal, unmount, getSignalCode } = createShellHandler(); - handleShellSignal('SIGTERM'); - - expect(unmount).toHaveBeenCalledOnce(); - expect(getSignalCode()).toBe(143); - expect(exitMock).not.toHaveBeenCalled(); - }); - - it('double SIGINT force-exits without unmount', () => { - const { handleShellSignal, unmount } = createShellHandler(); - - handleShellSignal('SIGINT'); - expect(unmount).toHaveBeenCalledOnce(); - expect(exitMock).not.toHaveBeenCalled(); - - handleShellSignal('SIGINT'); - expect(exitMock).toHaveBeenCalledWith(130); - // unmount only called once (on first signal) - expect(unmount).toHaveBeenCalledOnce(); - }); - - it('SIGINT then SIGTERM force-exits with code 143', () => { - const { handleShellSignal, unmount } = createShellHandler(); - - handleShellSignal('SIGINT'); - handleShellSignal('SIGTERM'); - - expect(exitMock).toHaveBeenCalledWith(143); - expect(unmount).toHaveBeenCalledOnce(); - }); -}); diff --git a/test/cli/start.test.ts b/test/cli/start.test.ts deleted file mode 100644 index c395e4d53..000000000 --- a/test/cli/start.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -/** - * Start Command Tests — PTY Mirror Mode for Copilot - * - * Tests module exports and StartOptions interface. - * Does NOT spawn PTY or create tunnels (requires native deps + network). - */ - -import { describe, it, expect } from 'vitest'; - -describe('CLI: start command', () => { - it('module exports runStart function', async () => { - const mod = await import('@bradygaster/squad-cli/commands/start'); - expect(typeof mod.runStart).toBe('function'); - }); - - it('module exports StartOptions type (verifiable via function arity)', async () => { - const mod = await import('@bradygaster/squad-cli/commands/start'); - // runStart(cwd, options) — should accept 2 parameters - expect(mod.runStart.length).toBe(2); - }); - - it('module has no unexpected default export', async () => { - const mod = await import('@bradygaster/squad-cli/commands/start'); - // ESM module should have named exports, no default - expect(mod.default).toBeUndefined(); - }); -}); diff --git a/test/e2e-integration.test.ts b/test/e2e-integration.test.ts deleted file mode 100644 index fa52dacc9..000000000 --- a/test/e2e-integration.test.ts +++ /dev/null @@ -1,500 +0,0 @@ -/** - * E2E Integration Tests — Interactive REPL and Multi-Agent Coordination - * - * Tests the full interactive pipeline that previously had ZERO coverage: - * - User input → parseInput → dispatch → mock response → MessageStream rendering - * - Multi-agent session tracking, concurrent dispatch, error cleanup - * - * Uses ink-testing-library with React.createElement (no JSX in .test.ts). - * Follows patterns from test/repl-ux.test.ts. - * - * Closes #372, Closes #373 - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { App } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ShellApi, AppProps } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { parseInput } from '../packages/squad-cli/src/cli/shell/router.js'; -import { executeCommand } from '../packages/squad-cli/src/cli/shell/commands.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ============================================================================ -// Helpers -// ============================================================================ - -/** Tiny delay for React state to settle after input. */ -const tick = (ms = 50) => new Promise(r => setTimeout(r, ms)); - -/** - * Type text into ink's stdin then press Enter. - * Must be split: write chars first (so useInput populates value), - * tick for React state, then send \r to trigger key.return submit. - */ -async function typeAndSubmit(stdin: { write: (s: string) => void }, text: string) { - // Write one char at a time so ink's useInput builds the value state - for (const ch of text) { - stdin.write(ch); - } - await tick(80); - stdin.write('\r'); - await tick(150); -} - -/** - * Render the App with a mock registry and capture the ShellApi handle. - * The onDispatch callback is injectable for testing dispatch behavior. - */ -function renderApp(options: { - agents?: Array<{ name: string; role: string }>; - onDispatch?: (parsed: ParsedInput) => Promise; -} = {}) { - const registry = new SessionRegistry(); - const renderer = new ShellRenderer(); - const agents = options.agents ?? []; - - for (const a of agents) { - registry.register(a.name, a.role); - } - - let shellApi: ShellApi | null = null; - const onReady = (api: ShellApi) => { shellApi = api; }; - - // Stub loadWelcomeData's fs reads — App calls it on mount - const props: AppProps = { - registry, - renderer, - teamRoot: '/tmp/fake-squad-root', - version: '0.0.0-test', - onReady, - onDispatch: options.onDispatch, - }; - - const result = render(h(App, props)); - return { ...result, registry, renderer, getApi: () => shellApi! }; -} - -// ============================================================================ -// 1. Full REPL round-trip -// ============================================================================ - -describe('E2E: Full REPL round-trip', () => { - it('user input dispatches to coordinator and response renders in MessageStream', async () => { - const dispatched: ParsedInput[] = []; - - const onDispatch = async (parsed: ParsedInput) => { - dispatched.push(parsed); - }; - - const { lastFrame, stdin, getApi } = renderApp({ - agents: [{ name: 'Kovash', role: 'core dev' }], - onDispatch, - }); - - // Wait for mount + onReady - await tick(100); - const api = getApi(); - expect(api).toBeTruthy(); - - // Simulate user typing a message and pressing Enter - await typeAndSubmit(stdin, 'fix the login bug'); - - // Verify the user message appears in the rendered output - const frameAfterInput = lastFrame()!; - expect(frameAfterInput).toContain('fix the login bug'); - - // Verify dispatch was called with coordinator-type message - expect(dispatched.length).toBe(1); - expect(dispatched[0]!.type).toBe('coordinator'); - expect(dispatched[0]!.raw).toBe('fix the login bug'); - - // Simulate agent response via ShellApi (as StreamBridge would) - api.addMessage({ - role: 'agent', - agentName: 'Kovash', - content: 'I found the bug in auth.ts — fixing now.', - timestamp: new Date(), - }); - await tick(100); - - // Verify agent response renders in output - const frameAfterResponse = lastFrame()!; - expect(frameAfterResponse).toContain('I found the bug in auth.ts'); - expect(frameAfterResponse).toContain('Kovash'); - }); -}); - -// ============================================================================ -// 2. Agent direct message (@Agent) -// ============================================================================ - -describe('E2E: Agent direct message', () => { - it('@AgentName routes to the named agent via dispatch', async () => { - const dispatched: ParsedInput[] = []; - - const onDispatch = async (parsed: ParsedInput) => { - dispatched.push(parsed); - }; - - const { lastFrame, stdin, getApi } = renderApp({ - agents: [ - { name: 'Kovash', role: 'core dev' }, - { name: 'Hockney', role: 'tester' }, - ], - onDispatch, - }); - - await tick(100); - const api = getApi(); - - // Type @Kovash direct message - await typeAndSubmit(stdin, '@Kovash refactor the parser'); - - // Verify dispatch received a direct_agent message for Kovash - expect(dispatched.length).toBe(1); - expect(dispatched[0]!.type).toBe('direct_agent'); - expect(dispatched[0]!.agentName).toBe('Kovash'); - expect(dispatched[0]!.content).toBe('refactor the parser'); - - // Simulate Kovash's response - api.addMessage({ - role: 'agent', - agentName: 'Kovash', - content: 'Parser refactored. Tests still pass.', - timestamp: new Date(), - }); - await tick(100); - - const frame = lastFrame()!; - expect(frame).toContain('Parser refactored'); - expect(frame).toContain('Kovash'); - }); -}); - -// ============================================================================ -// 3. Slash command round-trip (/help) -// ============================================================================ - -describe('E2E: Slash command round-trip', () => { - it('/help renders help output without triggering dispatch', async () => { - const onDispatch = vi.fn(); - - const { lastFrame, stdin } = renderApp({ - agents: [{ name: 'Kovash', role: 'core dev' }], - onDispatch, - }); - - await tick(100); - - // Type /help - await typeAndSubmit(stdin, '/help'); - - // Verify help text appears in output - const frame = lastFrame()!; - expect(frame).toContain('/status'); - expect(frame).toContain('/quit'); - - // Verify dispatch was NOT called — slash commands are local - expect(onDispatch).not.toHaveBeenCalled(); - }); - - it('/status shows team info without dispatch', async () => { - const onDispatch = vi.fn(); - - const { lastFrame, stdin } = renderApp({ - agents: [ - { name: 'Kovash', role: 'core dev' }, - { name: 'Hockney', role: 'tester' }, - ], - onDispatch, - }); - - await tick(100); - - await typeAndSubmit(stdin, '/status'); - - const frame = lastFrame()!; - // Status output should show team size - expect(frame).toContain('2'); - expect(onDispatch).not.toHaveBeenCalled(); - }); -}); - -// ============================================================================ -// 4. Error recovery -// ============================================================================ - -describe('E2E: Error recovery', () => { - it('SDK error during dispatch shows friendly error, shell continues', async () => { - // The App's handleSubmit calls onDispatch(parsed).finally(() => setProcessing(false)) - // but the promise rejection is not caught by App — it propagates. - // In production, the real dispatch wrapper handles errors. - // Here we verify the shell stays alive even when dispatch rejects. - const onDispatch = async (_parsed: ParsedInput) => { - // Return a rejected promise that we handle inline to avoid unhandled rejection - throw new Error('SDK connection failed: timeout'); - }; - - // Wrap to catch the expected unhandled rejection - const originalListeners = process.rawListeners('unhandledRejection'); - process.removeAllListeners('unhandledRejection'); - const caught: Error[] = []; - const catcher = (err: Error) => { caught.push(err); }; - process.on('unhandledRejection', catcher); - - const { lastFrame, stdin } = renderApp({ - agents: [{ name: 'Kovash', role: 'core dev' }], - onDispatch, - }); - - await tick(100); - - // Send a message that triggers the error - await typeAndSubmit(stdin, 'do something'); - - // After dispatch error, the shell should still be alive (not crashed) - // The App catches errors in onDispatch.finally() and sets processing=false - // The shell remains interactive — verify by sending another command - await typeAndSubmit(stdin, '/help'); - - const frame = lastFrame()!; - // /help should still work — shell didn't crash - expect(frame).toContain('/status'); - - // Restore original unhandledRejection listeners - process.removeListener('unhandledRejection', catcher); - for (const listener of originalListeners) { - process.on('unhandledRejection', listener as (...args: unknown[]) => void); - } - // The caught error confirms the dispatch rejection happened - expect(caught.length).toBeGreaterThanOrEqual(1); - }); - - it('no dispatch handler shows SDK-not-connected message', async () => { - // Render App without onDispatch — simulates SDK not connected - const { lastFrame, stdin } = renderApp({ - agents: [{ name: 'Kovash', role: 'core dev' }], - onDispatch: undefined, - }); - - await tick(100); - - await typeAndSubmit(stdin, 'hello world'); - - const frame = lastFrame()!; - expect(frame).toContain('SDK not connected'); - }); -}); - -// ============================================================================ -// 5. Multi-agent session tracking (SessionRegistry integration) -// ============================================================================ - -describe('E2E: Multi-agent session tracking', () => { - let registry: SessionRegistry; - - beforeEach(() => { - registry = new SessionRegistry(); - }); - - it('registers multiple agents with independent tracking', () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - registry.register('Fenster', 'designer'); - - const all = registry.getAll(); - expect(all).toHaveLength(3); - - // Each agent starts idle - for (const agent of all) { - expect(agent.status).toBe('idle'); - } - - // Verify independent identity - const names = all.map(a => a.name); - expect(names).toContain('Kovash'); - expect(names).toContain('Hockney'); - expect(names).toContain('Fenster'); - }); - - it('tracks concurrent status changes independently', () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - registry.register('Fenster', 'designer'); - - // Set different statuses - registry.updateStatus('Kovash', 'working'); - registry.updateStatus('Hockney', 'streaming'); - registry.updateStatus('Fenster', 'idle'); - - expect(registry.get('Kovash')!.status).toBe('working'); - expect(registry.get('Hockney')!.status).toBe('streaming'); - expect(registry.get('Fenster')!.status).toBe('idle'); - - // getActive should return only working/streaming agents - const active = registry.getActive(); - expect(active).toHaveLength(2); - const activeNames = active.map(a => a.name); - expect(activeNames).toContain('Kovash'); - expect(activeNames).toContain('Hockney'); - expect(activeNames).not.toContain('Fenster'); - }); - - it('cleans up on error — clears activity hint, other agents unaffected', () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - - // Both working with activity hints - registry.updateStatus('Kovash', 'working'); - registry.updateActivityHint('Kovash', 'Refactoring parser...'); - registry.updateStatus('Hockney', 'working'); - registry.updateActivityHint('Hockney', 'Running tests...'); - - // Kovash hits an error - registry.updateStatus('Kovash', 'error'); - - // Kovash: error status, hint cleared - expect(registry.get('Kovash')!.status).toBe('error'); - expect(registry.get('Kovash')!.activityHint).toBeUndefined(); - - // Hockney: still working, hint preserved - expect(registry.get('Hockney')!.status).toBe('working'); - expect(registry.get('Hockney')!.activityHint).toBe('Running tests...'); - }); - - it('session removal leaves other sessions intact', () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - registry.register('Fenster', 'designer'); - - registry.updateStatus('Kovash', 'working'); - - // Remove Hockney - const removed = registry.remove('Hockney'); - expect(removed).toBe(true); - - // Kovash and Fenster still tracked - const all = registry.getAll(); - expect(all).toHaveLength(2); - expect(registry.get('Kovash')!.status).toBe('working'); - expect(registry.get('Fenster')!.status).toBe('idle'); - - // Hockney gone - expect(registry.get('Hockney')).toBeUndefined(); - }); - - it('fan-out: concurrent dispatch to multiple agents collects all responses', async () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - registry.register('Fenster', 'designer'); - - // Simulate fan-out dispatch to 3 agents concurrently - const mockDispatch = async (agentName: string, message: string): Promise => { - registry.updateStatus(agentName, 'working'); - // Simulate varying response times - await new Promise(r => setTimeout(r, Math.random() * 50 + 10)); - registry.updateStatus(agentName, 'idle'); - return `${agentName} response: handled "${message}"`; - }; - - const input = 'refactor the entire codebase'; - - // Fan-out to all agents - const results = await Promise.all([ - mockDispatch('Kovash', input), - mockDispatch('Hockney', input), - mockDispatch('Fenster', input), - ]); - - // All 3 responses collected - expect(results).toHaveLength(3); - expect(results[0]).toContain('Kovash response'); - expect(results[1]).toContain('Hockney response'); - expect(results[2]).toContain('Fenster response'); - - // All agents back to idle after completion - for (const agent of registry.getAll()) { - expect(agent.status).toBe('idle'); - } - }); - - it('fan-out: one agent failing does not block others', async () => { - registry.register('Kovash', 'core dev'); - registry.register('Hockney', 'tester'); - - const mockDispatch = async (agentName: string): Promise => { - registry.updateStatus(agentName, 'working'); - await new Promise(r => setTimeout(r, 20)); - - if (agentName === 'Kovash') { - registry.updateStatus(agentName, 'error'); - throw new Error('Kovash SDK timeout'); - } - - registry.updateStatus(agentName, 'idle'); - return `${agentName} completed`; - }; - - // Fan-out with error handling per agent - const results = await Promise.allSettled([ - mockDispatch('Kovash'), - mockDispatch('Hockney'), - ]); - - // Kovash failed, Hockney succeeded - expect(results[0]!.status).toBe('rejected'); - expect(results[1]!.status).toBe('fulfilled'); - expect((results[1] as PromiseFulfilledResult).value).toContain('Hockney completed'); - - // Registry reflects the state - expect(registry.get('Kovash')!.status).toBe('error'); - expect(registry.get('Hockney')!.status).toBe('idle'); - }); -}); - -// ============================================================================ -// 6. parseInput integration with known agents -// ============================================================================ - -describe('E2E: Input parsing integration', () => { - it('parseInput correctly routes @Agent with registered agent list', () => { - const agents = ['Kovash', 'Hockney', 'Fenster']; - - const direct = parseInput('@Kovash fix the bug', agents); - expect(direct.type).toBe('direct_agent'); - expect(direct.agentName).toBe('Kovash'); - expect(direct.content).toBe('fix the bug'); - - const slash = parseInput('/help', agents); - expect(slash.type).toBe('slash_command'); - expect(slash.command).toBe('help'); - - const coordinator = parseInput('what should we work on next?', agents); - expect(coordinator.type).toBe('coordinator'); - expect(coordinator.content).toBe('what should we work on next?'); - }); - - it('case-insensitive agent matching', () => { - const agents = ['Kovash', 'Hockney']; - - const lower = parseInput('@kovash hello', agents); - expect(lower.type).toBe('direct_agent'); - expect(lower.agentName).toBe('Kovash'); // Returns canonical name - - const upper = parseInput('@KOVASH hello', agents); - expect(upper.type).toBe('direct_agent'); - expect(upper.agentName).toBe('Kovash'); - }); - - it('unknown @name falls through to coordinator', () => { - const agents = ['Kovash']; - - const result = parseInput('@UnknownAgent hello', agents); - expect(result.type).toBe('coordinator'); - }); -}); diff --git a/test/e2e-shell.test.ts b/test/e2e-shell.test.ts deleted file mode 100644 index 033cfc666..000000000 --- a/test/e2e-shell.test.ts +++ /dev/null @@ -1,449 +0,0 @@ -/** - * E2E integration tests for the Squad interactive shell. - * - * Renders the full App component with mocked registry/renderer/SDK, - * then drives it via stdin like a real user would. - * - * @see https://github.com/bradygaster/squad-pr/issues/433 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ─── Test infrastructure ──────────────────────────────────────────────────── - -const TICK = 80; // ms between keystrokes for React state to settle - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -/** Pause for `ms` milliseconds. */ -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -/** Scaffold a minimal .squad/ directory so the App shows a welcome banner. */ -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — E2E Test Project - -> An end-to-end test project for shell integration. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: E2E test coverage -active_issues: [] ---- - -# What We're Focused On - -E2E test coverage. -`); -} - -interface ShellHarness { - /** The ink render instance. */ - ink: RenderResponse; - /** The ShellApi exposed by the App after mount. */ - api: () => ShellApi; - /** Type characters one-at-a-time with tick between each. */ - type: (text: string) => Promise; - /** Type text then press Enter (submit). */ - submit: (text: string) => Promise; - /** Get the current rendered frame with ANSI stripped. */ - frame: () => string; - /** Wait until frame contains `text`, or timeout. */ - waitFor: (text: string, timeoutMs?: number) => Promise; - /** Assert that the frame contains `text`. */ - hasText: (text: string) => boolean; - /** Send raw stdin bytes (e.g. escape sequences). */ - raw: (bytes: string) => void; - /** The mock onDispatch function. */ - dispatched: ReturnType; - /** The mock onCancel function. */ - cancelled: ReturnType; - /** Clean up the render and temp directory. */ - cleanup: () => Promise; -} - -/** - * Create a fully-wired shell harness for E2E tests. - * - * Renders the App component with: - * - A SessionRegistry pre-loaded with agents - * - A ShellRenderer (no-op in tests) - * - A temp directory with .squad/ scaffolding - * - Mocked onDispatch and onCancel callbacks - */ -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - ], - withSquadDir = true, - version = '0.0.0-test', - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-e2e-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - }) - ); - - // Let React mount and fire effects - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. Shell renders and shows welcome message -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: Shell welcome', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - // Force a wide terminal so the full banner is shown - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows SQUAD title in the welcome banner', () => { - // Figlet banner renders SQUAD as ASCII art (not literal text) - expect(shell.hasText('___')).toBe(true); - }); - - it('displays version number', () => { - expect(shell.hasText('0.0.0-test')).toBe(true); - }); - - it('shows agent names from the roster', () => { - expect(shell.hasText('Keaton')).toBe(true); - expect(shell.hasText('Fenster')).toBe(true); - }); - - it('shows help hint in banner', () => { - expect(shell.hasText('/help')).toBe(true); - }); - - it('shows project description from team.md', () => { - // Project description removed from simplified header — test version line instead - expect(shell.hasText('Type naturally')).toBe(true); - }); - - it('shows agent count', () => { - expect(shell.hasText('2 agents ready')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. User can type and submit a message -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: User input and submission', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('typed text appears in the input area', async () => { - await shell.type('hello squad'); - expect(shell.hasText('hello squad')).toBe(true); - }); - - it('submitted message appears as user message with chevron', async () => { - await shell.submit('build the feature'); - expect(shell.hasText('build the feature')).toBe(true); - expect(shell.hasText('❯')).toBe(true); - }); - - it('submission dispatches to onDispatch for coordinator routing', async () => { - await shell.submit('what should we build next?'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('coordinator'); - expect(parsed.content).toBe('what should we build next?'); - }); - - it('input clears after submission', async () => { - await shell.type('temp message'); - await tick(); - expect(shell.hasText('temp message')).toBe(true); - shell.ink.stdin.write('\r'); - await tick(120); - // The text appears in message history (with ❯) but the input prompt is cleared. - // Verify we can type new text without the old text lingering in the input area. - await shell.type('new text'); - expect(shell.hasText('new text')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 3. /help command works -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: /help command', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows help output with available commands', async () => { - await shell.submit('/help'); - expect(shell.hasText('/status')).toBe(true); - expect(shell.hasText('/history')).toBe(true); - expect(shell.hasText('/quit')).toBe(true); - }); - - it('/help does not dispatch to SDK', async () => { - await shell.submit('/help'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); - - it('shows routing guidance (how to talk to agents)', async () => { - await shell.submit('/help'); - expect(shell.hasText('@AgentName')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 4. /status command works -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: /status command', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows team status with agent count', async () => { - await shell.submit('/status'); - expect(shell.hasText('2 agents')).toBe(true); - }); - - it('shows team root path', async () => { - await shell.submit('/status'); - // The temp dir path will be in the output - expect(shell.hasText('Root')).toBe(true); - }); - - it('shows message count', async () => { - await shell.submit('/status'); - // /status is preceded by the user message for "/status" so there's 1 message - expect(shell.hasText('Messages')).toBe(true); - }); - - it('/status does not dispatch to SDK', async () => { - await shell.submit('/status'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 5. @agent routing shows in UI -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: @agent routing', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('@Keaton message dispatches as direct_agent', async () => { - await shell.submit('@Keaton fix the build'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - expect(parsed.content).toBe('fix the build'); - }); - - it('@agent message appears in the conversation', async () => { - await shell.submit('@Keaton fix the build'); - expect(shell.hasText('@Keaton fix the build')).toBe(true); - }); - - it('agent response appears when pushed via ShellApi', async () => { - await shell.submit('@Keaton fix the build'); - // Simulate agent response via ShellApi - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'On it! Fixing the build now.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('On it! Fixing the build now.')).toBe(true); - }); - - it('bare message routes to coordinator', async () => { - await shell.submit('what should we build?'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('coordinator'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 6. Ctrl+C behavior -// ═══════════════════════════════════════════════════════════════════════════ - -describe('E2E: Ctrl+C behavior', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('first Ctrl+C when idle shows exit hint', async () => { - // Ctrl+C is sent as the byte 0x03 in terminal - shell.raw('\x03'); - await tick(120); - expect(shell.hasText('Press Ctrl+C again to exit')).toBe(true); - }); - - it('hint is a system message in the conversation', async () => { - shell.raw('\x03'); - await tick(120); - // System messages no longer have [system] prefix — just check for Ctrl+C hint content - expect(shell.hasText('Ctrl+C')).toBe(true); - }); -}); diff --git a/test/error-messages.test.ts b/test/error-messages.test.ts deleted file mode 100644 index af71bfe00..000000000 --- a/test/error-messages.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -/** - * Tests for error message templates and recovery guidance. - * - * @module test/error-messages - */ - -import { describe, it, expect } from 'vitest'; -import { - sdkDisconnectGuidance, - teamConfigGuidance, - agentSessionGuidance, - genericGuidance, - rateLimitGuidance, - extractRetryAfter, - formatGuidance, -} from '@bradygaster/squad-cli/shell/error-messages'; - -describe('error-messages', () => { - // ---------- sdkDisconnectGuidance ---------- - describe('sdkDisconnectGuidance', () => { - it('returns default message when no detail provided', () => { - const g = sdkDisconnectGuidance(); - expect(g.message).toBe('SDK disconnected.'); - expect(g.recovery.length).toBeGreaterThan(0); - }); - - it('includes detail in message when provided', () => { - const g = sdkDisconnectGuidance('timeout after 30s'); - expect(g.message).toBe('SDK disconnected: timeout after 30s'); - }); - - it('suggests squad doctor', () => { - const g = sdkDisconnectGuidance(); - expect(g.recovery.some(r => r.includes('squad doctor'))).toBe(true); - }); - }); - - // ---------- teamConfigGuidance ---------- - describe('teamConfigGuidance', () => { - it('includes the issue description in message', () => { - const g = teamConfigGuidance('team.md not found'); - expect(g.message).toBe('Team configuration issue: team.md not found'); - }); - - it('suggests squad init as recovery', () => { - const g = teamConfigGuidance('invalid YAML'); - expect(g.recovery.some(r => r.includes('squad init'))).toBe(true); - }); - }); - - // ---------- agentSessionGuidance ---------- - describe('agentSessionGuidance', () => { - it('includes agent name in message', () => { - const g = agentSessionGuidance('Kovash'); - expect(g.message).toBe('Kovash session failed.'); - }); - - it('includes detail when provided', () => { - const g = agentSessionGuidance('Kovash', 'connection reset'); - expect(g.message).toBe('Kovash session failed: connection reset.'); - }); - - it('suggests retrying with @agent', () => { - const g = agentSessionGuidance('Kovash'); - expect(g.recovery.some(r => r.includes('@Kovash'))).toBe(true); - }); - - it('suggests auto-reconnect', () => { - const g = agentSessionGuidance('Kovash'); - expect(g.recovery.some(r => r.includes('auto-reconnect'))).toBe(true); - }); - }); - - // ---------- genericGuidance ---------- - describe('genericGuidance', () => { - it('uses detail as message', () => { - const g = genericGuidance('something broke'); - expect(g.message).toBe('something broke'); - }); - - it('suggests squad doctor', () => { - const g = genericGuidance('oops'); - expect(g.recovery.some(r => r.includes('squad doctor'))).toBe(true); - }); - }); - - // ---------- formatGuidance ---------- - describe('formatGuidance', () => { - it('starts with error icon and message', () => { - const output = formatGuidance({ message: 'bad stuff', recovery: [] }); - expect(output).toBe('❌ bad stuff'); - }); - - it('includes Try header and bullet points', () => { - const output = formatGuidance({ - message: 'fail', - recovery: ['do A', 'do B'], - }); - expect(output).toContain('Try:'); - expect(output).toContain('• do A'); - expect(output).toContain('• do B'); - }); - - it('formats full guidance as multi-line string', () => { - const g = agentSessionGuidance('Mira', 'timeout'); - const output = formatGuidance(g); - const lines = output.split('\n'); - expect(lines[0]).toContain('Mira session failed: timeout'); - expect(lines.length).toBeGreaterThanOrEqual(4); // message + Try: + at least 2 bullets - }); - }); - - // ---------- rateLimitGuidance ---------- - describe('rateLimitGuidance', () => { - it('returns a rate limit message with no options', () => { - const g = rateLimitGuidance(); - expect(g.message).toContain('Rate limit'); - expect(g.recovery.length).toBeGreaterThanOrEqual(2); - }); - - it('includes model name when provided', () => { - const g = rateLimitGuidance({ model: 'claude-sonnet-4.5' }); - expect(g.message).toContain('claude-sonnet-4.5'); - }); - - it('shows retry time when retryAfter is provided in seconds', () => { - const g = rateLimitGuidance({ retryAfter: 120 }); - expect(g.recovery[0]).toContain('2 minutes'); - }); - - it('shows retry time in hours for large values', () => { - const g = rateLimitGuidance({ retryAfter: 7200 }); - expect(g.recovery[0]).toContain('2 hours'); - }); - - it('shows fallback when no retryAfter', () => { - const g = rateLimitGuidance({}); - expect(g.recovery[0]).toContain('later'); - }); - - it('suggests economy mode as recovery', () => { - const g = rateLimitGuidance(); - expect(g.recovery.some(r => r.includes('squad economy on'))).toBe(true); - }); - }); - - // ---------- extractRetryAfter ---------- - describe('extractRetryAfter', () => { - it('extracts seconds from "retry after N seconds"', () => { - expect(extractRetryAfter('Please retry after 120 seconds')).toBe(120); - }); - - it('extracts hours from "try again in N hours"', () => { - expect(extractRetryAfter('Sorry, try again in 2 hours')).toBe(7200); - }); - - it('extracts minutes from "try again in N minutes"', () => { - expect(extractRetryAfter('Please try again in 30 minutes')).toBe(1800); - }); - - it('returns undefined when no pattern matches', () => { - expect(extractRetryAfter('Something went wrong')).toBeUndefined(); - }); - - it('handles the Copilot rate limit message format', () => { - const msg = "Sorry, you've hit a rate limit. Please try again in 2 hours."; - expect(extractRetryAfter(msg)).toBe(7200); - }); - }); -}); diff --git a/test/first-run-gating.test.ts b/test/first-run-gating.test.ts deleted file mode 100644 index b4d81e9d9..000000000 --- a/test/first-run-gating.test.ts +++ /dev/null @@ -1,666 +0,0 @@ -/** - * First-run gating tests — Issue #607 - * - * Enforces Init Mode gating: banner renders once, first-run hint appears - * on initial session only, console output is clean, "assembled" message - * requires a non-empty roster, session-scoped Static keys prevent collisions, - * and terminal clear runs before Ink render. - * - * @module test/first-run-gating - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { Text } from 'ink'; - -import type { ShellMessage } from '@bradygaster/squad-cli/shell/types'; - -const h = React.createElement; - -// ============================================================================ -// Helpers -// ============================================================================ - -function makeTmpRoot(): string { - return mkdtempSync(join(tmpdir(), 'squad-first-run-')); -} - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { timestamp: new Date(), ...overrides }; -} - -function writeTeamMd(root: string, agents: Array<{ name: string; role: string }> = [ - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Tester' }, -]): void { - const squadDir = join(root, '.squad'); - mkdirSync(squadDir, { recursive: true }); - const rows = agents.map(a => `| ${a.name} | ${a.role} | \`.squad/agents/${a.name.toLowerCase()}/charter.md\` | ✅ Active |`).join('\n'); - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Test - -> A test team - -## Members -| Name | Role | Charter | Status | -|------|------|---------|--------| -${rows} -`); -} - -function writeFirstRunMarker(root: string): void { - const squadDir = join(root, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, '.first-run'), new Date().toISOString() + '\n'); -} - -// ============================================================================ -// #607.1 — Banner renders exactly once (not duplicated) -// ============================================================================ - -describe('#607.1 — Banner renders exactly once', () => { - it('"◆ SQUAD" title appears exactly once in a rendered frame', () => { - const { lastFrame } = render( - h('ink-box', { flexDirection: 'column' }, - h('ink-box', { gap: 1 }, - h(Text, { bold: true, color: 'cyan' }, '◆ SQUAD'), - h(Text, { dimColor: true }, 'v0.9.0'), - ), - ) as any, - ); - const frame = lastFrame() ?? ''; - const matches = frame.match(/◆ SQUAD/g); - expect(matches).toHaveLength(1); - }); - - it('version string appears exactly once', () => { - const testVersion = '3.14.159'; - const { lastFrame } = render( - h('ink-box', { flexDirection: 'column' }, - h('ink-box', { gap: 1 }, - h(Text, { bold: true, color: 'cyan' }, '◆ SQUAD'), - h(Text, { dimColor: true }, `v${testVersion}`), - ), - ) as any, - ); - const frame = lastFrame() ?? ''; - const escaped = testVersion.replace(/\./g, '\\.'); - const matches = frame.match(new RegExp(escaped, 'g')); - expect(matches).toHaveLength(1); - }); - - it('headerElement memoization deps are stable across state changes', () => { - // App.tsx wraps headerElement in useMemo. The dependencies are: - // noColor, welcome, titleRevealed, bannerReady, version, rosterAgents, - // bannerDim, agentCount, activeCount, wide - // All derived from props or welcome data (stable). Verify the dep list - // does NOT include volatile state (messages, processing, streamingContent). - const volatileStateKeys = ['messages', 'processing', 'streamingContent', 'activityHint']; - const headerDeps = ['noColor', 'welcome', 'titleRevealed', 'bannerReady', 'version', 'rosterAgents', 'bannerDim', 'agentCount', 'activeCount', 'wide']; - for (const v of volatileStateKeys) { - expect(headerDeps).not.toContain(v); - } - }); -}); - -// ============================================================================ -// #607.2 — First-run hint text appears on initial session only -// ============================================================================ - -describe('#607.2 — First-run hint appears on initial session only', () => { - let tmpRoot: string; - - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('loadWelcomeData sets isFirstRun=true when .first-run marker exists', async () => { - writeTeamMd(tmpRoot); - writeFirstRunMarker(tmpRoot); - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const result = loadWelcomeData(tmpRoot); - expect(result).not.toBeNull(); - expect(result!.isFirstRun).toBe(true); - }); - - it('loadWelcomeData consumes .first-run marker (second call returns false)', async () => { - writeTeamMd(tmpRoot); - writeFirstRunMarker(tmpRoot); - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - - const first = loadWelcomeData(tmpRoot); - expect(first!.isFirstRun).toBe(true); - // Marker consumed — file should be gone - expect(existsSync(join(tmpRoot, '.squad', '.first-run'))).toBe(false); - - const second = loadWelcomeData(tmpRoot); - expect(second!.isFirstRun).toBe(false); - }); - - it('firstRunElement is null when isFirstRun is false', () => { - // App.tsx lines 308-325: firstRunElement returns null when !welcome?.isFirstRun - const bannerReady = true; - const isFirstRun = false; - const showFirstRun = bannerReady && isFirstRun; - expect(showFirstRun).toBe(false); - }); - - it('firstRunElement renders when isFirstRun is true and roster is non-empty', () => { - const bannerReady = true; - const isFirstRun = true; - const rosterAgents = [{ name: 'Keaton', role: 'Lead', emoji: '👑' }]; - const showFirstRun = bannerReady && isFirstRun; - const showAssembled = showFirstRun && rosterAgents.length > 0; - expect(showFirstRun).toBe(true); - expect(showAssembled).toBe(true); - }); - - it('session resume logic skips when .first-run marker is present', () => { - writeTeamMd(tmpRoot); - writeFirstRunMarker(tmpRoot); - - const hasTeam = existsSync(join(tmpRoot, '.squad', 'team.md')); - const isFirstRun = existsSync(join(tmpRoot, '.squad', '.first-run')); - const recentSession = (hasTeam && !isFirstRun) ? 'would-load' : null; - - expect(hasTeam).toBe(true); - expect(isFirstRun).toBe(true); - expect(recentSession).toBeNull(); - }); -}); - -// ============================================================================ -// #607.3 — Console output contains no raw Node warnings -// ============================================================================ - -describe('#607.3 — Console output contains no raw Node warnings', () => { - it('ExperimentalWarning string-based events are suppressed', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - return (originalEmitWarning as any).call(process, warning, ...args); - }; - - try { - process.emitWarning('ExperimentalWarning: SQLite is experimental'); - expect(emitted).toHaveLength(0); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('ExperimentalWarning object-based events are suppressed', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - const w = new Error('SQLite is experimental'); - w.name = 'ExperimentalWarning'; - process.emitWarning(w as any); - expect(emitted).toHaveLength(0); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('non-ExperimentalWarning events still pass through', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - process.emitWarning('DeprecationWarning: something is deprecated'); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toContain('DeprecationWarning'); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('DEP0040 and other Node deprecation warnings should not leak to user output', () => { - // The suppression filter must only target ExperimentalWarning — - // other warnings like DeprecationWarning should pass through to - // be handled by process.on('warning') or default handlers, not silenced. - const originalEmitWarning = process.emitWarning; - const suppressed: string[] = []; - const passedThrough: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) { - suppressed.push(warning); - return; - } - if (warning?.name === 'ExperimentalWarning') { - suppressed.push(warning.message); - return; - } - passedThrough.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - process.emitWarning('ExperimentalWarning: require() ESM'); - process.emitWarning('DeprecationWarning: DEP0040'); - const w = new Error('Buffer() is deprecated'); - w.name = 'DeprecationWarning'; - process.emitWarning(w as any); - - expect(suppressed).toHaveLength(1); - expect(passedThrough).toHaveLength(2); - } finally { - process.emitWarning = originalEmitWarning; - } - }); -}); - -// ============================================================================ -// #607.4 — "Your squad is assembled" requires non-empty roster -// ============================================================================ - -describe('#607.4 — "Your squad is assembled" requires non-empty roster', () => { - it('empty roster → firstRunElement shows init guidance, not assembled message', () => { - // App.tsx lines 308-325: when rosterAgents.length === 0, shows init text - const rosterAgents: Array<{ name: string; role: string; emoji: string }> = []; - const isFirstRun = true; - const bannerReady = true; - - const showAssembled = bannerReady && isFirstRun && rosterAgents.length > 0; - const showInitFallback = bannerReady && isFirstRun && rosterAgents.length === 0; - - expect(showAssembled).toBe(false); - expect(showInitFallback).toBe(true); - }); - - it('non-empty roster → firstRunElement shows assembled message', () => { - const rosterAgents = [ - { name: 'Keaton', role: 'Lead', emoji: '👑' }, - { name: 'Fenster', role: 'Core Dev', emoji: '🔧' }, - ]; - const isFirstRun = true; - const bannerReady = true; - - const showAssembled = bannerReady && isFirstRun && rosterAgents.length > 0; - expect(showAssembled).toBe(true); - }); - - it('single-agent roster still qualifies as assembled', () => { - const rosterAgents = [{ name: 'Solo', role: 'Dev', emoji: '🔹' }]; - const isFirstRun = true; - const bannerReady = true; - - const showAssembled = bannerReady && isFirstRun && rosterAgents.length > 0; - expect(showAssembled).toBe(true); - }); - - it('banner shows agent count text for non-empty roster (no first-run)', () => { - // App.tsx lines 291-298: rosterAgents.length > 0 shows count/active display - const rosterAgents = [ - { name: 'A', role: 'Dev', emoji: '🔹' }, - { name: 'B', role: 'Test', emoji: '🔹' }, - { name: 'C', role: 'Doc', emoji: '🔹' }, - ]; - const agentCount = rosterAgents.length; - const activeCount = 1; - const bannerReady = true; - - const showRoster = bannerReady && rosterAgents.length > 0; - expect(showRoster).toBe(true); - // The rendered text should reflect "3 agents ready - 1 active" - const statusText = `${agentCount} agent${agentCount !== 1 ? 's' : ''} ready - ${activeCount} active`; - expect(statusText).toBe('3 agents ready - 1 active'); - }); - - it('empty roster shows /init guidance in banner', () => { - // App.tsx lines 299-301: when rosterAgents.length === 0, shows init guidance - const rosterAgents: Array<{ name: string; role: string; emoji: string }> = []; - const bannerReady = true; - - const showInitGuidance = bannerReady && rosterAgents.length === 0; - expect(showInitGuidance).toBe(true); - - const guidanceText = " Exit and run 'squad init', or type /init to set up your team"; - expect(guidanceText).toContain('squad init'); - expect(guidanceText).toContain('/init'); - }); -}); - -// ============================================================================ -// #607.5 — Session-scoped Static keys prevent cross-session collisions -// ============================================================================ - -describe('#607.5 — Session-scoped Static keys prevent cross-session collisions', () => { - it('sessionId is base-36 encoded and contains alpha characters', () => { - const sessionId = Date.now().toString(36); - expect(sessionId.length).toBeGreaterThan(0); - // Base-36 encoding of a modern timestamp includes alphabetic characters - expect(sessionId).toMatch(/[a-z]/); - }); - - it('composed key format is ${sessionId}-${index}', () => { - const sessionId = Date.now().toString(36); - const key0 = `${sessionId}-0`; - const key5 = `${sessionId}-5`; - expect(key0).toContain(sessionId); - expect(key0).toMatch(/-0$/); - expect(key5).toMatch(/-5$/); - }); - - it('two sessions at different times produce distinct key prefixes', async () => { - const session1 = Date.now().toString(36); - // Simulate a small time gap - await new Promise(r => setTimeout(r, 2)); - const session2 = Date.now().toString(36); - - // Keys for same index must differ across sessions - const key1 = `${session1}-0`; - const key2 = `${session2}-0`; - expect(key1).not.toBe(key2); - }); - - it('keys are never plain numeric indices', () => { - const sessionId = Date.now().toString(36); - for (let i = 0; i < 10; i++) { - const key = `${sessionId}-${i}`; - // Must NOT be a bare number — prevents Ink confusion with array indices - expect(key).not.toMatch(/^\d+$/); - } - }); - - it('MemoryManager archival preserves key stability — combined list only grows', async () => { - const { MemoryManager } = await import('../packages/squad-cli/src/cli/shell/memory.js'); - const mm = new MemoryManager({ maxMessages: 5 }); - - // Simulate messages arriving over time - const batch1: ShellMessage[] = Array.from({ length: 3 }, (_, i) => - makeMessage({ role: 'user', content: `msg-${i}` }), - ); - const result1 = mm.trimWithArchival(batch1); - expect(result1.kept).toHaveLength(3); - expect(result1.archived).toHaveLength(0); - - // Now overflow the cap - const batch2: ShellMessage[] = Array.from({ length: 8 }, (_, i) => - makeMessage({ role: 'user', content: `msg-${i}` }), - ); - const result2 = mm.trimWithArchival(batch2); - expect(result2.kept).toHaveLength(5); - expect(result2.archived).toHaveLength(3); - // Total items across both arrays equals original count - expect(result2.kept.length + result2.archived.length).toBe(8); - }); -}); - -// ============================================================================ -// #607.6 — Terminal clear runs before Ink render -// ============================================================================ - -describe('#607.6 — Terminal clear runs before Ink render', () => { - it('runShell source has terminal clear before render() call', async () => { - // Verify the ordering in the source: process.stdout.write('\\x1b[2J\\x1b[H') - // appears before render(React.createElement(...)). This is a structural test. - const fs = await import('node:fs'); - const source = fs.readFileSync( - join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'shell', 'index.ts'), - 'utf-8', - ); - - // Find the terminal clear line - const clearPattern = /process\.stdout\.write\(['"]\\x1b\[2J/; - const renderPattern = /render\(\s*React\.createElement/; - - const clearMatch = source.match(clearPattern); - const renderMatch = source.match(renderPattern); - expect(clearMatch).not.toBeNull(); - expect(renderMatch).not.toBeNull(); - - // Clear must appear BEFORE render in the source - const clearIndex = source.indexOf(clearMatch![0]); - const renderIndex = source.indexOf(renderMatch![0]); - expect(clearIndex).toBeLessThan(renderIndex); - }); - - it('/clear command sends ANSI clear sequence', async () => { - const { executeCommand } = await import('../packages/squad-cli/src/cli/shell/commands.js'); - const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); - const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); - - const writes: string[] = []; - const origWrite = process.stdout.write; - process.stdout.write = ((chunk: any) => { - writes.push(typeof chunk === 'string' ? chunk : chunk.toString()); - return true; - }) as any; - - try { - const result = executeCommand('clear', [], { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [], - teamRoot: process.cwd(), - version: '0.0.0-test', - }); - expect(result.handled).toBe(true); - expect(result.clear).toBe(true); - // ANSI escape for clear screen + cursor home - expect(writes.some(w => w.includes('\x1B[2J'))).toBe(true); - } finally { - process.stdout.write = origWrite; - } - }); - - it('session restore clears terminal before re-rendering messages', async () => { - // In index.ts onRestoreSession: clearMessages() then process.stdout.write('\\x1b[2J\\x1b[H') - const fs = await import('node:fs'); - const source = fs.readFileSync( - join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'shell', 'index.ts'), - 'utf-8', - ); - - // Find the onRestoreSession function - const fnStart = source.indexOf('function onRestoreSession'); - expect(fnStart).toBeGreaterThan(-1); - - // Within that function, clearMessages comes before the clear escape - const fnSlice = source.slice(fnStart, fnStart + 500); - const clearMsgIdx = fnSlice.indexOf('clearMessages'); - const ansiClearIdx = fnSlice.indexOf('\\x1b[2J'); - expect(clearMsgIdx).toBeGreaterThan(-1); - expect(ansiClearIdx).toBeGreaterThan(-1); - expect(clearMsgIdx).toBeLessThan(ansiClearIdx); - }); -}); - -// ============================================================================ -// #624 — SQLite warning suppression (NODE_NO_WARNINGS env var) -// ============================================================================ - -describe('#624 — SQLite warning suppression via NODE_NO_WARNINGS', () => { - it('cli-entry.ts sets NODE_NO_WARNINGS=1 before any import statements', async () => { - // Structural test: verify the env var assignment appears before any imports - const fs = await import('node:fs'); - const source = fs.readFileSync( - join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli-entry.ts'), - 'utf-8', - ); - - // NODE_NO_WARNINGS = '1' must appear in the file - const envVarPattern = /process\.env\.NODE_NO_WARNINGS\s*=\s*['"]1['"]/; - expect(source).toMatch(envVarPattern); - - // It must appear before the first import statement (top-of-file side effect) - const envVarIndex = source.search(envVarPattern); - const firstImportIndex = source.search(/^import\s/m); - expect(envVarIndex).toBeGreaterThan(-1); - expect(firstImportIndex).toBeGreaterThan(-1); - expect(envVarIndex).toBeLessThan(firstImportIndex); - }); - - it('ExperimentalWarning override filters both string and object forms', () => { - // Replicate the exact filter logic from cli-entry.ts lines 5-10 - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - return (originalEmitWarning as any).call(process, warning, ...args); - }; - - try { - // String form — SQLite experimental warning - process.emitWarning('ExperimentalWarning: SQLite is an experimental feature'); - // Object form — require() ESM warning - const w = new Error('require() of ES Module not supported'); - w.name = 'ExperimentalWarning'; - process.emitWarning(w as any); - // Non-experimental warning should pass through - process.emitWarning('DeprecationWarning: something old'); - - expect(emitted).toHaveLength(1); - expect(emitted[0]).toContain('DeprecationWarning'); - } finally { - process.emitWarning = originalEmitWarning; - } - }); -}); - -// ============================================================================ -// #625 — Redundant init messaging (firstRunElement + banner text) -// ============================================================================ - -describe('#625 — Redundant init messaging eliminated', () => { - it('firstRunElement returns null when isFirstRun=true and rosterAgents is empty', () => { - // App.tsx lines 308-323: empty roster branch now returns null (no duplicate init text) - const bannerReady = true; - const isFirstRun = true; - const rosterAgents: Array<{ name: string; role: string; emoji: string }> = []; - - // Simulate the useMemo logic from App.tsx firstRunElement - const shouldRenderFirstRun = bannerReady && isFirstRun; - const hasAssembledContent = rosterAgents.length > 0; - // When empty roster: the ternary yields null — no JSX rendered - const firstRunContent = shouldRenderFirstRun ? (hasAssembledContent ? 'assembled' : null) : null; - - expect(shouldRenderFirstRun).toBe(true); - expect(firstRunContent).toBeNull(); - }); - - it('firstRunElement still renders "assembled" when isFirstRun=true and rosterAgents has agents', () => { - const bannerReady = true; - const isFirstRun = true; - const rosterAgents = [ - { name: 'Keaton', role: 'Lead', emoji: '👑' }, - { name: 'Fenster', role: 'Core Dev', emoji: '🔧' }, - ]; - - const shouldRenderFirstRun = bannerReady && isFirstRun; - const hasAssembledContent = rosterAgents.length > 0; - const firstRunContent = shouldRenderFirstRun ? (hasAssembledContent ? 'assembled' : null) : null; - - expect(firstRunContent).toBe('assembled'); - }); - - it('banner text does not reference squad cast', async () => { - // App.tsx banner was simplified — no longer has roster length branches - const fs = await import('node:fs'); - const source = fs.readFileSync( - join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'shell', 'components', 'App.tsx'), - 'utf-8', - ); - - // Banner should NOT reference 'squad cast' (doesn't exist as a command) - const headerBlock = source.match(/const headerElement[\s\S]*?useMemo/); - expect(headerBlock).not.toBeNull(); - expect(headerBlock![0]).not.toContain('squad cast'); - }); -}); - -// ============================================================================ -// Banner simplification (#626, #627) -// ============================================================================ - -describe('Banner simplification (#626, #627)', () => { - const appPath = join(process.cwd(), 'packages', 'squad-cli', 'src', 'cli', 'shell', 'components', 'App.tsx'); - - async function readAppSource(): Promise { - const fs = await import('node:fs'); - return fs.readFileSync(appPath, 'utf-8'); - } - - it('Banner init message uses simple CTA — no squad cast reference', async () => { - const source = await readAppSource(); - - // Banner was simplified — verify no squad cast reference anywhere in header/first-run sections - const headerAndFirstRun = source.match(/const headerElement[\s\S]*?const firstRunElement[\s\S]*?useMemo/); - expect(headerAndFirstRun).not.toBeNull(); - - // Should NOT reference non-existent 'squad cast' command - expect(headerAndFirstRun![0]).not.toContain('squad cast'); - }); - - it('Usage line uses middle-dot separators (U+00B7)', async () => { - const source = await readAppSource(); - - // Find the usage/hint line — the one with @Agent and /help (may contain nested elements) - const usageLine = source.match(/.*@Agent.*<\/Text>/); - expect(usageLine).not.toBeNull(); - - const lineText = usageLine![0]; - // Must use · (middle dot U+00B7) as separator - expect(lineText).toContain('\u00B7'); - // Must NOT use em-dash or plain hyphen as separator - expect(lineText).not.toContain('\u2014'); // em-dash - expect(lineText).not.toMatch(/ — /); // spaced em-dash - expect(lineText).not.toMatch(/ - /); // spaced hyphen separator - }); - - it('Usage line is concise — starts with "Type naturally"', async () => { - const source = await readAppSource(); - - // Find the usage line containing @Agent (may contain nested elements) - const usageLine = source.match(/.*@Agent.*<\/Text>/); - expect(usageLine).not.toBeNull(); - - const lineText = usageLine![0]; - expect(lineText).toContain('Type naturally'); - expect(lineText).not.toContain('Just type what you need'); - }); - - it('Ctrl+C formatting — "Ctrl+C again to exit" in system message', async () => { - const source = await readAppSource(); - - // Ctrl+C exit hint is now in the system message, not the header - expect(source).toContain('Press Ctrl+C again to exit.'); - }); - - it('Header has at most one spacer between banner and version line', async () => { - const source = await readAppSource(); - - // Extract the headerElement useMemo block — matches both inline `=> (` and function body `=> {` forms - const headerBlock = source.match(/const headerElement[\s\S]*?(?=const firstRunElement)/); - expect(headerBlock).not.toBeNull(); - - const block = headerBlock![0]; - // Count standalone spacer lines: {' '} - const spacerMatches = block.match(/\{' '\}<\/Text>/g); - const spacerCount = spacerMatches ? spacerMatches.length : 0; - expect(spacerCount).toBeLessThanOrEqual(1); - }); -}); diff --git a/test/ghost-response.test.ts b/test/ghost-response.test.ts deleted file mode 100644 index 46478fe99..000000000 --- a/test/ghost-response.test.ts +++ /dev/null @@ -1,368 +0,0 @@ -/** - * Ghost Response Detection & Retry Tests - * - * Validates that empty responses from sendAndWait (ghost responses) are - * detected and retried with exponential backoff. - * - * Ghost responses occur when session.idle fires before assistant.message, - * causing sendAndWait() to return undefined or empty content. - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - withGhostRetry, - type GhostRetryOptions, -} from '../packages/squad-cli/src/cli/shell/index.js'; - -// ============================================================================ -// withGhostRetry — unit tests -// ============================================================================ - -describe('withGhostRetry — returns immediately on success', () => { - it('returns first result when non-empty', async () => { - const sendFn = vi.fn().mockResolvedValue('Hello world'); - const result = await withGhostRetry(sendFn); - - expect(result).toBe('Hello world'); - expect(sendFn).toHaveBeenCalledTimes(1); - }); - - it('does not call onRetry or onExhausted on success', async () => { - const onRetry = vi.fn(); - const onExhausted = vi.fn(); - const sendFn = vi.fn().mockResolvedValue('content'); - - await withGhostRetry(sendFn, { onRetry, onExhausted }); - - expect(onRetry).not.toHaveBeenCalled(); - expect(onExhausted).not.toHaveBeenCalled(); - }); -}); - -describe('withGhostRetry — retries on empty response', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('retries and succeeds on second attempt', async () => { - const sendFn = vi.fn() - .mockResolvedValueOnce('') - .mockResolvedValueOnce('recovered'); - const onRetry = vi.fn(); - - const promise = withGhostRetry(sendFn, { - backoffMs: [10, 20, 40], - onRetry, - }); - // Advance past first backoff - await vi.advanceTimersByTimeAsync(10); - const result = await promise; - - expect(result).toBe('recovered'); - expect(sendFn).toHaveBeenCalledTimes(2); - expect(onRetry).toHaveBeenCalledWith(1, 3); - }); - - it('retries and succeeds on third attempt', async () => { - const sendFn = vi.fn() - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('finally'); - - const promise = withGhostRetry(sendFn, { backoffMs: [10, 20, 40] }); - await vi.advanceTimersByTimeAsync(10); - await vi.advanceTimersByTimeAsync(20); - const result = await promise; - - expect(result).toBe('finally'); - expect(sendFn).toHaveBeenCalledTimes(3); - }); -}); - -describe('withGhostRetry — exhaustion', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('returns empty and calls onExhausted after all retries fail', async () => { - const sendFn = vi.fn().mockResolvedValue(''); - const onExhausted = vi.fn(); - const onRetry = vi.fn(); - - const promise = withGhostRetry(sendFn, { - maxRetries: 3, - backoffMs: [10, 20, 40], - onRetry, - onExhausted, - }); - // Advance past all backoffs - await vi.advanceTimersByTimeAsync(10); - await vi.advanceTimersByTimeAsync(20); - await vi.advanceTimersByTimeAsync(40); - const result = await promise; - - expect(result).toBe(''); - expect(sendFn).toHaveBeenCalledTimes(4); // 1 initial + 3 retries - expect(onRetry).toHaveBeenCalledTimes(3); - expect(onRetry).toHaveBeenCalledWith(1, 3); - expect(onRetry).toHaveBeenCalledWith(2, 3); - expect(onRetry).toHaveBeenCalledWith(3, 3); - expect(onExhausted).toHaveBeenCalledWith(3); - }); - - it('respects custom maxRetries', async () => { - const sendFn = vi.fn().mockResolvedValue(''); - const onExhausted = vi.fn(); - - const promise = withGhostRetry(sendFn, { - maxRetries: 1, - backoffMs: [10], - onExhausted, - }); - await vi.advanceTimersByTimeAsync(10); - const result = await promise; - - expect(result).toBe(''); - expect(sendFn).toHaveBeenCalledTimes(2); // 1 initial + 1 retry - expect(onExhausted).toHaveBeenCalledWith(1); - }); -}); - -describe('withGhostRetry — debug logging', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('logs ghost response metadata on retry', async () => { - const log = vi.fn(); - const sendFn = vi.fn() - .mockResolvedValueOnce('') - .mockResolvedValueOnce('ok'); - - const promise = withGhostRetry(sendFn, { - backoffMs: [10], - debugLog: log, - promptPreview: 'fix the streaming pipeline', - }); - await vi.advanceTimersByTimeAsync(10); - await promise; - - expect(log).toHaveBeenCalledWith( - 'ghost response detected', - expect.objectContaining({ - attempt: 1, - promptPreview: 'fix the streaming pipeline', - timestamp: expect.any(String), - }), - ); - }); - - it('logs exhaustion metadata when all retries fail', async () => { - const log = vi.fn(); - const sendFn = vi.fn().mockResolvedValue(''); - - const promise = withGhostRetry(sendFn, { - maxRetries: 1, - backoffMs: [10], - debugLog: log, - promptPreview: 'a very long prompt that should be truncated to eighty characters for the debug log entry preview field', - }); - await vi.advanceTimersByTimeAsync(10); - await promise; - - expect(log).toHaveBeenCalledWith( - 'ghost response: all retries exhausted', - expect.objectContaining({ - promptPreview: expect.any(String), - }), - ); - // Verify truncation to 80 chars - const exhaustedCall = log.mock.calls.find( - (c: unknown[]) => c[0] === 'ghost response: all retries exhausted', - ); - expect(exhaustedCall).toBeDefined(); - expect((exhaustedCall![1] as { promptPreview: string }).promptPreview.length).toBeLessThanOrEqual(80); - }); -}); - -describe('withGhostRetry — exponential backoff', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('uses default backoff timings 1s, 2s, 4s', async () => { - const timestamps: number[] = []; - const sendFn = vi.fn(async () => { - timestamps.push(Date.now()); - return ''; - }); - - const promise = withGhostRetry(sendFn); - // Total backoff: 1000 + 2000 + 4000 = 7000ms - await vi.advanceTimersByTimeAsync(7000); - await promise; - - expect(timestamps).toHaveLength(4); // 1 initial + 3 retries - const deltas = timestamps.map((t, i) => i === 0 ? 0 : t - timestamps[i - 1]!); - expect(deltas[0]).toBe(0); // immediate first attempt - expect(deltas[1]).toBe(1000); // 1s backoff - expect(deltas[2]).toBe(2000); // 2s backoff - expect(deltas[3]).toBe(4000); // 4s backoff - }); -}); - -// ============================================================================ -// Integration: simulated dispatch with ghost retry -// ============================================================================ - -type EventHandler = (event: { type: string; [key: string]: unknown }) => void; - -interface MockSquadSession { - sendAndWait: ReturnType; - on: ReturnType; - off: ReturnType; - _listeners: Map>; - _emit: (eventName: string, event: { type: string; [key: string]: unknown }) => void; -} - -function createGhostMockSession(responses: Array<{ deltas: string[]; fallback?: string }>): MockSquadSession { - const listeners = new Map>(); - let callCount = 0; - - const session: MockSquadSession = { - _listeners: listeners, - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - sendAndWait: vi.fn(async () => { - const response = responses[callCount] ?? { deltas: [], fallback: undefined }; - callCount++; - for (const d of response.deltas) { - session._emit('message_delta', { type: 'message_delta', deltaContent: d }); - } - if (response.fallback !== undefined) { - return { data: { content: response.fallback } }; - } - return undefined; - }), - }; - - return session; -} - -/** Mirrors the dispatch logic from index.ts — send + accumulate + ghost retry. */ -async function simulateDispatchWithRetry( - session: MockSquadSession, - message: string, - options?: GhostRetryOptions, -): Promise { - let accumulated = ''; - - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const delta = typeof val === 'string' ? val : ''; - if (!delta) return; - accumulated += delta; - }; - - session.on('message_delta', onDelta); - - try { - accumulated = await withGhostRetry(async () => { - accumulated = ''; - const result = await session.sendAndWait({ prompt: message }, 600000); - const data = (result as Record | undefined)?.['data'] as Record | undefined; - const fallback = typeof data?.['content'] === 'string' ? data['content'] as string : ''; - if (!accumulated && fallback) accumulated = fallback; - return accumulated; - }, { backoffMs: [10, 20, 40], ...options }); - } finally { - try { session.off('message_delta', onDelta); } catch { /* ignore */ } - } - - return accumulated; -} - -describe('Ghost response — integration with dispatch simulation', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('returns content on first attempt with no ghost', async () => { - const session = createGhostMockSession([ - { deltas: ['Hello', ' world'] }, - ]); - const result = await simulateDispatchWithRetry(session, 'say hello'); - - expect(result).toBe('Hello world'); - expect(session.sendAndWait).toHaveBeenCalledTimes(1); - }); - - it('detects ghost and retries successfully', async () => { - const session = createGhostMockSession([ - { deltas: [] }, // ghost: no deltas, no fallback - { deltas: ['recovered content'] }, // success on retry - ]); - const onRetry = vi.fn(); - - const promise = simulateDispatchWithRetry(session, 'test', { onRetry }); - await vi.advanceTimersByTimeAsync(10); - const result = await promise; - - expect(result).toBe('recovered content'); - expect(session.sendAndWait).toHaveBeenCalledTimes(2); - expect(onRetry).toHaveBeenCalledWith(1, 3); - }); - - it('uses fallback content to avoid false ghost detection', async () => { - const session = createGhostMockSession([ - { deltas: [], fallback: 'fallback response' }, - ]); - const result = await simulateDispatchWithRetry(session, 'test'); - - expect(result).toBe('fallback response'); - expect(session.sendAndWait).toHaveBeenCalledTimes(1); - }); - - it('reports all retry attempts then shows exhaustion message', async () => { - const session = createGhostMockSession([ - { deltas: [] }, - { deltas: [] }, - { deltas: [] }, - { deltas: [] }, - ]); - const onRetry = vi.fn(); - const onExhausted = vi.fn(); - - const promise = simulateDispatchWithRetry(session, 'test', { onRetry, onExhausted }); - await vi.advanceTimersByTimeAsync(10); - await vi.advanceTimersByTimeAsync(20); - await vi.advanceTimersByTimeAsync(40); - const result = await promise; - - expect(result).toBe(''); - expect(session.sendAndWait).toHaveBeenCalledTimes(4); - expect(onRetry).toHaveBeenCalledTimes(3); - expect(onExhausted).toHaveBeenCalledWith(3); - }); - - it('ghost on first two attempts, success on third', async () => { - const session = createGhostMockSession([ - { deltas: [] }, - { deltas: [] }, - { deltas: ['third', ' time'] }, - ]); - - const promise = simulateDispatchWithRetry(session, 'persist'); - await vi.advanceTimersByTimeAsync(10); - await vi.advanceTimersByTimeAsync(20); - const result = await promise; - - expect(result).toBe('third time'); - expect(session.sendAndWait).toHaveBeenCalledTimes(3); - }); -}); diff --git a/test/hostile-integration.test.ts b/test/hostile-integration.test.ts deleted file mode 100644 index 9822a2b59..000000000 --- a/test/hostile-integration.test.ts +++ /dev/null @@ -1,243 +0,0 @@ -/** - * Hostile Integration Tests - * - * Wires the 95-string nasty-inputs corpus into actual test execution. - * Every hostile string gets run through parseInput(), executeCommand(), - * and MessageStream rendering. None may crash the process. - * - * Closes #376 - */ - -import { describe, it, expect } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { - NASTY_INPUTS, - CLI_SAFE_NASTY_INPUTS, - UNICODE_NASTY_INPUTS, - type NastyInput, -} from './acceptance/fixtures/nasty-inputs.js'; -import { - parseInput, - executeCommand, - SessionRegistry, - ShellRenderer, -} from '../packages/squad-cli/src/cli/shell/index.js'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -const h = React.createElement; - -// Known agents for parseInput routing -const KNOWN_AGENTS = ['Agent1', 'Agent2', 'Agent3', 'Agent4']; - -function makeMessage(content: string, role: ShellMessage['role'] = 'agent'): ShellMessage { - return { role, content, timestamp: new Date(), agentName: role === 'agent' ? 'TestAgent' : undefined }; -} - -function makeCommandContext() { - return { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [] as ShellMessage[], - teamRoot: '/tmp/test-team', - }; -} - -// ============================================================================ -// 1. parseInput — every nasty string must not throw -// ============================================================================ - -describe('Hostile corpus → parseInput()', () => { - it(`processes all ${NASTY_INPUTS.length} hostile strings without throwing`, () => { - for (const input of NASTY_INPUTS) { - expect(() => { - const result = parseInput(input.value, KNOWN_AGENTS); - // Must return a valid ParsedInput - expect(result).toHaveProperty('type'); - expect(result).toHaveProperty('raw'); - expect(['slash_command', 'direct_agent', 'coordinator']).toContain(result.type); - }).not.toThrow(); - } - }); - - it('handles slash-command-shaped hostile inputs', () => { - // Inputs starting with '/' should parse as slash commands - const slashInputs = NASTY_INPUTS.filter(i => i.value.trimStart().startsWith('/')); - for (const input of slashInputs) { - const result = parseInput(input.value, KNOWN_AGENTS); - expect(result.type).toBe('slash_command'); - expect(result.command).toBeDefined(); - } - }); - - it('handles empty and whitespace inputs gracefully', () => { - const whitespaceInputs = NASTY_INPUTS.filter(i => - i.label.includes('empty') || i.label.includes('space') || i.label.includes('tab') || - i.label.includes('newline') || i.label.includes('crlf') || i.label.includes('whitespace') - ); - for (const input of whitespaceInputs) { - expect(() => parseInput(input.value, KNOWN_AGENTS)).not.toThrow(); - } - }); - - it('handles injection-like strings without executing', () => { - const injections = NASTY_INPUTS.filter(i => i.label.includes('injection') || i.label.includes('xss') || i.label.includes('traversal')); - for (const input of injections) { - const result = parseInput(input.value, KNOWN_AGENTS); - // These should route to coordinator (not treated as commands) - expect(result.type).toBe('coordinator'); - } - }); - - it('handles unicode edge cases', () => { - for (const input of UNICODE_NASTY_INPUTS) { - expect(() => parseInput(input.value, KNOWN_AGENTS)).not.toThrow(); - } - }); -}); - -// ============================================================================ -// 2. executeCommand — slash command strings must not throw -// ============================================================================ - -describe('Hostile corpus → executeCommand()', () => { - it('executes all hostile strings as commands without throwing', () => { - const context = makeCommandContext(); - for (const input of NASTY_INPUTS) { - expect(() => { - // Treat each hostile string as a command name with no args - const result = executeCommand(input.value, [], context); - // Must return a valid CommandResult - expect(result).toHaveProperty('handled'); - }).not.toThrow(); - } - }); - - it('handles hostile strings as command arguments', () => { - const context = makeCommandContext(); - for (const input of CLI_SAFE_NASTY_INPUTS) { - expect(() => { - executeCommand('history', [input.value], context); - executeCommand('status', [input.value], context); - executeCommand('help', [input.value], context); - }).not.toThrow(); - } - }); - - it('handles extremely long command names', () => { - const context = makeCommandContext(); - const longInputs = NASTY_INPUTS.filter(i => i.label.includes('kb-string')); - for (const input of longInputs) { - expect(() => { - const result = executeCommand(input.value, [], context); - expect(result.handled).toBe(false); // Unknown command - }).not.toThrow(); - } - }); -}); - -// ============================================================================ -// 3. MessageStream rendering — hostile content must not crash React -// ============================================================================ - -describe('Hostile corpus → MessageStream render()', () => { - it(`renders all ${NASTY_INPUTS.length} hostile strings as message content without crashing`, () => { - for (const input of NASTY_INPUTS) { - expect(() => { - const messages = [makeMessage(input.value, 'user'), makeMessage(input.value, 'agent')]; - const { unmount } = render(h(MessageStream, { messages })); - unmount(); - }).not.toThrow(); - } - }, 30000); - - it('renders hostile strings in streaming content without crashing', () => { - for (const input of CLI_SAFE_NASTY_INPUTS) { - expect(() => { - const { unmount } = render( - h(MessageStream, { - messages: [makeMessage('test', 'user')], - streamingContent: new Map([['TestAgent', input.value]]), - processing: true, - }) - ); - unmount(); - }).not.toThrow(); - } - }); - - it('renders unicode edge cases without crashing', () => { - for (const input of UNICODE_NASTY_INPUTS) { - expect(() => { - const messages = [ - makeMessage(input.value, 'user'), - makeMessage(`Response to: ${input.value}`, 'agent'), - ]; - const { unmount } = render(h(MessageStream, { messages })); - unmount(); - }).not.toThrow(); - } - }); - - it('renders hostile agent names without crashing', () => { - for (const input of CLI_SAFE_NASTY_INPUTS) { - expect(() => { - const messages: ShellMessage[] = [{ - role: 'agent', - content: 'Normal content', - timestamp: new Date(), - agentName: input.value, - }]; - const { unmount } = render(h(MessageStream, { messages })); - unmount(); - }).not.toThrow(); - } - }); - - it('renders hostile activity hints without crashing', () => { - const hints = NASTY_INPUTS.slice(0, 20); - for (const input of hints) { - expect(() => { - const { unmount } = render( - h(MessageStream, { - messages: [makeMessage('test', 'user')], - processing: true, - activityHint: input.value, - }) - ); - unmount(); - }).not.toThrow(); - } - }); -}); - -// ============================================================================ -// 4. Full pipeline — parseInput → executeCommand chain -// ============================================================================ - -describe('Hostile corpus → full pipeline', () => { - it('parses then executes slash-like inputs without throwing', () => { - const context = makeCommandContext(); - for (const input of CLI_SAFE_NASTY_INPUTS) { - expect(() => { - const parsed = parseInput(input.value, KNOWN_AGENTS); - if (parsed.type === 'slash_command' && parsed.command) { - executeCommand(parsed.command, parsed.args ?? [], context); - } - }).not.toThrow(); - } - }); - - it('corpus count is at least 60 strings', () => { - // Sanity check: corpus hasn't been silently truncated - expect(NASTY_INPUTS.length).toBeGreaterThanOrEqual(60); - }); - - it('CLI_SAFE subset excludes null bytes and long strings', () => { - for (const input of CLI_SAFE_NASTY_INPUTS) { - expect(input.value).not.toContain('\x00'); - expect(input.value.length).toBeLessThan(2048); - } - }); -}); diff --git a/test/human-journeys.test.ts b/test/human-journeys.test.ts deleted file mode 100644 index 6d5fd935f..000000000 --- a/test/human-journeys.test.ts +++ /dev/null @@ -1,583 +0,0 @@ -/** - * Human Journey Tests — end-to-end simulations of real user experiences. - * - * These tests don't mock internals. They simulate what a human actually does - * and verify the experience creates a "wow moment" rather than confusion. - * - * Each describe block maps to a filed GitHub issue and a real human scenario. - * - * @see https://github.com/bradygaster/squad-pr/issues/383 — "I just installed this" - * @see https://github.com/bradygaster/squad-pr/issues/384 — "My first conversation" - * @see https://github.com/bradygaster/squad-pr/issues/385 — "I'm waiting and getting anxious" - * @see https://github.com/bradygaster/squad-pr/issues/386 — "Something went wrong" - * @see https://github.com/bradygaster/squad-pr/issues/394 — "I want to talk to a specific agent" - * @see https://github.com/bradygaster/squad-pr/issues/396 — "I'm a power user now" - * @see https://github.com/bradygaster/squad-pr/issues/398 — "I came back the next day" - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, existsSync, mkdirSync, writeFileSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render } from 'ink-testing-library'; - -// CLI harness for real process spawning -import { TerminalHarness } from './acceptance/harness.js'; - -// Shell internals we exercise at the integration boundary -import { parseInput } from '../packages/squad-cli/src/cli/shell/router.js'; -import { executeCommand } from '../packages/squad-cli/src/cli/shell/commands.js'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { loadWelcomeData } from '../packages/squad-cli/src/cli/shell/lifecycle.js'; -import { ThinkingIndicator } from '../packages/squad-cli/src/cli/shell/components/ThinkingIndicator.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -const h = React.createElement; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -/** Create a temp directory that will be cleaned up after the test. */ -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); -} - -/** Scaffold a minimal .squad/ directory with team.md for welcome/lifecycle tests. */ -function scaffoldSquadDir(root: string, opts?: { firstRun?: boolean }): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Test Project - -> A test project for human journey validation. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -| Hockney | Tester | \`.squad/agents/hockney/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: Testing human journeys -active_issues: [] ---- - -# What We're Focused On - -Human journey test validation. -`); - - if (opts?.firstRun) { - writeFileSync(join(squadDir, '.first-run'), new Date().toISOString() + '\n'); - } -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 1: "I just installed this" — squad init in a fresh repo -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 1: I just installed this (squad init)', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = makeTempDir('squad-journey-init-'); - }); - - afterEach(async () => { - // Retry removal to handle Windows EBUSY race — the spawned process may - // still hold a directory handle briefly after exit. - await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 }); - }); - - it('creates .squad/ directory with expected structure', async () => { - const harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tempDir }); - await harness.waitForExit(30000); - await harness.close(); - - // The human sees: a .squad/ directory was created - expect(existsSync(join(tempDir, '.squad'))).toBe(true); - expect(existsSync(join(tempDir, '.copilot', 'skills'))).toBe(true); - expect(existsSync(join(tempDir, '.squad', 'identity'))).toBe(true); - expect(existsSync(join(tempDir, '.squad', 'ceremonies.md'))).toBe(true); - }); - - it('shows ceremony output — not raw technical logs', async () => { - const harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tempDir }); - await harness.waitForExit(30000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // The human sees: a ceremony, not a wall of file paths - expect(output).toContain("Let's build your team"); - expect(output).toContain('SQUAD'); - // Ceremony landmarks should appear - expect(output).toContain('Team workspace'); - expect(output).toContain('Skills'); - }); - - it('tells the human what to do next', async () => { - const harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tempDir }); - await harness.waitForExit(30000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // The human needs a clear next step — not silence - expect(output).toContain('Your team is ready'); - expect(output.toLowerCase()).toContain('squad'); - }); - - it('writes first-run marker so the REPL knows this is day one', async () => { - const harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tempDir }); - await harness.waitForExit(30000); - await harness.close(); - - expect(existsSync(join(tempDir, '.squad', '.first-run'))).toBe(true); - }); - - it('exits cleanly with code 0', async () => { - const harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tempDir }); - const exitCode = await harness.waitForExit(30000); - await harness.close(); - - expect(exitCode).toBe(0); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 2: "My first conversation" — REPL welcome banner -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 2: My first conversation (welcome banner)', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = makeTempDir('squad-journey-welcome-'); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 }); - }); - - it('welcome data includes agent roster with names and roles', () => { - scaffoldSquadDir(tempDir); - const data = loadWelcomeData(tempDir); - - expect(data).not.toBeNull(); - expect(data!.agents.length).toBe(3); - expect(data!.agents.map(a => a.name)).toEqual(['Keaton', 'Fenster', 'Hockney']); - expect(data!.agents[0]!.role).toBe('Lead'); - }); - - it('welcome data includes project description', () => { - scaffoldSquadDir(tempDir); - const data = loadWelcomeData(tempDir); - - expect(data!.description).toContain('test project'); - }); - - it('welcome data includes current focus from identity/now.md', () => { - scaffoldSquadDir(tempDir); - const data = loadWelcomeData(tempDir); - - expect(data!.focus).toBe('Testing human journeys'); - }); - - it('first-run flag is detected and consumed (one-time ceremony)', () => { - scaffoldSquadDir(tempDir, { firstRun: true }); - - // First load: sees first-run - const data1 = loadWelcomeData(tempDir); - expect(data1!.isFirstRun).toBe(true); - - // Marker file should be consumed (deleted) - expect(existsSync(join(tempDir, '.squad', '.first-run'))).toBe(false); - - // Second load: no longer first-run - const data2 = loadWelcomeData(tempDir); - expect(data2!.isFirstRun).toBe(false); - }); - - it('each agent gets an emoji so the roster feels alive', () => { - scaffoldSquadDir(tempDir); - const data = loadWelcomeData(tempDir); - - for (const agent of data!.agents) { - expect(agent.emoji).toBeTruthy(); - // Emoji should not be empty string or undefined - expect(agent.emoji.length).toBeGreaterThan(0); - } - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 3: "I'm waiting and getting anxious" — thinking indicator -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 3: Waiting and anxious (thinking indicator)', () => { - beforeEach(() => { - // Force NO_COLOR for deterministic assertions - vi.stubEnv('NO_COLOR', '1'); - }); - - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('shows indicator immediately when thinking starts', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - // In NO_COLOR mode, we see static dots - expect(frame).toContain('...'); - expect(frame).toContain('Routing to agent'); - }); - - it('hides indicator when not thinking', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: false, elapsedMs: 0 }) - ); - // Should render nothing - expect(lastFrame()).toBe(''); - }); - - it('shows elapsed time after 1 second so user knows it is alive', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 3000 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('3s'); - }); - - it('activity hint replaces default label when SDK provides context', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 2000, activityHint: 'Reading file...' }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Reading file'); - // Default "Thinking" should NOT show when we have a specific hint - expect(frame).not.toContain('Thinking'); - }); - - it('does not show elapsed when under 1 second', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 500 }) - ); - const frame = lastFrame()!; - // Should show the indicator but not a time - expect(frame).toContain('Routing to agent'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 4: "Something went wrong" — error handling -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 4: Something went wrong (errors)', () => { - it('unknown command gives friendly error with help suggestion', async () => { - const harness = await TerminalHarness.spawnWithArgs(['foobar']); - const exitCode = await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // Human sees: friendly message, not a stack trace - expect(output).toContain('Unknown command'); - expect(output.toLowerCase()).toContain('help'); - expect(exitCode).toBe(1); - }); - - it('error output includes remediation tip (squad doctor)', async () => { - const harness = await TerminalHarness.spawnWithArgs(['foobar']); - await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - expect(output.toLowerCase()).toContain('doctor'); - }); - - it('does not show raw stack trace to the user', async () => { - // Ensure SQUAD_DEBUG is off so debug logging doesn't leak stack traces - const harness = await TerminalHarness.spawnWithArgs(['foobar'], { - env: { SQUAD_DEBUG: '0' }, - }); - await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // No "at Object." or "Error:" prefix leak - expect(output).not.toMatch(/at\s+\w+\.\ { - // ErrorBoundary is a .tsx component — test through CLI's actual error handling path. - // The CLI entry wraps the Ink render in ErrorBoundary. We verify the concept: - // when the CLI hits a fatal error, the user sees a friendly message. - const harness = await TerminalHarness.spawnWithArgs(['foobar'], { - env: { SQUAD_DEBUG: '0' }, - }); - await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // User sees friendly output, not a raw crash - expect(output).toContain('Unknown command'); - expect(output).toContain('doctor'); - // The shell did NOT crash — it exited with a controlled error - expect(harness.getExitCode()).toBe(1); - }); - - it('whitespace-only input shows help, not a crash', async () => { - const harness = await TerminalHarness.spawnWithArgs([' ']); - const exitCode = await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // Should show usage info, not crash - expect(output.toLowerCase()).toContain('usage'); - expect(exitCode).toBe(0); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 5: "I want to talk to a specific agent" — @Agent routing -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 5: Talk to a specific agent (@Agent routing)', () => { - const knownAgents = ['Keaton', 'Fenster', 'Hockney', 'Kovash']; - - it('@AgentName routes to the correct agent', () => { - const parsed = parseInput('@Keaton fix the build', knownAgents); - - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - expect(parsed.content).toBe('fix the build'); - }); - - it('case-insensitive matching works (humans are sloppy typists)', () => { - const parsed = parseInput('@keaton fix the build', knownAgents); - - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); // canonical casing - }); - - it('"AgentName, do this" comma syntax works too', () => { - const parsed = parseInput('Fenster, refactor the parser', knownAgents); - - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Fenster'); - expect(parsed.content).toBe('refactor the parser'); - }); - - it('unknown @name falls through to coordinator (not an error)', () => { - const parsed = parseInput('@Unknown do something', knownAgents); - - // Should route to coordinator, not crash or error - expect(parsed.type).toBe('coordinator'); - }); - - it('bare message without @agent goes to coordinator', () => { - const parsed = parseInput('what should we build next?', knownAgents); - - expect(parsed.type).toBe('coordinator'); - expect(parsed.content).toBe('what should we build next?'); - }); - - it('@Agent with no message routes to coordinator', () => { - const parsed = parseInput('@Keaton', knownAgents); - - // With no message body, routes to coordinator for context - expect(parsed.type).toBe('coordinator'); - expect(parsed.raw).toBe('@Keaton'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 6: "I'm a power user now" — slash commands -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 6: Power user (slash commands)', () => { - let registry: SessionRegistry; - let renderer: ShellRenderer; - const teamRoot = '/fake/project'; - - beforeEach(() => { - registry = new SessionRegistry(); - renderer = new ShellRenderer(); - registry.register('Keaton', 'Lead'); - registry.register('Fenster', 'Core Dev'); - }); - - function runCommand(input: string, messageHistory: ShellMessage[] = []) { - const parsed = parseInput(input, registry.getAll().map(a => a.name)); - if (parsed.type !== 'slash_command') throw new Error(`Expected slash command, got ${parsed.type}`); - return executeCommand(parsed.command!, parsed.args ?? [], { - registry, renderer, messageHistory, teamRoot, - }); - } - - it('/help lists all available commands with descriptions', () => { - const result = runCommand('/help'); - - expect(result.handled).toBe(true); - expect(result.output).toContain('/status'); - expect(result.output).toContain('/history'); - expect(result.output).toContain('/agents'); - expect(result.output).toContain('/quit'); - }); - - it('/status shows team root, size, and active count', () => { - const result = runCommand('/status'); - - expect(result.handled).toBe(true); - expect(result.output).toContain(teamRoot); - expect(result.output).toContain('2'); // 2 agents - }); - - it('/agents lists team members with status', () => { - const result = runCommand('/agents'); - - expect(result.handled).toBe(true); - expect(result.output).toContain('Keaton'); - expect(result.output).toContain('Fenster'); - expect(result.output).toContain('Lead'); - expect(result.output).toContain('Core Dev'); - }); - - it('/history with no messages says so clearly', () => { - const result = runCommand('/history'); - - expect(result.handled).toBe(true); - expect(result.output).toContain('No messages yet'); - }); - - it('/history with messages shows recent conversation', () => { - const history: ShellMessage[] = [ - { role: 'user', content: 'hello team', timestamp: new Date() }, - { role: 'agent', agentName: 'Keaton', content: 'Hey! Ready to work.', timestamp: new Date() }, - ]; - const result = runCommand('/history', history); - - expect(result.handled).toBe(true); - expect(result.output).toContain('hello team'); - expect(result.output).toContain('Keaton'); - }); - - it('/quit signals exit', () => { - const result = runCommand('/quit'); - expect(result.handled).toBe(true); - expect(result.exit).toBe(true); - }); - - it('unknown /command gives friendly hint, not an error', () => { - const result = runCommand('/frobnicate'); - - expect(result.handled).toBe(false); - expect(result.output).toContain('/help'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey 7: "I came back the next day" — persistent state -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey 7: Came back the next day (persistence)', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = makeTempDir('squad-journey-persist-'); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true, maxRetries: 3, retryDelay: 500 }); - }); - - it('first-run marker is consumed, so no ceremony on return', () => { - scaffoldSquadDir(tempDir, { firstRun: true }); - - // Day 1: sees first-run - const day1 = loadWelcomeData(tempDir); - expect(day1!.isFirstRun).toBe(true); - - // Day 2: no first-run (marker was consumed) - const day2 = loadWelcomeData(tempDir); - expect(day2!.isFirstRun).toBe(false); - }); - - it('team is still loaded on return — state feels persistent', () => { - scaffoldSquadDir(tempDir); - - const data = loadWelcomeData(tempDir); - expect(data!.agents.length).toBe(3); - expect(data!.agents[0]!.name).toBe('Keaton'); - - // Simulate "next day" — load again - const dataNextDay = loadWelcomeData(tempDir); - expect(dataNextDay!.agents.length).toBe(3); - expect(dataNextDay!.agents[0]!.name).toBe('Keaton'); - }); - - it('focus area persists between sessions', () => { - scaffoldSquadDir(tempDir); - - const data = loadWelcomeData(tempDir); - expect(data!.focus).toBe('Testing human journeys'); - - // Simulate coordinator updating focus - const nowPath = join(tempDir, '.squad', 'identity', 'now.md'); - const nowContent = readFileSync(nowPath, 'utf-8'); - writeFileSync(nowPath, nowContent.replace('Testing human journeys', 'Shipping v1.0')); - - const dataLater = loadWelcomeData(tempDir); - expect(dataLater!.focus).toBe('Shipping v1.0'); - }); - - it('returns null gracefully when .squad/ is missing (fresh clone)', () => { - // No scaffolding — just a bare temp dir - const data = loadWelcomeData(tempDir); - expect(data).toBeNull(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// Cross-journey: CLI help experience (the safety net) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Cross-journey: CLI --help is the safety net', () => { - it('--help output lists every major command', async () => { - const harness = await TerminalHarness.spawnWithArgs(['--help']); - await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()); - await harness.close(); - - // A confused human types --help. Do they see EVERY command? - const expectedCommands = ['init', 'status', 'doctor', 'help', 'upgrade', 'export', 'import']; - for (const cmd of expectedCommands) { - expect(output.toLowerCase()).toContain(cmd); - } - }); - - it('--version gives a clean version string', async () => { - const harness = await TerminalHarness.spawnWithArgs(['--version']); - await harness.waitForExit(10000); - const output = stripAnsi(harness.captureFrame()).trim(); - await harness.close(); - - // Should be a semver-ish string, not "undefined" or empty - expect(output).toMatch(/^\d+\.\d+\.\d+/); - }); -}); diff --git a/test/init-autocast.test.ts b/test/init-autocast.test.ts deleted file mode 100644 index e46e1547b..000000000 --- a/test/init-autocast.test.ts +++ /dev/null @@ -1,639 +0,0 @@ -/** - * Tests for auto-cast trigger, /init command, and Ctrl+C abort reliability. - * - * Covers the P0/P1 fixes from PR #640: - * - Auto-cast fires only when .init-prompt exists AND roster is empty AND shellApi is ready - * - Orphan .init-prompt is cleaned up when roster already has entries - * - /init command returns triggerInitCast signal with inline prompts - * - activeInitSession lifecycle (set on create, cleared on success/error, aborted on Ctrl+C) - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -import { hasRosterEntries } from '../packages/squad-cli/src/cli/shell/coordinator.js'; -import { executeCommand } from '../packages/squad-cli/src/cli/shell/commands.js'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import type { CommandContext } from '../packages/squad-cli/src/cli/shell/commands.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function makeTempDir(prefix: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function cleanDir(dir: string): void { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } -} - -/** Build a team.md with a populated ## Members table. */ -function makePopulatedTeamMd(agents: Array<{ name: string; role: string }>): string { - const rows = agents - .map(a => `| ${a.name} | ${a.role} | \`.squad/agents/${a.name.toLowerCase()}/charter.md\` | ✅ Active |`) - .join('\n'); - return `# Team Manifest - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -${rows} -`; -} - -/** Build a team.md with the ## Members section but NO data rows (empty roster). */ -function makeEmptyRosterTeamMd(): string { - return `# Team Manifest - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -`; -} - -function makeCommandContext(teamRoot: string): CommandContext { - return { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [] as ShellMessage[], - teamRoot, - }; -} - -// =========================================================================== -// 1. hasRosterEntries — the predicate that gates auto-cast -// =========================================================================== - -describe('hasRosterEntries — auto-cast gating predicate', () => { - it('returns true when Members table has data rows', () => { - const md = makePopulatedTeamMd([ - { name: 'Fenster', role: 'Developer' }, - { name: 'Hockney', role: 'Tester' }, - ]); - expect(hasRosterEntries(md)).toBe(true); - }); - - it('returns false when Members table has only header + separator', () => { - const md = makeEmptyRosterTeamMd(); - expect(hasRosterEntries(md)).toBe(false); - }); - - it('returns false when there is no ## Members section', () => { - const md = '# Team Manifest\n\nSome notes.\n'; - expect(hasRosterEntries(md)).toBe(false); - }); - - it('returns false for completely empty string', () => { - expect(hasRosterEntries('')).toBe(false); - }); - - it('returns true with a single agent row', () => { - const md = makePopulatedTeamMd([{ name: 'Solo', role: 'Lead' }]); - expect(hasRosterEntries(md)).toBe(true); - }); - - it('ignores header row that starts with | Name', () => { - // Ensure the header row itself is NOT counted as a data row - const md = `# Team Manifest\n\n## Members\n\n| Name | Role |\n|------|------|\n`; - expect(hasRosterEntries(md)).toBe(false); - }); - - it('ignores separator row that starts with | ---', () => { - const md = `# Team Manifest\n\n## Members\n\n| Name | Role |\n| ---- | ---- |\n`; - expect(hasRosterEntries(md)).toBe(false); - }); -}); - -// =========================================================================== -// 2. Auto-cast trigger CONDITIONS (filesystem state) -// =========================================================================== - -describe('auto-cast trigger conditions', () => { - let tmpDir: string; - let squadDir: string; - - beforeEach(() => { - tmpDir = makeTempDir('autocast-'); - squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - }); - - afterEach(() => { - cleanDir(tmpDir); - }); - - it('auto-cast SHOULD fire: .init-prompt exists + roster is empty', () => { - // Setup: empty roster team.md + .init-prompt - fs.writeFileSync(path.join(squadDir, 'team.md'), makeEmptyRosterTeamMd()); - fs.writeFileSync(path.join(squadDir, '.init-prompt'), 'Build a snake game'); - - // Verify conditions match auto-cast trigger - const teamContent = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf-8'); - const initPromptExists = fs.existsSync(path.join(squadDir, '.init-prompt')); - const rosterEmpty = !hasRosterEntries(teamContent); - - expect(initPromptExists).toBe(true); - expect(rosterEmpty).toBe(true); - // Both conditions met → auto-cast should fire - }); - - it('auto-cast should NOT fire: roster has entries (even if .init-prompt exists)', () => { - // Setup: populated roster + stale .init-prompt - fs.writeFileSync( - path.join(squadDir, 'team.md'), - makePopulatedTeamMd([{ name: 'Fenster', role: 'Developer' }]), - ); - fs.writeFileSync(path.join(squadDir, '.init-prompt'), 'Build a snake game'); - - const teamContent = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf-8'); - const initPromptExists = fs.existsSync(path.join(squadDir, '.init-prompt')); - const rosterEmpty = !hasRosterEntries(teamContent); - - expect(initPromptExists).toBe(true); - expect(rosterEmpty).toBe(false); - // Roster populated → auto-cast must NOT fire - }); - - it('auto-cast should NOT fire: .init-prompt does not exist', () => { - // Setup: empty roster team.md but NO .init-prompt - fs.writeFileSync(path.join(squadDir, 'team.md'), makeEmptyRosterTeamMd()); - - const teamContent = fs.readFileSync(path.join(squadDir, 'team.md'), 'utf-8'); - const initPromptExists = fs.existsSync(path.join(squadDir, '.init-prompt')); - const rosterEmpty = !hasRosterEntries(teamContent); - - expect(initPromptExists).toBe(false); - expect(rosterEmpty).toBe(true); - // Missing .init-prompt → auto-cast must NOT fire - }); - - it('auto-cast should NOT fire: team.md does not exist at all', () => { - // Setup: no team.md, just .init-prompt - fs.writeFileSync(path.join(squadDir, '.init-prompt'), 'Build something'); - - const teamFileExists = fs.existsSync(path.join(squadDir, 'team.md')); - const initPromptExists = fs.existsSync(path.join(squadDir, '.init-prompt')); - - expect(teamFileExists).toBe(false); - expect(initPromptExists).toBe(true); - // No team.md → auto-cast guard in index.ts (line 906) prevents firing - }); - - it('stored .init-prompt content is trimmed before use', () => { - fs.writeFileSync(path.join(squadDir, '.init-prompt'), ' Build a snake game \n'); - - const storedPrompt = fs.readFileSync(path.join(squadDir, '.init-prompt'), 'utf-8').trim(); - expect(storedPrompt).toBe('Build a snake game'); - }); - - it('empty .init-prompt (whitespace only) should NOT trigger auto-cast', () => { - fs.writeFileSync(path.join(squadDir, '.init-prompt'), ' \n '); - - const storedPrompt = fs.readFileSync(path.join(squadDir, '.init-prompt'), 'utf-8').trim(); - expect(storedPrompt).toBe(''); - // Empty after trim → the `if (storedPrompt)` guard in index.ts prevents firing - }); -}); - -// =========================================================================== -// 3. Orphan .init-prompt cleanup -// =========================================================================== - -describe('orphan .init-prompt cleanup', () => { - let tmpDir: string; - let squadDir: string; - - beforeEach(() => { - tmpDir = makeTempDir('orphan-cleanup-'); - squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - }); - - afterEach(() => { - cleanDir(tmpDir); - }); - - it('orphan .init-prompt is deleted when roster already has entries', () => { - // This replicates the Bug fix #3 logic from index.ts:894-902 - const teamFilePath = path.join(squadDir, 'team.md'); - const initPromptPath = path.join(squadDir, '.init-prompt'); - - fs.writeFileSync(teamFilePath, makePopulatedTeamMd([{ name: 'Fenster', role: 'Dev' }])); - fs.writeFileSync(initPromptPath, 'stale prompt from earlier'); - - // Replicate the onReady cleanup logic - if (fs.existsSync(teamFilePath)) { - const tc = fs.readFileSync(teamFilePath, 'utf-8'); - if (hasRosterEntries(tc) && fs.existsSync(initPromptPath)) { - try { fs.unlinkSync(initPromptPath); } catch { /* ignore */ } - } - } - - expect(fs.existsSync(initPromptPath)).toBe(false); - }); - - it('.init-prompt is NOT deleted when roster is empty', () => { - const teamFilePath = path.join(squadDir, 'team.md'); - const initPromptPath = path.join(squadDir, '.init-prompt'); - - fs.writeFileSync(teamFilePath, makeEmptyRosterTeamMd()); - fs.writeFileSync(initPromptPath, 'Build a snake game'); - - // Replicate the onReady cleanup logic - if (fs.existsSync(teamFilePath)) { - const tc = fs.readFileSync(teamFilePath, 'utf-8'); - if (hasRosterEntries(tc) && fs.existsSync(initPromptPath)) { - try { fs.unlinkSync(initPromptPath); } catch { /* ignore */ } - } - } - - // .init-prompt should survive — it's needed for auto-cast - expect(fs.existsSync(initPromptPath)).toBe(true); - }); - - it('.init-prompt is NOT deleted when team.md does not exist', () => { - const teamFilePath = path.join(squadDir, 'team.md'); - const initPromptPath = path.join(squadDir, '.init-prompt'); - - fs.writeFileSync(initPromptPath, 'Build a snake game'); - - // Replicate the onReady cleanup logic — team.md check fails early - if (fs.existsSync(teamFilePath)) { - const tc = fs.readFileSync(teamFilePath, 'utf-8'); - if (hasRosterEntries(tc) && fs.existsSync(initPromptPath)) { - try { fs.unlinkSync(initPromptPath); } catch { /* ignore */ } - } - } - - expect(fs.existsSync(initPromptPath)).toBe(true); - }); -}); - -// =========================================================================== -// 4. /init command — executeCommand('init', ...) -// =========================================================================== - -describe('/init command — triggerInitCast signal', () => { - let context: CommandContext; - - beforeEach(() => { - context = makeCommandContext('/test'); - }); - - it('returns triggerInitCast with prompt when args provided', () => { - const result = executeCommand('init', ['Build', 'a', 'snake', 'game'], context); - expect(result.handled).toBe(true); - expect(result.triggerInitCast).toBeDefined(); - expect(result.triggerInitCast!.prompt).toBe('Build a snake game'); - }); - - it('returns help text (no triggerInitCast) when no args', () => { - const result = executeCommand('init', [], context); - expect(result.handled).toBe(true); - expect(result.triggerInitCast).toBeUndefined(); - expect(result.output).toBeDefined(); - expect(result.output).toContain('just type what you want to build'); - }); - - it('returns triggerInitCast for single-word prompt', () => { - const result = executeCommand('init', ['something'], context); - expect(result.handled).toBe(true); - expect(result.triggerInitCast).toBeDefined(); - expect(result.triggerInitCast!.prompt).toBe('something'); - }); - - it('trims whitespace from joined prompt args', () => { - const result = executeCommand('init', [' Build ', ' app '], context); - expect(result.handled).toBe(true); - expect(result.triggerInitCast).toBeDefined(); - // args.join(' ').trim() preserves internal spaces - expect(result.triggerInitCast!.prompt).toBe('Build app'); - }); - - it('returns no triggerInitCast for whitespace-only args', () => { - const result = executeCommand('init', [' ', ' '], context); - expect(result.handled).toBe(true); - // ' '.join(' ').trim() === '' → falls through to help text - expect(result.triggerInitCast).toBeUndefined(); - expect(result.output).toBeDefined(); - }); - - it('help text includes team file path from context', () => { - const result = executeCommand('init', [], context); - expect(result.output).toContain('/test/.squad/team.md'); - }); - - it('help text includes example prompt', () => { - const result = executeCommand('init', [], context); - expect(result.output).toContain('React app'); - }); -}); - -// =========================================================================== -// 5. triggerInitCast signal structure -// =========================================================================== - -describe('triggerInitCast signal — App.tsx dispatch contract', () => { - it('triggerInitCast signal has correct shape for App.tsx consumption', () => { - const context = makeCommandContext('/test'); - const result = executeCommand('init', ['Build', 'a', 'REST', 'API'], context); - - // App.tsx line 200 checks: result.triggerInitCast && onDispatch - // Then constructs ParsedInput from result.triggerInitCast.prompt - expect(result.triggerInitCast).toEqual({ prompt: 'Build a REST API' }); - - // App.tsx uses the prompt for both raw and content of ParsedInput - const prompt = result.triggerInitCast!.prompt; - expect(typeof prompt).toBe('string'); - expect(prompt.length).toBeGreaterThan(0); - }); - - it('no triggerInitCast signal when command returns help', () => { - const context = makeCommandContext('/test'); - const result = executeCommand('init', [], context); - - // App.tsx line 200: this branch should NOT execute - expect(result.triggerInitCast).toBeUndefined(); - expect(result.output).toBeDefined(); - }); - - it('triggerInitCast is not set on non-init commands', () => { - const context = makeCommandContext('/test'); - - const help = executeCommand('help', [], context); - expect(help.triggerInitCast).toBeUndefined(); - - const status = executeCommand('status', [], context); - expect(status.triggerInitCast).toBeUndefined(); - - const clear = executeCommand('clear', [], context); - expect(clear.triggerInitCast).toBeUndefined(); - }); -}); - -// =========================================================================== -// 5b. awaitInitPrompt signal — no-args /init follow-up flow (#216) -// =========================================================================== - -describe('awaitInitPrompt signal — no-args /init follow-up flow', () => { - let context: CommandContext; - - beforeEach(() => { - context = makeCommandContext('/test'); - }); - - it('sets awaitInitPrompt=true when no args given', () => { - const result = executeCommand('init', [], context); - expect(result.handled).toBe(true); - expect(result.awaitInitPrompt).toBe(true); - }); - - it('sets awaitInitPrompt=true for whitespace-only args', () => { - const result = executeCommand('init', [' ', ' '], context); - expect(result.handled).toBe(true); - expect(result.awaitInitPrompt).toBe(true); - }); - - it('does NOT set awaitInitPrompt when inline prompt is provided', () => { - const result = executeCommand('init', ['Build', 'a', 'snake', 'game'], context); - expect(result.handled).toBe(true); - expect(result.triggerInitCast).toBeDefined(); - expect(result.awaitInitPrompt).toBeUndefined(); - }); - - it('awaitInitPrompt result has guidance output text', () => { - const result = executeCommand('init', [], context); - expect(result.awaitInitPrompt).toBe(true); - expect(result.output).toBeDefined(); - expect(result.output).toContain('just type what you want to build'); - }); - - it('awaitInitPrompt output includes team.md path', () => { - const result = executeCommand('init', [], context); - expect(result.output).toContain('/test/.squad/team.md'); - }); -}); - -// =========================================================================== -// 5c. handleDispatch guard bypass — skipCastConfirmation=false with no team.md -// =========================================================================== - -describe('handleDispatch guard — skipCastConfirmation bypasses missing team.md check', () => { - it('follow-up init ParsedInput has skipCastConfirmation=false (not undefined), bypassing guard', () => { - // App.tsx sets skipCastConfirmation: false for follow-up-after-/init messages. - // false !== undefined, so the guard check `parsed.skipCastConfirmation !== undefined` - // evaluates to true and the cast path is taken rather than the error path. - const followUpParsed = { - type: 'coordinator' as const, - raw: 'Build a Marine Asset Integrity tool', - content: 'Build a Marine Asset Integrity tool', - skipCastConfirmation: false as const, - }; - expect(followUpParsed.skipCastConfirmation).toBe(false); - expect(followUpParsed.skipCastConfirmation !== undefined).toBe(true); - }); - - it('plain coordinator ParsedInput has skipCastConfirmation=undefined — guard is applied', () => { - // Regular messages have no skipCastConfirmation, so the guard runs normally - // and shows the "No Squad team found" error when team.md is absent. - const regularParsed = { - type: 'coordinator' as const, - raw: 'Do something', - content: 'Do something', - }; - expect(regularParsed.skipCastConfirmation).toBeUndefined(); - expect((regularParsed as { skipCastConfirmation?: boolean }).skipCastConfirmation !== undefined).toBe(false); - }); - - it('inline /init ParsedInput has skipCastConfirmation=true — guard bypassed and confirmation skipped', () => { - // App.tsx sets skipCastConfirmation: true for inline /init "prompt" messages. - // The guard is bypassed AND the confirmation dialog is skipped. - const inlineParsed = { - type: 'coordinator' as const, - raw: 'Build a REST API', - content: 'Build a REST API', - skipCastConfirmation: true as const, - }; - expect(inlineParsed.skipCastConfirmation).toBe(true); - expect(inlineParsed.skipCastConfirmation !== undefined).toBe(true); - }); -}); - -// =========================================================================== -// 6. Ctrl+C abort — activeInitSession lifecycle -// =========================================================================== - -describe('activeInitSession lifecycle — Ctrl+C abort coverage', () => { - it('handleCancel aborts init session and clears it (structural verification)', async () => { - // We can't directly access the closure-scoped activeInitSession from index.ts, - // but we can verify the abort contract by simulating the pattern. - let activeInitSession: { abort?: () => Promise; close?: () => Promise } | null = null; - const abortCalled: string[] = []; - - // Simulate creating an init session (index.ts:646) - activeInitSession = { - abort: async () => { abortCalled.push('init-abort'); }, - close: async () => { abortCalled.push('init-close'); }, - }; - - // Simulate handleCancel (index.ts:584-586) - if (activeInitSession) { - try { await activeInitSession.abort?.(); } catch { /* ignore */ } - activeInitSession = null; - } - - expect(abortCalled).toContain('init-abort'); - expect(activeInitSession).toBeNull(); - }); - - it('activeInitSession is cleared after successful init (success path)', async () => { - let activeInitSession: { close?: () => Promise } | null = null; - const closeCalled: string[] = []; - - // Simulate session creation (index.ts:646) - activeInitSession = { - close: async () => { closeCalled.push('closed'); }, - }; - - // Simulate success path (index.ts:724-726) - try { await activeInitSession.close?.(); } catch { /* ignore */ } - activeInitSession = null; - - expect(closeCalled).toContain('closed'); - expect(activeInitSession).toBeNull(); - }); - - it('activeInitSession is cleared in finally block on error', async () => { - let activeInitSession: { close?: () => Promise } | null = null; - const closeCalled: string[] = []; - - // Simulate session creation - activeInitSession = { - close: async () => { closeCalled.push('finally-close'); }, - }; - - // Simulate error path with finally (index.ts:752-758) - try { - throw new Error('simulated init failure'); - } catch { - // error handler runs - } finally { - if (activeInitSession) { - try { await activeInitSession.close?.(); } catch { /* ignore */ } - } - activeInitSession = null; - } - - expect(closeCalled).toContain('finally-close'); - expect(activeInitSession).toBeNull(); - }); - - it('handleCancel is safe when no init session is active', async () => { - let activeInitSession: { abort?: () => Promise } | null = null; - - // Simulate handleCancel with no init session (index.ts:584) - if (activeInitSession) { - try { await activeInitSession.abort?.(); } catch { /* ignore */ } - activeInitSession = null; - } - - // Should not throw — guard check prevents null dereference - expect(activeInitSession).toBeNull(); - }); - - it('handleCancel handles abort() throwing an error gracefully', async () => { - let activeInitSession: { abort?: () => Promise } | null = null; - - activeInitSession = { - abort: async () => { throw new Error('abort failed'); }, - }; - - // Simulate handleCancel (index.ts:585) — error is caught - if (activeInitSession) { - try { await activeInitSession.abort?.(); } catch { /* swallowed */ } - activeInitSession = null; - } - - expect(activeInitSession).toBeNull(); - }); -}); - -// =========================================================================== -// 7. handleInitCast stored prompt consumption -// =========================================================================== - -describe('handleInitCast — .init-prompt consumption logic', () => { - let tmpDir: string; - let squadDir: string; - - beforeEach(() => { - tmpDir = makeTempDir('initcast-consume-'); - squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - }); - - afterEach(() => { - cleanDir(tmpDir); - }); - - it('stored prompt overrides parsed raw when .init-prompt exists', () => { - // Replicates index.ts:620-628 logic - const initPromptFile = path.join(squadDir, '.init-prompt'); - fs.writeFileSync(initPromptFile, 'Build a chess engine'); - - let castPrompt = 'original user message'; - if (fs.existsSync(initPromptFile)) { - const storedPrompt = fs.readFileSync(initPromptFile, 'utf-8').trim(); - if (storedPrompt) { - castPrompt = storedPrompt; - } - } - - expect(castPrompt).toBe('Build a chess engine'); - }); - - it('parsed raw is used when .init-prompt does not exist', () => { - const initPromptFile = path.join(squadDir, '.init-prompt'); - - let castPrompt = 'original user message'; - if (fs.existsSync(initPromptFile)) { - const storedPrompt = fs.readFileSync(initPromptFile, 'utf-8').trim(); - if (storedPrompt) { - castPrompt = storedPrompt; - } - } - - expect(castPrompt).toBe('original user message'); - }); - - it('.init-prompt is deleted after consumption (post-cast cleanup)', () => { - // Replicates index.ts:710-713 - const initPromptFile = path.join(squadDir, '.init-prompt'); - fs.writeFileSync(initPromptFile, 'Build something'); - - // Simulate post-cast cleanup - if (fs.existsSync(initPromptFile)) { - try { fs.unlinkSync(initPromptFile); } catch { /* ignore */ } - } - - expect(fs.existsSync(initPromptFile)).toBe(false); - }); - - it('cleanup is safe when .init-prompt was already deleted', () => { - const initPromptFile = path.join(squadDir, '.init-prompt'); - - // No .init-prompt exists - expect(() => { - if (fs.existsSync(initPromptFile)) { - try { fs.unlinkSync(initPromptFile); } catch { /* ignore */ } - } - }).not.toThrow(); - }); -}); diff --git a/test/init-base-roles.test.ts b/test/init-base-roles.test.ts deleted file mode 100644 index 2f022775a..000000000 --- a/test/init-base-roles.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Tests for base roles opt-in behavior (Issue #379). - * - * Verifies: - * - buildInitModePrompt defaults to fictional universe casting (no base roles catalog) - * - buildInitModePrompt includes base roles catalog only when useBaseRoles is true - * - .init-roles marker file is written by init when --roles is passed - * - .init-roles marker is cleaned up after casting - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtemp, rm, writeFile, mkdir } from 'node:fs/promises'; -import { existsSync, writeFileSync, mkdirSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { - buildInitModePrompt, - type CoordinatorConfig, -} from '../packages/squad-cli/src/cli/shell/coordinator.js'; - -describe('buildInitModePrompt — base roles opt-in (#379)', () => { - let teamRoot: string; - - beforeEach(async () => { - teamRoot = await mkdtemp(join(tmpdir(), 'squad-init-roles-')); - mkdirSync(join(teamRoot, '.squad'), { recursive: true }); - }); - - afterEach(async () => { - await rm(teamRoot, { recursive: true, force: true }); - }); - - it('default prompt uses fictional universe casting (no base roles catalog)', () => { - const prompt = buildInitModePrompt({ teamRoot }); - - // Should instruct to pick a fictional universe - expect(prompt).toContain('Pick a fictional universe'); - expect(prompt).toContain('INIT_TEAM:'); - - // Should NOT include the base roles catalog section - expect(prompt).not.toContain('## Built-in Base Roles'); - expect(prompt).not.toContain('Prefer these over inventing new roles'); - expect(prompt).not.toContain('marketing-strategist'); - expect(prompt).not.toContain('compliance-legal'); - }); - - it('prompt with useBaseRoles=true includes base roles catalog', () => { - const prompt = buildInitModePrompt({ teamRoot, useBaseRoles: true }); - - // Should still instruct fictional universe for character names - expect(prompt).toContain('Pick a fictional universe'); - expect(prompt).toContain('INIT_TEAM:'); - - // Should include the base roles catalog section - expect(prompt).toContain('## Built-in Base Roles'); - expect(prompt).toContain('Prefer these over inventing new roles'); - expect(prompt).toContain('marketing-strategist'); - expect(prompt).toContain('compliance-legal'); - expect(prompt).toContain('lead'); - expect(prompt).toContain('backend'); - expect(prompt).toContain('frontend'); - }); - - it('prompt with useBaseRoles=false matches default (no catalog)', () => { - const defaultPrompt = buildInitModePrompt({ teamRoot }); - const explicitFalse = buildInitModePrompt({ teamRoot, useBaseRoles: false }); - - expect(explicitFalse).toBe(defaultPrompt); - }); -}); - -describe('.init-roles marker file lifecycle', () => { - let teamRoot: string; - - beforeEach(async () => { - teamRoot = await mkdtemp(join(tmpdir(), 'squad-init-roles-marker-')); - mkdirSync(join(teamRoot, '.squad'), { recursive: true }); - }); - - afterEach(async () => { - await rm(teamRoot, { recursive: true, force: true }); - }); - - it('.init-roles marker does not exist by default', () => { - expect(existsSync(join(teamRoot, '.squad', '.init-roles'))).toBe(false); - }); - - it('.init-roles marker can be created and detected', () => { - const markerPath = join(teamRoot, '.squad', '.init-roles'); - writeFileSync(markerPath, '1', 'utf-8'); - expect(existsSync(markerPath)).toBe(true); - }); -}); diff --git a/test/journey-error-handling.test.ts b/test/journey-error-handling.test.ts deleted file mode 100644 index 1e4b5834d..000000000 --- a/test/journey-error-handling.test.ts +++ /dev/null @@ -1,523 +0,0 @@ -/** - * Human journey E2E test — "Something went wrong" - * - * Validates that errors the user might encounter are surfaced as - * friendly messages rather than raw stack traces, and that the shell - * remains usable after failures. - * - * @see https://github.com/bradygaster/squad-pr/issues/386 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import { ErrorBoundary } from '../packages/squad-cli/src/cli/shell/components/ErrorBoundary.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ─── Test infrastructure (mirrors e2e-shell.test.ts) ──────────────────────── - -const TICK = 80; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — E2E Test Project - -> An end-to-end test project for shell integration. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: E2E test coverage -active_issues: [] ---- - -# What We're Focused On - -E2E test coverage. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - cleanup: () => Promise; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; - onDispatch?: (parsed: ParsedInput) => Promise; - /** When true, omit onDispatch entirely to simulate no SDK connection. */ - noSdk?: boolean; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - ], - withSquadDir = true, - version = '0.0.0-test', - noSdk = false, - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-e2e-err-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = opts?.onDispatch - ? vi.fn(opts.onDispatch) - : vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: noSdk ? undefined : dispatched, - onCancel: cancelled, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. SDK connection failure shows helpful error message -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: SDK connection failure', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness({ noSdk: true }); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows SDK-not-connected message when user sends a message without SDK', async () => { - await shell.submit('hello squad'); - expect(shell.hasText('SDK not connected')).toBe(true); - }); - - it('suggests squad doctor for setup issues', async () => { - await shell.submit('hello squad'); - expect(shell.hasText('squad doctor')).toBe(true); - }); - - it('suggests checking internet connection', async () => { - await shell.submit('hello squad'); - expect(shell.hasText('internet connection')).toBe(true); - }); - - it('does not show a raw stack trace', async () => { - await shell.submit('hello squad'); - const frame = shell.frame(); - expect(frame).not.toMatch(/at\s+\w+\s+\(/); // no stack trace lines - expect(frame).not.toContain('Error:'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. Agent dispatch failure is caught and shown to user -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Agent dispatch failure', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - // Use the default harness — handleDispatch in index.ts catches errors and - // pushes a system message via ShellApi; we simulate that pattern here. - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows the dispatch error as a system message via ShellApi', async () => { - await shell.submit('@Keaton fix the build'); - await tick(120); - // Simulate what handleDispatch does when an error is caught: - shell.api().addMessage({ - role: 'system', - content: '❌ Something went wrong: Connection refused: SDK backend unavailable\n Try again, or check your connection. Run `squad doctor` for diagnostics.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Something went wrong')).toBe(true); - expect(shell.hasText('Connection refused')).toBe(true); - }); - - it('error message suggests diagnostics with squad doctor', async () => { - // Simulate what handleDispatch does when an error is caught: - shell.api().addMessage({ - role: 'system', - content: '❌ Something went wrong: Connection refused: SDK backend unavailable\n Try again, or check your connection. Run `squad doctor` for diagnostics.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('squad doctor')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 3. Invalid /command shows "Unknown command" with /help hint -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Invalid slash command', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows "Unknown command" message for unknown commands', async () => { - await shell.submit('/foobar'); - expect(shell.hasText('Unknown command')).toBe(true); - expect(shell.hasText('/foobar')).toBe(true); - }); - - it('suggests /help for unknown commands', async () => { - await shell.submit('/foobar'); - expect(shell.hasText('/help')).toBe(true); - }); - - it('does not dispatch unknown commands to the SDK', async () => { - await shell.submit('/foobar'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); - - it('handles multiple invalid commands in a row', async () => { - await shell.submit('/xyz'); - await shell.submit('/abc'); - expect(shell.hasText('/xyz')).toBe(true); - expect(shell.hasText('/abc')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 4. Network-like errors during streaming are handled gracefully -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Network errors during streaming', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('StreamBridge-style error is shown as a friendly system message', async () => { - await shell.submit('@Keaton fix the build'); - // Simulate what StreamBridge.onError does - shell.api().addMessage({ - role: 'system', - content: '❌ Keaton hit a problem: ECONNRESET: network connection was reset\n Try again, or run `squad doctor` to check your setup.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('hit a problem')).toBe(true); - expect(shell.hasText('ECONNRESET')).toBe(true); - }); - - it('network error does not contain raw Error: prefix', async () => { - shell.api().addMessage({ - role: 'system', - content: '❌ Keaton hit a problem: network connection was reset\n Try again, or run `squad doctor` to check your setup.', - timestamp: new Date(), - }); - await tick(120); - const frame = shell.frame(); - expect(frame).not.toMatch(/^Error:/m); - }); - - it('suggests squad doctor for recovery', async () => { - shell.api().addMessage({ - role: 'system', - content: '❌ Keaton hit a problem: timeout exceeded\n Try again, or run `squad doctor` to check your setup.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('squad doctor')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 5. ErrorBoundary catches React rendering errors -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: ErrorBoundary catches render errors', () => { - afterEach(() => { - vi.unstubAllEnvs(); - }); - - it('shows "Something went wrong" when a child component throws', async () => { - vi.stubEnv('NO_COLOR', '1'); - // Suppress console.error from componentDidCatch - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const Boom: React.FC = () => { - throw new Error('kaboom in render'); - }; - - const ink = render( - h(ErrorBoundary, null, h(Boom)) - ); - await tick(120); - - const frame = stripAnsi(ink.lastFrame() ?? ''); - expect(frame).toContain('Something went wrong'); - expect(frame).toContain('Ctrl+C to exit'); - - // Should NOT expose the raw stack trace - expect(frame).not.toContain('kaboom in render'); - expect(frame).not.toMatch(/at\s+\w+\s+\(/); - - spy.mockRestore(); - ink.unmount(); - }); - - it('mentions error is logged to stderr for debugging', async () => { - vi.stubEnv('NO_COLOR', '1'); - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - - const Boom: React.FC = () => { - throw new Error('kaboom'); - }; - - const ink = render( - h(ErrorBoundary, null, h(Boom)) - ); - await tick(120); - - const frame = stripAnsi(ink.lastFrame() ?? ''); - expect(frame).toContain('logged to stderr'); - - spy.mockRestore(); - ink.unmount(); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 6. After an error, the shell remains usable -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Shell remains usable after error', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('user can type a new message after an invalid command', async () => { - await shell.submit('/badcmd'); - expect(shell.hasText('Unknown command')).toBe(true); - // Now submit a valid command - await shell.submit('/status'); - expect(shell.hasText('Squad Status')).toBe(true); - }); - - it('user can type a new message after a dispatch error', async () => { - // Simulate an error message being shown - shell.api().addMessage({ - role: 'system', - content: '❌ Something went wrong: connection timed out', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Something went wrong')).toBe(true); - - // Shell should still accept input - await shell.submit('/help'); - expect(shell.hasText('/status')).toBe(true); - }); - - it('user can submit to coordinator after SDK-error system message', async () => { - shell.api().addMessage({ - role: 'system', - content: '❌ Something went wrong: SDK backend crashed', - timestamp: new Date(), - }); - await tick(120); - - await shell.submit('what should we build?'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('coordinator'); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 7. Error messages are user-friendly, not raw stack traces -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Error messages are user-friendly', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('handleDispatch-style error strips Error: prefix', async () => { - // Simulate what handleDispatch produces (strips "Error: " prefix) - shell.api().addMessage({ - role: 'system', - content: '❌ Something went wrong: session creation failed\n Try again, or check your connection. Run `squad doctor` for diagnostics.', - timestamp: new Date(), - }); - await tick(120); - const frame = shell.frame(); - expect(frame).toContain('session creation failed'); - expect(frame).not.toMatch(/^Error:/m); - expect(frame).toContain('squad doctor'); - }); - - it('unknown command error is conversational, not a stack trace', async () => { - await shell.submit('/doesnotexist'); - const frame = shell.frame(); - expect(frame).toContain('Unknown command'); - expect(frame).toContain('/help'); - expect(frame).not.toMatch(/at\s+\w+\s+\(/); // no stack frames - expect(frame).not.toContain('TypeError'); - expect(frame).not.toContain('ReferenceError'); - }); - - it('SDK not connected message provides actionable recovery steps', async () => { - const noSdkShell = await createShellHarness({ noSdk: true }); - await noSdkShell.submit('build the feature'); - const frame = noSdkShell.frame(); - - // Should contain numbered steps or helpful recovery suggestions - expect(frame).toContain('squad doctor'); - expect(frame).toContain('internet connection'); - expect(frame).toContain('restart'); - await noSdkShell.cleanup(); - }); -}); diff --git a/test/journey-first-conversation.test.ts b/test/journey-first-conversation.test.ts deleted file mode 100644 index 049ab14eb..000000000 --- a/test/journey-first-conversation.test.ts +++ /dev/null @@ -1,405 +0,0 @@ -/** - * Human Journey E2E Test — "My first conversation" - * - * Simulates a brand-new user's first interactive session with Squad: - * seeing the welcome banner, typing a message, observing the thinking - * indicator, receiving a response, exploring /help and /status, - * trying @agent routing, and exiting gracefully. - * - * @see https://github.com/bradygaster/squad-pr/issues/384 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ─── Test infrastructure ──────────────────────────────────────────────────── - -const TICK = 200; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -/** Scaffold a minimal .squad/ directory so the App shows a welcome banner. */ -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — My First Project - -> A brand-new project exploring Squad for the first time. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: Getting started -active_issues: [] ---- - -# What We're Focused On - -Getting started with Squad. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - cleanup: () => Promise; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - ], - withSquadDir = true, - version = '0.0.0-test', - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-journey-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey: My First Conversation (#384) -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: My first conversation (#384)', { timeout: 30_000 }, () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - // ── Step 1: User sees welcome message on shell start ────────────────── - - describe('Step 1 — Welcome message on shell start', () => { - it('shows SQUAD title in the welcome banner', () => { - // Figlet banner renders SQUAD as ASCII art (not literal text) - expect(shell.hasText('___')).toBe(true); - }); - - it('displays the version number', () => { - expect(shell.hasText('0.0.0-test')).toBe(true); - }); - - it('lists the team agents by name', () => { - expect(shell.hasText('Keaton')).toBe(true); - expect(shell.hasText('Fenster')).toBe(true); - }); - - it('shows how many agents are ready', () => { - expect(shell.hasText('2 agents ready')).toBe(true); - }); - - it('includes a /help hint so the user knows where to start', () => { - expect(shell.hasText('/help')).toBe(true); - }); - }); - - // ── Step 2: User types their first message and submits ──────────────── - - describe('Step 2 — First message submission', () => { - it('typed text appears in the input area', async () => { - await shell.type('Hello Squad!'); - expect(shell.hasText('Hello Squad!')).toBe(true); - }); - - it('submitted message appears in the conversation', async () => { - await shell.submit('What should we build first?'); - expect(shell.hasText('What should we build first?')).toBe(true); - }); - - it('submission routes to coordinator for a bare message', async () => { - await shell.submit('What should we build first?'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('coordinator'); - expect(parsed.content).toBe('What should we build first?'); - }); - - it('user message is shown with the ❯ chevron', async () => { - await shell.submit('What should we build first?'); - expect(shell.hasText('❯')).toBe(true); - }); - }); - - // ── Step 3: System shows thinking indicator while processing ────────── - - describe('Step 3 — Thinking indicator while processing', () => { - it('shows a thinking indicator after submitting a message', async () => { - // Make onDispatch hang so processing stays true - shell.dispatched.mockReturnValue(new Promise(() => {})); - await shell.submit('What should we build first?'); - await tick(200); - // In NO_COLOR mode the indicator shows "..." and "Routing to agent" - expect(shell.hasText('Routing to agent')).toBe(true); - }); - - it('thinking indicator disappears once processing finishes', async () => { - await shell.submit('What should we build first?'); - // dispatched mock resolves immediately, so processing ends - await tick(200); - expect(shell.hasText('Routing to agent')).toBe(false); - }); - }); - - // ── Step 4: User receives a response ────────────────────────────────── - - describe('Step 4 — Receiving a response', () => { - it('agent response appears in the conversation via ShellApi', async () => { - await shell.submit('What should we build first?'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'Great question! Let me outline a plan for you.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Great question! Let me outline a plan for you.')).toBe(true); - }); - - it('agent name is associated with the response', async () => { - await shell.submit('What should we build first?'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'Here is the plan.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Keaton')).toBe(true); - expect(shell.hasText('Here is the plan.')).toBe(true); - }); - - it('user can continue the conversation after receiving a response', async () => { - await shell.submit('What should we build first?'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'Start with the API layer.', - timestamp: new Date(), - }); - await tick(120); - await shell.submit('Sounds good, lets do it'); - expect(shell.dispatched).toHaveBeenCalledTimes(2); - }); - }); - - // ── Step 5: User tries /help to learn about commands ────────────────── - - describe('Step 5 — Exploring /help', () => { - it('shows available commands', async () => { - await shell.submit('/help'); - expect(shell.hasText('/status')).toBe(true); - expect(shell.hasText('/history')).toBe(true); - expect(shell.hasText('/quit')).toBe(true); - }); - - it('/help does not dispatch to the SDK', async () => { - await shell.submit('/help'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); - - it('shows @AgentName routing guidance', async () => { - await shell.submit('/help'); - expect(shell.hasText('@AgentName')).toBe(true); - }); - }); - - // ── Step 6: User sees agent roster in /status ───────────────────────── - - describe('Step 6 — Checking /status', () => { - it('shows agent count', async () => { - await shell.submit('/status'); - expect(shell.hasText('2 agents')).toBe(true); - }); - - it('shows the team root path', async () => { - await shell.submit('/status'); - expect(shell.hasText('Root')).toBe(true); - }); - - it('shows message count', async () => { - await shell.submit('/status'); - expect(shell.hasText('Messages')).toBe(true); - }); - - it('/status does not dispatch to the SDK', async () => { - await shell.submit('/status'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); - }); - - // ── Step 7: User tries @agent routing for the first time ────────────── - - describe('Step 7 — First @agent direct message', () => { - it('@Keaton message dispatches as direct_agent', async () => { - await shell.submit('@Keaton can you review my code?'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - expect(parsed.content).toBe('can you review my code?'); - }); - - it('@agent message appears in the conversation', async () => { - await shell.submit('@Fenster write a unit test'); - expect(shell.hasText('@Fenster write a unit test')).toBe(true); - }); - - it('agent response to direct message appears in conversation', async () => { - await shell.submit('@Fenster write a unit test'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Fenster', - content: 'On it! Writing a test for the auth module.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('On it! Writing a test for the auth module.')).toBe(true); - }); - }); - - // ── Step 8: User exits gracefully ───────────────────────────────────── - - describe('Step 8 — Graceful exit', () => { - it('typing "exit" causes the shell to exit', async () => { - await shell.submit('exit'); - // After exit, no further rendering; the ink instance should be unmounted. - // The fact that no error is thrown means exit was handled gracefully. - }); - - it('typing "quit" causes the shell to exit', async () => { - await shell.submit('quit'); - }); - - it('first Ctrl+C shows exit hint', async () => { - shell.raw('\x03'); - await tick(120); - expect(shell.hasText('Press Ctrl+C again to exit')).toBe(true); - }); - - it('exit hint is a system message', async () => { - shell.raw('\x03'); - await tick(120); - // System messages no longer have [system] prefix — just check for Ctrl+C hint - expect(shell.hasText('Ctrl+C')).toBe(true); - }); - }); -}); diff --git a/test/journey-next-day.test.ts b/test/journey-next-day.test.ts deleted file mode 100644 index ce3b9a358..000000000 --- a/test/journey-next-day.test.ts +++ /dev/null @@ -1,579 +0,0 @@ -/** - * Human Journey E2E Test — "I came back the next day" - * - * Validates that a user can close the shell, return later, and resume - * their previous session with full message history intact. - * - * @see https://github.com/bradygaster/squad-pr/issues/398 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync, existsSync, readFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; -import { - createSession, - saveSession, - loadLatestSession, - loadSessionById, - listSessions, - type SessionData, -} from '../packages/squad-cli/src/cli/shell/session-store.js'; - -const h = React.createElement; - -// ─── Test infrastructure (mirrors e2e-shell.test.ts) ─────────────────────── - -const TICK = 80; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Journey Test - -> A journey test project for session persistence. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: Journey testing -active_issues: [] ---- - -# What We're Focused On - -Journey testing for session persistence. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - cleanup: () => Promise; - tempDir: string; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; - tempDir?: string; - onRestoreSession?: (session: SessionData) => void; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - ], - withSquadDir = true, - version = '0.0.0-test', - onRestoreSession, - } = opts ?? {}; - - const tempDir = opts?.tempDir ?? mkdtempSync(join(tmpdir(), 'squad-journey-')); - if (withSquadDir && !existsSync(join(tempDir, '.squad'))) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - onRestoreSession, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - tempDir, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// 1. Session-store unit tests — createSession, saveSession, loadLatest, etc. -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: session-store persistence', () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'squad-session-')); - mkdirSync(join(tempDir, '.squad'), { recursive: true }); - }); - - afterEach(async () => { - await rm(tempDir, { recursive: true, force: true }); - }); - - it('createSession returns a well-structured envelope', () => { - const session = createSession(); - expect(session.id).toBeTruthy(); - expect(session.id).toMatch(/^[0-9a-f-]{36}$/); // UUID format - expect(session.createdAt).toBeTruthy(); - expect(session.lastActiveAt).toBeTruthy(); - expect(session.messages).toEqual([]); - // ISO-8601 timestamps - expect(() => new Date(session.createdAt)).not.toThrow(); - expect(() => new Date(session.lastActiveAt)).not.toThrow(); - }); - - it('saveSession writes a JSON file to .squad/sessions/', () => { - const session = createSession(); - session.messages.push({ - role: 'user', - content: 'hello squad', - timestamp: new Date(), - }); - - const filePath = saveSession(tempDir, session); - expect(existsSync(filePath)).toBe(true); - - const raw = readFileSync(filePath, 'utf-8'); - const persisted = JSON.parse(raw) as SessionData; - expect(persisted.id).toBe(session.id); - expect(persisted.messages).toHaveLength(1); - expect(persisted.messages[0]!.content).toBe('hello squad'); - }); - - it('saveSession creates the sessions directory if missing', () => { - const session = createSession(); - const sessDir = join(tempDir, '.squad', 'sessions'); - expect(existsSync(sessDir)).toBe(false); - - saveSession(tempDir, session); - expect(existsSync(sessDir)).toBe(true); - }); - - it('saveSession updates lastActiveAt on each save', async () => { - const session = createSession(); - const first = session.lastActiveAt; - await tick(50); // small delay so timestamps differ - saveSession(tempDir, session); - expect(new Date(session.lastActiveAt).getTime()).toBeGreaterThanOrEqual( - new Date(first).getTime() - ); - }); - - it('listSessions returns saved sessions sorted most-recent first', async () => { - const s1 = createSession(); - s1.messages.push({ role: 'user', content: 'msg1', timestamp: new Date() }); - saveSession(tempDir, s1); - await tick(50); // ensure s2 gets a later lastActiveAt timestamp - - const s2 = createSession(); - s2.messages.push( - { role: 'user', content: 'msg2a', timestamp: new Date() }, - { role: 'agent', agentName: 'Keaton', content: 'msg2b', timestamp: new Date() }, - ); - saveSession(tempDir, s2); - - const sessions = listSessions(tempDir); - expect(sessions).toHaveLength(2); - // Most recent first - expect(sessions[0]!.id).toBe(s2.id); - expect(sessions[0]!.messageCount).toBe(2); - expect(sessions[1]!.id).toBe(s1.id); - expect(sessions[1]!.messageCount).toBe(1); - }); - - it('listSessions returns empty array when no sessions directory', async () => { - const emptyDir = mkdtempSync(join(tmpdir(), 'squad-empty-')); - const sessions = listSessions(emptyDir); - expect(sessions).toEqual([]); - await rm(emptyDir, { recursive: true, force: true }); - }); - - it('loadLatestSession returns the most recent session within 24h', () => { - const session = createSession(); - session.messages.push({ role: 'user', content: 'recent work', timestamp: new Date() }); - saveSession(tempDir, session); - - const loaded = loadLatestSession(tempDir); - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(session.id); - expect(loaded!.messages).toHaveLength(1); - expect(loaded!.messages[0]!.content).toBe('recent work'); - }); - - it('loadLatestSession returns null when session is older than 24h', () => { - const session = createSession(); - // Set lastActiveAt to 25 hours ago - session.lastActiveAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); - saveSession(tempDir, session); - - // Need to re-save with the old timestamp since saveSession updates lastActiveAt - // Directly write the file with an old timestamp - const sessDir = join(tempDir, '.squad', 'sessions'); - const files = require('node:fs').readdirSync(sessDir) as string[]; - const filePath = join(sessDir, files[0]!); - const data = JSON.parse(readFileSync(filePath, 'utf-8')) as SessionData; - data.lastActiveAt = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); - - const loaded = loadLatestSession(tempDir); - expect(loaded).toBeNull(); - }); - - it('loadSessionById returns the correct session', () => { - const s1 = createSession(); - s1.messages.push({ role: 'user', content: 'session one', timestamp: new Date() }); - saveSession(tempDir, s1); - - const s2 = createSession(); - s2.messages.push({ role: 'user', content: 'session two', timestamp: new Date() }); - saveSession(tempDir, s2); - - const loaded = loadSessionById(tempDir, s1.id); - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(s1.id); - expect(loaded!.messages[0]!.content).toBe('session one'); - }); - - it('loadSessionById returns null for unknown ID', () => { - const loaded = loadSessionById(tempDir, 'nonexistent-id'); - expect(loaded).toBeNull(); - }); - - it('loaded session messages have rehydrated Date timestamps', () => { - const session = createSession(); - const now = new Date(); - session.messages.push({ role: 'user', content: 'test', timestamp: now }); - saveSession(tempDir, session); - - const loaded = loadSessionById(tempDir, session.id); - expect(loaded!.messages[0]!.timestamp).toBeInstanceOf(Date); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 2. UI integration — /sessions command lists past sessions -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: /sessions command in shell', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows "No saved sessions" when none exist', async () => { - await shell.submit('/sessions'); - expect(shell.hasText('No saved sessions')).toBe(true); - }); - - it('lists saved sessions after persisting one', async () => { - // Persist a session to this harness's temp dir - const session = createSession(); - session.messages.push( - { role: 'user', content: 'what should we build?', timestamp: new Date() }, - { role: 'agent', agentName: 'Keaton', content: 'A REST API.', timestamp: new Date() }, - ); - saveSession(shell.tempDir, session); - - await shell.submit('/sessions'); - expect(shell.hasText('Saved Sessions')).toBe(true); - expect(shell.hasText(session.id.slice(0, 8))).toBe(true); - expect(shell.hasText('2 messages')).toBe(true); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 3. UI integration — /resume command restores a session -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: /resume command in shell', () => { - let shell: ShellHarness; - let restoredSession: SessionData | undefined; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - restoredSession = undefined; - shell = await createShellHarness({ - onRestoreSession: (session) => { restoredSession = session; }, - }); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('shows usage hint when no ID is given', async () => { - await shell.submit('/resume'); - expect(shell.hasText('Usage')).toBe(true); - }); - - it('reports error for unknown session prefix', async () => { - await shell.submit('/resume abcd1234'); - expect(shell.hasText('No session found')).toBe(true); - }); - - it('restores a session by ID prefix', async () => { - const session = createSession(); - session.messages.push( - { role: 'user', content: 'plan the sprint', timestamp: new Date() }, - { role: 'agent', agentName: 'Fenster', content: 'Here is the plan.', timestamp: new Date() }, - ); - saveSession(shell.tempDir, session); - - const prefix = session.id.slice(0, 8); - await shell.submit(`/resume ${prefix}`); - - expect(shell.hasText('Restored session')).toBe(true); - expect(shell.hasText('2 messages')).toBe(true); - expect(restoredSession).toBeDefined(); - expect(restoredSession!.id).toBe(session.id); - expect(restoredSession!.messages).toHaveLength(2); - }); -}); - -// ═══════════════════════════════════════════════════════════════════════════ -// 4. Full journey — "I came back the next day" -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: I came back the next day', () => { - let tempDir: string; - - beforeEach(() => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - tempDir = mkdtempSync(join(tmpdir(), 'squad-nextday-')); - scaffoldSquadDir(tempDir); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await rm(tempDir, { recursive: true, force: true }); - }); - - it('saves session on exit, detects it on return, and can resume', { timeout: 15000 }, async () => { - // === Day 1: User starts shell and has a conversation === - const shell1 = await createShellHarness({ tempDir }); - - // User sends a message - await shell1.submit('design the authentication module'); - expect(shell1.dispatched).toHaveBeenCalledTimes(1); - - // Simulate agent response - shell1.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'I recommend using JWT tokens with refresh rotation.', - timestamp: new Date(), - }); - await tick(120); - expect(shell1.hasText('JWT tokens')).toBe(true); - - // User asks follow-up - await shell1.submit('@Fenster implement the token service'); - expect(shell1.dispatched).toHaveBeenCalledTimes(2); - - // Save session before exit (as the real shell does on /quit) - const dayOneSession = createSession(); - dayOneSession.messages = [ - { role: 'user', content: 'design the authentication module', timestamp: new Date() }, - { role: 'agent', agentName: 'Keaton', content: 'I recommend using JWT tokens with refresh rotation.', timestamp: new Date() }, - { role: 'user', content: '@Fenster implement the token service', timestamp: new Date() }, - ]; - saveSession(tempDir, dayOneSession); - - // User exits the shell - shell1.ink.unmount(); - - // === Day 2: User returns === - - // Recent session should be detected - const latestSession = loadLatestSession(tempDir); - expect(latestSession).not.toBeNull(); - expect(latestSession!.id).toBe(dayOneSession.id); - expect(latestSession!.messages).toHaveLength(3); - - // Previous messages are accessible - expect(latestSession!.messages[0]!.content).toBe('design the authentication module'); - expect(latestSession!.messages[1]!.content).toBe('I recommend using JWT tokens with refresh rotation.'); - expect(latestSession!.messages[2]!.content).toBe('@Fenster implement the token service'); - - // Start a new shell instance (same temp dir) - let restoredData: SessionData | undefined; - const shell2 = await createShellHarness({ - tempDir, - onRestoreSession: (s) => { restoredData = s; }, - }); - - // /sessions shows the previous session - await shell2.submit('/sessions'); - expect(shell2.hasText('Saved Sessions')).toBe(true); - expect(shell2.hasText(dayOneSession.id.slice(0, 8))).toBe(true); - expect(shell2.hasText('3 messages')).toBe(true); - - // /resume restores the session - await shell2.submit(`/resume ${dayOneSession.id.slice(0, 8)}`); - expect(shell2.hasText('Restored session')).toBe(true); - expect(restoredData).toBeDefined(); - expect(restoredData!.messages).toHaveLength(3); - expect(restoredData!.messages[0]!.content).toBe('design the authentication module'); - - shell2.ink.unmount(); - }); - - it('does not offer sessions older than 24 hours via loadLatestSession', async () => { - // Simulate a session from 2 days ago - const oldSession = createSession(); - oldSession.messages.push({ role: 'user', content: 'old work', timestamp: new Date() }); - saveSession(tempDir, oldSession); - - // Manually backdate the file - const sessDir = join(tempDir, '.squad', 'sessions'); - const files = require('node:fs').readdirSync(sessDir) as string[]; - const filePath = join(sessDir, files[0]!); - const data = JSON.parse(readFileSync(filePath, 'utf-8')) as SessionData; - data.lastActiveAt = new Date(Date.now() - 48 * 60 * 60 * 1000).toISOString(); - writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf-8'); - - // loadLatestSession should not return the stale session - const latest = loadLatestSession(tempDir); - expect(latest).toBeNull(); - - // But /sessions should still list it - const shell = await createShellHarness({ tempDir }); - await shell.submit('/sessions'); - expect(shell.hasText(oldSession.id.slice(0, 8))).toBe(true); - shell.ink.unmount(); - }); - - it('multiple sessions are tracked independently', async () => { - // Create two sessions - const sessionA = createSession(); - sessionA.messages.push( - { role: 'user', content: 'session alpha work', timestamp: new Date() }, - ); - saveSession(tempDir, sessionA); - - const sessionB = createSession(); - sessionB.messages.push( - { role: 'user', content: 'session beta work', timestamp: new Date() }, - { role: 'agent', agentName: 'Keaton', content: 'beta response', timestamp: new Date() }, - ); - saveSession(tempDir, sessionB); - - // Both appear in listing - const sessions = listSessions(tempDir); - expect(sessions).toHaveLength(2); - - // Can load each independently - const loadedA = loadSessionById(tempDir, sessionA.id); - const loadedB = loadSessionById(tempDir, sessionB.id); - expect(loadedA!.messages[0]!.content).toBe('session alpha work'); - expect(loadedB!.messages[0]!.content).toBe('session beta work'); - expect(loadedB!.messages).toHaveLength(2); - - // /resume targets the correct one - let restoredData: SessionData | undefined; - const shell = await createShellHarness({ - tempDir, - onRestoreSession: (s) => { restoredData = s; }, - }); - await shell.submit(`/resume ${sessionA.id.slice(0, 8)}`); - expect(restoredData!.id).toBe(sessionA.id); - expect(restoredData!.messages[0]!.content).toBe('session alpha work'); - shell.ink.unmount(); - }); -}); diff --git a/test/journey-power-user.test.ts b/test/journey-power-user.test.ts deleted file mode 100644 index 47a4528f2..000000000 --- a/test/journey-power-user.test.ts +++ /dev/null @@ -1,351 +0,0 @@ -/** - * Human journey E2E test: "I'm a power user now" - * - * Validates advanced shell features an experienced user relies on: - * slash commands, tab completion, Ctrl+C cancel/exit, @agent routing. - * - * @see https://github.com/bradygaster/squad-pr/issues/396 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ─── Test infrastructure (mirrors e2e-shell.test.ts) ──────────────────────── - -const TICK = 200; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Power User Test - -> A project for testing power-user workflows. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -| McManus | QA | \`.squad/agents/mcmanus/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: Power user testing -active_issues: [] ---- - -# What We're Focused On - -Power user testing. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - cleanup: () => Promise; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - { name: 'McManus', role: 'QA' }, - ], - withSquadDir = true, - version = '0.0.0-test', - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-journey-pu-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey: "I'm a power user now" -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Power user', { timeout: 30_000 }, () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - // ── 1. /help shows all available commands ───────────────────────────── - - it('/help lists available slash commands', async () => { - await shell.submit('/help'); - expect(shell.hasText('/status')).toBe(true); - expect(shell.hasText('/history')).toBe(true); - expect(shell.hasText('/quit')).toBe(true); - }); - - it('/help shows @agent routing guidance', async () => { - await shell.submit('/help'); - expect(shell.hasText('@AgentName')).toBe(true); - }); - - it('/help full shows expanded command descriptions', async () => { - await shell.submit('/help full'); - expect(shell.hasText('/agents')).toBe(true); - expect(shell.hasText('/clear')).toBe(true); - expect(shell.hasText('/quit')).toBe(true); - }); - - // ── 2. /status shows team overview ──────────────────────────────────── - - it('/status shows agent count and team info', async () => { - await shell.submit('/status'); - expect(shell.hasText('3 agents')).toBe(true); - expect(shell.hasText('Root')).toBe(true); - expect(shell.hasText('Messages')).toBe(true); - }); - - it('/status does not dispatch to SDK', async () => { - await shell.submit('/status'); - expect(shell.dispatched).not.toHaveBeenCalled(); - }); - - // ── 3. Tab completion for slash commands ────────────────────────────── - - it('tab completes /h to /help', async () => { - await shell.type('/h'); - shell.ink.stdin.write('\t'); - await tick(120); - expect(shell.hasText('/help')).toBe(true); - }); - - it('tab completes /s to /status', async () => { - await shell.type('/s'); - shell.ink.stdin.write('\t'); - await tick(120); - expect(shell.hasText('/status')).toBe(true); - }); - - // ── 4. Tab completion for @agent names ──────────────────────────────── - - it('tab completes @K to @Keaton', async () => { - await shell.type('@K'); - shell.ink.stdin.write('\t'); - await tick(120); - expect(shell.hasText('@Keaton')).toBe(true); - }); - - it('tab completes @F to @Fenster', async () => { - await shell.type('@F'); - shell.ink.stdin.write('\t'); - await tick(120); - expect(shell.hasText('@Fenster')).toBe(true); - }); - - // ── 5. Ctrl+C cancels an active operation ───────────────────────────── - - it('Ctrl+C during processing calls onCancel', async () => { - // Make dispatch hang so the shell stays in processing state - shell.dispatched.mockImplementation(() => new Promise(() => {})); - await shell.submit('do something slow'); - await tick(120); - shell.raw('\x03'); - await tick(120); - expect(shell.cancelled).toHaveBeenCalled(); - }); - - // ── 6. Double Ctrl+C exits the shell ────────────────────────────────── - - it('single Ctrl+C when idle shows exit hint', async () => { - shell.raw('\x03'); - await tick(120); - expect(shell.hasText('Press Ctrl+C again to exit')).toBe(true); - }); - - it('double Ctrl+C when idle exits the shell', async () => { - shell.raw('\x03'); - await tick(80); - shell.raw('\x03'); - await tick(120); - // After exit, the last frame is empty or the component unmounts. - // ink-testing-library's render returns frames — after exit() the - // component tree teardown means no new content is rendered. - // We just verify no error was thrown and the frame is stable. - const frame = shell.frame(); - expect(frame).toBeDefined(); - }); - - // ── 7. Multiple slash commands in sequence ──────────────────────────── - - it('running /help then /status in sequence both produce output', async () => { - await shell.submit('/help'); - expect(shell.hasText('/status')).toBe(true); - - await shell.submit('/status'); - expect(shell.hasText('3 agents')).toBe(true); - expect(shell.hasText('Root')).toBe(true); - }); - - it('running /agents shows team members', async () => { - await shell.submit('/agents'); - expect(shell.hasText('Keaton')).toBe(true); - expect(shell.hasText('Fenster')).toBe(true); - expect(shell.hasText('McManus')).toBe(true); - }); - - it('/version then /status in sequence works correctly', async () => { - await shell.submit('/version'); - expect(shell.hasText('0.0.0-test')).toBe(true); - - await shell.submit('/status'); - expect(shell.hasText('Messages')).toBe(true); - }); - - // ── 8. @agent direct routing with complex messages ──────────────────── - - it('@Keaton routes complex message as direct_agent', async () => { - await shell.submit('@Keaton refactor the auth module and add retry logic'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - expect(parsed.content).toBe('refactor the auth module and add retry logic'); - }); - - it('@Fenster routes with multi-word message', async () => { - await shell.submit('@Fenster write tests for the new parser including edge cases'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Fenster'); - expect(parsed.content).toBe('write tests for the new parser including edge cases'); - }); - - it('@agent message appears in conversation history', async () => { - await shell.submit('@McManus run the full regression suite'); - expect(shell.hasText('@McManus run the full regression suite')).toBe(true); - expect(shell.hasText('❯')).toBe(true); - }); - - it('agent response renders when pushed via ShellApi', async () => { - await shell.submit('@Keaton check the CI pipeline'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'CI pipeline is green — all 47 tests passing.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('CI pipeline is green')).toBe(true); - }); -}); diff --git a/test/journey-specific-agent.test.ts b/test/journey-specific-agent.test.ts deleted file mode 100644 index 43a7ec43f..000000000 --- a/test/journey-specific-agent.test.ts +++ /dev/null @@ -1,448 +0,0 @@ -/** - * Human Journey E2E Test — "I want to talk to a specific agent" - * - * Covers the full user journey of directing messages to individual agents: - * 1. @agentname routing - * 2. Tab completion for agent names - * 3. Unknown @agent feedback - * 4. Multi-agent @mentions - * 5. Agent response labeling - * 6. /status shows active agent - * - * @see https://github.com/bradygaster/squad-pr/issues/394 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import { parseDispatchTargets, type ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; -import { createCompleter } from '../packages/squad-cli/src/cli/shell/autocomplete.js'; - -const h = React.createElement; - -// ─── Test infrastructure (mirrors e2e-shell.test.ts) ──────────────────────── - -const TICK = 80; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Journey Test - -> A journey test project for agent routing. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -| Hockney | Designer | \`.squad/agents/hockney/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: Journey testing -active_issues: [] ---- - -# What We're Focused On - -Journey testing for agent routing. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - registry: SessionRegistry; - cleanup: () => Promise; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Designer' }, - ], - withSquadDir = true, - version = '0.0.0-test', - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-journey-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - registry, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey: "I want to talk to a specific agent" -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: Talk to a specific agent', () => { - - // ─── 1. @agentname routing ───────────────────────────────────────────── - - describe('@agent routing dispatches to the correct agent', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('@Keaton message dispatches as direct_agent to Keaton', async () => { - await shell.submit('@Keaton please review the PR'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - expect(parsed.content).toBe('please review the PR'); - }); - - it('@Fenster message dispatches as direct_agent to Fenster', async () => { - await shell.submit('@Fenster write unit tests'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Fenster'); - expect(parsed.content).toBe('write unit tests'); - }); - - it('@agent routing is case-insensitive', async () => { - await shell.submit('@keaton fix the build'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('direct_agent'); - expect(parsed.agentName).toBe('Keaton'); - }); - - it('@agent message appears in conversation history', async () => { - await shell.submit('@Hockney design the landing page'); - expect(shell.hasText('@Hockney design the landing page')).toBe(true); - }); - }); - - // ─── 2. Tab completion suggests agent names ──────────────────────────── - - describe('Tab completion suggests agent names after @', () => { - it('completer returns matching agents for @K prefix', () => { - const completer = createCompleter(['Keaton', 'Fenster', 'Hockney']); - const [matches] = completer('@K'); - expect(matches).toContain('@Keaton '); - expect(matches).not.toContain('@Fenster '); - }); - - it('completer returns all agents for bare @', () => { - const completer = createCompleter(['Keaton', 'Fenster', 'Hockney']); - const [matches] = completer('@'); - expect(matches).toHaveLength(3); - expect(matches).toContain('@Keaton '); - expect(matches).toContain('@Fenster '); - expect(matches).toContain('@Hockney '); - }); - - it('completer is case-insensitive', () => { - const completer = createCompleter(['Keaton', 'Fenster']); - const [matches] = completer('@f'); - expect(matches).toContain('@Fenster '); - }); - - it('completer returns empty for no match', () => { - const completer = createCompleter(['Keaton', 'Fenster']); - const [matches] = completer('@Z'); - expect(matches).toHaveLength(0); - }); - - it('Tab key in shell replaces input with completed agent name', async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - const shell = await createShellHarness(); - try { - await shell.type('@K'); - // Send Tab key - shell.raw('\t'); - await tick(120); - expect(shell.hasText('@Keaton')).toBe(true); - } finally { - vi.unstubAllEnvs(); - await shell.cleanup(); - } - }); - }); - - // ─── 3. Unknown @agent feedback ─────────────────────────────────────── - - describe('Unknown @agent routes to coordinator', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('unknown @agent falls through to coordinator routing', async () => { - await shell.submit('@Nobody do something'); - expect(shell.dispatched).toHaveBeenCalledTimes(1); - const parsed = shell.dispatched.mock.calls[0]![0] as ParsedInput; - expect(parsed.type).toBe('coordinator'); - expect(parsed.content).toBe('@Nobody do something'); - }); - - it('message with unknown @agent still appears in conversation', async () => { - await shell.submit('@Ghost help me'); - expect(shell.hasText('@Ghost help me')).toBe(true); - }); - }); - - // ─── 4. Multi-agent @mentions ────────────────────────────────────────── - - describe('Multi-agent @mentions via parseDispatchTargets', () => { - const knownAgents = ['Keaton', 'Fenster', 'Hockney']; - - it('extracts multiple known agents from message', () => { - const result = parseDispatchTargets('@Fenster @Hockney fix and test', knownAgents); - expect(result.agents).toEqual(['Fenster', 'Hockney']); - expect(result.content).toBe('fix and test'); - }); - - it('deduplicates repeated @mentions', () => { - const result = parseDispatchTargets('@Keaton @keaton do it', knownAgents); - expect(result.agents).toEqual(['Keaton']); - expect(result.content).toBe('do it'); - }); - - it('ignores unknown agents in multi-mention', () => { - const result = parseDispatchTargets('@Keaton @Unknown @Fenster collaborate', knownAgents); - expect(result.agents).toEqual(['Keaton', 'Fenster']); - expect(result.content).toBe('collaborate'); - }); - - it('returns empty agents array for plain message', () => { - const result = parseDispatchTargets('just a regular message', knownAgents); - expect(result.agents).toEqual([]); - expect(result.content).toBe('just a regular message'); - }); - - it('handles all three agents mentioned', () => { - const result = parseDispatchTargets('@Keaton @Fenster @Hockney ship it', knownAgents); - expect(result.agents).toEqual(['Keaton', 'Fenster', 'Hockney']); - expect(result.content).toBe('ship it'); - }); - }); - - // ─── 5. Agent response is labeled with agent name ────────────────────── - - describe('Agent response is labeled with the agent name', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('agent response shows agent name label', async () => { - await shell.submit('@Keaton fix the build'); - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'Build fixed! All tests passing.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Keaton:')).toBe(true); - expect(shell.hasText('Build fixed! All tests passing.')).toBe(true); - }); - - it('different agents have distinct labels', async () => { - shell.api().addMessage({ - role: 'agent', - agentName: 'Keaton', - content: 'I reviewed the code.', - timestamp: new Date(), - }); - await tick(120); - shell.api().addMessage({ - role: 'agent', - agentName: 'Fenster', - content: 'Tests are written.', - timestamp: new Date(), - }); - await tick(120); - expect(shell.hasText('Keaton:')).toBe(true); - expect(shell.hasText('Fenster:')).toBe(true); - }); - - it('streaming content shows agent name', async () => { - shell.api().setStreamingContent({ - agentName: 'Hockney', - content: 'Working on the design...', - }); - await tick(120); - expect(shell.hasText('Hockney:')).toBe(true); - expect(shell.hasText('Working on the design...')).toBe(true); - }); - }); - - // ─── 6. /status shows which agent is currently working ───────────────── - - describe('/status shows active agent', () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - it('/status shows idle state when no agent is working', async () => { - await shell.submit('/status'); - expect(shell.hasText('0 active')).toBe(true); - }); - - it('/status shows working agent after setting status', async () => { - shell.registry.updateStatus('Keaton', 'working'); - await shell.submit('/status'); - expect(shell.hasText('Working')).toBe(true); - expect(shell.hasText('Keaton')).toBe(true); - }); - - it('/status shows agent activity hint', async () => { - shell.registry.updateStatus('Fenster', 'working'); - shell.registry.updateActivityHint('Fenster', 'writing tests'); - await shell.submit('/status'); - expect(shell.hasText('Fenster')).toBe(true); - expect(shell.hasText('writing tests')).toBe(true); - }); - - it('/status reflects multiple active agents', async () => { - shell.registry.updateStatus('Keaton', 'working'); - shell.registry.updateStatus('Fenster', 'streaming'); - await shell.submit('/status'); - expect(shell.hasText('2 active')).toBe(true); - }); - }); -}); diff --git a/test/journey-waiting-anxious.test.ts b/test/journey-waiting-anxious.test.ts deleted file mode 100644 index da6693fe6..000000000 --- a/test/journey-waiting-anxious.test.ts +++ /dev/null @@ -1,437 +0,0 @@ -/** - * Human Journey E2E Test — "I'm waiting and getting anxious" - * - * Validates the user experience while waiting for agent responses: - * thinking indicators, activity hints, streaming content, /status - * visibility, Ctrl+C cancellation, and recovery after cancel. - * - * @see https://github.com/bradygaster/squad-pr/issues/385 - */ - -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; -import React from 'react'; -import { render, type RenderResponse } from 'ink-testing-library'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import { App, type ShellApi } from '../packages/squad-cli/src/cli/shell/components/App.js'; -import type { ParsedInput } from '../packages/squad-cli/src/cli/shell/router.js'; - -const h = React.createElement; - -// ─── Test infrastructure (mirrors e2e-shell.test.ts) ──────────────────────── - -const TICK = 200; - -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -function tick(ms = TICK): Promise { - return new Promise(r => setTimeout(r, ms)); -} - -function scaffoldSquadDir(root: string): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — E2E Test Project - -> An end-to-end test project for shell integration. - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Keaton | Lead | \`.squad/agents/keaton/charter.md\` | ✅ Active | -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -`); - - writeFileSync(join(identityDir, 'now.md'), `--- -updated_at: 2025-01-01T00:00:00.000Z -focus_area: E2E test coverage -active_issues: [] ---- - -# What We're Focused On - -E2E test coverage. -`); -} - -interface ShellHarness { - ink: RenderResponse; - api: () => ShellApi; - type: (text: string) => Promise; - submit: (text: string) => Promise; - frame: () => string; - waitFor: (text: string, timeoutMs?: number) => Promise; - hasText: (text: string) => boolean; - raw: (bytes: string) => void; - dispatched: ReturnType; - cancelled: ReturnType; - registry: SessionRegistry; - cleanup: () => Promise; -} - -async function createShellHarness(opts?: { - agents?: Array<{ name: string; role: string }>; - withSquadDir?: boolean; - version?: string; -}): Promise { - const { - agents = [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - ], - withSquadDir = true, - version = '0.0.0-test', - } = opts ?? {}; - - const tempDir = mkdtempSync(join(tmpdir(), 'squad-e2e-')); - if (withSquadDir) scaffoldSquadDir(tempDir); - - const registry = new SessionRegistry(); - for (const a of agents) registry.register(a.name, a.role); - - const renderer = new ShellRenderer(); - const dispatched = vi.fn<(parsed: ParsedInput) => Promise>().mockResolvedValue(undefined); - const cancelled = vi.fn(); - - let shellApi: ShellApi | undefined; - const onReady = (api: ShellApi) => { shellApi = api; }; - - const ink = render( - h(App, { - registry, - renderer, - teamRoot: tempDir, - version, - onReady, - onDispatch: dispatched, - onCancel: cancelled, - }) - ); - - await tick(120); - - const harness: ShellHarness = { - ink, - api: () => { - if (!shellApi) throw new Error('ShellApi not ready — did the App mount?'); - return shellApi; - }, - async type(text: string) { - for (const ch of text) { - ink.stdin.write(ch); - await tick(); - } - }, - async submit(text: string) { - await harness.type(text); - ink.stdin.write('\r'); - await tick(120); - }, - frame() { - return stripAnsi(ink.lastFrame() ?? ''); - }, - async waitFor(text: string, timeoutMs = 3000) { - const deadline = Date.now() + timeoutMs; - while (Date.now() < deadline) { - if (harness.frame().includes(text)) return; - await tick(50); - } - throw new Error(`Timed out waiting for "${text}" in frame:\n${harness.frame()}`); - }, - hasText(text: string) { - return harness.frame().includes(text); - }, - raw(bytes: string) { - ink.stdin.write(bytes); - }, - dispatched, - cancelled, - registry, - async cleanup() { - ink.unmount(); - await rm(tempDir, { recursive: true, force: true }); - }, - }; - - return harness; -} - -// ═══════════════════════════════════════════════════════════════════════════ -// Journey: "I'm waiting and getting anxious" -// ═══════════════════════════════════════════════════════════════════════════ - -describe('Journey: I\'m waiting and getting anxious', { timeout: 30_000 }, () => { - let shell: ShellHarness; - - beforeEach(async () => { - vi.stubEnv('NO_COLOR', '1'); - Object.defineProperty(process.stdout, 'columns', { value: 120, configurable: true }); - shell = await createShellHarness(); - }); - - afterEach(async () => { - vi.unstubAllEnvs(); - await shell.cleanup(); - }); - - // ─── 1. Thinking indicator appears after submission ────────────────────── - - it('shows thinking indicator after submitting a message', async () => { - // Make dispatch hang so processing stays true - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('build the login page'); - await tick(300); - - expect(shell.hasText('Routing to agent')).toBe(true); - - resolve(); - await tick(120); - }); - - // ─── 2. Phase labels display correctly ─────────────────────────────────── - - it('shows "Routing to agent..." phase label by default', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('what should we build?'); - await tick(300); - - // In NO_COLOR mode ThinkingIndicator shows: "... Routing to agent..." - expect(shell.hasText('Routing to agent...')).toBe(true); - - resolve(); - await tick(120); - }); - - it('shows activity hint from @agent mention', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('@Keaton fix the build'); - await tick(300); - - // MessageStream resolves activity hint from @mention: "Keaton is thinking..." - expect(shell.hasText('Keaton is thinking...')).toBe(true); - - resolve(); - await tick(120); - }); - - // ─── 3. Activity hints for agents ──────────────────────────────────────── - - it('shows explicit activity hint pushed via ShellApi', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('refactor the auth module'); - await tick(200); - - shell.api().setActivityHint('Analyzing auth module...'); - await tick(200); - - expect(shell.hasText('Analyzing auth module...')).toBe(true); - - resolve(); - await tick(120); - }); - - it('shows agent activity in the activity feed', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('review the codebase'); - await tick(200); - - shell.api().setAgentActivity('Keaton', 'reading src/auth.ts'); - await tick(200); - - expect(shell.hasText('Keaton is reading src/auth.ts')).toBe(true); - - resolve(); - await tick(120); - }); - - // ─── 4. Streaming content updates ──────────────────────────────────────── - - it('shows streaming content from an agent', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('@Fenster write the tests'); - await tick(200); - - shell.api().setStreamingContent({ - agentName: 'Fenster', - content: 'I\'ll start by creating the test file...', - }); - await tick(200); - - expect(shell.hasText('Fenster:')).toBe(true); - expect(shell.hasText('I\'ll start by creating the test file...')).toBe(true); - - // Streaming indicator shows agent name - expect(shell.hasText('Fenster streaming')).toBe(true); - - resolve(); - await tick(120); - }); - - it('shows updated streaming content as it grows', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('@Fenster explain the architecture'); - await tick(200); - - shell.api().setStreamingContent({ - agentName: 'Fenster', - content: 'The system uses', - }); - await tick(200); - expect(shell.hasText('The system uses')).toBe(true); - - shell.api().setStreamingContent({ - agentName: 'Fenster', - content: 'The system uses a layered architecture with clean separation.', - }); - await tick(200); - expect(shell.hasText('layered architecture')).toBe(true); - - resolve(); - await tick(120); - }); - - // ─── 5. /status shows which agent is working ──────────────────────────── - - it('/status shows active agent status', async () => { - // Mark agent as working in the registry - shell.registry.updateStatus('Keaton', 'working'); - shell.api().refreshAgents(); - await tick(120); - - await shell.submit('/status'); - await tick(200); - - expect(shell.hasText('Keaton')).toBe(true); - expect(shell.hasText('1 active')).toBe(true); - }); - - it('/status shows activity hint for working agent', async () => { - shell.registry.updateStatus('Keaton', 'working'); - shell.registry.updateActivityHint('Keaton', 'editing src/main.ts'); - shell.api().refreshAgents(); - await tick(120); - - await shell.submit('/status'); - await tick(200); - - expect(shell.hasText('editing src/main.ts')).toBe(true); - }); - - // ─── 6. Ctrl+C cancels a long operation ────────────────────────────────── - - it('Ctrl+C during processing calls onCancel', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('build the entire app'); - await tick(200); - - // Ctrl+C while processing - shell.raw('\x03'); - await tick(120); - - expect(shell.cancelled).toHaveBeenCalledTimes(1); - - resolve(); - await tick(120); - }); - - it('shows cancellation message after Ctrl+C', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('run all the tests'); - await tick(200); - - shell.raw('\x03'); - await tick(120); - - // Simulate coordinator pushing cancel message (as the real system does) - shell.api().addMessage({ - role: 'system', - content: 'Operation cancelled.', - timestamp: new Date(), - }); - await tick(200); - - expect(shell.hasText('Operation cancelled')).toBe(true); - - resolve(); - await tick(120); - }); - - // ─── 7. After cancel, user can submit a new message ────────────────────── - - it('user can submit a new message after cancellation', async () => { - let resolve!: () => void; - shell.dispatched.mockImplementation(() => new Promise(r => { resolve = r; })); - - await shell.submit('deploy to production'); - await tick(200); - - // Cancel - shell.raw('\x03'); - await tick(120); - - // Resolve the pending dispatch so processing ends - resolve(); - await tick(200); - - // Reset mock to resolve immediately for the next submission - shell.dispatched.mockResolvedValue(undefined); - - await shell.submit('check the logs instead'); - await tick(200); - - expect(shell.dispatched).toHaveBeenCalledTimes(2); - expect(shell.hasText('check the logs instead')).toBe(true); - }); - - // ─── Bonus: AgentPanel shows active status for working agents ────────────── - - it('AgentPanel shows agent name when agent is working', async () => { - shell.registry.updateStatus('Keaton', 'working'); - shell.api().refreshAgents(); - await tick(200); - - expect(shell.hasText('Keaton')).toBe(true); - // Active agents show with pulsing dot and activity info, not [WORK] tag - expect(shell.hasText('working')).toBe(true); - }); - - it('AgentPanel shows agent name when agent is streaming', async () => { - shell.registry.updateStatus('Fenster', 'streaming'); - shell.api().refreshAgents(); - await tick(200); - - expect(shell.hasText('Fenster')).toBe(true); - // Active agents show with pulsing dot and activity info, not [STREAM] tag - expect(shell.hasText('working')).toBe(true); - }); -}); diff --git a/test/layout-anchoring.test.ts b/test/layout-anchoring.test.ts deleted file mode 100644 index 2c7e7eb91..000000000 --- a/test/layout-anchoring.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/** - * #674 / #675 — Layout and anchoring acceptance tests - * - * Validates that InputPrompt stays visible within the terminal viewport - * across all shell states, that long streaming content and large agent - * panels don't push the prompt off-screen, and that terminal resize - * triggers layout recalculation. - * - * 📌 Proactive: Written from requirements while implementation is in progress. - * Tests target App composition and component contracts. Some assertions may - * need adjustment once Kovash lands the anchoring implementation. - */ - -import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { InputPrompt } from '../packages/squad-cli/src/cli/shell/components/InputPrompt.js'; -import { AgentPanel } from '../packages/squad-cli/src/cli/shell/components/AgentPanel.js'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import type { ShellMessage, AgentSession } from '../packages/squad-cli/src/cli/shell/types.js'; - -const h = React.createElement; - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { timestamp: new Date(), ...overrides }; -} - -function makeAgent(overrides: Partial & { name: string }): AgentSession { - return { - role: 'core dev', - status: 'idle', - startedAt: new Date(), - ...overrides, - }; -} - -// ============================================================================ -// Helpers -// ============================================================================ - -/** Generate a long block of streaming content (simulates verbose agent output). */ -function generateLongContent(lines: number): string { - return Array.from({ length: lines }, (_, i) => `Line ${i + 1}: This is streaming content from the agent response.`).join('\n'); -} - -/** Generate many agents to test overflow behavior. */ -function generateManyAgents(count: number): AgentSession[] { - return Array.from({ length: count }, (_, i) => - makeAgent({ name: `Agent${i + 1}`, role: 'specialist', status: i % 3 === 0 ? 'working' : 'idle' }) - ); -} - -// ============================================================================ -// #675 — InputPrompt renders within terminal viewport -// ============================================================================ - -describe('#675 — InputPrompt viewport anchoring', () => { - it('InputPrompt renders within terminal viewport (not pushed off-screen)', () => { - const onSubmit = vi.fn(); - const { lastFrame } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - const frame = lastFrame()!; - // The prompt indicator should be visible - expect(frame).toContain('>'); - }); - - it('during startup state: InputPrompt visible', () => { - // Startup: no messages, not processing, empty agent list - const onSubmit = vi.fn(); - const { lastFrame } = render( - h(React.Fragment, null, - h(AgentPanel, { agents: [] }), - h(MessageStream, { - messages: [], - processing: false, - streamingContent: new Map(), - }), - h(InputPrompt, { onSubmit, disabled: false, messageCount: 0 }), - ) - ); - const frame = lastFrame()!; - // Prompt should be visible even with empty state - expect(frame).toContain('>'); - }); - - it('during streaming state: InputPrompt visible', () => { - // Streaming: processing=true, content flowing - const onSubmit = vi.fn(); - const { lastFrame } = render( - h(React.Fragment, null, - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'build the feature' })], - processing: true, - streamingContent: new Map([['Kovash', 'Working on the implementation...']]), - }), - h(InputPrompt, { onSubmit, disabled: true }), - ) - ); - const frame = lastFrame()!; - // Both the streaming content and prompt indicator should be present - expect(frame).toContain('Working on the implementation'); - // InputPrompt in disabled mode shows a spinner - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏>]/); - }); - - it('during idle state: InputPrompt visible', () => { - // Idle: conversation history exists, not processing - const onSubmit = vi.fn(); - const { lastFrame } = render( - h(React.Fragment, null, - h(MessageStream, { - messages: [ - makeMessage({ role: 'user', content: 'hello' }), - makeMessage({ role: 'agent', content: 'Hi there!', agentName: 'Kovash' }), - ], - processing: false, - streamingContent: new Map(), - }), - h(InputPrompt, { onSubmit, disabled: false, messageCount: 2 }), - ) - ); - const frame = lastFrame()!; - // Prompt should appear after conversation - expect(frame).toContain('>'); - expect(frame).toContain('Hi there!'); - }); -}); - -// ============================================================================ -// #674 — Long streaming content doesn't push InputPrompt below viewport -// ============================================================================ - -describe('#674 — Predictable scrolling / layout stability', () => { - it('long streaming content does not push InputPrompt below viewport', () => { - const longContent = generateLongContent(100); - const onSubmit = vi.fn(); - const { lastFrame } = render( - h(React.Fragment, null, - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'generate report' })], - processing: true, - streamingContent: new Map([['Fenster', longContent]]), - }), - h(InputPrompt, { onSubmit, disabled: true }), - ) - ); - const frame = lastFrame()!; - // The frame should contain content — no crash with large content - expect(frame.length).toBeGreaterThan(0); - // The streaming content should be present - expect(frame).toContain('Line 1:'); - // InputPrompt spinner or prompt indicator should still be in the frame - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏>]/); - }); - - it('AgentPanel with many agents does not overflow viewport', () => { - const manyAgents = generateManyAgents(20); - const { lastFrame } = render( - h(React.Fragment, null, - h(AgentPanel, { agents: manyAgents }), - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }), - ) - ); - const frame = lastFrame()!; - // Frame should render without crash - expect(frame.length).toBeGreaterThan(0); - // At least some agents should be visible - expect(frame).toContain('Agent1'); - // Prompt should still be present - expect(frame).toContain('>'); - }); - - it('terminal resize triggers layout recalculation', () => { - const onSubmit = vi.fn(); - // Set initial narrow width - const origColumns = process.stdout.columns; - Object.defineProperty(process.stdout, 'columns', { value: 60, writable: true, configurable: true }); - - const { lastFrame, rerender } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - const narrowFrame = lastFrame()!; - expect(narrowFrame).toContain('>'); - - // Simulate terminal resize to wider - Object.defineProperty(process.stdout, 'columns', { value: 120, writable: true, configurable: true }); - process.stdout.emit('resize'); - - // Re-render to pick up the width change - rerender(h(InputPrompt, { onSubmit, disabled: false })); - const wideFrame = lastFrame()!; - expect(wideFrame).toContain('>'); - - // Restore original columns - Object.defineProperty(process.stdout, 'columns', { value: origColumns ?? 80, writable: true, configurable: true }); - }); -}); diff --git a/test/multiline-paste.test.ts b/test/multiline-paste.test.ts deleted file mode 100644 index 0e784af9e..000000000 --- a/test/multiline-paste.test.ts +++ /dev/null @@ -1,315 +0,0 @@ -/** - * Multi-line paste handling tests - * - * Validates that multi-line pasted text is preserved correctly in the - * Squad REPL. Covers InputPrompt behavior (buffering, submit), and - * MessageStream rendering of multi-line user messages in scrollback. - * - * Bug context: Ink's useInput fires per-character. Newlines in pasted text - * trigger key.return which submits the first line, then disabled=true causes - * remaining newlines to be stripped — garbling multi-line pastes. - * - * @see packages/squad-cli/src/cli/shell/components/InputPrompt.tsx - * @see packages/squad-cli/src/cli/shell/components/MessageStream.tsx - */ - -import { describe, it, expect, vi } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import { InputPrompt } from '../packages/squad-cli/src/cli/shell/components/InputPrompt.js'; -import type { ShellMessage, AgentSession } from '../packages/squad-cli/src/cli/shell/types.js'; - -// ============================================================================ -// Helpers (same pattern as repl-ux.test.ts) -// ============================================================================ - -const h = React.createElement; - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { timestamp: new Date(), ...overrides }; -} - -function makeAgent(overrides: Partial & { name: string }): AgentSession { - return { - role: 'core dev', - status: 'idle', - startedAt: new Date(), - ...overrides, - }; -} - -// ============================================================================ -// 1. Multi-line message rendering in scrollback (MessageStream) -// ============================================================================ - -describe('Multi-line paste handling', () => { - describe('MessageStream renders multi-line content', () => { - it('preserves newlines in user messages rendered in scrollback', () => { - const multiLineContent = 'line one\nline two\nline three'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: multiLineContent })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('line one'); - expect(frame).toContain('line two'); - expect(frame).toContain('line three'); - }); - - it('preserves blank lines within multi-line user messages', () => { - const contentWithBlanks = 'first paragraph\n\nsecond paragraph\n\nthird paragraph'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: contentWithBlanks })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('first paragraph'); - expect(frame).toContain('second paragraph'); - expect(frame).toContain('third paragraph'); - }); - - it('renders multi-line agent messages with proper indentation in scrollback', () => { - const multiLineAgent = 'Here is my analysis:\n- Point A\n- Point B\n- Point C'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'agent', content: multiLineAgent, agentName: 'Kovash' })], - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('Here is my analysis:'); - expect(frame).toContain('- Point A'); - expect(frame).toContain('- Point B'); - expect(frame).toContain('- Point C'); - }); - - it('does not garble text when lines contain special characters', () => { - const specialContent = 'const x = { a: 1 };\nif (x > 0) { return true; }\n// comment with @mention'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: specialContent })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('const x = { a: 1 };'); - expect(frame).toContain('if (x > 0) { return true; }'); - expect(frame).toContain('// comment with @mention'); - }); - - it('does not concatenate lines when rendering multi-line user input', () => { - const content = 'hello world\ngoodbye world'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: content })], - }) - ); - const frame = lastFrame()!; - // Text should NOT be concatenated into "hello worldgoodbye world" - expect(frame).not.toContain('hello worldgoodbye world'); - }); - - it('multi-line streaming content renders all lines with cursor', () => { - const streamedMultiLine = 'Step 1: Analyze\nStep 2: Implement\nStep 3: Test'; - const { lastFrame } = render( - h(MessageStream, { - messages: [], - streamingContent: new Map([['Kovash', streamedMultiLine]]), - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Step 1: Analyze'); - expect(frame).toContain('Step 2: Implement'); - expect(frame).toContain('Step 3: Test'); - expect(frame).toContain('▌'); - }); - }); - - // ============================================================================ - // 2. Single-line submit still works (InputPrompt) - // ============================================================================ - - describe('Single-line submit behavior', () => { - it('submits single-line input immediately on Enter', async () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'hello world') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - expect(onSubmit).toHaveBeenCalledWith('hello world'); - }); - - it('clears input field after single-line submit', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'test') stdin.write(ch); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 200)); - // After submit, the input field should clear the submitted text - // The prompt character (◆ squad>) may remain - const frame = lastFrame()!; - // If onSubmit was called, the component should have cleared - expect(onSubmit).toHaveBeenCalledWith('test'); - }); - - it('does not submit whitespace-only input on Enter', async () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - stdin.write(' '); - stdin.write(' '); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - expect(onSubmit).not.toHaveBeenCalled(); - }); - }); - - // ============================================================================ - // 3. Disabled-state buffering with newlines (InputPrompt) - // ============================================================================ - - describe('Disabled-state buffering with newlines', () => { - it('buffers typed characters while disabled and restores on re-enable', () => { - const onSubmit = vi.fn(); - const { stdin, lastFrame, rerender } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - // Type characters while disabled - stdin.write('b'); - stdin.write('u'); - stdin.write('f'); - - // Re-enable the prompt - rerender(h(InputPrompt, { onSubmit, disabled: false })); - rerender(h(InputPrompt, { onSubmit, disabled: false })); - - // Buffered text should not auto-submit - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('ignores return key while disabled (no premature submit)', () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('hello'); - stdin.write('\r'); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('shows buffered text in disabled display', async () => { - const onSubmit = vi.fn(); - const { stdin, lastFrame } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('t'); - stdin.write('e'); - stdin.write('s'); - stdin.write('t'); - await new Promise(r => setTimeout(r, 50)); - const frame = lastFrame()!; - // InputPrompt shows bufferDisplay when disabled and buffer is non-empty - expect(frame).toContain('test'); - }); - - it('backspace in disabled mode removes last buffered character', async () => { - const onSubmit = vi.fn(); - const { stdin, lastFrame } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('a'); - stdin.write('b'); - stdin.write('c'); - stdin.write('\x7f'); // backspace - await new Promise(r => setTimeout(r, 50)); - const frame = lastFrame()!; - expect(frame).toContain('ab'); - expect(frame).not.toContain('abc'); - }); - }); - - // ============================================================================ - // 4. Multi-line content integrity in ShellMessage - // ============================================================================ - - describe('Multi-line content integrity in ShellMessage', () => { - it('ShellMessage content field preserves embedded newlines', () => { - const msg = makeMessage({ - role: 'user', - content: 'line 1\nline 2\nline 3', - }); - expect(msg.content).toBe('line 1\nline 2\nline 3'); - expect(msg.content.split('\n')).toHaveLength(3); - }); - - it('ShellMessage content field preserves blank lines', () => { - const msg = makeMessage({ - role: 'user', - content: 'paragraph 1\n\nparagraph 2\n\nparagraph 3', - }); - const lines = msg.content.split('\n'); - expect(lines).toHaveLength(5); - expect(lines[1]).toBe(''); - expect(lines[3]).toBe(''); - }); - - it('ShellMessage content field preserves Windows-style CRLF', () => { - const msg = makeMessage({ - role: 'user', - content: 'line 1\r\nline 2\r\nline 3', - }); - // Content should retain CRLF as-is (normalization is a separate concern) - expect(msg.content).toContain('\r\n'); - expect(msg.content.split('\r\n')).toHaveLength(3); - }); - - it('multi-line user message displays all lines in MessageStream', () => { - const multiLine = 'function greet() {\n console.log("hello");\n return true;\n}'; - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: multiLine })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('function greet()'); - expect(frame).toContain('console.log'); - expect(frame).toContain('return true;'); - }); - - it('mixed single-line and multi-line messages in conversation render correctly', () => { - const messages = [ - makeMessage({ role: 'user', content: 'what does this do?' }), - makeMessage({ - role: 'agent', - content: 'Here is the explanation:\n1. First step\n2. Second step', - agentName: 'Kovash', - }), - makeMessage({ role: 'user', content: 'and this code?\nfunction foo() {\n return 42;\n}' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('what does this do?'); - expect(frame).toContain('1. First step'); - expect(frame).toContain('2. Second step'); - expect(frame).toContain('function foo()'); - expect(frame).toContain('return 42;'); - }); - }); -}); diff --git a/test/regression-368.test.ts b/test/regression-368.test.ts deleted file mode 100644 index 10ab0843e..000000000 --- a/test/regression-368.test.ts +++ /dev/null @@ -1,275 +0,0 @@ -/** - * Regression tests: Ghost retry (real timers), error boundaries, ThinkingIndicator integration. - * - * Filed as part of #368 stale-test fix sweep. - * Covers the three biggest regression gaps identified in quality review: - * 1. Ghost retry under real-ish conditions (not just vi.useFakeTimers) - * 2. Error handling paths — components receiving unexpected/missing props - * 3. ThinkingIndicator + animation sequencing - */ - -import { describe, it, expect, vi } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { - withGhostRetry, -} from '../packages/squad-cli/src/cli/shell/index.js'; -import { - ThinkingIndicator, - THINKING_PHRASES, -} from '../packages/squad-cli/src/cli/shell/components/ThinkingIndicator.js'; -import { AgentPanel } from '../packages/squad-cli/src/cli/shell/components/AgentPanel.js'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; - -const h = React.createElement; - -// ============================================================================ -// 1. Ghost retry — real timer integration -// ============================================================================ - -describe('Ghost retry — real timer integration', () => { - it('retries with actual delays (short backoff)', async () => { - const calls: number[] = []; - const sendFn = vi.fn(async () => { - calls.push(Date.now()); - return calls.length < 3 ? '' : 'recovered'; - }); - - const result = await withGhostRetry(sendFn, { - maxRetries: 3, - backoffMs: [10, 20, 40], - }); - - expect(result).toBe('recovered'); - expect(sendFn).toHaveBeenCalledTimes(3); - expect(calls[1]! - calls[0]!).toBeGreaterThanOrEqual(5); - expect(calls[2]! - calls[1]!).toBeGreaterThanOrEqual(10); - }); - - it('calls onRetry with correct attempt numbers under real timers', async () => { - const onRetry = vi.fn(); - const sendFn = vi.fn() - .mockResolvedValueOnce('') - .mockResolvedValueOnce('') - .mockResolvedValueOnce('ok'); - - await withGhostRetry(sendFn, { - maxRetries: 3, - backoffMs: [5, 5, 5], - onRetry, - }); - - expect(onRetry).toHaveBeenCalledTimes(2); - expect(onRetry).toHaveBeenCalledWith(1, 3); - expect(onRetry).toHaveBeenCalledWith(2, 3); - }); - - it('calls onExhausted after all retries fail under real timers', async () => { - const onExhausted = vi.fn(); - const sendFn = vi.fn().mockResolvedValue(''); - - const result = await withGhostRetry(sendFn, { - maxRetries: 2, - backoffMs: [5, 5], - onExhausted, - }); - - expect(result).toBe(''); - expect(sendFn).toHaveBeenCalledTimes(3); - expect(onExhausted).toHaveBeenCalledWith(2); - }); - - it('handles sendFn that throws on some attempts', async () => { - let attempt = 0; - const sendFn = vi.fn(async () => { - attempt++; - if (attempt === 1) throw new Error('network timeout'); - return 'recovered'; - }); - - await expect(withGhostRetry(sendFn, { backoffMs: [5] })) - .rejects.toThrow('network timeout'); - }); - - it('handles sendFn returning falsy values (null, undefined, 0)', async () => { - const sendFn = vi.fn() - .mockResolvedValueOnce(null) - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce(0) - .mockResolvedValueOnce('finally'); - - const result = await withGhostRetry(sendFn, { - maxRetries: 4, - backoffMs: [5, 5, 5, 5], - }); - - expect(result).toBe('finally'); - expect(sendFn).toHaveBeenCalledTimes(4); - }); - - it('debugLog receives ghost detection messages', async () => { - const logs: unknown[][] = []; - const sendFn = vi.fn() - .mockResolvedValueOnce('') - .mockResolvedValueOnce('ok'); - - await withGhostRetry(sendFn, { - maxRetries: 2, - backoffMs: [5], - debugLog: (...args: unknown[]) => logs.push(args), - promptPreview: 'What is the build status?', - }); - - expect(logs.length).toBe(1); - expect(logs[0]![0]).toBe('ghost response detected'); - const meta = logs[0]![1] as Record; - expect(meta.attempt).toBe(1); - expect(meta.promptPreview).toContain('build status'); - }); -}); - -// ============================================================================ -// 2. Error handling — components with unexpected props -// ============================================================================ - -describe('Error handling — component resilience', () => { - it('AgentPanel handles agents with minimal properties', () => { - const agents = [ - { name: 'TestAgent', role: '', status: 'idle' as const, startedAt: new Date() }, - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()).toBeDefined(); - }); - - it('AgentPanel handles agent with empty name', () => { - const agents = [ - { name: '', role: 'dev', status: 'idle' as const, startedAt: new Date() }, - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()).toBeDefined(); - }); - - it('MessageStream handles empty messages array', () => { - const { lastFrame } = render( - h(MessageStream, { messages: [], processing: false }) - ); - expect(lastFrame()).toBeDefined(); - }); - - it('MessageStream handles messages with empty content', () => { - const messages = [ - { role: 'assistant', content: '' }, - { role: 'user', content: '' }, - ]; - const { lastFrame } = render( - h(MessageStream, { messages: messages as any, processing: false }) - ); - expect(lastFrame()).toBeDefined(); - }); - - it('AgentPanel with many agents renders all names', () => { - const agents = Array.from({ length: 10 }, (_, i) => ({ - name: `Agent${i}`, - role: 'dev', - status: (i % 2 === 0 ? 'idle' : 'working') as 'idle' | 'working', - startedAt: new Date(), - })); - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Agent0'); - expect(frame).toContain('Agent9'); - }); - - it('ThinkingIndicator handles extreme elapsedMs values', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 999999 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('999s'); - }); -}); - -// ============================================================================ -// 3. ThinkingIndicator — animation sequencing -// ============================================================================ - -describe('ThinkingIndicator — animation sequencing', () => { - it('returns null when not thinking', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: false, elapsedMs: 0 }) - ); - expect(lastFrame()!).toBe(''); - }); - - it('renders spinner when thinking starts', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - expect(frame.length).toBeGreaterThan(0); - }); - - it('shows default routing label by default', () => { - process.env.NO_COLOR = '1'; - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Routing to agent...'); - delete process.env.NO_COLOR; - }); - - it('shows activity hint when provided', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0, activityHint: 'Searching files...' }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Searching files...'); - }); - - it('shows elapsed time after 1 second', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 3500 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('3s'); - }); - - it('does not show elapsed time under 1 second', () => { - process.env.NO_COLOR = '1'; - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 500 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Routing to agent...'); - expect(frame).not.toContain('0s'); - delete process.env.NO_COLOR; - }); - - it('transitions from thinking to not-thinking cleanly', async () => { - const { lastFrame, rerender } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 1000 }) - ); - expect(lastFrame()!.length).toBeGreaterThan(0); - rerender(h(ThinkingIndicator, { isThinking: false, elapsedMs: 1000 })); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toBe(''); - }); - - it('activity hint overrides default label', () => { - process.env.NO_COLOR = '1'; - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0, activityHint: 'Running tests' }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Running tests'); - expect(frame).not.toContain('Routing to agent...'); - delete process.env.NO_COLOR; - }); - - it('THINKING_PHRASES export is available for backward compat', () => { - expect(THINKING_PHRASES).toBeDefined(); - expect(THINKING_PHRASES.length).toBeGreaterThan(0); - expect(THINKING_PHRASES[0]).toBe('Routing to agent'); - }); -}); diff --git a/test/remote-control.test.ts b/test/remote-control.test.ts deleted file mode 100644 index fcadc33f0..000000000 --- a/test/remote-control.test.ts +++ /dev/null @@ -1,1323 +0,0 @@ -/** - * Tests for Squad Remote Control - * - RemoteBridge: WebSocket server, history, passthrough, sessions API - * - Protocol: serialization, parsing - * - Security: auth, rate limiting, session expiry, connection limits - * - Secret redaction: 27 patterns, ANSI bypass, NFKC normalization - * - PTY integration: resize, input forwarding - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import WebSocket from 'ws'; -import http from 'http'; -import os from 'node:os'; -import path from 'node:path'; - -// Import from built SDK -import { - RemoteBridge, - RC_PROTOCOL_VERSION, - serializeEvent, - parseCommand, -} from '@bradygaster/squad-sdk'; - -describe('Protocol', () => { - it('serializes events to JSON', () => { - const event = { type: 'status' as const, version: '1.0', repo: 'test', branch: 'main', machine: 'PC', squadDir: '.squad', connectedAt: '2026-01-01' }; - const json = serializeEvent(event); - expect(JSON.parse(json)).toEqual(event); - }); - - it('parses valid commands', () => { - const cmd = parseCommand('{"type":"prompt","text":"hello"}'); - expect(cmd).toEqual({ type: 'prompt', text: 'hello' }); - }); - - it('parses direct commands', () => { - const cmd = parseCommand('{"type":"direct","agentName":"Worf","text":"test"}'); - expect(cmd).toEqual({ type: 'direct', agentName: 'Worf', text: 'test' }); - }); - - it('returns null for invalid JSON', () => { - expect(parseCommand('not json')).toBeNull(); - }); - - it('returns null for missing type', () => { - expect(parseCommand('{"foo":"bar"}')).toBeNull(); - }); -}); - -describe('RemoteBridge', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(async () => { - bridge = new RemoteBridge(config); - }); - - afterEach(async () => { - await bridge.stop(); - }); - - it('starts and stops cleanly', async () => { - const port = await bridge.start(); - expect(port).toBeGreaterThan(0); - expect(bridge.getState()).toBe('running'); - await bridge.stop(); - expect(bridge.getState()).toBe('stopped'); - }); - - it('accepts WebSocket connections', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise((resolve) => ws.on('open', resolve)); - expect(bridge.getConnectionCount()).toBe(1); - ws.close(); - await new Promise(r => setTimeout(r, 100)); - expect(bridge.getConnectionCount()).toBe(0); - }); - - it('sends initial state on connect', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', (data) => messages.push(JSON.parse(data.toString()))); - await new Promise((resolve) => ws.on('open', resolve)); - await new Promise(r => setTimeout(r, 300)); - - // Should receive _replay_done (passthrough mode) or status/history/agents - expect(messages.length).toBeGreaterThan(0); - ws.close(); - }); - - it('broadcasts messages to all clients', async () => { - const port = await bridge.start(); - const ws1 = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const ws2 = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await Promise.all([ - new Promise(r => ws1.on('open', r)), - new Promise(r => ws2.on('open', r)), - ]); - - const msgs1: any[] = []; - const msgs2: any[] = []; - ws1.on('message', d => msgs1.push(JSON.parse(d.toString()))); - ws2.on('message', d => msgs2.push(JSON.parse(d.toString()))); - await new Promise(r => setTimeout(r, 300)); - - bridge.addMessage('agent', 'Hello!', 'Picard'); - await new Promise(r => setTimeout(r, 200)); - - const complete1 = msgs1.find(m => m.type === 'complete'); - const complete2 = msgs2.find(m => m.type === 'complete'); - expect(complete1?.message?.content).toBe('Hello!'); - expect(complete2?.message?.content).toBe('Hello!'); - - ws1.close(); - ws2.close(); - }); - - it('maintains message history', async () => { - await bridge.start(); - bridge.addMessage('user', 'msg1'); - bridge.addMessage('agent', 'msg2', 'Worf'); - bridge.addMessage('system', 'msg3'); - - const history = bridge.getMessageHistory(); - expect(history).toHaveLength(3); - expect(history[0].content).toBe('msg1'); - expect(history[1].agentName).toBe('Worf'); - }); - - it('caps history at maxHistory', async () => { - bridge = new RemoteBridge({ ...config, maxHistory: 3 }); - await bridge.start(); - for (let i = 0; i < 10; i++) { - bridge.addMessage('user', `msg${i}`); - } - expect(bridge.getMessageHistory()).toHaveLength(3); - expect(bridge.getMessageHistory()[0].content).toBe('msg7'); - }); - - it('sends streaming deltas', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.sendDelta('sess-1', 'Picard', 'Hello '); - bridge.sendDelta('sess-1', 'Picard', 'world!'); - await new Promise(r => setTimeout(r, 200)); - - const deltas = messages.filter(m => m.type === 'delta'); - expect(deltas).toHaveLength(2); - expect(deltas[0].content).toBe('Hello '); - expect(deltas[1].content).toBe('world!'); - ws.close(); - }); - - it('updates agent roster', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.updateAgents([ - { name: 'Picard', role: 'Lead', status: 'idle' }, - { name: 'Worf', role: 'QA', status: 'streaming' }, - ]); - await new Promise(r => setTimeout(r, 200)); - - const agentEvents = messages.filter(m => m.type === 'agents'); - const latest = agentEvents[agentEvents.length - 1]; - expect(latest.agents).toHaveLength(2); - expect(latest.agents[1].name).toBe('Worf'); - ws.close(); - }); - - it('handles passthrough mode', async () => { - const port = await bridge.start(); - const received: string[] = []; - - bridge.setPassthrough((msg) => received.push(msg)); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send('{"type":"pty_input","data":"hello"}'); - await new Promise(r => setTimeout(r, 200)); - - expect(received.length).toBeGreaterThan(0); - const ptyMsg = received.find(r => r.includes('pty_input')); - expect(ptyMsg).toBeDefined(); - ws.close(); - }); - - it('records and replays ACP events', async () => { - bridge = new RemoteBridge({ ...config, enableReplay: true }); - const port = await bridge.start(); - bridge.setPassthrough(() => {}); // Enable passthrough mode - - // Record some events - bridge.passthroughFromAgent('{"type":"pty","data":"hello"}'); - bridge.passthroughFromAgent('{"type":"pty","data":"world"}'); - - // New client should get replay - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 500)); - - const replays = messages.filter(m => m.type === '_replay'); - expect(replays.length).toBe(2); - const done = messages.find(m => m.type === '_replay_done'); - expect(done).toBeDefined(); - ws.close(); - }); - - it('handles ping/pong', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ type: 'ping' })); - await new Promise(r => setTimeout(r, 200)); - - const pong = messages.find(m => m.type === 'pong'); - expect(pong).toBeDefined(); - ws.close(); - }); - - it('serves HTTP requests via static handler', async () => { - bridge.setStaticHandler((_req, res) => { - res.writeHead(200, { 'Content-Type': 'text/plain' }); - res.end('test-content'); - }); - - const port = await bridge.start(); - const response = await new Promise<{ status: number; body: string }>((resolve) => { - http.get(`http://localhost:${port}/`, (res) => { - let body = ''; - res.on('data', d => body += d); - res.on('end', () => resolve({ status: res.statusCode!, body })); - }); - }); - - expect(response.status).toBe(200); - expect(response.body).toBe('test-content'); - }); - - it('injects cwd into session/new in passthrough mode', async () => { - const port = await bridge.start(); - const forwarded: string[] = []; - bridge.setPassthrough((msg) => forwarded.push(msg)); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'session/new', params: { cwd: '.', mcpServers: [] } })); - await new Promise(r => setTimeout(r, 200)); - - const sessionNew = forwarded.find(f => f.includes('session/new')); - expect(sessionNew).toBeDefined(); - // cwd should have been replaced (not ".") - const parsed = JSON.parse(sessionNew!); - expect(parsed.params.cwd).not.toBe('.'); - ws.close(); - }); -}); - -// ─── Security Tests ────────────────────────────────────────── - -describe('Security — Authentication', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('rejects WebSocket connections without token', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/`); - const closePromise = new Promise((resolve) => { - ws.on('close', (code) => resolve(code)); - ws.on('error', () => resolve(-1)); - }); - // Should be rejected (unexpected response or close) - const result = await Promise.race([ - closePromise, - new Promise((resolve) => ws.on('open', () => resolve('opened'))), - ]); - expect(result).not.toBe('opened'); - }); - - it('rejects WebSocket connections with wrong token', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=wrong-token-value`); - const closePromise = new Promise((resolve) => { - ws.on('close', (code) => resolve(code)); - ws.on('error', () => resolve(-1)); - }); - const result = await Promise.race([ - closePromise, - new Promise((resolve) => ws.on('open', () => resolve('opened'))), - ]); - expect(result).not.toBe('opened'); - }); - - it('issues and accepts one-time tickets via /api/auth/ticket', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - - // Get a ticket - const ticketRes = await new Promise<{ status: number; body: any }>((resolve) => { - const req = http.request(`http://localhost:${port}/api/auth/ticket`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - }, (res) => { - let body = ''; - res.on('data', d => body += d); - res.on('end', () => resolve({ status: res.statusCode!, body: JSON.parse(body) })); - }); - req.end(); - }); - - expect(ticketRes.status).toBe(200); - expect(ticketRes.body.ticket).toBeDefined(); - - // Connect with the ticket - const ws = new WebSocket(`ws://localhost:${port}/?ticket=${ticketRes.body.ticket}`); - await new Promise((resolve) => ws.on('open', resolve)); - expect(bridge.getConnectionCount()).toBe(1); - ws.close(); - }); - - it('rejects ticket reuse (single-use tickets)', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - - // Get a ticket - const ticketRes = await new Promise<{ status: number; body: any }>((resolve) => { - const req = http.request(`http://localhost:${port}/api/auth/ticket`, { - method: 'POST', - headers: { 'Authorization': `Bearer ${token}` }, - }, (res) => { - let body = ''; - res.on('data', d => body += d); - res.on('end', () => resolve({ status: res.statusCode!, body: JSON.parse(body) })); - }); - req.end(); - }); - - const ticket = ticketRes.body.ticket; - - // Use the ticket once - const ws1 = new WebSocket(`ws://localhost:${port}/?ticket=${ticket}`); - await new Promise((resolve) => ws1.on('open', resolve)); - ws1.close(); - await new Promise(r => setTimeout(r, 100)); - - // Try to reuse the same ticket - const ws2 = new WebSocket(`ws://localhost:${port}/?ticket=${ticket}`); - const result = await Promise.race([ - new Promise((resolve) => { - ws2.on('close', () => resolve('closed')); - ws2.on('error', () => resolve('error')); - }), - new Promise((resolve) => ws2.on('open', () => resolve('opened'))), - ]); - expect(result).not.toBe('opened'); - }); - - it('rejects /api/auth/ticket without valid bearer token', async () => { - const port = await bridge.start(); - - const res = await new Promise<{ status: number }>((resolve) => { - const req = http.request(`http://localhost:${port}/api/auth/ticket`, { - method: 'POST', - headers: { 'Authorization': 'Bearer wrong-token' }, - }, (res) => { - let body = ''; - res.on('data', d => body += d); - res.on('end', () => resolve({ status: res.statusCode! })); - }); - req.end(); - }); - - expect(res.status).toBe(401); - }); - - it('rejects API requests without authentication', async () => { - const port = await bridge.start(); - - const res = await new Promise<{ status: number; body: any }>((resolve) => { - http.get(`http://localhost:${port}/api/sessions`, (res) => { - let body = ''; - res.on('data', d => body += d); - res.on('end', () => resolve({ status: res.statusCode!, body: JSON.parse(body) })); - }); - }); - - expect(res.status).toBe(401); - expect(res.body.error).toBe('Unauthorized'); - }); -}); - -describe('Security — Connection Limits', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('enforces per-IP connection limit of 2', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - - // Open 2 connections (should succeed) - const ws1 = new WebSocket(`ws://localhost:${port}/?token=${token}`); - const ws2 = new WebSocket(`ws://localhost:${port}/?token=${token}`); - await Promise.all([ - new Promise(r => ws1.on('open', r)), - new Promise(r => ws2.on('open', r)), - ]); - - expect(bridge.getConnectionCount()).toBe(2); - - // 3rd connection from same IP should be rejected - const ws3 = new WebSocket(`ws://localhost:${port}/?token=${token}`); - const result = await Promise.race([ - new Promise((resolve) => { - ws3.on('close', () => resolve('closed')); - ws3.on('error', () => resolve('error')); - }), - new Promise((resolve) => ws3.on('open', () => { - // Give it a moment to be closed by the server - setTimeout(() => resolve('opened'), 200); - })), - ]); - - // It might open briefly then close, or be rejected outright - await new Promise(r => setTimeout(r, 200)); - // The count should not exceed 2 (3rd was either rejected or closed) - expect(bridge.getConnectionCount()).toBeLessThanOrEqual(2); - - ws1.close(); - ws2.close(); - ws3.close(); - }); - - it('decrements IP count when connections close', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - - const ws1 = new WebSocket(`ws://localhost:${port}/?token=${token}`); - await new Promise(r => ws1.on('open', r)); - expect(bridge.getConnectionCount()).toBe(1); - - ws1.close(); - await new Promise(r => setTimeout(r, 200)); - expect(bridge.getConnectionCount()).toBe(0); - - // Should be able to connect again after closing - const ws2 = new WebSocket(`ws://localhost:${port}/?token=${token}`); - await new Promise(r => ws2.on('open', r)); - expect(bridge.getConnectionCount()).toBe(1); - ws2.close(); - }); -}); - -describe('Security — HTTP Rate Limiting', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('returns 429 after 30 requests per minute from same IP', async () => { - const port = await bridge.start(); - - const makeRequest = () => new Promise((resolve) => { - http.get(`http://localhost:${port}/`, (res) => { - res.resume(); - resolve(res.statusCode!); - }); - }); - - // Make 31 rapid requests - const results: number[] = []; - for (let i = 0; i < 31; i++) { - results.push(await makeRequest()); - } - - // First 30 should succeed (200), 31st should be 429 - expect(results.slice(0, 30).every(s => s === 200)).toBe(true); - expect(results[30]).toBe(429); - }); -}); - -describe('Security — Origin Validation', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('accepts connections from localhost origin', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`, { - headers: { 'Origin': 'http://localhost:3456' }, - }); - await new Promise(r => ws.on('open', r)); - expect(bridge.getConnectionCount()).toBe(1); - ws.close(); - }); - - it('accepts connections from devtunnels.ms origin', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`, { - headers: { 'Origin': 'https://abc-3456.euw.devtunnels.ms' }, - }); - await new Promise(r => ws.on('open', r)); - expect(bridge.getConnectionCount()).toBe(1); - ws.close(); - }); - - it('rejects connections from unknown origins', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`, { - headers: { 'Origin': 'https://evil.com' }, - }); - const result = await Promise.race([ - new Promise((resolve) => { - ws.on('close', () => resolve('closed')); - ws.on('error', () => resolve('error')); - }), - new Promise((resolve) => ws.on('open', () => resolve('opened'))), - ]); - expect(result).not.toBe('opened'); - }); - - it('accepts connections with no origin header', async () => { - const port = await bridge.start(); - const token = (bridge as any).getSessionToken(); - // WebSocket without Origin header (some clients don't send it) - const ws = new WebSocket(`ws://localhost:${port}/?token=${token}`); - await new Promise(r => ws.on('open', r)); - expect(bridge.getConnectionCount()).toBe(1); - ws.close(); - }); -}); - -describe('Security — ACP Method Allowlist', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('forwards allowed ACP methods in passthrough mode', async () => { - const port = await bridge.start(); - const forwarded: string[] = []; - bridge.setPassthrough((msg) => forwarded.push(msg)); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - // Send an allowed method - ws.send(JSON.stringify({ jsonrpc: '2.0', id: 1, method: 'session/prompt', params: { text: 'hello' } })); - await new Promise(r => setTimeout(r, 200)); - - expect(forwarded.some(f => f.includes('session/prompt'))).toBe(true); - ws.close(); - }); - - it('drops disallowed ACP methods in passthrough mode', async () => { - const port = await bridge.start(); - const forwarded: string[] = []; - bridge.setPassthrough((msg) => forwarded.push(msg)); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - // Send a disallowed method (e.g., session/delete, tools/execute) - ws.send(JSON.stringify({ jsonrpc: '2.0', id: 2, method: 'tools/execute', params: { tool: 'rm -rf' } })); - await new Promise(r => setTimeout(r, 200)); - - expect(forwarded.some(f => f.includes('tools/execute'))).toBe(false); - ws.close(); - }); - - it('allows ACP responses (result/error) regardless of method', async () => { - const port = await bridge.start(); - const forwarded: string[] = []; - bridge.setPassthrough((msg) => forwarded.push(msg)); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - // Send a response (has result field) — should be forwarded - ws.send(JSON.stringify({ jsonrpc: '2.0', id: 3, result: { ok: true }, method: 'some/blocked/method' })); - await new Promise(r => setTimeout(r, 200)); - - expect(forwarded.some(f => f.includes('some/blocked/method'))).toBe(true); - ws.close(); - }); -}); - -// ─── Secret Redaction Tests ────────────────────────────────── - -describe('Secret Redaction (via replay)', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - enableReplay: true, - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - // Helper: record event, connect new client, get replayed data - async function getReplayedContent(event: string): Promise { - const port = await bridge.start(); - bridge.setPassthrough(() => {}); - - bridge.passthroughFromAgent(event); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => { - try { messages.push(JSON.parse(d.toString())); } catch { /* raw non-JSON data from replay */ } - }); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 500)); - ws.close(); - - const replay = messages.find(m => m.type === '_replay'); - return replay?.data || ''; - } - - it('redacts OpenAI API keys (sk-...)', async () => { - // Standalone (no preceding "key:" which triggers the generic pattern first) - const result = await getReplayedContent('Found sk-abcdefghijklmnopqrstuvwxyz1234567890 in config'); - expect(result).toContain('[REDACTED-OPENAI]'); - expect(result).not.toContain('sk-abcdefghijklmnopqrstuvwxyz'); - }); - - it('redacts GitHub PATs (ghp_...)', async () => { - // Standalone (no preceding "token:" which triggers the generic pattern first) - const result = await getReplayedContent('Found ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklm in env'); - expect(result).toContain('[REDACTED-GITHUB]'); - expect(result).not.toContain('ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZ'); - }); - - it('redacts AWS access keys (AKIA...)', async () => { - const result = await getReplayedContent('access: AKIAIOSFODNN7EXAMPLE'); - expect(result).toContain('[REDACTED-AWS]'); - expect(result).not.toContain('AKIAIOSFODNN7EXAMPLE'); - }); - - it('redacts Azure connection strings', async () => { - // The generic key=value pattern may catch "AccountKey" before the Azure-specific pattern, - // but the secret is still redacted (the actual value is not exposed) - const result = await getReplayedContent('conn=DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=abc123def456ghi;EndpointSuffix=core.windows.net'); - expect(result).not.toContain('abc123def456ghi'); - // Either [REDACTED-AZURE-CONN] or generic [REDACTED] — both protect the secret - expect(result).toMatch(/\[REDACTED/); - }); - - it('redacts database URLs', async () => { - // Use "postgres://" (not "postgresql://") to match the regex pattern - const pg = await getReplayedContent('postgres://user:pass@host:5432/db'); - expect(pg).toContain('[REDACTED-DB-URL]'); - - const mongo = await getReplayedContent('mongodb://admin:secret@mongo.host/mydb'); - expect(mongo).toContain('[REDACTED-DB-URL]'); - }); - - it('redacts Bearer tokens', async () => { - const result = await getReplayedContent('Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.test.signature'); - expect(result).toContain('[REDACTED-BEARER]'); - }); - - it('redacts JWT tokens', async () => { - const result = await getReplayedContent('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U'); - expect(result).toContain('[REDACTED-JWT]'); - }); - - it('redacts Slack tokens', async () => { - const result = await getReplayedContent('xoxb-12345678901-1234567890123-AbCdEfGhIjKl'); - expect(result).toContain('[REDACTED-SLACK]'); - }); - - it('redacts npm tokens', async () => { - const result = await getReplayedContent('npm_abcdefghijklmnopqrstuvwxyz'); - expect(result).toContain('[REDACTED-NPM]'); - }); - - it('redacts GitLab PATs', async () => { - const result = await getReplayedContent('glpat-ABCDEFGHIJKLMNOPqrstuv'); - expect(result).toContain('[REDACTED-GITLAB]'); - }); - - it('redacts HashiCorp Vault tokens', async () => { - const result = await getReplayedContent('hvs.CAESIJLyMSDc23456789012345678901234'); - expect(result).toContain('[REDACTED-VAULT]'); - }); - - it('redacts GitHub fine-grained PATs', async () => { - const result = await getReplayedContent('github_pat_11ABCDEF0123456789_AbCdEfGhIjKlMnOpQrStUvWxYz'); - expect(result).toContain('[REDACTED-GITHUB-FG]'); - }); - - it('redacts Databricks tokens', async () => { - // Use a pattern that matches the regex but won't trigger GitHub push protection - const token = 'dapi' + '0'.repeat(16) + 'f'.repeat(16); - const result = await getReplayedContent(token); - expect(result).toContain('[REDACTED-DATABRICKS]'); - }); - - it('redacts HuggingFace tokens', async () => { - // Use a pattern that matches the regex but won't trigger GitHub push protection - const token = 'hf_' + 'A'.repeat(34); - const result = await getReplayedContent(token); - expect(result).toContain('[REDACTED-HUGGINGFACE]'); - }); - - it('redacts credentials in URLs', async () => { - const result = await getReplayedContent('https://user:password123@api.example.com/v1'); - expect(result).toContain('[REDACTED-CRED-URL]'); - expect(result).not.toContain('password123'); - }); - - it('redacts generic key=value secrets', async () => { - const result = await getReplayedContent('password = "super-secret-password-12345"'); - expect(result).toContain('[REDACTED]'); - expect(result).not.toContain('super-secret-password'); - }); - - it('redacts PEM private keys', async () => { - const result = await getReplayedContent('-----BEGIN RSA PRIVATE KEY-----\nMIIEowIBAAKCAQEA\n-----END RSA PRIVATE KEY-----'); - expect(result).toContain('[REDACTED-PEM]'); - }); - - it('strips ANSI escape codes before redaction', async () => { - // ANSI colored text wrapping a secret - const result = await getReplayedContent('\x1b[31msk-abcdefghijklmnopqrstuvwxyz1234567890\x1b[0m'); - expect(result).toContain('[REDACTED-OPENAI]'); - expect(result).not.toContain('\x1b[31m'); - }); - - it('strips zero-width characters before redaction', async () => { - // Zero-width spaces injected into a secret - const result = await getReplayedContent('sk-\u200Babcdefghijklmnopqrstuvwxyz1234567890'); - expect(result).toContain('[REDACTED-OPENAI]'); - expect(result).not.toContain('\u200B'); - }); - - it('strips C1 control codes before redaction', async () => { - const result = await getReplayedContent('sk-\x80abcdefghijklmnopqrstuvwxyz1234567890'); - expect(result).toContain('[REDACTED-OPENAI]'); - }); - - it('strips braille blank before redaction', async () => { - const result = await getReplayedContent('sk-\u2800abcdefghijklmnopqrstuvwxyz1234567890'); - expect(result).toContain('[REDACTED-OPENAI]'); - }); - - it('does not redact non-secret content', async () => { - const result = await getReplayedContent('Hello world, this is normal text with no secrets'); - expect(result).toBe('Hello world, this is normal text with no secrets'); - }); -}); - -// ─── Static File Serving Tests ─────────────────────────────── - -describe('Static File Serving', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('returns security headers on default handler', async () => { - const port = await bridge.start(); - - const res = await new Promise<{ status: number; headers: http.IncomingHttpHeaders }>((resolve) => { - http.get(`http://localhost:${port}/`, (res) => { - res.resume(); - resolve({ status: res.statusCode!, headers: res.headers }); - }); - }); - - expect(res.status).toBe(200); - expect(res.headers['x-content-type-options']).toBe('nosniff'); - expect(res.headers['x-frame-options']).toBe('DENY'); - expect(res.headers['referrer-policy']).toBe('no-referrer'); - expect(res.headers['cache-control']).toBe('no-store'); - }); - - it('static handler prevents path traversal with ..', async () => { - let handlerCalled = false; - bridge.setStaticHandler((_req, res) => { - handlerCalled = true; - res.writeHead(200); - res.end('OK'); - }); - const port = await bridge.start(); - - // Use URL-encoded path traversal (HTTP client won't normalize %2e%2e) - const res = await new Promise<{ status: number }>((resolve) => { - http.get(`http://localhost:${port}/%2e%2e/%2e%2e/etc/passwd`, (res) => { - res.resume(); - resolve({ status: res.statusCode! }); - }); - }); - - // The static handler IS called (the bridge delegates to it), - // but a proper implementation should check the decoded path - expect(typeof res.status).toBe('number'); - }); - - it('returns correct MIME types from static handler', async () => { - bridge.setStaticHandler((_req, res) => { - res.writeHead(200, { 'Content-Type': 'application/javascript' }); - res.end('console.log("test")'); - }); - const port = await bridge.start(); - - const res = await new Promise<{ headers: http.IncomingHttpHeaders }>((resolve) => { - http.get(`http://localhost:${port}/app.js`, (res) => { - res.resume(); - resolve({ headers: res.headers }); - }); - }); - - expect(res.headers['content-type']).toBe('application/javascript'); - }); -}); - -// ─── Client Command Handler Tests ──────────────────────────── - -describe('Client Commands', () => { - let bridge: RemoteBridge; - let receivedPrompts: string[]; - let receivedDirect: Array<{ agent: string; text: string }>; - let receivedCommands: Array<{ name: string; args?: string[] }>; - let receivedPermissions: Array<{ id: string; approved: boolean }>; - - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - onPrompt: (text: string) => { receivedPrompts.push(text); }, - onDirectMessage: (agentName: string, text: string) => { receivedDirect.push({ agent: agentName, text }); }, - onCommand: (name: string, args?: string[]) => { receivedCommands.push({ name, args }); }, - onPermissionResponse: (id: string, approved: boolean) => { receivedPermissions.push({ id, approved }); }, - }; - - beforeEach(() => { - receivedPrompts = []; - receivedDirect = []; - receivedCommands = []; - receivedPermissions = []; - bridge = new RemoteBridge(config); - }); - afterEach(async () => { await bridge.stop(); }); - - it('fires onPrompt callback for prompt commands', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ type: 'prompt', text: 'Build the login page' })); - await new Promise(r => setTimeout(r, 200)); - - expect(receivedPrompts).toContain('Build the login page'); - ws.close(); - }); - - it('fires onDirectMessage callback for direct commands', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ type: 'direct', agentName: 'Worf', text: 'Fix the tests' })); - await new Promise(r => setTimeout(r, 200)); - - expect(receivedDirect).toHaveLength(1); - expect(receivedDirect[0].agent).toBe('Worf'); - expect(receivedDirect[0].text).toBe('Fix the tests'); - ws.close(); - }); - - it('fires onCommand callback for slash commands', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ type: 'command', name: 'status', args: ['--verbose'] })); - await new Promise(r => setTimeout(r, 200)); - - expect(receivedCommands).toHaveLength(1); - expect(receivedCommands[0].name).toBe('status'); - expect(receivedCommands[0].args).toEqual(['--verbose']); - ws.close(); - }); - - it('fires onPermissionResponse callback for permission responses', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - ws.send(JSON.stringify({ type: 'permission_response', id: 'perm-1', approved: true })); - await new Promise(r => setTimeout(r, 200)); - - expect(receivedPermissions).toHaveLength(1); - expect(receivedPermissions[0].id).toBe('perm-1'); - expect(receivedPermissions[0].approved).toBe(true); - ws.close(); - }); -}); - -// ─── Tunnel Utility Tests ──────────────────────────────────── - -describe('Tunnel Utilities', () => { - // Test getGitInfo and getMachineId (these don't need devtunnel installed) - it('getMachineId returns hostname', async () => { - const { getMachineId } = await import('../packages/squad-cli/src/cli/commands/rc-tunnel.js'); - const id = getMachineId(); - expect(id).toBe(os.hostname()); - expect(typeof id).toBe('string'); - expect(id.length).toBeGreaterThan(0); - }); - - it('getGitInfo returns repo and branch from CWD', async () => { - const { getGitInfo } = await import('../packages/squad-cli/src/cli/commands/rc-tunnel.js'); - const info = getGitInfo(process.cwd()); - expect(info).toHaveProperty('repo'); - expect(info).toHaveProperty('branch'); - expect(typeof info.repo).toBe('string'); - expect(typeof info.branch).toBe('string'); - }); - - it('getGitInfo returns "unknown" for non-git directories', async () => { - const { getGitInfo } = await import('../packages/squad-cli/src/cli/commands/rc-tunnel.js'); - const info = getGitInfo(os.tmpdir()); - expect(info.repo).toBe('unknown'); - expect(info.branch).toBe('unknown'); - }); - - it('isDevtunnelAvailable returns a boolean', async () => { - const { isDevtunnelAvailable } = await import('../packages/squad-cli/src/cli/commands/rc-tunnel.js'); - const result = isDevtunnelAvailable(); - expect(typeof result).toBe('boolean'); - }); -}); - -// ─── Protocol Edge Cases ───────────────────────────────────── - -describe('Protocol — Edge Cases', () => { - it('parseCommand handles empty string', () => { - expect(parseCommand('')).toBeNull(); - }); - - it('parseCommand handles whitespace-only string', () => { - expect(parseCommand(' ')).toBeNull(); - }); - - it('parseCommand ignores extra fields', () => { - const cmd = parseCommand('{"type":"prompt","text":"hello","extra":"field","nested":{"a":1}}'); - expect(cmd).not.toBeNull(); - expect(cmd!.type).toBe('prompt'); - }); - - it('parseCommand handles ping type', () => { - const cmd = parseCommand('{"type":"ping"}'); - expect(cmd).toEqual({ type: 'ping' }); - }); - - it('parseCommand handles command type with args', () => { - const cmd = parseCommand('{"type":"command","name":"status","args":["--json"]}'); - expect(cmd).not.toBeNull(); - expect(cmd!.type).toBe('command'); - }); - - it('parseCommand handles permission_response type', () => { - const cmd = parseCommand('{"type":"permission_response","id":"abc","approved":false}'); - expect(cmd).not.toBeNull(); - expect(cmd!.type).toBe('permission_response'); - }); - - it('serializeEvent is valid JSON', () => { - const event = { - type: 'delta' as const, - sessionId: 'sess-1', - agentName: 'Test', - content: 'Hello "world" with\nnewlines\tand\ttabs', - }; - const json = serializeEvent(event); - const parsed = JSON.parse(json); - expect(parsed.content).toBe('Hello "world" with\nnewlines\tand\ttabs'); - }); - - it('RC_PROTOCOL_VERSION is defined', () => { - expect(RC_PROTOCOL_VERSION).toBe('1.0'); - }); -}); - -// ─── Error Handling & Edge Cases ───────────────────────────── - -describe('Error Handling', () => { - let bridge: RemoteBridge; - const config = { - port: 0, - maxHistory: 100, - repo: 'test-repo', - branch: 'main', - machine: 'TEST-PC', - squadDir: '.squad', - }; - - beforeEach(() => { bridge = new RemoteBridge(config); }); - afterEach(async () => { await bridge.stop(); }); - - it('double start returns same port', async () => { - const port1 = await bridge.start(); - const port2 = await bridge.start(); - expect(port1).toBe(port2); - }); - - it('stop on already-stopped bridge is safe', async () => { - await bridge.start(); - await bridge.stop(); - // Should not throw - await bridge.stop(); - expect(bridge.getState()).toBe('stopped'); - }); - - it('getPort returns config port when not started', () => { - const port = bridge.getPort(); - expect(typeof port).toBe('number'); - }); - - it('getConnectionCount is 0 when stopped', () => { - expect(bridge.getConnectionCount()).toBe(0); - }); - - it('getConnections returns empty array when no clients', async () => { - await bridge.start(); - expect(bridge.getConnections()).toEqual([]); - }); - - it('addMessage on started bridge creates and broadcasts', async () => { - await bridge.start(); - const msg = bridge.addMessage('system', 'test message'); - expect(msg.id).toBeDefined(); - expect(msg.role).toBe('system'); - expect(msg.content).toBe('test message'); - expect(msg.timestamp).toBeDefined(); - }); - - it('sendError broadcasts error event', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.sendError('Something went wrong', 'Picard'); - await new Promise(r => setTimeout(r, 200)); - - const errorMsg = messages.find(m => m.type === 'error'); - expect(errorMsg).toBeDefined(); - expect(errorMsg.message).toBe('Something went wrong'); - expect(errorMsg.agentName).toBe('Picard'); - ws.close(); - }); - - it('sendToolCall broadcasts tool_call event', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.sendToolCall('Worf', 'read_file', { path: '/test' }, 'running'); - await new Promise(r => setTimeout(r, 200)); - - const toolMsg = messages.find(m => m.type === 'tool_call'); - expect(toolMsg).toBeDefined(); - expect(toolMsg.agentName).toBe('Worf'); - expect(toolMsg.tool).toBe('read_file'); - expect(toolMsg.status).toBe('running'); - ws.close(); - }); - - it('sendPermissionRequest broadcasts permission event', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.sendPermissionRequest('perm-1', 'Picard', 'shell', { cmd: 'ls' }, 'Run shell command'); - await new Promise(r => setTimeout(r, 200)); - - const permMsg = messages.find(m => m.type === 'permission'); - expect(permMsg).toBeDefined(); - expect(permMsg.id).toBe('perm-1'); - expect(permMsg.description).toBe('Run shell command'); - ws.close(); - }); - - it('sendUsage broadcasts usage event', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.sendUsage('claude-sonnet-4', 1000, 500, 0.02); - await new Promise(r => setTimeout(r, 200)); - - const usageMsg = messages.find(m => m.type === 'usage'); - expect(usageMsg).toBeDefined(); - expect(usageMsg.model).toBe('claude-sonnet-4'); - expect(usageMsg.inputTokens).toBe(1000); - expect(usageMsg.outputTokens).toBe(500); - expect(usageMsg.cost).toBe(0.02); - ws.close(); - }); - - it('updateAgentStatus updates existing agent', async () => { - const port = await bridge.start(); - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 200)); - - bridge.updateAgents([ - { name: 'Picard', role: 'Lead', status: 'idle' }, - ]); - bridge.updateAgentStatus('Picard', 'working'); - await new Promise(r => setTimeout(r, 200)); - - const agentEvents = messages.filter(m => m.type === 'agents'); - const latest = agentEvents[agentEvents.length - 1]; - expect(latest.agents[0].status).toBe('working'); - ws.close(); - }); - - it('updateAgentStatus ignores unknown agents', async () => { - await bridge.start(); - bridge.updateAgents([{ name: 'Picard', role: 'Lead', status: 'idle' }]); - // Should not throw - bridge.updateAgentStatus('UnknownAgent', 'working'); - }); - - it('getSessionToken returns consistent UUID', () => { - const t1 = (bridge as any).getSessionToken(); - const t2 = (bridge as any).getSessionToken(); - expect(t1).toBe(t2); - expect(t1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/); - }); - - it('getSessionExpiry returns future timestamp', () => { - const expiry = bridge.getSessionExpiry(); - expect(expiry).toBeGreaterThan(Date.now()); - // Should be ~4 hours from now - const fourHours = 4 * 60 * 60 * 1000; - expect(expiry - Date.now()).toBeLessThanOrEqual(fourHours + 1000); - }); - - it('getAuditLogPath returns a valid path', () => { - const auditPath = bridge.getAuditLogPath(); - expect(auditPath).toContain('.cli-tunnel'); - expect(auditPath).toContain('audit'); - expect(auditPath).toContain('squad-audit-'); - }); -}); - -// ─── Replay Buffer Tests ───────────────────────────────────── - -describe('Replay Buffer', () => { - let bridge: RemoteBridge; - - afterEach(async () => { await bridge.stop(); }); - - it('does not replay when enableReplay is false', async () => { - bridge = new RemoteBridge({ - port: 0, maxHistory: 100, repo: 'test', branch: 'main', machine: 'TEST', squadDir: '.squad', - enableReplay: false, - }); - const port = await bridge.start(); - bridge.setPassthrough(() => {}); - - bridge.passthroughFromAgent('{"type":"pty","data":"test"}'); - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 500)); - - const replays = messages.filter(m => m.type === '_replay'); - expect(replays).toHaveLength(0); - ws.close(); - }); - - it('caps replay buffer at 2000 events', async () => { - bridge = new RemoteBridge({ - port: 0, maxHistory: 100, repo: 'test', branch: 'main', machine: 'TEST', squadDir: '.squad', - enableReplay: true, - }); - const port = await bridge.start(); - bridge.setPassthrough(() => {}); - - // Record 2100 events - for (let i = 0; i < 2100; i++) { - bridge.passthroughFromAgent(`event-${i}`); - } - - const ws = new WebSocket(`ws://localhost:${port}/?token=${(bridge as any).getSessionToken()}`); - const messages: any[] = []; - ws.on('message', d => messages.push(JSON.parse(d.toString()))); - await new Promise(r => ws.on('open', r)); - await new Promise(r => setTimeout(r, 500)); - - const replays = messages.filter(m => m.type === '_replay'); - expect(replays.length).toBeLessThanOrEqual(2000); - - // First replayed event should be from the end, not the beginning - const firstReplay = replays[0]?.data; - expect(firstReplay).toContain('event-'); - // Should NOT contain event-0 through event-99 (those were trimmed) - const hasOldEvent = replays.some(m => m.data === 'event-0'); - expect(hasOldEvent).toBe(false); - - ws.close(); - }); -}); diff --git a/test/repl-dogfood.test.ts b/test/repl-dogfood.test.ts deleted file mode 100644 index 6588c4333..000000000 --- a/test/repl-dogfood.test.ts +++ /dev/null @@ -1,1113 +0,0 @@ -/** - * REPL Dogfood Tests — realistic repository structures, no mocks, no network. - * - * Tests the shell modules (ShellLifecycle, parseInput, executeCommand, - * parseCoordinatorResponse, loadWelcomeData, SessionRegistry) against - * locally-scaffolded fixtures that simulate real-world repositories. - * - * Fixtures: - * 1. Small Python project (src/, tests/, setup.py, README, nested dirs) - * 2. Node.js monorepo (packages/, workspaces, multiple tsconfig) - * 3. Large mixed-language (Go + Python + TypeScript, deep nesting) - * 4. Edge cases (deep dirs, large files, many agents, minimal repos) - * - * @see https://github.com/bradygaster/squad-pr/issues/532 - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { - mkdtempSync, - mkdirSync, - writeFileSync, - symlinkSync, - existsSync, -} from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { rm } from 'node:fs/promises'; - -import { ShellLifecycle } from '../packages/squad-cli/src/cli/shell/lifecycle.js'; -import { loadWelcomeData } from '../packages/squad-cli/src/cli/shell/lifecycle.js'; -import { parseInput } from '../packages/squad-cli/src/cli/shell/router.js'; -import { executeCommand } from '../packages/squad-cli/src/cli/shell/commands.js'; -import { parseCoordinatorResponse } from '../packages/squad-cli/src/cli/shell/coordinator.js'; -import { SessionRegistry } from '../packages/squad-cli/src/cli/shell/sessions.js'; -import { ShellRenderer } from '../packages/squad-cli/src/cli/shell/render.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -// ============================================================================ -// Helpers -// ============================================================================ - -function makeTempDir(prefix: string): string { - return mkdtempSync(join(tmpdir(), prefix)); -} - -function makeTeamMd( - projectName: string, - description: string, - agents: Array<{ name: string; role: string; status?: string }>, -): string { - const rows = agents - .map( - (a) => - `| ${a.name} | ${a.role} | \`.squad/agents/${a.name.toLowerCase()}/charter.md\` | ✅ ${a.status ?? 'Active'} |`, - ) - .join('\n'); - return `# Squad Team — ${projectName} - -> ${description} - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -${rows} - -## Project Context - -- **Stack:** Various -`; -} - -function scaffoldSquad( - root: string, - opts: { - projectName: string; - description: string; - agents: Array<{ name: string; role: string; status?: string }>; - focus?: string; - routingMd?: string; - firstRun?: boolean; - }, -): void { - const squadDir = join(root, '.squad'); - const agentsDir = join(squadDir, 'agents'); - const identityDir = join(squadDir, 'identity'); - mkdirSync(agentsDir, { recursive: true }); - mkdirSync(identityDir, { recursive: true }); - - writeFileSync( - join(squadDir, 'team.md'), - makeTeamMd(opts.projectName, opts.description, opts.agents), - ); - - // Create agent charter stubs - for (const agent of opts.agents) { - const agentDir = join(agentsDir, agent.name.toLowerCase()); - mkdirSync(agentDir, { recursive: true }); - writeFileSync( - join(agentDir, 'charter.md'), - `# ${agent.name} — ${agent.role}\n\nCharter for ${agent.name}.\n`, - ); - } - - if (opts.focus) { - writeFileSync( - join(identityDir, 'now.md'), - `---\nupdated_at: 2025-01-01T00:00:00.000Z\nfocus_area: ${opts.focus}\nactive_issues: []\n---\n\n# Focus\n\n${opts.focus}\n`, - ); - } - - if (opts.routingMd) { - writeFileSync(join(squadDir, 'routing.md'), opts.routingMd); - } - - if (opts.firstRun) { - writeFileSync(join(squadDir, '.first-run'), new Date().toISOString() + '\n'); - } -} - -function makeLifecycle(teamRoot: string): { - lifecycle: ShellLifecycle; - registry: SessionRegistry; -} { - const registry = new SessionRegistry(); - const lifecycle = new ShellLifecycle({ - teamRoot, - renderer: new ShellRenderer(), - registry, - }); - return { lifecycle, registry }; -} - -function makeCommandContext(teamRoot: string, registry: SessionRegistry) { - return { - registry, - renderer: new ShellRenderer(), - messageHistory: [] as ShellMessage[], - teamRoot, - }; -} - -// ============================================================================ -// Fixture Builders -// ============================================================================ - -/** Small Python project: src/, tests/, setup.py, README, nested dirs */ -function buildPythonFixture(root: string): void { - // Python source tree - mkdirSync(join(root, 'src', 'mypackage', 'utils'), { recursive: true }); - mkdirSync(join(root, 'tests', 'unit'), { recursive: true }); - mkdirSync(join(root, 'tests', 'integration'), { recursive: true }); - mkdirSync(join(root, 'docs'), { recursive: true }); - - writeFileSync(join(root, 'setup.py'), `from setuptools import setup\nsetup(name='mypackage', version='1.0.0')\n`); - writeFileSync(join(root, 'README.md'), '# My Python Package\n\nA sample Python project.\n'); - writeFileSync(join(root, 'requirements.txt'), 'flask>=2.0\nrequests>=2.28\npytest>=7.0\n'); - writeFileSync(join(root, '.gitignore'), '__pycache__/\n*.pyc\n.venv/\ndist/\n'); - writeFileSync(join(root, 'src', 'mypackage', '__init__.py'), '"""My package."""\n__version__ = "1.0.0"\n'); - writeFileSync(join(root, 'src', 'mypackage', 'app.py'), 'from flask import Flask\napp = Flask(__name__)\n'); - writeFileSync(join(root, 'src', 'mypackage', 'utils', '__init__.py'), ''); - writeFileSync(join(root, 'src', 'mypackage', 'utils', 'helpers.py'), 'def greet(name: str) -> str:\n return f"Hello, {name}!"\n'); - writeFileSync(join(root, 'tests', 'unit', 'test_helpers.py'), 'from mypackage.utils.helpers import greet\n\ndef test_greet():\n assert greet("World") == "Hello, World!"\n'); - writeFileSync(join(root, 'tests', 'integration', 'test_app.py'), '# Integration tests for the Flask app\n'); - - scaffoldSquad(root, { - projectName: 'my-python-pkg', - description: 'A small Python web service with Flask.', - agents: [ - { name: 'Alice', role: 'Lead' }, - { name: 'Bob', role: 'Core Dev' }, - { name: 'Carol', role: 'Tester' }, - ], - focus: 'Flask API endpoint coverage', - }); -} - -/** Node.js monorepo: packages/, workspaces, multiple tsconfigs */ -function buildMonorepoFixture(root: string): void { - // Root config - writeFileSync( - join(root, 'package.json'), - JSON.stringify( - { - name: 'my-monorepo', - private: true, - workspaces: ['packages/*'], - devDependencies: { typescript: '^5.0.0', vitest: '^1.0.0' }, - }, - null, - 2, - ), - ); - writeFileSync(join(root, 'tsconfig.json'), JSON.stringify({ compilerOptions: { strict: true, target: 'ES2022', module: 'Node16' } }, null, 2)); - writeFileSync(join(root, 'README.md'), '# My Monorepo\n\nA multi-package TypeScript workspace.\n'); - - // Package A — SDK - const pkgA = join(root, 'packages', 'sdk', 'src'); - mkdirSync(pkgA, { recursive: true }); - mkdirSync(join(root, 'packages', 'sdk', 'test'), { recursive: true }); - writeFileSync( - join(root, 'packages', 'sdk', 'package.json'), - JSON.stringify({ name: '@myorg/sdk', version: '2.0.0', main: 'dist/index.js' }, null, 2), - ); - writeFileSync(join(root, 'packages', 'sdk', 'tsconfig.json'), JSON.stringify({ extends: '../../tsconfig.json', include: ['src'] }, null, 2)); - writeFileSync(join(pkgA, 'index.ts'), 'export function createClient() { return {}; }\n'); - writeFileSync(join(pkgA, 'types.ts'), 'export interface Config { apiKey: string; }\n'); - writeFileSync(join(root, 'packages', 'sdk', 'test', 'index.test.ts'), 'import { createClient } from "../src/index";\n'); - - // Package B — CLI - const pkgB = join(root, 'packages', 'cli', 'src'); - mkdirSync(pkgB, { recursive: true }); - writeFileSync( - join(root, 'packages', 'cli', 'package.json'), - JSON.stringify({ name: '@myorg/cli', version: '2.0.0', bin: { mycli: './dist/index.js' } }, null, 2), - ); - writeFileSync(join(root, 'packages', 'cli', 'tsconfig.json'), JSON.stringify({ extends: '../../tsconfig.json', include: ['src'] }, null, 2)); - writeFileSync(join(pkgB, 'index.ts'), '#!/usr/bin/env node\nconsole.log("hello");\n'); - - // Package C — shared utilities - const pkgC = join(root, 'packages', 'shared', 'src'); - mkdirSync(pkgC, { recursive: true }); - writeFileSync( - join(root, 'packages', 'shared', 'package.json'), - JSON.stringify({ name: '@myorg/shared', version: '1.0.0' }, null, 2), - ); - writeFileSync(join(pkgC, 'utils.ts'), 'export const VERSION = "1.0.0";\n'); - - scaffoldSquad(root, { - projectName: 'my-monorepo', - description: 'A multi-package TypeScript workspace with SDK, CLI, and shared utils.', - agents: [ - { name: 'Keaton', role: 'Lead' }, - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Tester' }, - { name: 'Verbal', role: 'Prompt Engineer' }, - { name: 'McManus', role: 'DevRel' }, - ], - focus: 'SDK v2 migration and CLI improvements', - routingMd: `# Routing Rules\n\n- SDK changes → Fenster\n- CLI changes → Keaton\n- Test coverage → Hockney\n- Docs → McManus\n`, - }); -} - -/** Large mixed-language: Go + Python + TypeScript, deep nesting */ -function buildMixedLanguageFixture(root: string): void { - // Go service - const goSvc = join(root, 'services', 'api', 'internal', 'handlers'); - mkdirSync(goSvc, { recursive: true }); - mkdirSync(join(root, 'services', 'api', 'cmd'), { recursive: true }); - writeFileSync(join(root, 'services', 'api', 'go.mod'), 'module github.com/example/api\n\ngo 1.21\n'); - writeFileSync(join(root, 'services', 'api', 'cmd', 'main.go'), 'package main\n\nfunc main() {}\n'); - writeFileSync(join(goSvc, 'health.go'), 'package handlers\n\nfunc HealthCheck() string { return "ok" }\n'); - - // Python ML pipeline - const pyML = join(root, 'ml', 'pipelines', 'training'); - mkdirSync(pyML, { recursive: true }); - mkdirSync(join(root, 'ml', 'models'), { recursive: true }); - writeFileSync(join(root, 'ml', 'requirements.txt'), 'torch>=2.0\nnumpy>=1.24\n'); - writeFileSync(join(pyML, 'train.py'), 'import torch\n\ndef train(): pass\n'); - writeFileSync(join(root, 'ml', 'models', 'config.yaml'), 'model:\n name: transformer\n layers: 12\n'); - - // TypeScript frontend - const tsFE = join(root, 'frontend', 'src', 'components'); - mkdirSync(tsFE, { recursive: true }); - mkdirSync(join(root, 'frontend', 'src', 'hooks'), { recursive: true }); - writeFileSync(join(root, 'frontend', 'package.json'), JSON.stringify({ name: '@example/frontend', dependencies: { react: '^18.0.0' } }, null, 2)); - writeFileSync(join(root, 'frontend', 'tsconfig.json'), JSON.stringify({ compilerOptions: { jsx: 'react-jsx' } }, null, 2)); - writeFileSync(join(tsFE, 'App.tsx'), 'export default function App() { return
Hello
; }\n'); - writeFileSync(join(root, 'frontend', 'src', 'hooks', 'useAuth.ts'), 'export function useAuth() { return { user: null }; }\n'); - - // Deep nested config (simulating Kubernetes / infra) - const k8s = join(root, 'infra', 'k8s', 'overlays', 'production', 'patches'); - mkdirSync(k8s, { recursive: true }); - writeFileSync(join(k8s, 'deployment.yaml'), 'apiVersion: apps/v1\nkind: Deployment\n'); - - writeFileSync(join(root, 'README.md'), '# Mixed Language Platform\n\nGo API, Python ML, TypeScript Frontend.\n'); - - scaffoldSquad(root, { - projectName: 'platform', - description: 'Full-stack platform with Go microservices, Python ML pipelines, and TypeScript frontend.', - agents: [ - { name: 'GoLead', role: 'Core Dev' }, - { name: 'PyExpert', role: 'Core Dev' }, - { name: 'TSWizard', role: 'TypeScript Engineer' }, - { name: 'Infra', role: 'Core Dev' }, - { name: 'QA', role: 'Tester' }, - { name: 'PM', role: 'Lead' }, - { name: 'Docs', role: 'DevRel' }, - ], - focus: 'API v2 launch with ML integration', - routingMd: `# Routing\n\n- Go services → GoLead\n- ML pipeline → PyExpert\n- Frontend → TSWizard\n- Infrastructure → Infra\n- Test coverage → QA\n`, - }); -} - -/** Edge case: deep nesting, large team.md, many agents, empty dirs */ -function buildEdgeCaseFixture(root: string): void { - // 50-level deep nesting - let deepPath = root; - for (let i = 0; i < 50; i++) { - deepPath = join(deepPath, `level${i}`); - } - mkdirSync(deepPath, { recursive: true }); - writeFileSync(join(deepPath, 'deep-file.txt'), 'I am 50 levels deep.\n'); - - // Large file (100KB of content) - const largeContent = 'x'.repeat(100 * 1024) + '\n'; - mkdirSync(join(root, 'data'), { recursive: true }); - writeFileSync(join(root, 'data', 'large-file.txt'), largeContent); - - // Many empty directories - for (let i = 0; i < 30; i++) { - mkdirSync(join(root, 'empty-dirs', `dir-${i}`), { recursive: true }); - } - - // Filename with spaces and special chars (safe cross-platform subset) - mkdirSync(join(root, 'special-names'), { recursive: true }); - writeFileSync(join(root, 'special-names', 'file with spaces.txt'), 'spaces\n'); - writeFileSync(join(root, 'special-names', 'file-with-dashes.txt'), 'dashes\n'); - writeFileSync(join(root, 'special-names', 'file_underscores.txt'), 'underscores\n'); - writeFileSync(join(root, 'special-names', 'CamelCase.TXT'), 'mixed case\n'); - - // Symlink (non-Windows only) — we'll test separately - if (process.platform !== 'win32') { - try { - writeFileSync(join(root, 'real-target.txt'), 'symlink target\n'); - symlinkSync(join(root, 'real-target.txt'), join(root, 'symlinked.txt')); - } catch { - // Symlinks may fail even on non-Windows (permissions) - } - } - - // Generate 20+ agents - const manyAgents = Array.from({ length: 22 }, (_, i) => ({ - name: `Agent${String(i + 1).padStart(2, '0')}`, - role: i % 5 === 0 ? 'Lead' : i % 5 === 1 ? 'Core Dev' : i % 5 === 2 ? 'Tester' : i % 5 === 3 ? 'DevRel' : 'TypeScript Engineer', - })); - - scaffoldSquad(root, { - projectName: 'edge-case-repo', - description: 'Repository with extreme edge cases for dogfood testing.', - agents: manyAgents, - focus: 'Stress testing the REPL', - }); -} - -/** Minimal repo: .squad/ with team.md only, nothing else */ -function buildMinimalFixture(root: string): void { - scaffoldSquad(root, { - projectName: 'minimal', - description: 'A bare-bones project.', - agents: [{ name: 'Solo', role: 'Lead' }], - }); -} - -// ============================================================================ -// 1. Small Python project -// ============================================================================ - -describe('Dogfood: Small Python project', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-python-'); - buildPythonFixture(root); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - describe('ShellLifecycle.initialize()', () => { - it('discovers 3 agents (Alice, Bob, Carol)', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - const agents = lifecycle.getDiscoveredAgents(); - expect(agents).toHaveLength(3); - expect(agents.map((a) => a.name)).toEqual(['Alice', 'Bob', 'Carol']); - }); - - it('sets state to ready', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getState().status).toBe('ready'); - }); - - it('registers active agents in SessionRegistry', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - expect(registry.get('Alice')).toBeDefined(); - expect(registry.get('Alice')?.role).toBe('Lead'); - expect(registry.get('Bob')?.role).toBe('Core Dev'); - expect(registry.get('Carol')?.role).toBe('Tester'); - }); - }); - - describe('loadWelcomeData()', () => { - it('returns correct project name and description', () => { - const data = loadWelcomeData(root); - expect(data).not.toBeNull(); - expect(data!.projectName).toBe('my-python-pkg'); - expect(data!.description).toContain('Flask'); - }); - - it('lists active agents with roles', () => { - const data = loadWelcomeData(root); - expect(data!.agents).toHaveLength(3); - expect(data!.agents.map((a) => a.name)).toEqual(['Alice', 'Bob', 'Carol']); - expect(data!.agents[0]!.role).toBe('Lead'); - }); - - it('reads focus area from identity/now.md', () => { - const data = loadWelcomeData(root); - expect(data!.focus).toBe('Flask API endpoint coverage'); - }); - }); - - describe('parseInput — realistic Python queries', () => { - const knownAgents = ['Alice', 'Bob', 'Carol']; - - it('"run the pytest suite" → coordinator', () => { - const result = parseInput('run the pytest suite', knownAgents); - expect(result.type).toBe('coordinator'); - expect(result.content).toBe('run the pytest suite'); - }); - - it('"@Bob fix the import error in helpers.py" → direct_agent', () => { - const result = parseInput('@Bob fix the import error in helpers.py', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Bob'); - expect(result.content).toContain('import error'); - }); - - it('"/status" → slash_command', () => { - const result = parseInput('/status', knownAgents); - expect(result.type).toBe('slash_command'); - expect(result.command).toBe('status'); - }); - - it('"Carol, write tests for the Flask routes" → direct_agent comma syntax', () => { - const result = parseInput('Carol, write tests for the Flask routes', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Carol'); - expect(result.content).toContain('Flask routes'); - }); - }); - - describe('executeCommand', () => { - it('/status shows 3 agents', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('status', [], ctx); - expect(result.handled).toBe(true); - expect(result.output).toContain('3 agent'); - }); - - it('/help outputs command list', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('help', [], ctx); - expect(result.handled).toBe(true); - expect(result.output).toContain('/status'); - expect(result.output).toContain('/quit'); - }); - - it('/agents lists all team members', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('agents', [], ctx); - expect(result.handled).toBe(true); - expect(result.output).toContain('Alice'); - expect(result.output).toContain('Bob'); - expect(result.output).toContain('Carol'); - }); - }); -}); - -// ============================================================================ -// 2. Node.js monorepo -// ============================================================================ - -describe('Dogfood: Node.js monorepo', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-monorepo-'); - buildMonorepoFixture(root); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - describe('ShellLifecycle.initialize()', () => { - it('discovers 5 agents', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getDiscoveredAgents()).toHaveLength(5); - }); - - it('agent names match team.md roster', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - const names = lifecycle.getDiscoveredAgents().map((a) => a.name); - expect(names).toContain('Keaton'); - expect(names).toContain('Fenster'); - expect(names).toContain('Hockney'); - expect(names).toContain('Verbal'); - expect(names).toContain('McManus'); - }); - }); - - describe('loadWelcomeData()', () => { - it('returns monorepo project name', () => { - const data = loadWelcomeData(root); - expect(data!.projectName).toBe('my-monorepo'); - }); - - it('description mentions TypeScript workspace', () => { - const data = loadWelcomeData(root); - expect(data!.description).toContain('TypeScript'); - }); - - it('focus reflects SDK migration', () => { - const data = loadWelcomeData(root); - expect(data!.focus).toBe('SDK v2 migration and CLI improvements'); - }); - }); - - describe('parseInput — monorepo queries', () => { - const knownAgents = ['Keaton', 'Fenster', 'Hockney', 'Verbal', 'McManus']; - - it('"add a new workspace package for auth" → coordinator', () => { - const result = parseInput('add a new workspace package for auth', knownAgents); - expect(result.type).toBe('coordinator'); - }); - - it('"@Fenster fix the SDK build error in packages/sdk" → direct_agent', () => { - const result = parseInput('@Fenster fix the SDK build error in packages/sdk', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - }); - - it('"@Hockney add test coverage for the CLI package" → direct_agent', () => { - const result = parseInput('@Hockney add test coverage for the CLI package', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Hockney'); - }); - - it('"/who" → slash_command (unknown command, handled gracefully)', () => { - const result = parseInput('/who', knownAgents); - expect(result.type).toBe('slash_command'); - expect(result.command).toBe('who'); - }); - }); - - describe('executeCommand', () => { - it('/status shows correct agent count', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('status', [], ctx); - expect(result.output).toContain('5 agent'); - }); - - it('unknown command returns helpful message', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('who', [], ctx); - expect(result.handled).toBe(false); - expect(result.output).toContain('/help'); - }); - - it('/exit signals exit', () => { - const registry = new SessionRegistry(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('exit', [], ctx); - expect(result.handled).toBe(true); - expect(result.exit).toBe(true); - }); - }); - - describe('parseCoordinatorResponse — monorepo routing', () => { - it('routes SDK work to Fenster', () => { - const resp = `ROUTE: Fenster\nTASK: Fix the type error in packages/sdk/src/index.ts\nCONTEXT: Build is failing on CI`; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('route'); - expect(decision.routes![0]!.agent).toBe('Fenster'); - expect(decision.routes![0]!.task).toContain('type error'); - }); - - it('multi-agent routing for cross-package work', () => { - const resp = `MULTI:\n- Fenster: Update SDK types for the new auth flow\n- Keaton: Wire the CLI to use the new auth client`; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('multi'); - expect(decision.routes).toHaveLength(2); - expect(decision.routes![0]!.agent).toBe('Fenster'); - expect(decision.routes![1]!.agent).toBe('Keaton'); - }); - - it('direct answer for factual team query', () => { - const resp = 'DIRECT: The monorepo has 3 packages: @myorg/sdk, @myorg/cli, and @myorg/shared.'; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('direct'); - expect(decision.directAnswer).toContain('3 packages'); - }); - }); -}); - -// ============================================================================ -// 3. Large mixed-language -// ============================================================================ - -describe('Dogfood: Large mixed-language project', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-mixed-'); - buildMixedLanguageFixture(root); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - describe('ShellLifecycle.initialize()', () => { - it('discovers 7 agents across all domains', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getDiscoveredAgents()).toHaveLength(7); - }); - - it('agents span Go, Python, TypeScript, and Infra roles', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - const roles = lifecycle.getDiscoveredAgents().map((a) => a.role); - expect(roles).toContain('Core Dev'); - expect(roles).toContain('TypeScript Engineer'); - expect(roles).toContain('Tester'); - expect(roles).toContain('Lead'); - }); - }); - - describe('loadWelcomeData()', () => { - it('description mentions all three languages', () => { - const data = loadWelcomeData(root); - expect(data!.description).toContain('Go'); - expect(data!.description).toContain('Python'); - expect(data!.description).toContain('TypeScript'); - }); - - it('agent list has correct size', () => { - const data = loadWelcomeData(root); - expect(data!.agents).toHaveLength(7); - }); - }); - - describe('parseInput — cross-language queries', () => { - const knownAgents = ['GoLead', 'PyExpert', 'TSWizard', 'Infra', 'QA', 'PM', 'Docs']; - - it('"deploy the Go API to staging" → coordinator', () => { - const result = parseInput('deploy the Go API to staging', knownAgents); - expect(result.type).toBe('coordinator'); - }); - - it('"@GoLead add health check endpoint" → direct_agent', () => { - const result = parseInput('@GoLead add health check endpoint', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('GoLead'); - }); - - it('"@TSWizard the React component has a hydration error" → direct_agent', () => { - const result = parseInput('@TSWizard the React component has a hydration error', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('TSWizard'); - }); - - it('"@PyExpert retrain the model with the new dataset" → direct_agent', () => { - const result = parseInput('@PyExpert retrain the model with the new dataset', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('PyExpert'); - }); - - it('"Infra, scale the k8s deployment to 5 replicas" → comma syntax', () => { - const result = parseInput('Infra, scale the k8s deployment to 5 replicas', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Infra'); - }); - }); - - describe('executeCommand', () => { - it('/status shows 7 agents', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('status', [], ctx); - expect(result.output).toContain('7 agent'); - }); - - it('/agents lists mixed-language team', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('agents', [], ctx); - expect(result.output).toContain('GoLead'); - expect(result.output).toContain('PyExpert'); - expect(result.output).toContain('TSWizard'); - }); - }); -}); - -// ============================================================================ -// 4. Edge cases -// ============================================================================ - -describe('Dogfood: Edge cases', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-edge-'); - buildEdgeCaseFixture(root); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - describe('Deep nesting (50 levels)', () => { - it('ShellLifecycle initializes despite 50-level nesting in the repo', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getState().status).toBe('ready'); - }); - - it('deep-file.txt exists at level 50', () => { - let deepPath = root; - for (let i = 0; i < 50; i++) { - deepPath = join(deepPath, `level${i}`); - } - expect(existsSync(join(deepPath, 'deep-file.txt'))).toBe(true); - }); - }); - - describe('Many agents (22)', () => { - it('discovers all 22 agents', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getDiscoveredAgents()).toHaveLength(22); - }); - - it('registers all 22 agents in SessionRegistry', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - expect(registry.getAll()).toHaveLength(22); - }); - - it('loadWelcomeData returns all 22 agents', () => { - const data = loadWelcomeData(root); - expect(data!.agents).toHaveLength(22); - }); - - it('/status shows 22 agents', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('status', [], ctx); - expect(result.output).toContain('22 agent'); - }); - - it('/agents lists all 22 team members', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('agents', [], ctx); - expect(result.handled).toBe(true); - // Spot-check first and last - expect(result.output).toContain('Agent01'); - expect(result.output).toContain('Agent22'); - }); - - it('parseInput correctly routes to any of the 22 agents', () => { - const agentNames = Array.from({ length: 22 }, (_, i) => `Agent${String(i + 1).padStart(2, '0')}`); - const result = parseInput('@Agent15 fix the edge case', agentNames); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Agent15'); - }); - }); - - describe('Large team.md', () => { - it('loadWelcomeData handles the 22-agent manifest', () => { - const data = loadWelcomeData(root); - expect(data).not.toBeNull(); - expect(data!.projectName).toBe('edge-case-repo'); - }); - }); - - describe('Symlinks (non-Windows)', () => { - it('ShellLifecycle initializes in presence of symlinks', async () => { - // On Windows, symlinks are skipped; lifecycle should still work - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getState().status).toBe('ready'); - }); - }); -}); - -// ============================================================================ -// 5. Minimal / empty repos -// ============================================================================ - -describe('Dogfood: Minimal repo', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-minimal-'); - buildMinimalFixture(root); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('ShellLifecycle initializes with 1 agent', async () => { - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - expect(lifecycle.getDiscoveredAgents()).toHaveLength(1); - expect(lifecycle.getDiscoveredAgents()[0]!.name).toBe('Solo'); - }); - - it('loadWelcomeData returns data with 1 agent', () => { - const data = loadWelcomeData(root); - expect(data).not.toBeNull(); - expect(data!.agents).toHaveLength(1); - expect(data!.agents[0]!.name).toBe('Solo'); - }); - - it('loadWelcomeData returns null focus when no identity/now.md', () => { - const data = loadWelcomeData(root); - // Minimal fixture has no focus set (no identity/now.md with focus_area) - // Our scaffoldSquad creates identity/now.md only when opts.focus is set - expect(data!.focus).toBeNull(); - }); - - it('/status shows 1 agent', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('status', [], ctx); - expect(result.output).toContain('1 agent'); - }); - - it('/history shows no messages initially', async () => { - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - const ctx = makeCommandContext(root, registry); - const result = executeCommand('history', [], ctx); - expect(result.output).toContain('No messages'); - }); -}); - -describe('Dogfood: No .squad/ directory', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-nosquad-'); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('ShellLifecycle.initialize() throws "No team found"', async () => { - const { lifecycle } = makeLifecycle(root); - await expect(lifecycle.initialize()).rejects.toThrow('No team found'); - }); - - it('loadWelcomeData returns null', () => { - const data = loadWelcomeData(root); - expect(data).toBeNull(); - }); -}); - -// ============================================================================ -// 6. Performance — initialization must be fast -// ============================================================================ - -describe('Dogfood: Performance gates', () => { - let roots: string[]; - - beforeEach(() => { - roots = []; - // Build each fixture - const fixtures = [ - { name: 'python', build: buildPythonFixture }, - { name: 'monorepo', build: buildMonorepoFixture }, - { name: 'mixed', build: buildMixedLanguageFixture }, - { name: 'edge', build: buildEdgeCaseFixture }, - { name: 'minimal', build: buildMinimalFixture }, - ]; - for (const f of fixtures) { - const r = makeTempDir(`dogfood-perf-${f.name}-`); - f.build(r); - roots.push(r); - } - }); - - afterEach(async () => { - await Promise.all(roots.map((r) => rm(r, { recursive: true, force: true }))); - }); - - it('ShellLifecycle.initialize() completes in <2s for Python fixture', async () => { - const start = performance.now(); - const { lifecycle } = makeLifecycle(roots[0]!); - await lifecycle.initialize(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it('ShellLifecycle.initialize() completes in <2s for monorepo fixture', async () => { - const start = performance.now(); - const { lifecycle } = makeLifecycle(roots[1]!); - await lifecycle.initialize(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it('ShellLifecycle.initialize() completes in <2s for mixed-language fixture', async () => { - const start = performance.now(); - const { lifecycle } = makeLifecycle(roots[2]!); - await lifecycle.initialize(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it('ShellLifecycle.initialize() completes in <2s for edge-case fixture (22 agents, deep nesting)', async () => { - const start = performance.now(); - const { lifecycle } = makeLifecycle(roots[3]!); - await lifecycle.initialize(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it('ShellLifecycle.initialize() completes in <2s for minimal fixture', async () => { - const start = performance.now(); - const { lifecycle } = makeLifecycle(roots[4]!); - await lifecycle.initialize(); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(2000); - }); - - it('loadWelcomeData is fast (<500ms) for 22-agent team', () => { - const start = performance.now(); - const data = loadWelcomeData(roots[3]!); - const elapsed = performance.now() - start; - expect(data).not.toBeNull(); - expect(elapsed).toBeLessThan(500); - }); -}); - -// ============================================================================ -// 7. SessionRegistry cross-fixture consistency -// ============================================================================ - -describe('Dogfood: SessionRegistry behavior across fixtures', () => { - let root: string; - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('registry starts empty, populates on init, clears on shutdown', async () => { - root = makeTempDir('dogfood-registry-'); - buildMonorepoFixture(root); - const { lifecycle, registry } = makeLifecycle(root); - - // Before init: empty - expect(registry.getAll()).toHaveLength(0); - - // After init: populated - await lifecycle.initialize(); - expect(registry.getAll()).toHaveLength(5); - expect(registry.getActive()).toHaveLength(0); // all idle - - // After shutdown: empty - await lifecycle.shutdown(); - expect(registry.getAll()).toHaveLength(0); - }); - - it('status transitions work correctly', async () => { - root = makeTempDir('dogfood-status-'); - buildPythonFixture(root); - const { lifecycle, registry } = makeLifecycle(root); - await lifecycle.initialize(); - - // Set an agent to working - registry.updateStatus('Bob', 'working'); - registry.updateActivityHint('Bob', 'Fixing helpers.py'); - expect(registry.get('Bob')?.status).toBe('working'); - expect(registry.get('Bob')?.activityHint).toBe('Fixing helpers.py'); - expect(registry.getActive()).toHaveLength(1); - - // Complete the work - registry.updateStatus('Bob', 'idle'); - expect(registry.get('Bob')?.activityHint).toBeUndefined(); - expect(registry.getActive()).toHaveLength(0); - }); - - it('message history tracks across add/get cycle', async () => { - root = makeTempDir('dogfood-history-'); - buildMixedLanguageFixture(root); - const { lifecycle } = makeLifecycle(root); - await lifecycle.initialize(); - - lifecycle.addUserMessage('fix the Go API'); - lifecycle.addAgentMessage('GoLead', 'On it — fixing the health check handler.'); - lifecycle.addSystemMessage('GoLead session started'); - - const history = lifecycle.getHistory(); - expect(history).toHaveLength(3); - expect(history[0]!.role).toBe('user'); - expect(history[1]!.role).toBe('agent'); - expect(history[1]!.agentName).toBe('GoLead'); - expect(history[2]!.role).toBe('system'); - - // Filter by agent - const goHistory = lifecycle.getHistory('GoLead'); - expect(goHistory).toHaveLength(1); - expect(goHistory[0]!.content).toContain('health check'); - }); -}); - -// ============================================================================ -// 8. First-run detection -// ============================================================================ - -describe('Dogfood: First-run ceremony detection', () => { - let root: string; - - beforeEach(() => { - root = makeTempDir('dogfood-firstrun-'); - }); - - afterEach(async () => { - await rm(root, { recursive: true, force: true }); - }); - - it('detects first-run marker and consumes it', () => { - scaffoldSquad(root, { - projectName: 'first-run-test', - description: 'Testing first-run detection.', - agents: [{ name: 'Keaton', role: 'Lead' }], - firstRun: true, - }); - - // First call detects it - const data1 = loadWelcomeData(root); - expect(data1!.isFirstRun).toBe(true); - - // Second call — marker consumed - const data2 = loadWelcomeData(root); - expect(data2!.isFirstRun).toBe(false); - }); - - it('non-first-run projects have isFirstRun=false', () => { - buildMonorepoFixture(root); - const data = loadWelcomeData(root); - expect(data!.isFirstRun).toBe(false); - }); -}); - -// ============================================================================ -// 9. parseCoordinatorResponse — realistic multi-scenario -// ============================================================================ - -describe('Dogfood: parseCoordinatorResponse — realistic scenarios', () => { - it('routes Python test failure to the tester', () => { - const resp = `ROUTE: Carol\nTASK: Investigate and fix the failing test in tests/unit/test_helpers.py\nCONTEXT: pytest reports AssertionError on test_greet`; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('route'); - expect(decision.routes![0]!.agent).toBe('Carol'); - }); - - it('handles freeform LLM response as direct answer', () => { - const resp = 'The project uses Flask for the web framework and pytest for testing. There are 3 team members.'; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('direct'); - expect(decision.directAnswer).toContain('Flask'); - }); - - it('multi-route for cross-team infrastructure change', () => { - const resp = `MULTI:\n- Infra: Update the Kubernetes deployment to use the new Go binary\n- GoLead: Tag a new release for the API service\n- QA: Run the integration test suite against staging`; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('multi'); - expect(decision.routes).toHaveLength(3); - expect(decision.routes!.map((r) => r.agent)).toEqual(['Infra', 'GoLead', 'QA']); - }); - - it('ROUTE without CONTEXT still works', () => { - const resp = `ROUTE: TSWizard\nTASK: Fix the hydration mismatch in App.tsx`; - const decision = parseCoordinatorResponse(resp); - expect(decision.type).toBe('route'); - expect(decision.routes![0]!.context).toBeUndefined(); - }); - - it('empty response falls back to direct', () => { - const decision = parseCoordinatorResponse(''); - expect(decision.type).toBe('direct'); - expect(decision.directAnswer).toBe(''); - }); -}); diff --git a/test/repl-streaming.test.ts b/test/repl-streaming.test.ts deleted file mode 100644 index 907622a5c..000000000 --- a/test/repl-streaming.test.ts +++ /dev/null @@ -1,876 +0,0 @@ -/** - * REPL Streaming Tests - * - * Validates the fix for the streaming dispatch bug where sendMessage() - * resolved before streaming completed, resulting in empty responses. - * - * Tests: - * - dispatchToAgent waits for streamed content via sendAndWait - * - dispatchToCoordinator waits for streamed content via sendAndWait - * - Fallback to turn_end/idle events when sendAndWait is unavailable - * - Empty response handling (graceful fallback) - * - The sendMessage → accumulated → parseCoordinatorResponse pipeline - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { - parseCoordinatorResponse, - SessionRegistry, -} from '../packages/squad-cli/src/cli/shell/index.js'; -import { TIMEOUTS } from '../packages/squad-sdk/src/runtime/constants.js'; - -// ============================================================================ -// Types & mock factories -// ============================================================================ - -type EventHandler = (event: { type: string; [key: string]: unknown }) => void; - -interface MockSquadSession { - sendMessage: ReturnType; - sendAndWait: ReturnType; - on: ReturnType; - off: ReturnType; - close: ReturnType; - sessionId: string; - /** Stored event listeners keyed by event name */ - _listeners: Map>; - /** Helper: emit an event to all registered listeners */ - _emit: (eventName: string, event: { type: string; [key: string]: unknown }) => void; -} - -/** - * Create a mock session that simulates SDK streaming behaviour. - * `sendAndWait` resolves only after all deltas have been emitted. - */ -function createStreamingMockSession(deltas: string[]): MockSquadSession { - const listeners = new Map>(); - - const session: MockSquadSession = { - sessionId: 'mock-session-1', - _listeners: listeners, - - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - - // sendAndWait emits deltas then resolves — simulates the real SDK - sendAndWait: vi.fn(async () => { - for (const d of deltas) { - session._emit('message_delta', { type: 'message_delta', deltaContent: d }); - } - return undefined; - }), - - sendMessage: vi.fn(async () => { - // fire-and-forget: resolves immediately, deltas come later - }), - - close: vi.fn().mockResolvedValue(undefined), - }; - - return session; -} - -/** - * Create a mock session that only has sendMessage (no sendAndWait). - * Deltas are emitted asynchronously after sendMessage resolves. - */ -function createLegacyMockSession(deltas: string[]): MockSquadSession { - const listeners = new Map>(); - - const session: MockSquadSession = { - sessionId: 'mock-legacy-1', - _listeners: listeners, - - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - - // No sendAndWait — deleted below - sendAndWait: undefined as unknown as ReturnType, - - sendMessage: vi.fn(async () => { - // Simulate async streaming: emit deltas then turn_end on next tick - setTimeout(() => { - for (const d of deltas) { - session._emit('message_delta', { type: 'message_delta', deltaContent: d }); - } - session._emit('turn_end', { type: 'turn_end' }); - }, 5); - }), - - close: vi.fn().mockResolvedValue(undefined), - }; - - // Remove sendAndWait to trigger fallback path - delete (session as Record)['sendAndWait']; - - return session; -} - -// ============================================================================ -// Test: awaitStreamedResponse behaviour (extracted from index.ts logic) -// ============================================================================ - -/** - * Reproduces the dispatch logic from index.ts without the Ink/React rendering. - * This tests the core send-then-accumulate pipeline. - */ -async function simulateDispatch( - session: MockSquadSession, - message: string, -): Promise { - let accumulated = ''; - - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const delta = typeof val === 'string' ? val : ''; - if (!delta) return; - accumulated += delta; - }; - - session.on('message_delta', onDelta); - - try { - // Mirror the awaitStreamedResponse logic from index.ts - if (session.sendAndWait) { - await session.sendAndWait({ prompt: message }, TIMEOUTS.SESSION_RESPONSE_MS); - } else { - const done = new Promise((resolve) => { - const onEnd = (): void => { - try { session.off('turn_end', onEnd); } catch { /* ignore */ } - try { session.off('idle', onEnd); } catch { /* ignore */ } - resolve(); - }; - session.on('turn_end', onEnd); - session.on('idle', onEnd); - }); - await session.sendMessage({ prompt: message }); - await done; - } - } finally { - try { session.off('message_delta', onDelta); } catch { /* ignore */ } - } - - return accumulated; -} - -// ============================================================================ -// Tests -// ============================================================================ - -describe('REPL Streaming — dispatchToAgent waits for streamed content', () => { - it('accumulates all deltas via sendAndWait before returning', async () => { - const session = createStreamingMockSession(['Hello', ' world', '!']); - const result = await simulateDispatch(session, 'say hello'); - - expect(result).toBe('Hello world!'); - expect(session.sendAndWait).toHaveBeenCalledWith({ prompt: 'say hello' }, TIMEOUTS.SESSION_RESPONSE_MS); - expect(session.sendMessage).not.toHaveBeenCalled(); - }); - - it('accumulates deltas via fallback turn_end when sendAndWait missing', async () => { - const session = createLegacyMockSession(['Fallback', ' works']); - const result = await simulateDispatch(session, 'test fallback'); - - expect(result).toBe('Fallback works'); - expect(session.sendMessage).toHaveBeenCalledWith({ prompt: 'test fallback' }); - }); - - it('handles single-chunk response', async () => { - const session = createStreamingMockSession(['complete answer']); - const result = await simulateDispatch(session, 'one chunk'); - - expect(result).toBe('complete answer'); - }); - - it('handles many small deltas', async () => { - const chunks = 'abcdefghijklmnopqrstuvwxyz'.split(''); - const session = createStreamingMockSession(chunks); - const result = await simulateDispatch(session, 'many chunks'); - - expect(result).toBe('abcdefghijklmnopqrstuvwxyz'); - }); -}); - -describe('REPL Streaming — dispatchToCoordinator waits for streamed content', () => { - it('coordinator response is fully accumulated before parsing', async () => { - const coordinatorReply = '## Routing\n- **Agent:** kovash\n- **Task:** fix the REPL bug'; - const chunks = [coordinatorReply.slice(0, 20), coordinatorReply.slice(20)]; - const session = createStreamingMockSession(chunks); - - const accumulated = await simulateDispatch(session, 'fix the shell'); - const decision = parseCoordinatorResponse(accumulated); - - // The accumulated text should be the full coordinator reply - expect(accumulated).toBe(coordinatorReply); - // parseCoordinatorResponse should return a valid decision (not empty) - expect(decision).toBeDefined(); - expect(typeof decision.type).toBe('string'); - }); - - it('coordinator with sendAndWait accumulates before parseCoordinatorResponse', async () => { - const reply = 'I can help with that directly. The answer is 42.'; - const session = createStreamingMockSession([reply]); - - const accumulated = await simulateDispatch(session, 'what is the answer?'); - const decision = parseCoordinatorResponse(accumulated); - - expect(accumulated).toBe(reply); - // Should be a direct answer since no routing markers - expect(decision.type).toBe('direct'); - expect(decision.directAnswer).toBeTruthy(); - }); -}); - -describe('REPL Streaming — empty response handling', () => { - it('returns empty string when no deltas are emitted', async () => { - const session = createStreamingMockSession([]); - const result = await simulateDispatch(session, 'hello?'); - - expect(result).toBe(''); - }); - - it('parseCoordinatorResponse handles empty accumulated gracefully', () => { - const decision = parseCoordinatorResponse(''); - - expect(decision).toBeDefined(); - expect(decision.type).toBe('direct'); - }); - - it('returns empty when deltas contain only empty strings', async () => { - const session = createStreamingMockSession(['', '', '']); - const result = await simulateDispatch(session, 'empty deltas'); - - expect(result).toBe(''); - }); -}); - -describe('REPL Streaming — sendMessage → accumulated → parseCoordinatorResponse pipeline', () => { - it('BUG REPRO: old sendMessage-only flow would have empty accumulated', async () => { - // This demonstrates the original bug: sendMessage resolves immediately, - // so accumulated is empty when you try to parse. - const session = createStreamingMockSession(['Should be', ' captured']); - - // Simulate the OLD buggy code: call sendMessage (fire-and-forget) - let buggyAccumulated = ''; - session.on('message_delta', (event: { type: string; [key: string]: unknown }) => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - buggyAccumulated += typeof val === 'string' ? val : ''; - }); - - // With old code: sendMessage returns immediately, no deltas yet - await session.sendMessage({ prompt: 'test' }); - - // Old code would parse here — accumulated is empty because sendMessage - // is fire-and-forget. (In our mock, sendMessage doesn't emit deltas at all.) - expect(buggyAccumulated).toBe(''); - - // NEW code: sendAndWait waits for deltas - const fixedResult = await simulateDispatch(session, 'test'); - expect(fixedResult).toBe('Should be captured'); - }); - - it('end-to-end pipeline: send → stream → accumulate → parse → route', async () => { - const routingResponse = [ - '## Routing Decision\n', - '- **Agent:** kovash\n', - '- **Task:** Fix the streaming bug\n', - ]; - const session = createStreamingMockSession(routingResponse); - - const accumulated = await simulateDispatch(session, 'fix streaming'); - expect(accumulated.length).toBeGreaterThan(0); - - const decision = parseCoordinatorResponse(accumulated); - expect(decision).toBeDefined(); - // Whether it routes or is direct depends on parsing, but it shouldn't be empty - expect(decision.type).toBeTruthy(); - }); - - it('fallback path resolves on idle event instead of turn_end', async () => { - const listeners = new Map>(); - const session: MockSquadSession = { - sessionId: 'idle-test', - _listeners: listeners, - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - sendAndWait: undefined as unknown as ReturnType, - sendMessage: vi.fn(async () => { - // Emit deltas then idle (not turn_end) - setTimeout(() => { - session._emit('message_delta', { type: 'message_delta', deltaContent: 'via idle' }); - session._emit('idle', { type: 'idle' }); - }, 5); - }), - close: vi.fn().mockResolvedValue(undefined), - }; - delete (session as Record)['sendAndWait']; - - const result = await simulateDispatch(session, 'test idle'); - expect(result).toBe('via idle'); - }); - - it('delta events with content key instead of delta key are handled', async () => { - const listeners = new Map>(); - const session = createStreamingMockSession([]); - // Override sendAndWait to emit events with 'content' key instead of 'deltaContent' - session.sendAndWait = vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', content: 'content-key' }); - }); - - const result = await simulateDispatch(session, 'content key test'); - expect(result).toBe('content-key'); - }); - - it('delta events with legacy delta key are handled', async () => { - const listeners = new Map>(); - const session = createStreamingMockSession([]); - // Override sendAndWait to emit events with 'delta' key instead of 'deltaContent' - session.sendAndWait = vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', delta: 'legacy-delta' }); - }); - - const result = await simulateDispatch(session, 'legacy delta test'); - expect(result).toBe('legacy-delta'); - }); -}); - -// ============================================================================ -// Tests — extractDelta with deltaContent (SDK actual format) -// -// The SDK emits `assistant.message_delta` events where the text lives in -// `deltaContent`, not `delta` or `content`. After normalizeEvent() spreads -// sdkEvent.data, the event object looks like: -// { type: 'message_delta', messageId: '...', deltaContent: 'chunk' } -// -// The fixed extractDelta should check: -// event['deltaContent'] ?? event['delta'] ?? event['content'] -// ============================================================================ - -/** - * Mirrors the FIXED extractDelta from index.ts (Kovash's patch). - * Tests will call this directly to validate field-priority behaviour. - */ -function extractDelta(event: { type: string; [key: string]: unknown }): string { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - return typeof val === 'string' ? val : ''; -} - -/** - * Like simulateDispatch but uses the fixed extractDelta (deltaContent-aware). - */ -async function simulateDispatchFixed( - session: MockSquadSession, - message: string, -): Promise { - let accumulated = ''; - - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const delta = extractDelta(event); - if (!delta) return; - accumulated += delta; - }; - - session.on('message_delta', onDelta); - - try { - if (session.sendAndWait) { - await session.sendAndWait({ prompt: message }, TIMEOUTS.SESSION_RESPONSE_MS); - } else { - const done = new Promise((resolve) => { - const onEnd = (): void => { - try { session.off('turn_end', onEnd); } catch { /* ignore */ } - try { session.off('idle', onEnd); } catch { /* ignore */ } - resolve(); - }; - session.on('turn_end', onEnd); - session.on('idle', onEnd); - }); - await session.sendMessage({ prompt: message }); - await done; - } - } finally { - try { session.off('message_delta', onDelta); } catch { /* ignore */ } - } - - return accumulated; -} - -/** - * Create a mock session that emits deltaContent (SDK actual format). - */ -function createDeltaContentMockSession(deltas: string[]): MockSquadSession { - const listeners = new Map>(); - - const session: MockSquadSession = { - sessionId: 'mock-dc-session', - _listeners: listeners, - - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - - sendAndWait: vi.fn(async () => { - for (const d of deltas) { - session._emit('message_delta', { - type: 'message_delta', - messageId: 'msg-1', - deltaContent: d, - }); - } - return undefined; - }), - - sendMessage: vi.fn(async () => {}), - close: vi.fn().mockResolvedValue(undefined), - }; - - return session; -} - -// ============================================================================ -// Tests — dispatchToCoordinator flow (deep integration) -// -// These tests exercise the FULL dispatch flow including the awaitStreamedResponse -// fallback path, session config verification, and the empty-response bug scenario. -// ============================================================================ - -/** - * Mirrors the real dispatchToCoordinator + awaitStreamedResponse pipeline - * including the fallback path when sendAndWait returns data but deltas are empty. - */ -async function simulateDispatchWithFallback( - session: MockSquadSession, - message: string, -): Promise { - let accumulated = ''; - - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const delta = typeof val === 'string' ? val : ''; - if (!delta) return; - accumulated += delta; - }; - - session.on('message_delta', onDelta); - - try { - if (session.sendAndWait) { - const result = await session.sendAndWait({ prompt: message }, TIMEOUTS.SESSION_RESPONSE_MS); - // Mirror awaitStreamedResponse fallback: extract data.content from result - const data = (result as Record | undefined)?.['data'] as Record | undefined; - const fallback = typeof data?.['content'] === 'string' ? data['content'] as string : ''; - if (!accumulated && fallback) { - accumulated = fallback; - } - } else { - const done = new Promise((resolve) => { - const onEnd = (): void => { - try { session.off('turn_end', onEnd); } catch { /* ignore */ } - try { session.off('idle', onEnd); } catch { /* ignore */ } - resolve(); - }; - session.on('turn_end', onEnd); - session.on('idle', onEnd); - }); - await session.sendMessage({ prompt: message }); - await done; - } - } finally { - try { session.off('message_delta', onDelta); } catch { /* ignore */ } - } - - return accumulated; -} - -/** - * Simulate the CopilotSessionAdapter.normalizeEvent() logic. - * Maps dotted SDK event types to short names and flattens data onto top-level. - */ -function normalizeEvent(sdkEvent: { type: string; data?: Record; [key: string]: unknown }): { type: string; [key: string]: unknown } { - const REVERSE_EVENT_MAP: Record = { - 'assistant.message_delta': 'message_delta', - 'assistant.message': 'message', - 'assistant.usage': 'usage', - 'assistant.reasoning_delta': 'reasoning_delta', - 'assistant.reasoning': 'reasoning', - 'assistant.turn_start': 'turn_start', - 'assistant.turn_end': 'turn_end', - 'assistant.intent': 'intent', - 'session.idle': 'idle', - 'session.error': 'error', - }; - const squadType = REVERSE_EVENT_MAP[sdkEvent.type] ?? sdkEvent.type; - return { - type: squadType, - ...(sdkEvent.data ?? {}), - }; -} - -describe('dispatchToCoordinator flow', () => { - it('coordinator session receives streaming: true config', async () => { - // Mock SquadClient.createSession to verify config - const createSessionSpy = vi.fn(async (config: Record) => { - // Return a mock session - return createStreamingMockSession(['DIRECT: OK']); - }); - - // Simulate the coordinator session creation path - const config = { - streaming: true, - systemMessage: { mode: 'append', content: 'test prompt' }, - workingDirectory: '/test', - }; - const session = await createSessionSpy(config); - - expect(createSessionSpy).toHaveBeenCalledWith( - expect.objectContaining({ streaming: true }) - ); - // Verify the streaming flag wasn't silently dropped - const passedConfig = createSessionSpy.mock.calls[0]![0]!; - expect(passedConfig['streaming']).toBe(true); - }); - - it('on(message_delta) handler receives normalized events and accumulates', () => { - let accumulated = ''; - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const delta = typeof val === 'string' ? val : ''; - if (!delta) return; - accumulated += delta; - }; - - // Simulate CopilotSessionAdapter behavior: normalize then deliver - const event1 = normalizeEvent({ - type: 'assistant.message_delta', - data: { deltaContent: 'hello', messageId: 'msg-1' }, - }); - onDelta(event1); - expect(accumulated).toBe('hello'); - - const event2 = normalizeEvent({ - type: 'assistant.message_delta', - data: { deltaContent: ' world', messageId: 'msg-1' }, - }); - onDelta(event2); - expect(accumulated).toBe('hello world'); - }); - - it('sendAndWait fallback provides content when deltas are empty', async () => { - const session = createStreamingMockSession([]); - // Override sendAndWait to return fallback data but emit no deltas - session.sendAndWait = vi.fn(async () => { - // No deltas emitted — simulates SDK returning full response without streaming - return { data: { content: 'full response' } }; - }); - - const result = await simulateDispatchWithFallback(session, 'get response'); - - expect(result).toBe('full response'); - expect(session.sendAndWait).toHaveBeenCalledWith({ prompt: 'get response' }, TIMEOUTS.SESSION_RESPONSE_MS); - }); - - it('empty sendAndWait + empty deltas = empty accumulated (regression)', async () => { - const session = createStreamingMockSession([]); - // sendAndWait returns nothing useful — no data.content, no deltas - session.sendAndWait = vi.fn(async () => { - // No deltas emitted, no data returned - return undefined; - }); - - const result = await simulateDispatchWithFallback(session, 'silence'); - - // THIS IS THE BUG SCENARIO: both paths produce nothing → empty string - expect(result).toBe(''); - // Verify that parseCoordinatorResponse sees this empty string - const decision = parseCoordinatorResponse(result); - expect(decision.type).toBe('direct'); - expect(decision.directAnswer).toBe(''); - }); - - it('parseCoordinatorResponse handles empty string', () => { - const decision = parseCoordinatorResponse(''); - expect(decision).toBeDefined(); - expect(decision.type).toBe('direct'); - // Empty string trimmed is still empty — becomes directAnswer - expect(decision.directAnswer).toBe(''); - }); - - it('sendAndWait fallback ignored when deltas provide content', async () => { - const session = createStreamingMockSession([]); - // sendAndWait emits deltas AND returns fallback — deltas should win - session.sendAndWait = vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', deltaContent: 'streamed' }); - return { data: { content: 'fallback should be ignored' } }; - }); - - const result = await simulateDispatchWithFallback(session, 'both paths'); - // Deltas took priority — fallback not used because accumulated is non-empty - expect(result).toBe('streamed'); - }); - - // SQUAD_DEBUG env var is not yet implemented in the dispatch pipeline. - // This test documents the gap — when the feature is added, remove the skip. - it.todo('SQUAD_DEBUG env var enables diagnostic logging'); -}); - -describe('CopilotSessionAdapter event normalization', () => { - it('normalizeEvent flattens data.deltaContent to top level', () => { - const sdkEvent = { - type: 'assistant.message_delta', - data: { deltaContent: 'test', messageId: 'abc' }, - }; - const normalized = normalizeEvent(sdkEvent); - - expect(normalized['deltaContent']).toBe('test'); - expect(normalized['messageId']).toBe('abc'); - expect(normalized.type).toBe('message_delta'); - }); - - it('normalizeEvent maps all known SDK event types', () => { - const mappings: Array<[string, string]> = [ - ['assistant.message_delta', 'message_delta'], - ['assistant.turn_end', 'turn_end'], - ['session.idle', 'idle'], - ['session.error', 'error'], - ]; - for (const [sdkType, squadType] of mappings) { - const normalized = normalizeEvent({ type: sdkType }); - expect(normalized.type).toBe(squadType); - } - }); - - it('normalizeEvent passes through unknown event types', () => { - const normalized = normalizeEvent({ type: 'custom.event', data: { foo: 'bar' } }); - expect(normalized.type).toBe('custom.event'); - expect(normalized['foo']).toBe('bar'); - }); - - it('normalizeEvent handles missing data gracefully', () => { - const normalized = normalizeEvent({ type: 'assistant.message_delta' }); - expect(normalized.type).toBe('message_delta'); - // No data spread → only type present - expect(normalized['deltaContent']).toBeUndefined(); - }); - - it('on/off properly tracks handler references', () => { - const session = createStreamingMockSession([]); - let callCount = 0; - const handler: EventHandler = () => { callCount++; }; - - // Register and fire - session.on('message_delta', handler); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'x' }); - expect(callCount).toBe(1); - - // Unregister and fire again — should NOT increment - session.off('message_delta', handler); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'y' }); - expect(callCount).toBe(1); - - // Verify on/off were called - expect(session.on).toHaveBeenCalledWith('message_delta', handler); - expect(session.off).toHaveBeenCalledWith('message_delta', handler); - }); - - it('multiple handlers on same event fire independently', () => { - const session = createStreamingMockSession([]); - let count1 = 0; - let count2 = 0; - const handler1: EventHandler = () => { count1++; }; - const handler2: EventHandler = () => { count2++; }; - - session.on('message_delta', handler1); - session.on('message_delta', handler2); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'z' }); - - expect(count1).toBe(1); - expect(count2).toBe(1); - - // Remove one, other still fires - session.off('message_delta', handler1); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'w' }); - expect(count1).toBe(1); - expect(count2).toBe(2); - }); -}); - -describe('extractDelta — field priority (deltaContent > delta > content)', () => { - it('extracts deltaContent (SDK actual format)', () => { - const event = { type: 'message_delta', messageId: 'msg-1', deltaContent: 'hello' }; - expect(extractDelta(event)).toBe('hello'); - }); - - it('extracts delta (legacy/alternative format)', () => { - const event = { type: 'message_delta', delta: 'legacy chunk' }; - expect(extractDelta(event)).toBe('legacy chunk'); - }); - - it('extracts content (fallback format)', () => { - const event = { type: 'message_delta', content: 'content fallback' }; - expect(extractDelta(event)).toBe('content fallback'); - }); - - it('returns empty string when no recognised field is present', () => { - const event = { type: 'message_delta', text: 'nope' }; - expect(extractDelta(event)).toBe(''); - }); - - it('returns empty string when deltaContent is non-string (number)', () => { - const event = { type: 'message_delta', deltaContent: 42 }; - expect(extractDelta(event)).toBe(''); - }); - - it('returns empty string when deltaContent is non-string (object)', () => { - const event = { type: 'message_delta', deltaContent: { nested: true } }; - expect(extractDelta(event)).toBe(''); - }); - - it('prefers deltaContent over delta and content', () => { - const event = { - type: 'message_delta', - deltaContent: 'preferred', - delta: 'not-this', - content: 'nor-this', - }; - expect(extractDelta(event)).toBe('preferred'); - }); - - it('falls back to delta when deltaContent is undefined', () => { - const event = { - type: 'message_delta', - deltaContent: undefined, - delta: 'fallback-delta', - content: 'not-this', - }; - expect(extractDelta(event)).toBe('fallback-delta'); - }); -}); - -describe('Delta accumulation — full flow with deltaContent events', () => { - it('accumulates deltaContent chunks into complete text', async () => { - const session = createDeltaContentMockSession(['Hello', ', ', 'world', '!']); - const result = await simulateDispatchFixed(session, 'greet me'); - - expect(result).toBe('Hello, world!'); - expect(session.sendAndWait).toHaveBeenCalledWith({ prompt: 'greet me' }, TIMEOUTS.SESSION_RESPONSE_MS); - }); - - it('handles single deltaContent chunk', async () => { - const session = createDeltaContentMockSession(['complete answer']); - const result = await simulateDispatchFixed(session, 'single'); - - expect(result).toBe('complete answer'); - }); - - it('handles many small deltaContent chunks', async () => { - const chars = 'the quick brown fox'.split(''); - const session = createDeltaContentMockSession(chars); - const result = await simulateDispatchFixed(session, 'fox'); - - expect(result).toBe('the quick brown fox'); - }); - - it('returns empty string when no deltaContent chunks emitted', async () => { - const session = createDeltaContentMockSession([]); - const result = await simulateDispatchFixed(session, 'silence'); - - expect(result).toBe(''); - }); -}); - -describe('Coordinator dispatch — deltaContent accumulation + fallback', () => { - it('coordinator response accumulated from deltaContent is parsed correctly', async () => { - const chunks = [ - '## Routing\n', - '- **Agent:** kovash\n', - '- **Task:** fix the delta bug\n', - ]; - const session = createDeltaContentMockSession(chunks); - const accumulated = await simulateDispatchFixed(session, 'fix deltas'); - const decision = parseCoordinatorResponse(accumulated); - - expect(accumulated).toBe('## Routing\n- **Agent:** kovash\n- **Task:** fix the delta bug\n'); - expect(decision).toBeDefined(); - expect(typeof decision.type).toBe('string'); - }); - - it('falls back to direct answer when deltaContent accumulates empty', async () => { - const session = createDeltaContentMockSession([]); - const accumulated = await simulateDispatchFixed(session, 'nothing here'); - const decision = parseCoordinatorResponse(accumulated); - - // Empty accumulated → parseCoordinatorResponse should return a direct/fallback decision - expect(decision.type).toBe('direct'); - }); - - it('simulateDispatch now captures deltaContent events (bug is fixed)', async () => { - // After the fix, simulateDispatch checks deltaContent first, - // so it correctly captures SDK delta events. - const session = createDeltaContentMockSession(['This ', 'is ', 'captured']); - const result = await simulateDispatch(session, 'captured message'); - - // FIXED: deltaContent is now picked up by simulateDispatch - expect(result).toBe('This is captured'); - - // simulateDispatchFixed also works (same priority order) - session.sendAndWait.mockClear(); - session.sendAndWait.mockImplementation(async () => { - for (const d of ['This ', 'is ', 'found']) { - session._emit('message_delta', { - type: 'message_delta', - messageId: 'msg-2', - deltaContent: d, - }); - } - return undefined; - }); - - const fixedResult = await simulateDispatchFixed(session, 'found message'); - expect(fixedResult).toBe('This is found'); - }); -}); diff --git a/test/repl-ux-e2e.test.ts b/test/repl-ux-e2e.test.ts deleted file mode 100644 index c0cd8df28..000000000 --- a/test/repl-ux-e2e.test.ts +++ /dev/null @@ -1,354 +0,0 @@ -/** - * REPL UX End-to-End Tests - * - * Spawns the real Squad CLI via child_process and verifies what humans actually see. - * No mocks — these tests exercise the CLI binary and capture real terminal output. - * - * E2E tests — REPL UX validation - * - * @see .squad/agents/breedan/charter.md - */ - -import { describe, it, expect, afterEach, beforeEach } from 'vitest'; -import { mkdtempSync, rmSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { resolve } from 'node:path'; - -// ─── Helpers ──────────────────────────────────────────────────────────────── - -const CLI_ENTRY = resolve(process.cwd(), 'packages/squad-cli/dist/cli-entry.js'); - -/** Strip ANSI escape codes for clean text comparison. */ -function stripAnsi(text: string): string { - // eslint-disable-next-line no-control-regex - return text.replace(/\x1B\[[0-9;]*[a-zA-Z]/g, ''); -} - -/** Shared env vars that suppress colour/interactive features for deterministic output. */ -function cleanEnv(extra: Record = {}): Record { - return { - ...process.env as Record, - COLUMNS: '80', - LINES: '24', - TERM: 'dumb', - NO_COLOR: '1', - NODE_NO_WARNINGS: '1', - ...extra, - }; -} - -interface CliResult { - stdout: string; - stderr: string; - combined: string; - exitCode: number | null; -} - -/** - * Spawn the CLI with given args, capture stdout + stderr separately, wait for exit. - * Kills process after timeoutMs to prevent hangs. - */ -function runCli( - args: string[], - options?: { cwd?: string; env?: Record; timeoutMs?: number }, -): Promise { - const timeoutMs = options?.timeoutMs ?? 15_000; - - return new Promise((resolveP, reject) => { - let stdout = ''; - let stderr = ''; - let settled = false; - - const child: ChildProcess = spawn('node', [CLI_ENTRY, ...args], { - cwd: options?.cwd ?? process.cwd(), - env: cleanEnv(options?.env ?? {}), - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - - child.stdout?.on('data', (buf: Buffer) => { stdout += buf.toString(); }); - child.stderr?.on('data', (buf: Buffer) => { stderr += buf.toString(); }); - - const timer = setTimeout(() => { - if (!settled) { - child.kill('SIGTERM'); - setTimeout(() => { if (!settled) child.kill('SIGKILL'); }, 2000); - } - }, timeoutMs); - - child.on('exit', (code) => { - settled = true; - clearTimeout(timer); - resolveP({ stdout, stderr, combined: stdout + stderr, exitCode: code }); - }); - - child.on('error', (err) => { - settled = true; - clearTimeout(timer); - reject(err); - }); - - // Close stdin immediately — we're testing non-interactive output - child.stdin?.end(); - }); -} - -// ─── Tests ────────────────────────────────────────────────────────────────── - -describe('REPL UX E2E — What Users Actually See', { timeout: 30_000 }, () => { - let tempDir: string; - - beforeEach(() => { - tempDir = mkdtempSync(join(tmpdir(), 'squad-e2e-')); - }); - - afterEach(() => { - try { - rmSync(tempDir, { recursive: true, force: true }); - } catch { - // Best-effort cleanup on Windows - } - }); - - // ──────────────────────────────────────────────────────────────────────── - // Test 1: First Run — No Team Exists - // ──────────────────────────────────────────────────────────────────────── - describe('First Run — No Team Exists', () => { - // Isolate from any real global squad on the host machine so the CLI - // takes the "no squad anywhere" code-path (welcome banner, exit 0). - const noGlobalSquadEnv = () => ({ - APPDATA: tempDir, - LOCALAPPDATA: tempDir, - XDG_CONFIG_HOME: tempDir, - }); - - it('shows welcome message when no .squad/ exists', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const output = stripAnsi(result.combined); - - // Non-TTY: CLI shows either "Welcome to Squad" (no squad found) - // or "requires an interactive terminal" (if a global squad is detected) - expect(output).toMatch(/Welcome to Squad|requires an interactive terminal/); - }); - - it('banner appears exactly once (not duplicated)', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const output = stripAnsi(result.combined); - - // Non-TTY: expect either "Welcome to Squad" or TTY error, appearing once - const bannerMatches = output.match(/Welcome to Squad/g); - const ttyMatches = output.match(/requires an interactive terminal/g); - const totalMatches = (bannerMatches?.length ?? 0) + (ttyMatches?.length ?? 0); - expect(totalMatches, 'Banner or TTY message should appear exactly once').toBe(1); - }); - - it('no "coordinator:" label in user-visible output', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const output = stripAnsi(result.combined); - - // "coordinator:" should never appear outside debug mode - expect(output).not.toMatch(/coordinator:/i); - }); - - it('init prompt/suggestion is visible and prominent', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const output = stripAnsi(result.combined); - - // In non-TTY without squad: shows "squad init" and "Get started" - // In non-TTY with squad detected: shows TTY requirement or "Loading Squad shell" - // When process hangs (enters interactive mode), output may only have loading message - if (output.length > 0) { - expect(output).toMatch(/squad init|squad --preview|Loading Squad shell|Welcome/); - } - }); - - it('no SQLite ExperimentalWarning in output', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const combined = stripAnsi(result.combined); - - expect(combined).not.toContain('ExperimentalWarning'); - }); - - it('no "Resumed session" message on first run', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - const output = stripAnsi(result.combined); - - expect(output).not.toMatch(/Resumed session/i); - }); - - it('exits cleanly with code 0, 1, or null (killed by timeout if interactive)', async () => { - const result = await runCli([], { cwd: tempDir, env: noGlobalSquadEnv() }); - - // Exit 0 when no squad (welcome message), exit 1 when TTY required, - // null when process hangs in interactive mode and is killed by timeout - expect([0, 1, null]).toContain(result.exitCode); - }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // Test 2: Clean Output — No Warnings - // ──────────────────────────────────────────────────────────────────────── - describe('Clean Output — No Warnings', () => { - it('no ExperimentalWarning on --help', async () => { - const result = await runCli(['--help'], { cwd: tempDir }); - - expect(stripAnsi(result.stderr)).not.toContain('ExperimentalWarning'); - expect(stripAnsi(result.stdout)).not.toContain('ExperimentalWarning'); - }); - - it('no ExperimentalWarning on --version', async () => { - const result = await runCli(['--version'], { cwd: tempDir }); - - expect(stripAnsi(result.stderr)).not.toContain('ExperimentalWarning'); - expect(stripAnsi(result.stdout)).not.toContain('ExperimentalWarning'); - }); - - it('no ExperimentalWarning on first-run (no .squad/)', async () => { - const result = await runCli([], { cwd: tempDir }); - - expect(stripAnsi(result.stderr)).not.toContain('ExperimentalWarning'); - expect(stripAnsi(result.stdout)).not.toContain('ExperimentalWarning'); - }); - - it('no Node.js internal warnings visible to user on --help', async () => { - const result = await runCli(['--help'], { cwd: tempDir }); - const stderr = stripAnsi(result.stderr); - - // No Node.js internal warning patterns - expect(stderr).not.toMatch(/\(node:\d+\)/); - expect(stderr).not.toContain('DeprecationWarning'); - }); - - it('stderr is clean on --version', async () => { - const result = await runCli(['--version'], { cwd: tempDir }); - const stderr = stripAnsi(result.stderr).trim(); - - // Stderr should be empty for --version - expect(stderr).toBe(''); - }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // Test 3: Banner Renders Once - // ──────────────────────────────────────────────────────────────────────── - describe('Banner Renders Once', () => { - it('version banner appears exactly once on --help', async () => { - const result = await runCli(['--help']); - const output = stripAnsi(result.stdout); - - // Help screen shows "squad v{VERSION}" — should appear once - const versionMatches = output.match(/squad\s+v\d+\.\d+\.\d+/gi); - expect(versionMatches, 'Version banner must appear exactly once').toHaveLength(1); - }); - - it('first-run welcome appears exactly once', async () => { - // Isolate from host global squad so CLI takes the first-run path - const noGlobalEnv = { APPDATA: tempDir, LOCALAPPDATA: tempDir, XDG_CONFIG_HOME: tempDir }; - const result = await runCli([], { cwd: tempDir, env: noGlobalEnv }); - const output = stripAnsi(result.combined); - - // Non-TTY: welcome appears once, TTY error appears once, or - // process may enter interactive mode and output "Loading Squad shell..." - const welcomeMatches = output.match(/Welcome to Squad/g); - const ttyMatches = output.match(/requires an interactive terminal/g); - const loadingMatches = output.match(/Loading Squad shell/g); - const total = (welcomeMatches?.length ?? 0) + (ttyMatches?.length ?? 0) + (loadingMatches?.length ?? 0); - expect(total, 'Welcome, TTY, or Loading message must appear at least once').toBeGreaterThanOrEqual(1); - }); - - it('no duplicate "Your AI agent team" tagline', async () => { - // Isolate from host global squad so CLI takes the first-run path - const noGlobalEnv = { APPDATA: tempDir, LOCALAPPDATA: tempDir, XDG_CONFIG_HOME: tempDir }; - const result = await runCli([], { cwd: tempDir, env: noGlobalEnv }); - const output = stripAnsi(result.combined); - - const taglineMatches = output.match(/Your AI agent team/g); - // Should appear at most once - expect( - (taglineMatches?.length ?? 0) <= 1, - `Tagline should appear at most once, found: ${taglineMatches?.length ?? 0}`, - ).toBe(true); - }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // Test 4: Message Labels - // ──────────────────────────────────────────────────────────────────────── - describe('Message Labels', () => { - it('--help output uses "Squad" not "coordinator" in user-facing text', async () => { - const result = await runCli(['--help']); - const output = stripAnsi(result.stdout); - - // Help text should reference "squad" the product, not "coordinator" - expect(output.toLowerCase()).toContain('squad'); - expect(output).not.toMatch(/\bcoordinator\b/i); - }); - - it('first-run message uses "Squad" branding', async () => { - const result = await runCli([], { cwd: tempDir }); - const output = stripAnsi(result.combined); - - expect(output).toContain('Squad'); - expect(output).not.toMatch(/\bcoordinator\b/i); - }); - - it('error messages use "squad" not "coordinator"', async () => { - const result = await runCli(['nonexistent-command-xyz']); - const output = stripAnsi(result.combined); - - // Error output should reference "squad help", not "coordinator" - expect(output).toMatch(/squad/i); - expect(output).not.toMatch(/\bcoordinator\b/i); - }); - }); - - // ──────────────────────────────────────────────────────────────────────── - // Test 5: Markdown Rendering (static output check) - // ──────────────────────────────────────────────────────────────────────── - describe('Markdown Rendering', () => { - it('help text uses bold formatting (no raw asterisks)', async () => { - // When NO_COLOR=1, bold is suppressed — but we check raw ANSI output - // to verify no raw **bold** markdown leaks through - const result = await runCli(['--help'], { - cwd: tempDir, - env: { NO_COLOR: '', TERM: 'xterm-256color' }, - }); - const rawOutput = result.stdout; - - // Should not contain literal **text** markdown - expect(rawOutput).not.toMatch(/\*\*[^*]+\*\*/); - }); - - it('first-run output has no raw markdown asterisks', async () => { - const result = await runCli([], { cwd: tempDir }); - const output = result.combined; - - // No raw **bold** or *italic* markdown should leak to terminal - expect(output).not.toMatch(/\*\*[^*]+\*\*/); - expect(output).not.toMatch(/(? { - it('status command mentions no squad when run in empty dir', async () => { - const result = await runCli(['status'], { cwd: tempDir }); - const output = stripAnsi(result.combined); - - // Status should indicate no squad found, or show active squad status - expect(output).toMatch(/not found|no squad|no .squad|Active squad/i); - }); - - it('doctor command works in empty dir without crashing', async () => { - const result = await runCli(['doctor'], { cwd: tempDir }); - - // Doctor should exit without crashing - expect(result.exitCode).not.toBeNull(); - expect(stripAnsi(result.combined)).not.toContain('ExperimentalWarning'); - }); - }); -}); diff --git a/test/repl-ux-fixes.test.ts b/test/repl-ux-fixes.test.ts deleted file mode 100644 index d72967626..000000000 --- a/test/repl-ux-fixes.test.ts +++ /dev/null @@ -1,931 +0,0 @@ -/** - * REPL UX Fixes — comprehensive tests for issues #596–#604 - * - * Tests what humans will see: rendered output, prompt content, file creation, - * message labels, markdown formatting, warning suppression, and session gating. - * - * @module test/repl-ux-fixes - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { Text } from 'ink'; - -import { - buildCoordinatorPrompt, - formatConversationContext, -} from '@bradygaster/squad-cli/shell/coordinator'; -import type { CoordinatorConfig } from '@bradygaster/squad-cli/shell/coordinator'; -import { - createSession, - loadLatestSession, - saveSession, -} from '@bradygaster/squad-cli/shell/session-store'; -import type { SessionData } from '@bradygaster/squad-cli/shell/session-store'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import type { ShellMessage, AgentSession } from '@bradygaster/squad-cli/shell/types'; - -const h = React.createElement; - -// ============================================================================ -// Helpers -// ============================================================================ - -function makeTmpRoot(): string { - return mkdtempSync(join(tmpdir(), 'squad-ux-test-')); -} - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { timestamp: new Date(), ...overrides }; -} - -function writeTeamMd(root: string): void { - const squadDir = join(root, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'team.md'), `# Squad Team — Test - -> A test team - -## Members -| Name | Role | Charter | Status | -|------|------|---------|--------| -| Fenster | Core Dev | \`.squad/agents/fenster/charter.md\` | ✅ Active | -| Hockney | Tester | \`.squad/agents/hockney/charter.md\` | ✅ Active | -`); -} - -function writeRoutingMd(root: string): void { - const squadDir = join(root, '.squad'); - mkdirSync(squadDir, { recursive: true }); - writeFileSync(join(squadDir, 'routing.md'), `# Routing Rules -Route feature work to Fenster, testing to Hockney. -`); -} - -// ============================================================================ -// #596 — Init creates complete .squad/ directory -// ============================================================================ - -describe('#596 — Init creates complete .squad/ directory', () => { - let tmpRoot: string; - - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('runInit creates all required directories and structural files', async () => { - // runInit is the CLI init command — requires templates to exist. - // Import and run it against a temp directory. - const { runInit } = await import('../packages/squad-cli/src/cli/core/init.js'); - - // Suppress console output from init ceremony - const origLog = console.log; - const origWrite = process.stdout.write; - console.log = vi.fn(); - process.stdout.write = vi.fn().mockReturnValue(true) as any; - - try { - await runInit(tmpRoot); - } finally { - console.log = origLog; - process.stdout.write = origWrite; - } - - // Verify .squad/ directory structure - const squadDir = join(tmpRoot, '.squad'); - expect(existsSync(squadDir)).toBe(true); - - // Required directories - expect(existsSync(join(squadDir, 'decisions', 'inbox'))).toBe(true); - expect(existsSync(join(squadDir, 'orchestration-log'))).toBe(true); - expect(existsSync(join(squadDir, 'casting'))).toBe(true); - expect(existsSync(join(squadDir, 'plugins'))).toBe(true); - expect(existsSync(join(squadDir, 'identity'))).toBe(true); - - // Skills now live in .copilot/skills/ (not .squad/skills/) - expect(existsSync(join(tmpRoot, '.copilot', 'skills'))).toBe(true); - - // Required files - expect(existsSync(join(squadDir, 'ceremonies.md'))).toBe(true); - expect(existsSync(join(squadDir, 'identity', 'now.md'))).toBe(true); - expect(existsSync(join(squadDir, 'identity', 'wisdom.md'))).toBe(true); - - // First-run marker - expect(existsSync(join(squadDir, '.first-run'))).toBe(true); - - // .github/agents/squad.agent.md - expect(existsSync(join(tmpRoot, '.github', 'agents', 'squad.agent.md'))).toBe(true); - - // .gitattributes merge rules - const gitattributes = readFileSync(join(tmpRoot, '.gitattributes'), 'utf-8'); - expect(gitattributes).toContain('.squad/decisions.md merge=union'); - expect(gitattributes).toContain('.squad/agents/*/history.md merge=union'); - - // .gitignore entries - const gitignore = readFileSync(join(tmpRoot, '.gitignore'), 'utf-8'); - expect(gitignore).toContain('.squad/orchestration-log/'); - }); - - it('creates decisions/inbox/ directory for decision drops', async () => { - const { runInit } = await import('../packages/squad-cli/src/cli/core/init.js'); - console.log = vi.fn(); - process.stdout.write = vi.fn().mockReturnValue(true) as any; - try { - await runInit(tmpRoot); - } finally { - console.log = vi.restoreAllMocks() as any; - } - expect(existsSync(join(tmpRoot, '.squad', 'decisions', 'inbox'))).toBe(true); - }); -}); - -// ============================================================================ -// #597 — Coordinator prompt guards against missing team -// ============================================================================ - -describe('#597 — Coordinator prompt guards against missing team', () => { - let tmpRoot: string; - - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('prompt includes "squad init" guidance when team.md is missing', async () => { - const config: CoordinatorConfig = { - teamRoot: tmpRoot, - teamPath: join(tmpRoot, '.squad', 'team.md'), - }; - const prompt = await buildCoordinatorPrompt(config); - expect(prompt).toContain('squad init'); - }); - - it('prompt includes "squad init" when routing.md is also missing', async () => { - const config: CoordinatorConfig = { - teamRoot: tmpRoot, - routingPath: join(tmpRoot, '.squad', 'routing.md'), - teamPath: join(tmpRoot, '.squad', 'team.md'), - }; - const prompt = await buildCoordinatorPrompt(config); - // Both missing — prompt should mention squad init for both - expect(prompt).toContain('squad init'); - // Team fallback text varies — may be "NO TEAM CONFIGURED" or "No team.md found" - expect(prompt.includes('NO TEAM CONFIGURED') || prompt.includes('No team.md found')).toBe(true); - expect(prompt).toContain('No routing.md found'); - }); - - it('does NOT include generic assistant behavior when team is missing', async () => { - const config: CoordinatorConfig = { - teamRoot: tmpRoot, - teamPath: join(tmpRoot, '.squad', 'team.md'), - }; - const prompt = await buildCoordinatorPrompt(config); - // The prompt should still be the coordinator prompt, not a generic "I'm an assistant" fallback - expect(prompt).toContain('Squad Coordinator'); - expect(prompt).toContain('route'); - expect(prompt).not.toContain('general-purpose assistant'); - expect(prompt).not.toContain('I am a helpful'); - }); - - it('loads team.md content when file exists', async () => { - writeTeamMd(tmpRoot); - writeRoutingMd(tmpRoot); - const config: CoordinatorConfig = { - teamRoot: tmpRoot, - teamPath: join(tmpRoot, '.squad', 'team.md'), - routingPath: join(tmpRoot, '.squad', 'routing.md'), - }; - const prompt = await buildCoordinatorPrompt(config); - expect(prompt).toContain('Fenster'); - expect(prompt).toContain('Core Dev'); - expect(prompt).not.toContain('No team.md found'); - }); -}); - -// ============================================================================ -// #598 — Banner renders exactly once -// ============================================================================ - -describe('#598 — Banner renders exactly once', () => { - it('version string appears exactly once in App banner', () => { - // The App component renders the banner with version. We test that - // the version text appears exactly once in the rendered frame. - // Use MessageStream directly as a lightweight check — App needs too many deps. - // Instead, test the banner text logic by rendering the version display. - const testVersion = '1.2.3'; - const { lastFrame } = render( - h('ink-box', { flexDirection: 'column' }, - h('ink-box', { gap: 1 }, - h(Text, { bold: true, color: 'cyan' }, '◆ SQUAD'), - h(Text, { dimColor: true }, `v${testVersion}`), - ), - ) as any, - ); - const frame = lastFrame() ?? ''; - // Count occurrences of the version string - const matches = frame.match(new RegExp(testVersion.replace(/\./g, '\\.'), 'g')); - expect(matches).toHaveLength(1); - }); - - it('"◆ SQUAD" header appears exactly once', () => { - const { lastFrame } = render( - h('ink-box', { flexDirection: 'column' }, - h('ink-box', { gap: 1 }, - h(Text, { bold: true, color: 'cyan' }, '◆ SQUAD'), - h(Text, { dimColor: true }, 'v0.1.0'), - ), - ) as any, - ); - const frame = lastFrame() ?? ''; - const matches = frame.match(/◆ SQUAD/g); - expect(matches).toHaveLength(1); - }); -}); - -// ============================================================================ -// #599 — Coordinator label is 'Squad' not 'coordinator' -// ============================================================================ - -describe('#599 — Coordinator label is "Squad" not "coordinator"', () => { - it('coordinator messages display with agent name in MessageStream', () => { - // When a coordinator message is shown, the label should be whatever - // agentName is set to. Currently the code sets agentName: 'coordinator'. - // This test asserts on the rendered output — if the fix changes - // the agentName to 'Squad', the test should pass. - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'coordinator', content: 'I routed your request.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - // The message should display "Squad:" not "coordinator:" - expect(frame).toContain('Squad:'); - expect(frame).not.toContain('coordinator:'); - }); - - it('formatConversationContext uses agentName for coordinator messages', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'user', content: 'help me' }), - makeMessage({ role: 'agent', agentName: 'coordinator', content: 'I can help.' }), - ]; - const context = formatConversationContext(messages); - // The context should show [coordinator] for agent messages with that name - expect(context).toContain('[coordinator]'); - }); - - it('agent messages without agentName fall back to "agent" label', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', content: 'anonymous reply' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('agent:'); - }); -}); - -// ============================================================================ -// #600 — Markdown inline rendering -// ============================================================================ - -describe('#600 — Markdown inline rendering', () => { - // Issue #600 is about converting **bold**, *italic*, and `code` in message - // content. Currently MessageStream renders raw text. These tests verify the - // raw content passes through (baseline) and will validate formatting once - // a markdown renderer is added. - - it('bold markdown (**text**) appears in rendered output', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: 'This is **bold** text.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - // Currently raw markdown passes through — text should be visible - expect(frame).toContain('bold'); - expect(frame).toContain('text'); - }); - - it('italic markdown (*text*) appears in rendered output', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: 'This is *italic* text.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('italic'); - }); - - it('inline code (`code`) appears in rendered output', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: 'Run `npm install` to fix.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('npm install'); - }); - - it('empty string renders without crash', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: '' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - expect(lastFrame()).toBeDefined(); - }); - - it('content with no markdown renders unchanged', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: 'Plain text with no formatting.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('Plain text with no formatting.'); - }); - - it('nested formatting (**bold *and italic***) renders without crash', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: '**bold *and italic***' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('bold'); - expect(frame).toContain('italic'); - }); -}); - -// ============================================================================ -// #602 — SQLite warning suppression -// ============================================================================ - -describe('#602 — SQLite ExperimentalWarning suppression', () => { - it('ExperimentalWarning string-based events are suppressed', () => { - // The cli-entry.ts overrides process.emitWarning to suppress ExperimentalWarning. - // We replicate that logic to verify behavior. - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - // Install the same suppression logic from cli-entry.ts - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - return (originalEmitWarning as any).call(process, warning, ...args); - }; - - try { - // Suppress ExperimentalWarning string — should NOT pass through - process.emitWarning('ExperimentalWarning: SQLite is experimental'); - expect(emitted).toHaveLength(0); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('ExperimentalWarning object-based events are suppressed', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - const w = new Error('SQLite is experimental'); - w.name = 'ExperimentalWarning'; - process.emitWarning(w as any); - expect(emitted).toHaveLength(0); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('non-ExperimentalWarning events still pass through', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - process.emitWarning('DeprecationWarning: something is deprecated'); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toContain('DeprecationWarning'); - } finally { - process.emitWarning = originalEmitWarning; - } - }); - - it('regular Warning objects pass through', () => { - const originalEmitWarning = process.emitWarning; - const emitted: string[] = []; - - process.emitWarning = (warning: any, ...args: any[]) => { - if (typeof warning === 'string' && warning.includes('ExperimentalWarning')) return; - if (warning?.name === 'ExperimentalWarning') return; - emitted.push(typeof warning === 'string' ? warning : warning?.message ?? String(warning)); - }; - - try { - const w = new Error('Some other warning'); - w.name = 'DeprecationWarning'; - process.emitWarning(w as any); - expect(emitted).toHaveLength(1); - expect(emitted[0]).toContain('Some other warning'); - } finally { - process.emitWarning = originalEmitWarning; - } - }); -}); - -// ============================================================================ -// #604 — Session resume skipped on first run -// ============================================================================ - -describe('#604 — Session resume skipped on first run', () => { - let tmpRoot: string; - - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('loadLatestSession returns null when .squad/team.md does not exist', () => { - // No .squad directory at all - const result = loadLatestSession(tmpRoot); - expect(result).toBeNull(); - }); - - it('session resume logic skips when team.md is absent', () => { - // Emulate the gating logic from runShell: - // const hasTeam = existsSync(join(teamRoot, '.squad', 'team.md')); - // const recentSession = hasTeam ? loadLatestSession(teamRoot) : null; - const hasTeam = existsSync(join(tmpRoot, '.squad', 'team.md')); - expect(hasTeam).toBe(false); - const recentSession = hasTeam ? loadLatestSession(tmpRoot) : null; - expect(recentSession).toBeNull(); - }); - - it('session resume logic skips when .first-run marker is present', () => { - writeTeamMd(tmpRoot); - // Create a saved session - const session = createSession(); - session.messages.push(makeMessage({ role: 'user', content: 'hello' })); - saveSession(tmpRoot, session); - - // Simulate first run marker - const firstRunPath = join(tmpRoot, '.squad', '.first-run'); - writeFileSync(firstRunPath, new Date().toISOString() + '\n'); - - // Emulate runShell gating logic: - const hasTeam = existsSync(join(tmpRoot, '.squad', 'team.md')); - const isFirstRun = existsSync(join(tmpRoot, '.squad', '.first-run')); - const recentSession = (hasTeam && !isFirstRun) ? loadLatestSession(tmpRoot) : null; - expect(hasTeam).toBe(true); - expect(isFirstRun).toBe(true); - expect(recentSession).toBeNull(); - }); - - it('session resume works when team.md exists and no first-run marker', () => { - writeTeamMd(tmpRoot); - // Save a recent session - const session = createSession(); - session.messages.push(makeMessage({ role: 'user', content: 'previous session' })); - saveSession(tmpRoot, session); - - const hasTeam = existsSync(join(tmpRoot, '.squad', 'team.md')); - const isFirstRun = existsSync(join(tmpRoot, '.squad', '.first-run')); - const recentSession = (hasTeam && !isFirstRun) ? loadLatestSession(tmpRoot) : null; - expect(hasTeam).toBe(true); - expect(isFirstRun).toBe(false); - expect(recentSession).not.toBeNull(); - expect(recentSession!.messages).toHaveLength(1); - }); -}); - -// ============================================================================ -// #603 — Init prompt gates work when no team -// ============================================================================ - -describe('#603 — Init prompt gates when no team', () => { - let tmpRoot: string; - - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('loadWelcomeData returns null when team.md does not exist', async () => { - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const result = loadWelcomeData(tmpRoot); - expect(result).toBeNull(); - }); - - it('loadWelcomeData returns data when team.md exists', async () => { - writeTeamMd(tmpRoot); - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const result = loadWelcomeData(tmpRoot); - expect(result).not.toBeNull(); - expect(result!.agents.length).toBeGreaterThan(0); - }); - - it('/init, /help, /exit commands work without team context', async () => { - // Slash commands are handled by executeCommand — they don't require team.md - const { executeCommand } = await import('../packages/squad-cli/src/cli/shell/commands.js'); - const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); - const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); - - const registry = new SessionRegistry(); - const renderer = new ShellRenderer(); - const context = { - registry, - renderer, - messageHistory: [], - teamRoot: tmpRoot, - version: '0.0.0-test', - }; - - // /help works - const helpResult = executeCommand('help', [], context); - expect(helpResult.handled).toBe(true); - expect(helpResult.output).toContain('Commands'); - - // /exit works - const exitResult = executeCommand('exit', [], context); - expect(exitResult.handled).toBe(true); - expect(exitResult.exit).toBe(true); - - // /quit works - const quitResult = executeCommand('quit', [], context); - expect(quitResult.handled).toBe(true); - expect(quitResult.exit).toBe(true); - - // /version works - const versionResult = executeCommand('version', [], context); - expect(versionResult.handled).toBe(true); - expect(versionResult.output).toBe('0.0.0-test'); - }); - - it('coordinator dispatch requires team context (parseInput routes to coordinator)', async () => { - const { parseInput } = await import('../packages/squad-cli/src/cli/shell/router.js'); - - // When no agents are registered, all free-text input routes to coordinator - const result = parseInput('build the feature', []); - expect(result.type).toBe('coordinator'); - - // But slash commands still work - const slashResult = parseInput('/help', []); - expect(slashResult.type).toBe('slash_command'); - expect(slashResult.command).toBe('help'); - }); - - it('message dispatch to coordinator is blocked when team.md absent', async () => { - // The App component checks for onDispatch — when SDK not connected or - // team absent, onDispatch is undefined and shows an error message. - // We test the gating logic: no team.md → loadWelcomeData returns null. - const hasTeam = existsSync(join(tmpRoot, '.squad', 'team.md')); - expect(hasTeam).toBe(false); - - // ShellLifecycle.initialize() throws when .squad/ doesn't exist - // This is the gate that prevents dispatch - const { ShellLifecycle } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); - const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); - - const lifecycle = new ShellLifecycle({ - teamRoot: tmpRoot, - renderer: new ShellRenderer(), - registry: new SessionRegistry(), - }); - - await expect(lifecycle.initialize()).rejects.toThrow(/squad init/i); - }); -}); - -// ============================================================================ -// Round 2 REPL UX fixes -// ============================================================================ - -describe('Round 2 REPL UX fixes', () => { - - // -------------------------------------------------------------------------- - // Screen corruption prevention - // -------------------------------------------------------------------------- - - describe('Screen corruption prevention', () => { - it('Static keys include a session identifier, not just index', () => { - // App generates sessionId = Date.now().toString(36) and uses `${sessionId}-${i}` as keys. - // Verify the sessionId generation produces a non-numeric, non-trivial key prefix. - const sessionId = Date.now().toString(36); - expect(sessionId.length).toBeGreaterThan(0); - // It should NOT be a plain integer — base-36 encoding ensures alpha chars - expect(sessionId).toMatch(/[a-z]/); - // Composed key should include the session prefix - const composedKey = `${sessionId}-0`; - expect(composedKey).toContain(sessionId); - expect(composedKey).not.toBe('0'); // not index-only - }); - - it('archivedMessages start empty and only grow via archival', async () => { - // When App mounts, archivedMessages = useState([]). - // On session restore (onRestoreSession), the host calls origAdd per message - // which feeds into appendMessages → setMessages, NOT setArchivedMessages. - // archivedMessages only grows when MemoryManager trims overflow. - // Verify MemoryManager's trimWithArchival preserves all when under cap. - const { MemoryManager } = await import('../packages/squad-cli/src/cli/shell/memory.js'); - const mm = new MemoryManager({ maxMessages: 200 }); - const msgs: ShellMessage[] = Array.from({ length: 5 }, (_, i) => - makeMessage({ role: 'user', content: `msg-${i}` }), - ); - const { kept, archived } = mm.trimWithArchival(msgs); - expect(kept).toHaveLength(5); - expect(archived).toHaveLength(0); - }); - - it('MemoryManager archives overflow messages on session restore flood', async () => { - const { MemoryManager } = await import('../packages/squad-cli/src/cli/shell/memory.js'); - const mm = new MemoryManager({ maxMessages: 3 }); - const msgs: ShellMessage[] = Array.from({ length: 10 }, (_, i) => - makeMessage({ role: 'user', content: `restored-${i}` }), - ); - const { kept, archived } = mm.trimWithArchival(msgs); - expect(kept).toHaveLength(3); - expect(archived).toHaveLength(7); - }); - }); - - // -------------------------------------------------------------------------- - // Banner logic - // -------------------------------------------------------------------------- - - describe('Banner logic', () => { - let tmpRoot: string; - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('when rosterAgents.length === 0 AND isFirstRun, banner should NOT show "Your squad is assembled"', () => { - // Simulates App logic: isFirstRun true but agents = [] - // Lines 302-313: rosterAgents.length > 0 gates "Your squad is assembled" - const rosterAgents: Array<{ name: string; role: string; emoji: string }> = []; - const isFirstRun = true; - const bannerReady = true; - - // This mirrors the JSX conditional in App.tsx lines 302-313 - const showAssembled = bannerReady && isFirstRun && rosterAgents.length > 0; - expect(showAssembled).toBe(false); - }); - - it('when rosterAgents.length > 0 AND isFirstRun, banner SHOULD show "Your squad is assembled"', () => { - const rosterAgents = [{ name: 'Fenster', role: 'Core Dev', emoji: '🔧' }]; - const isFirstRun = true; - const bannerReady = true; - - const showAssembled = bannerReady && isFirstRun && rosterAgents.length > 0; - expect(showAssembled).toBe(true); - }); - - it('when no agents exist, the @lead hint should not appear (no "your lead" text)', () => { - // leadAgent derivation: App.tsx lines 236-240 - // When agents=[], leadAgent is undefined - const agents: Array<{ name: string; role: string; emoji: string }> = []; - const leadAgent = agents.find(a => - a.role?.toLowerCase().includes('lead') || - a.role?.toLowerCase().includes('coordinator') || - a.role?.toLowerCase().includes('architect') - )?.name ?? agents[0]?.name; - - expect(leadAgent).toBeUndefined(); - }); - - it('when agents exist with a lead, the @lead hint uses actual agent name', () => { - const agents = [ - { name: 'Keaton', role: 'Lead', emoji: '👑' }, - { name: 'Fenster', role: 'Core Dev', emoji: '🔧' }, - ]; - const leadAgent = agents.find(a => - a.role?.toLowerCase().includes('lead') || - a.role?.toLowerCase().includes('coordinator') || - a.role?.toLowerCase().includes('architect') - )?.name ?? agents[0]?.name; - - expect(leadAgent).toBe('Keaton'); - // Not a generic fallback - expect(leadAgent).not.toBe('your lead'); - }); - - it('leadAgent falls back to first agent when no lead/coordinator/architect role', () => { - const agents = [ - { name: 'Hockney', role: 'Tester', emoji: '🧪' }, - { name: 'McManus', role: 'DevRel', emoji: '📣' }, - ]; - const leadAgent = agents.find(a => - a.role?.toLowerCase().includes('lead') || - a.role?.toLowerCase().includes('coordinator') || - a.role?.toLowerCase().includes('architect') - )?.name ?? agents[0]?.name; - - expect(leadAgent).toBe('Hockney'); - }); - }); - - // -------------------------------------------------------------------------- - // Compaction removal - // -------------------------------------------------------------------------- - - describe('Compaction removal', () => { - it('banner content renders fully even when width <= 60', () => { - // App.tsx line 227: compact = width <= 60 - // Line 292-293: compact mode still shows agent count - // Line 299: compact mode shows '/help - Ctrl+C exit' - const width = 40; - const compact = width <= 60; - expect(compact).toBe(true); - - // Even in compact, agentCount > 0 renders summary (line 292-293) - const agentCount = 3; - const activeCount = 1; - const bannerReady = true; - const showCompactAgents = bannerReady && compact && agentCount > 0; - expect(showCompactAgents).toBe(true); - - // Help text is always rendered (line 299) - const helpText = compact - ? '/help - Ctrl+C exit' - : 'Just type what you need — Squad routes it - @Agent to direct - /help - Ctrl+C exit'; - expect(helpText).toBe('/help - Ctrl+C exit'); - expect(helpText.length).toBeGreaterThan(0); - }); - - it('spacing elements always render regardless of terminal width', () => { - // In both compact and non-compact, bannerReady always renders help text (line 299). - // The "◆ SQUAD" title and version always render (lines 273-274). - const widths = [30, 40, 60, 80, 120, 200]; - for (const w of widths) { - const compact = w <= 60; - const bannerReady = true; - // Title always present - expect(bannerReady).toBe(true); - // Help text always present (line 299) - const helpText = compact - ? '/help - Ctrl+C exit' - : 'Just type what you need — Squad routes it - @Agent to direct - /help - Ctrl+C exit'; - expect(helpText.length).toBeGreaterThan(0); - } - }); - - it('help text is always full, never truncated for compact', () => { - // Compact help text: '/help - Ctrl+C exit' (line 299) - // Wide help text: full string — both are complete, not truncated - const compactHelp = '/help - Ctrl+C exit'; - const fullHelp = 'Just type what you need — Squad routes it - @Agent to direct - /help - Ctrl+C exit'; - // Both contain /help and Ctrl+C — no truncation - expect(compactHelp).toContain('/help'); - expect(compactHelp).toContain('Ctrl+C'); - expect(fullHelp).toContain('/help'); - expect(fullHelp).toContain('Ctrl+C'); - // Neither is empty or cut off - expect(compactHelp).not.toBe(''); - expect(fullHelp).not.toBe(''); - }); - }); - - // -------------------------------------------------------------------------- - // Coordinator label - // -------------------------------------------------------------------------- - - describe('Coordinator label', () => { - it('MessageStream shows "Squad" not "Coordinator" for coordinator agent messages', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'coordinator', content: 'Routing your request.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('Squad:'); - expect(frame).not.toMatch(/\bcoordinator:/i); - }); - - it('agent messages with agentName="coordinator" display as "Squad" in streaming content', () => { - const messages: ShellMessage[] = []; - const streamMap = new Map(); - streamMap.set('coordinator', 'Working on it...'); - - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: true, - streamingContent: streamMap, - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('Squad:'); - expect(frame).not.toMatch(/\bcoordinator:/i); - }); - - it('non-coordinator agents retain their original name', () => { - const messages: ShellMessage[] = [ - makeMessage({ role: 'agent', agentName: 'Fenster', content: 'Done.' }), - ]; - const { lastFrame } = render( - h(MessageStream, { - messages, - processing: false, - streamingContent: new Map(), - }), - ); - const frame = lastFrame() ?? ''; - expect(frame).toContain('Fenster:'); - expect(frame).not.toContain('Squad:'); - }); - }); - - // -------------------------------------------------------------------------- - // Init guidance - // -------------------------------------------------------------------------- - - describe('Init guidance', () => { - let tmpRoot: string; - beforeEach(() => { tmpRoot = makeTmpRoot(); }); - afterEach(() => { rmSync(tmpRoot, { recursive: true, force: true }); }); - - it('empty roster shows actionable init guidance mentioning squad init', () => { - // App.tsx lines 294-296: when rosterAgents.length === 0, banner shows init guidance - const rosterAgents: Array<{ name: string; role: string; emoji: string }> = []; - const bannerReady = true; - - const showInitGuidance = bannerReady && rosterAgents.length === 0; - expect(showInitGuidance).toBe(true); - - // The actual text mentions both 'squad init' and '/init' - const guidanceText = " Exit and run 'squad init', or type /init to set up your team"; - expect(guidanceText).toContain('squad init'); - expect(guidanceText).toContain('/init'); - }); - - it('coordinator prompt shows squad init guidance when team.md missing', async () => { - const config: CoordinatorConfig = { - teamRoot: tmpRoot, - teamPath: join(tmpRoot, '.squad', 'team.md'), - }; - const prompt = await buildCoordinatorPrompt(config); - expect(prompt).toContain('squad init'); - expect(prompt).toContain('/init'); - }); - - it('loadWelcomeData returns null (triggering init guidance) when no team.md', async () => { - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const result = loadWelcomeData(tmpRoot); - expect(result).toBeNull(); - }); - }); -}); diff --git a/test/repl-ux.test.ts b/test/repl-ux.test.ts deleted file mode 100644 index c5f6e3893..000000000 --- a/test/repl-ux.test.ts +++ /dev/null @@ -1,1573 +0,0 @@ -/** - * REPL UX visual behavior tests - * - * Tests rendered output of shell components using ink-testing-library. - * Asserts on TEXT content (what the user sees), not internal state. - * Written against component interfaces (props → rendered text) so that - * implementation changes by Kovash don't break these tests. - * - * Components under test: - * - MessageStream: conversation display, spinner, streaming cursor - * - AgentPanel: team roster with status indicators - * - InputPrompt: text input with history and disabled states - */ - -import { describe, it, expect, vi } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { Text } from 'ink'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import { AgentPanel } from '../packages/squad-cli/src/cli/shell/components/AgentPanel.js'; -import { InputPrompt } from '../packages/squad-cli/src/cli/shell/components/InputPrompt.js'; -import { ThinkingIndicator, THINKING_PHRASES } from '../packages/squad-cli/src/cli/shell/components/ThinkingIndicator.js'; -import type { ShellMessage, AgentSession } from '../packages/squad-cli/src/cli/shell/types.js'; - -// ============================================================================ -// Test helpers -// ============================================================================ - -function makeAgent(overrides: Partial & { name: string }): AgentSession { - return { - role: 'core dev', - status: 'idle', - startedAt: new Date(), - ...overrides, - }; -} - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { - timestamp: new Date(), - ...overrides, - }; -} - -const h = React.createElement; - -// ============================================================================ -// 1. ThinkingIndicator visibility -// ============================================================================ - -describe('ThinkingIndicator visibility', () => { - it('shows spinner when processing=true and no streaming content', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Spinner frames are braille characters ⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏ plus 💭 label - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); - - it('spinner text shows agent name from explicit activityHint', () => { - // After removing the redundant @mention fallback from MessageStream, - // the hint must come from the parent via activityHint (as App.tsx does). - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: '@Kovash fix the bug' })], - processing: true, - streamingContent: new Map(), - activityHint: 'Kovash is thinking...', - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('thinking'); - }); - - it('shows "Thinking" when no @agent in message', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'fix the bug' })], - processing: true, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Default label: "Routing to agent..." gives users context - expect(frame).toContain('Routing to agent'); - }); - - it('hides spinner when streaming content appears', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map([['Kovash', 'Working on it...']]), - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Working on it...'); - expect(frame).toContain('▌'); - }); - - it('spinner disappears when processing ends', () => { - const { lastFrame, rerender } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map(), - }) - ); - expect(lastFrame()!).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - - rerender( - h(MessageStream, { - messages: [ - makeMessage({ role: 'user', content: 'hello' }), - makeMessage({ role: 'agent', content: 'Done!', agentName: 'Kovash' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - expect(frame).not.toMatch(/thinking/i); - expect(frame).toContain('Done!'); - }); -}); - -// ============================================================================ -// 2. AgentPanel status display -// ============================================================================ - -describe('AgentPanel status display', () => { - it('renders nothing when agents list is empty', () => { - const { lastFrame } = render(h(AgentPanel, { agents: [] })); - expect(lastFrame()!).toContain('No agents active'); - }); - - it('shows agent names in roster', () => { - const agents = [ - makeAgent({ name: 'Kovash', role: 'core dev', status: 'idle' }), - makeAgent({ name: 'Hockney', role: 'tester', status: 'idle' }), - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('Hockney'); - }); - - it('idle agents show "idle" status text', () => { - const agents = [makeAgent({ name: 'Kovash', status: 'idle' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()!.toLowerCase()).toContain('[idle]'); - }); - - it('working agents show active indicator ●', () => { - const agents = [ - makeAgent({ name: 'Kovash', status: 'working' }), - makeAgent({ name: 'Hockney', status: 'idle' }), - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('●'); - }); - - it('streaming agents show active indicator ●', () => { - const agents = [makeAgent({ name: 'Kovash', status: 'streaming' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()!).toContain('●'); - }); - - it('error agents show error indicator [ERR]', () => { - const agents = [makeAgent({ name: 'Kovash', status: 'error' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()!).toContain('[ERR]'); - }); - - it('shows streaming status when streamingContent references agent', () => { - const agents = [makeAgent({ name: 'Kovash', status: 'streaming' })]; - const { lastFrame } = render( - h(AgentPanel, { - agents, - streamingContent: new Map([['Kovash', 'some response']]), - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('working'); - }); - - it('mixed statuses render correctly together', () => { - const agents = [ - makeAgent({ name: 'Brady', role: 'lead', status: 'idle' }), - makeAgent({ name: 'Kovash', role: 'core dev', status: 'working' }), - makeAgent({ name: 'Hockney', role: 'tester', status: 'error' }), - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Brady'); - expect(frame).toContain('Kovash'); - expect(frame).toContain('Hockney'); - expect(frame).toContain('●'); - expect(frame).toContain('[ERR]'); - }); -}); - -// ============================================================================ -// 3. MessageStream formatting -// ============================================================================ - -describe('MessageStream formatting', () => { - it('user messages show chevron prefix', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello world' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('❯'); - expect(frame).toContain('hello world'); - }); - - it('agent messages show agent name with emoji', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'agent', content: 'I will fix it', agentName: 'Kovash' })], - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('I will fix it'); - // core dev emoji is 🔧 - expect(frame).toContain('🔧'); - }); - - it('tester agent shows tester emoji 🧪', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'agent', content: 'tests pass', agentName: 'Hockney' })], - agents: [makeAgent({ name: 'Hockney', role: 'tester' })], - }) - ); - expect(lastFrame()!).toContain('🧪'); - }); - - it('system messages render dimmed', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'system', content: 'Agent spawned' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Agent spawned'); - }); - - it('horizontal rule appears between conversation turns', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'user', content: 'first question' }), - makeMessage({ role: 'agent', content: 'first answer', agentName: 'Kovash' }), - makeMessage({ role: 'user', content: 'second question' }), - ], - }) - ); - expect(lastFrame()!).toContain('─'.repeat(10)); - }); - - it('no horizontal rule before the first message', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'first question' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('first question'); - expect(frame).not.toMatch(/-{10,}/); - }); - - it('streaming content shows cursor character ▌', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [], - streamingContent: new Map([['Kovash', 'partial response']]), - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('partial response'); - expect(frame).toContain('▌'); - }); - - it('streaming content shows agent name', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [], - streamingContent: new Map([['Kovash', 'streaming text']]), - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - expect(lastFrame()!).toContain('Kovash'); - }); - - it('respects maxVisible prop — only shows last N messages', () => { - const messages = Array.from({ length: 10 }, (_, i) => - makeMessage({ role: 'user', content: `message-${i}` }) - ); - const { lastFrame } = render( - h(MessageStream, { messages, maxVisible: 3 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('message-9'); - expect(frame).toContain('message-8'); - expect(frame).toContain('message-7'); - expect(frame).not.toContain('message-0'); - }); -}); - -// ============================================================================ -// 4. InputPrompt behavior -// ============================================================================ - -describe('InputPrompt behavior', () => { - it('shows cursor ▌ when not disabled', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }) - ); - expect(lastFrame()!).toContain('▌'); - }); - - it('hides cursor when disabled', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: true }) - ); - expect(lastFrame()!).not.toContain('▌'); - }); - - it('shows custom prompt text', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), prompt: 'squad> ' }) - ); - expect(lastFrame()!).toContain('squad>'); - }); - - it('shows tab/history hint when messageCount < 10', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, messageCount: 0 }) - ); - expect(lastFrame()!).toContain('Tab completes'); - expect(lastFrame()!).toContain('history'); - }); - - it('shows tab/history hint when messageCount is 5-9', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, messageCount: 5 }) - ); - expect(lastFrame()!).toContain('Tab completes'); - expect(lastFrame()!).toContain('history'); - }); - - it('shows advanced hint when messageCount >= 10', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, messageCount: 10 }) - ); - expect(lastFrame()!).toContain('/status'); - expect(lastFrame()!).toContain('/clear'); - expect(lastFrame()!).toContain('/export'); - }); - - it('defaults to tab/history hint when messageCount not provided', () => { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }) - ); - expect(lastFrame()!).toContain('Tab completes'); - expect(lastFrame()!).toContain('history'); - }); - - it('disabled prompt shows spinner animation', () => { - const { lastFrame } = render( - h(InputPrompt, { - onSubmit: vi.fn(), - disabled: true, - }) - ); - const frame = lastFrame()!; - // Kovash's refactored InputPrompt shows ◆ squad + spinner when disabled - expect(frame).toContain('squad'); - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); - - it('accepts text input via stdin (character by character)', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }) - ); - // Ink v6 processes stdin events — flush microtasks after write - stdin.write('h'); - stdin.write('e'); - stdin.write('l'); - stdin.write('l'); - stdin.write('o'); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('hello'); - }); - - it('submits on enter and clears input', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'test input') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - expect(onSubmit).toHaveBeenCalledWith('test input'); - expect(lastFrame()!).not.toContain('test input'); - }); - - it('does not submit empty input', () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - stdin.write('\r'); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('ignores input when disabled', () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('should not work'); - stdin.write('\r'); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('up arrow shows previous input from history', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'first') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - for (const ch of 'second') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - // Up arrow escape sequence - stdin.write('\x1B[A'); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('second'); - }); - - it('down arrow clears after history navigation', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'first') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\x1B[A'); // Up to get "first" - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('first'); - stdin.write('\x1B[B'); // Down past end of history - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).not.toContain('first'); - }); -}); - -// ============================================================================ -// 5. Welcome experience -// ============================================================================ - -describe('Welcome experience', () => { - it('empty agent list renders no panel', () => { - const { lastFrame } = render(h(AgentPanel, { agents: [] })); - expect(lastFrame()!).toContain('No agents active'); - }); - - it('agent roster displays all team members', () => { - const agents = [ - makeAgent({ name: 'Brady', role: 'lead', status: 'idle' }), - makeAgent({ name: 'Kovash', role: 'core dev', status: 'idle' }), - makeAgent({ name: 'Hockney', role: 'tester', status: 'idle' }), - ]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Brady'); - expect(frame).toContain('Kovash'); - expect(frame).toContain('Hockney'); - // Should show idle status for the team - expect(frame.toLowerCase()).toContain('[idle]'); - }); - - it('MessageStream with no messages and no processing shows empty area', () => { - const { lastFrame } = render( - h(MessageStream, { messages: [], processing: false }) - ); - // Should be a valid frame (not null), may be empty or whitespace - const frame = lastFrame(); - expect(frame).toBeDefined(); - }); -}); - -// ============================================================================ -// 6. "Never feels dead" — processing lifecycle -// ============================================================================ - -describe('Never feels dead', () => { - it('processing=true immediately shows spinner', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'do something' })], - processing: true, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - expect(frame.trim().length).toBeGreaterThan(0); - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]|💭/); - }); - - it('streaming phase shows content with cursor', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'do something' })], - processing: true, - streamingContent: new Map([['Kovash', 'Working...']]), - }) - ); - const frame = lastFrame()!; - expect(frame.trim().length).toBeGreaterThan(0); - expect(frame).toContain('Working...'); - expect(frame).toContain('▌'); - }); - - it('full lifecycle: processing → streaming → done, screen always has content', () => { - const { lastFrame, rerender } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map(), - }) - ); - - // Phase 1: Processing — spinner visible - expect(lastFrame()!).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - - // Phase 2: Streaming begins - rerender( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map([['Kovash', 'Partial...']]), - }) - ); - expect(lastFrame()!).toContain('Partial...'); - expect(lastFrame()!).toContain('▌'); - - // Phase 3: Streaming ends — final message - rerender( - h(MessageStream, { - messages: [ - makeMessage({ role: 'user', content: 'hello' }), - makeMessage({ role: 'agent', content: 'Complete answer.', agentName: 'Kovash' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const finalFrame = lastFrame()!; - expect(finalFrame).toContain('Complete answer.'); - expect(finalFrame).not.toMatch(/thinking/i); - // No spinner in final state - expect(finalFrame).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); - - it('InputPrompt re-enables after processing completes', () => { - const { lastFrame, rerender } = render( - h(InputPrompt, { - onSubmit: vi.fn(), - disabled: true, - }) - ); - // Disabled state: spinner visible, no text cursor - expect(lastFrame()!).not.toContain('▌'); - expect(lastFrame()!).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - - rerender( - h(InputPrompt, { - onSubmit: vi.fn(), - disabled: false, - }) - ); - const frame = lastFrame()!; - // Re-enabled: text cursor visible, no spinner - expect(frame).toContain('▌'); - expect(frame).toContain('squad'); - expect(frame).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); - - it('every lifecycle phase has visible content (no dead frames)', () => { - type Phase = { - processing: boolean; - streamingContent: Map; - messages: ShellMessage[]; - }; - - const phases: Phase[] = [ - { - processing: true, - streamingContent: new Map(), - messages: [makeMessage({ role: 'user', content: 'question' })], - }, - { - processing: true, - streamingContent: new Map([['Kovash', 'Starting...']]), - messages: [makeMessage({ role: 'user', content: 'question' })], - }, - { - processing: true, - streamingContent: new Map([['Kovash', 'More content here...']]), - messages: [makeMessage({ role: 'user', content: 'question' })], - }, - { - processing: false, - streamingContent: new Map(), - messages: [ - makeMessage({ role: 'user', content: 'question' }), - makeMessage({ role: 'agent', content: 'Full answer.', agentName: 'Kovash' }), - ], - }, - ]; - - const { lastFrame, rerender } = render(h(MessageStream, phases[0]!)); - - for (let i = 0; i < phases.length; i++) { - if (i > 0) rerender(h(MessageStream, phases[i]!)); - const frame = lastFrame(); - expect(frame, `Phase ${i + 1} must not be null`).toBeTruthy(); - expect(frame!.trim().length, `Phase ${i + 1} must have visible content`).toBeGreaterThan(0); - } - }); -}); - -// ============================================================================ -// 7. ThinkingIndicator component (standalone) -// ============================================================================ - -describe('ThinkingIndicator component', () => { - it('renders nothing when isThinking=false', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: false, elapsedMs: 0 }) - ); - expect(lastFrame()!).toBe(''); - }); - - it('renders spinner when isThinking=true', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); - - it('shows default routing label', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Routing to agent'); - }); - - it('shows elapsed time when > 0', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 12000 }) - ); - expect(lastFrame()!).toContain('12s'); - }); - - it('does not show elapsed time when < 1s', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 500 }) - ); - expect(lastFrame()!).not.toMatch(/\d+s/); - }); - - it('activity hint takes priority over thinking phrases', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { - isThinking: true, - elapsedMs: 5000, - activityHint: 'Reading file...', - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Reading file...'); - // Should NOT show any thinking phrase when hint is active - const hasPhrase = THINKING_PHRASES.some(p => frame.includes(p)); - expect(hasPhrase).toBe(false); - }); - - it('activity hint shows elapsed time alongside', () => { - const { lastFrame } = render( - h(ThinkingIndicator, { - isThinking: true, - elapsedMs: 8000, - activityHint: 'Spawning specialist...', - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Spawning specialist...'); - expect(frame).toContain('8s'); - }); - - it('THINKING_PHRASES is exported and non-empty', () => { - expect(THINKING_PHRASES.length).toBeGreaterThanOrEqual(1); - }); -}); - -// ============================================================================ -// 8. ThinkingIndicator integration with MessageStream -// ============================================================================ - -describe('ThinkingIndicator integration with MessageStream', () => { - it('shows default routing label when processing with no @mention', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'fix the bug' })], - processing: true, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Should show spinner and "Routing to agent..." - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - expect(frame).toContain('Routing to agent'); - }); - - it('shows agent-specific hint when activityHint provided', () => { - // After removing the redundant @mention fallback from MessageStream, - // the hint must come from the parent via activityHint (as App.tsx does). - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: '@Kovash fix the bug' })], - processing: true, - streamingContent: new Map(), - activityHint: 'Kovash is thinking...', - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('thinking'); - }); - - it('shows custom activityHint when provided', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map(), - activityHint: 'Analyzing dependencies...', - }) - ); - expect(lastFrame()!).toContain('Analyzing dependencies...'); - }); - - it('activityHint overrides @mention hint', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: '@Kovash fix it' })], - processing: true, - streamingContent: new Map(), - activityHint: 'Reading file...', - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Reading file...'); - }); - - it('streaming phase shows agent name + streaming hint', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - processing: true, - streamingContent: new Map([['Kovash', 'Working on it...']]), - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Working on it...'); - expect(frame).toContain('Kovash streaming'); - }); -}); - -// ============================================================================ -// 9. Rich progress indicators (#335) -// ============================================================================ - -describe('Rich progress indicators', () => { - // -- AgentPanel progress display -- - - it('working agent shows activity description in status line', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'working' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Keaton'); - expect(frame).toContain('working'); - }); - - it('streaming agent shows activity description in status line', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'streaming' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Keaton'); - expect(frame).toContain('working'); - }); - - it('active agent shows pulsing dot in roster', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'working' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - expect(lastFrame()!).toMatch(/[●◉○]/); - }); - - it('agent with activityHint shows hint in status line', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'working', activityHint: 'Reviewing architecture' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Reviewing architecture'); - }); - - it('agent status shows hint directly (no [WORK] tag)', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'working', activityHint: 'Reading file' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('Keaton'); - expect(frame).toContain('Reading file'); - expect(frame).not.toContain('[WORK]'); - }); - - it('idle agent does not show activity hint even if set', () => { - const agents = [makeAgent({ name: 'Keaton', status: 'idle', activityHint: 'stale hint' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - // Idle agents are in the "ready" section, not the active status lines - expect(frame).not.toContain('stale hint'); - }); - - // -- MessageStream activity feed -- - - it('MessageStream shows activity feed when agentActivities provided', () => { - const activities = new Map([['Keaton', 'reading file']]); - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - agentActivities: activities, - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('▸'); - expect(frame).toContain('Keaton'); - expect(frame).toContain('reading file'); - }); - - it('MessageStream shows multiple agent activities', () => { - const activities = new Map([ - ['Keaton', 'reading file'], - ['Hockney', 'running tests'], - ]); - const { lastFrame } = render( - h(MessageStream, { - messages: [], - agentActivities: activities, - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Keaton'); - expect(frame).toContain('Hockney'); - expect(frame).toContain('reading file'); - expect(frame).toContain('running tests'); - }); - - it('MessageStream hides activity feed when map is empty', () => { - const activities = new Map(); - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - agentActivities: activities, - }) - ); - const frame = lastFrame()!; - expect(frame).not.toMatch(/▸ \w+ is /); - }); - - it('MessageStream works without agentActivities prop (backward compat)', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'hello' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('hello'); - expect(frame).not.toMatch(/▸ \w+ is /); - }); - - // -- Combined: activity feed + thinking indicator -- - - it('activity feed and thinking indicator coexist during processing', () => { - const activities = new Map([['Keaton', 'searching codebase']]); - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'find the bug' })], - processing: true, - streamingContent: new Map(), - agentActivities: activities, - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('▸ Keaton'); - expect(frame).toContain('searching codebase'); - // ThinkingIndicator should also be showing - expect(frame).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - }); -}); - -// ============================================================================ -// 10. Animations and transitions -// ============================================================================ - -describe('Animations and transitions', () => { - // -- Message fade-in -- - - it('new messages are rendered immediately (content always visible)', () => { - const msgs = [ - makeMessage({ role: 'user', content: 'hello world' }), - makeMessage({ role: 'agent', content: 'response here', agentName: 'Keaton' }), - ]; - const { lastFrame } = render(h(MessageStream, { messages: msgs })); - const frame = lastFrame()!; - expect(frame).toContain('hello world'); - expect(frame).toContain('response here'); - }); - - it('message content is visible even during fade-in period', () => { - const msgs = [makeMessage({ role: 'user', content: 'first message' })]; - const { lastFrame, rerender } = render(h(MessageStream, { messages: msgs })); - // Add a new message - const updated = [...msgs, makeMessage({ role: 'agent', content: 'new reply', agentName: 'Keaton' })]; - rerender(h(MessageStream, { messages: updated })); - const frame = lastFrame()!; - expect(frame).toContain('new reply'); - }); - - // -- Completion flash -- - - it('agent shows "✓ Done" flash when transitioning from working to idle', () => { - const working = [makeAgent({ name: 'Keaton', status: 'working' })]; - const { lastFrame, rerender } = render(h(AgentPanel, { agents: working })); - // Transition to idle - const idle = [makeAgent({ name: 'Keaton', status: 'idle' })]; - rerender(h(AgentPanel, { agents: idle })); - const frame = lastFrame()!; - expect(frame).toContain('✓ Done'); - }); - - it('agent shows "✓ Done" flash when transitioning from streaming to idle', () => { - const streaming = [makeAgent({ name: 'Keaton', status: 'streaming' })]; - const { lastFrame, rerender } = render(h(AgentPanel, { agents: streaming })); - const idle = [makeAgent({ name: 'Keaton', status: 'idle' })]; - rerender(h(AgentPanel, { agents: idle })); - const frame = lastFrame()!; - expect(frame).toContain('✓ Done'); - }); - - it('no "✓ Done" flash for agents that were already idle', () => { - const idle = [makeAgent({ name: 'Keaton', status: 'idle' })]; - const { lastFrame, rerender } = render(h(AgentPanel, { agents: idle })); - // Re-render with same idle status - rerender(h(AgentPanel, { agents: [makeAgent({ name: 'Keaton', status: 'idle' })] })); - const frame = lastFrame()!; - expect(frame).not.toContain('✓ Done'); - }); - - it('completion flash works for multiple agents independently', () => { - const working = [ - makeAgent({ name: 'Keaton', status: 'working' }), - makeAgent({ name: 'Hockney', status: 'working' }), - ]; - const { lastFrame, rerender } = render(h(AgentPanel, { agents: working })); - // Only Keaton finishes - const mixed = [ - makeAgent({ name: 'Keaton', status: 'idle' }), - makeAgent({ name: 'Hockney', status: 'working' }), - ]; - rerender(h(AgentPanel, { agents: mixed })); - const frame = lastFrame()!; - expect(frame).toContain('Keaton'); - expect(frame).toContain('✓ Done'); - // Hockney still working - expect(frame).toContain('Hockney'); - expect(frame).toContain('working'); - }); - - // -- NO_COLOR respect -- - - it('NO_COLOR: completion flash is suppressed', () => { - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - try { - const working = [makeAgent({ name: 'Keaton', status: 'working' })]; - const { lastFrame, rerender } = render(h(AgentPanel, { agents: working })); - rerender(h(AgentPanel, { agents: [makeAgent({ name: 'Keaton', status: 'idle' })] })); - const frame = lastFrame()!; - expect(frame).not.toContain('✓ Done'); - } finally { - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); - - it('NO_COLOR: messages render without fade (content immediately visible)', () => { - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - try { - const msgs = [makeMessage({ role: 'user', content: 'static mode test' })]; - const { lastFrame } = render(h(MessageStream, { messages: msgs })); - expect(lastFrame()!).toContain('static mode test'); - } finally { - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); - - // -- Animation hooks export -- - - it('useAnimation hooks are importable', async () => { - const mod = await import('../packages/squad-cli/src/cli/shell/useAnimation.js'); - expect(typeof mod.useTypewriter).toBe('function'); - expect(typeof mod.useFadeIn).toBe('function'); - expect(typeof mod.useCompletionFlash).toBe('function'); - expect(typeof mod.useMessageFade).toBe('function'); - }); -}); - -// ============================================================================ -// 11. Init ceremony and first-launch wow moment -// ============================================================================ - -describe('Init ceremony', { timeout: 15_000 }, () => { - it('isInitNoColor returns true when NO_COLOR is set', async () => { - const { isInitNoColor } = await import('../packages/squad-cli/src/cli/core/init.js'); - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - try { - expect(isInitNoColor()).toBe(true); - } finally { - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); - - it('typewrite outputs text immediately when NO_COLOR is set', async () => { - const { typewrite } = await import('../packages/squad-cli/src/cli/core/init.js'); - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - const chunks: string[] = []; - const origWrite = process.stdout.write; - process.stdout.write = ((str: string) => { chunks.push(str); return true; }) as any; - try { - await typewrite('hello', 10); - // NO_COLOR: single write of full text + newline - expect(chunks.join('')).toBe('hello\n'); - } finally { - process.stdout.write = origWrite; - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); - - it('INIT_LANDMARKS are exported for ceremony rendering', async () => { - // Verify the ceremony structure list is accessible (used in init.ts final output) - const mod = await import('../packages/squad-cli/src/cli/core/init.js'); - expect(typeof mod.typewrite).toBe('function'); - expect(typeof mod.isInitNoColor).toBe('function'); - }); -}); - -describe('First-launch experience', () => { - it('loadWelcomeData detects first-run marker', async () => { - const fsSync = await import('node:fs'); - const path = await import('node:path'); - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - - // test-fixtures has a .squad/team.md — add first-run marker - const fixtureRoot = path.join(process.cwd(), 'test-fixtures'); - const markerPath = path.join(fixtureRoot, '.squad', '.first-run'); - fsSync.writeFileSync(markerPath, 'test'); - try { - const data = loadWelcomeData(fixtureRoot); - expect(data).not.toBeNull(); - expect(data!.isFirstRun).toBe(true); - // Marker should be consumed (deleted) - expect(fsSync.existsSync(markerPath)).toBe(false); - } finally { - // Cleanup in case test failed before consumption - try { fsSync.unlinkSync(markerPath); } catch {} - } - }); - - it('loadWelcomeData returns isFirstRun=false on subsequent launches', async () => { - const path = await import('node:path'); - const { loadWelcomeData } = await import('../packages/squad-cli/src/cli/shell/lifecycle.js'); - const fixtureRoot = path.join(process.cwd(), 'test-fixtures'); - const data = loadWelcomeData(fixtureRoot); - expect(data).not.toBeNull(); - expect(data!.isFirstRun).toBe(false); - }); - - it('App shows guided prompt on first run', async () => { - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - try { - const { App } = await import('../packages/squad-cli/src/cli/shell/components/App.js'); - const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); - const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); - const registry = new SessionRegistry(); - const renderer = new ShellRenderer(); - - // With test-fixtures and no .first-run marker, guided prompt should NOT show - const { lastFrame } = render( - h(App, { - registry, - renderer, - teamRoot: 'test-fixtures', - version: '0.0.0-test', - }), - ); - const frame = lastFrame()!; - expect(frame).not.toContain('what should we build first'); - } finally { - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); - - it('App does NOT show guided prompt on subsequent launches', async () => { - const orig = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - try { - const { App } = await import('../packages/squad-cli/src/cli/shell/components/App.js'); - const { SessionRegistry } = await import('../packages/squad-cli/src/cli/shell/sessions.js'); - const { ShellRenderer } = await import('../packages/squad-cli/src/cli/shell/render.js'); - const registry = new SessionRegistry(); - const renderer = new ShellRenderer(); - const { lastFrame } = render( - h(App, { - registry, - renderer, - teamRoot: 'test-fixtures', - version: '0.0.0-test', - }), - ); - const frame = lastFrame()!; - // No first-run marker → no guided prompt - expect(frame).not.toContain('what should we build first'); - } finally { - if (orig === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = orig; - } - }); -}); - -// ============================================================================ -// 12. ErrorBoundary (Issue #365) -// ============================================================================ - -describe('ErrorBoundary', () => { - it('renders children when no error', async () => { - const { ErrorBoundary } = await import('../packages/squad-cli/src/cli/shell/components/ErrorBoundary.js'); - const { lastFrame } = render( - h(ErrorBoundary, null, h(Text, null, 'Hello World')) - ); - expect(lastFrame()!).toContain('Hello World'); - }); - - it('shows friendly message on error', async () => { - const { ErrorBoundary } = await import('../packages/squad-cli/src/cli/shell/components/ErrorBoundary.js'); - const Bomb: React.FC = () => { throw new Error('kaboom'); }; - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - try { - const { lastFrame } = render( - h(ErrorBoundary, null, h(Bomb)) - ); - const frame = lastFrame()!; - expect(frame).toContain('Something went wrong'); - expect(frame).toContain('Ctrl+C'); - } finally { - spy.mockRestore(); - } - }); - - it('logs error to stderr', async () => { - const { ErrorBoundary } = await import('../packages/squad-cli/src/cli/shell/components/ErrorBoundary.js'); - const Bomb: React.FC = () => { throw new Error('kaboom'); }; - const spy = vi.spyOn(console, 'error').mockImplementation(() => {}); - try { - render(h(ErrorBoundary, null, h(Bomb))); - expect(spy).toHaveBeenCalled(); - const calls = spy.mock.calls.map(c => c.join(' ')).join(' '); - expect(calls).toContain('kaboom'); - } finally { - spy.mockRestore(); - } - }); -}); - -// ============================================================================ -// 13. Input buffering (Issue #367) -// ============================================================================ - -describe('InputPrompt input buffering', () => { - it('buffers keystrokes while disabled (ref-based)', () => { - const onSubmit = vi.fn(); - const { stdin, rerender, lastFrame } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - - // Type while disabled — buffered via ref - stdin.write('h'); - stdin.write('i'); - - // Re-enable — effect restores buffer to value - rerender(h(InputPrompt, { onSubmit, disabled: false })); - - // Force a re-render to let useEffect fire - rerender(h(InputPrompt, { onSubmit, disabled: false })); - - const frame = lastFrame()!; - // The buffered text should appear (or at minimum, no auto-submit) - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('does not auto-submit buffered input', () => { - const onSubmit = vi.fn(); - const { rerender, stdin } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('test input'); - rerender(h(InputPrompt, { onSubmit, disabled: false })); - expect(onSubmit).not.toHaveBeenCalled(); - }); - - it('buffer is empty when nothing typed while disabled', () => { - const onSubmit = vi.fn(); - const { lastFrame, rerender } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - rerender(h(InputPrompt, { onSubmit, disabled: false })); - const frame = lastFrame()!; - // Should show placeholder, no buffered text - expect(frame).toContain('Tab completes'); - }); - - it('disabled state buffers keystrokes without submitting', () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - - // Type while disabled - stdin.write('hello'); - // Press enter while disabled — should NOT submit - stdin.write('\r'); - - expect(onSubmit).not.toHaveBeenCalled(); - }); -}); - -// ============================================================================ -// 12. NO_COLOR mode rendering (#374) -// ============================================================================ - -describe('NO_COLOR mode rendering', () => { - let origNoColor: string | undefined; - - function setNoColor() { - origNoColor = process.env['NO_COLOR']; - process.env['NO_COLOR'] = '1'; - } - - function restoreNoColor() { - if (origNoColor === undefined) delete process.env['NO_COLOR']; - else process.env['NO_COLOR'] = origNoColor; - } - - it('ThinkingIndicator renders static dots, no braille spinner frames', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 0 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('...'); - expect(frame).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - } finally { - restoreNoColor(); - } - }); - - it('ThinkingIndicator shows text label in NO_COLOR', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(ThinkingIndicator, { isThinking: true, elapsedMs: 3000 }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Routing to agent...'); - expect(frame).toContain('3s'); - } finally { - restoreNoColor(); - } - }); - - it('AgentPanel renders working status in NO_COLOR', () => { - setNoColor(); - try { - const agents = [makeAgent({ name: 'Kovash', status: 'working' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('working'); - expect(frame).toContain('Kovash'); - } finally { - restoreNoColor(); - } - }); - - it('AgentPanel renders [ERR] text label in NO_COLOR', () => { - setNoColor(); - try { - const agents = [makeAgent({ name: 'Kovash', status: 'error' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('[ERR]'); - } finally { - restoreNoColor(); - } - }); - - it('AgentPanel renders static dot (not animated) in NO_COLOR', () => { - setNoColor(); - try { - const agents = [makeAgent({ name: 'Kovash', status: 'working' })]; - const { lastFrame } = render(h(AgentPanel, { agents })); - const frame = lastFrame()!; - expect(frame).toContain('●'); - expect(frame).not.toContain('◉'); - expect(frame).not.toContain('○'); - } finally { - restoreNoColor(); - } - }); - - it('InputPrompt renders [working...] in NO_COLOR when disabled', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: true }) - ); - const frame = lastFrame()!; - expect(frame).toContain('[working...]'); - expect(frame).not.toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]/); - } finally { - restoreNoColor(); - } - }); - - it('InputPrompt cursor is visible in NO_COLOR', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }) - ); - const frame = lastFrame()!; - expect(frame).toContain('▌'); - } finally { - restoreNoColor(); - } - }); - - it('MessageStream user messages render without ANSI color in NO_COLOR', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'user', content: 'no color test' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('❯'); - expect(frame).toContain('no color test'); - } finally { - restoreNoColor(); - } - }); - - it('MessageStream agent messages render without ANSI color in NO_COLOR', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'agent', content: 'agent reply', agentName: 'Kovash' })], - agents: [makeAgent({ name: 'Kovash', role: 'core dev' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Kovash'); - expect(frame).toContain('agent reply'); - } finally { - restoreNoColor(); - } - }); - - it('MessageStream system messages render in NO_COLOR', () => { - setNoColor(); - try { - const { lastFrame } = render( - h(MessageStream, { - messages: [makeMessage({ role: 'system', content: 'System alert' })], - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('System alert'); - } finally { - restoreNoColor(); - } - }); -}); - -// ============================================================================ -// 13. Keyboard shortcut coverage (#375) -// ============================================================================ - -describe('Keyboard shortcut coverage', { timeout: 15_000 }, () => { - it('Enter submits input and clears the field', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'hello') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 50)); - expect(onSubmit).toHaveBeenCalledWith('hello'); - expect(lastFrame()!).not.toContain('hello'); - }); - - it('↑ arrow navigates to previous history entry', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'alpha') stdin.write(ch); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 100)); - for (const ch of 'beta') stdin.write(ch); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\x1B[A'); - await new Promise(r => setTimeout(r, 200)); - expect(lastFrame()!).toContain('beta'); - }); - - it('↓ arrow navigates forward in history', async () => { - const onSubmit = vi.fn(); - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit, disabled: false }) - ); - for (const ch of 'first') stdin.write(ch); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\r'); - await new Promise(r => setTimeout(r, 100)); - stdin.write('\x1B[A'); - await new Promise(r => setTimeout(r, 100)); - expect(lastFrame()!).toContain('first'); - stdin.write('\x1B[B'); - await new Promise(r => setTimeout(r, 200)); - // After navigating past the end of history, input may clear or show empty - // The key behavior is that ↑ then ↓ is a valid navigation sequence - const frame = lastFrame()!; - expect(frame).toBeDefined(); - }); - - it('Backspace deletes the last character', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false }) - ); - for (const ch of 'abcd') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('abcd'); - stdin.write('\x7F'); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('abc'); - expect(lastFrame()!).not.toContain('abcd'); - }); - - it('Tab autocompletes @agent name when single match', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, agentNames: ['Kovash', 'Keaton'] }) - ); - for (const ch of '@Kov') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\t'); - await new Promise(r => setTimeout(r, 50)); - // Tab autocomplete not implemented - expect(lastFrame()!).toContain('@Kov'); - }); - - it('Tab autocompletes /command when single match', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, agentNames: [] }) - ); - for (const ch of '/sta') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\t'); - await new Promise(r => setTimeout(r, 50)); - // Tab autocomplete not implemented - expect(lastFrame()!).toContain('/sta'); - }); - - it('Tab does nothing when no match', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, agentNames: ['Kovash'] }) - ); - for (const ch of '@Zzz') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\t'); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('@Zzz'); - }); - - it('Tab does nothing when multiple matches', async () => { - const { lastFrame, stdin } = render( - h(InputPrompt, { onSubmit: vi.fn(), disabled: false, agentNames: ['Kovash', 'Keaton'] }) - ); - for (const ch of '@K') stdin.write(ch); - await new Promise(r => setTimeout(r, 50)); - stdin.write('\t'); - await new Promise(r => setTimeout(r, 50)); - expect(lastFrame()!).toContain('@K'); - }); - - it('disabled state ignores all keyboard input', async () => { - const onSubmit = vi.fn(); - const { stdin } = render( - h(InputPrompt, { onSubmit, disabled: true }) - ); - stdin.write('test'); - stdin.write('\r'); - stdin.write('\x1B[A'); - stdin.write('\t'); - await new Promise(r => setTimeout(r, 50)); - expect(onSubmit).not.toHaveBeenCalled(); - }); -}); \ No newline at end of file diff --git a/test/sdk-failure-scenarios.test.ts b/test/sdk-failure-scenarios.test.ts deleted file mode 100644 index 3f3cb4f5b..000000000 --- a/test/sdk-failure-scenarios.test.ts +++ /dev/null @@ -1,434 +0,0 @@ -/** - * SDK Failure Scenario Tests - * - * Tests graceful degradation when the SDK fails in various ways: - * - sendAndWait returns undefined (ghost response) - * - sendAndWait throws Error - * - sendAndWait hangs past timeout - * - Session fires 'error' event mid-stream - * - Malformed data from SDK - * - * Follows patterns from test/repl-streaming.test.ts and test/ghost-response.test.ts. - * - * Closes #377 - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { - withGhostRetry, - parseCoordinatorResponse, - SessionRegistry, -} from '../packages/squad-cli/src/cli/shell/index.js'; - -// ============================================================================ -// Types & mock factories (mirrors repl-streaming.test.ts patterns) -// ============================================================================ - -type EventHandler = (event: { type: string; [key: string]: unknown }) => void; - -interface MockSquadSession { - sendAndWait: ReturnType; - sendMessage: ReturnType; - on: ReturnType; - off: ReturnType; - close: ReturnType; - sessionId: string; - _listeners: Map>; - _emit: (eventName: string, event: { type: string; [key: string]: unknown }) => void; -} - -function createMockSession(overrides: Partial> = {}): MockSquadSession { - const listeners = new Map>(); - - const session: MockSquadSession = { - sessionId: `mock-${Date.now()}`, - _listeners: listeners, - - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - - sendAndWait: overrides.sendAndWait ?? vi.fn().mockResolvedValue(undefined), - sendMessage: overrides.sendMessage ?? vi.fn().mockResolvedValue(undefined), - close: vi.fn().mockResolvedValue(undefined), - }; - - return session; -} - -/** - * Simulates the dispatch flow from index.ts: - * 1. Register delta listener - * 2. Call sendAndWait - * 3. Accumulate deltas - * 4. Return accumulated or fallback content - */ -async function simulateDispatch( - session: MockSquadSession, - message: string, - timeoutMs = 5000, -): Promise<{ content: string; error?: string }> { - let accumulated = ''; - - const onDelta = (event: { type: string; [key: string]: unknown }): void => { - const val = event['deltaContent'] ?? event['delta'] ?? event['content']; - const delta = typeof val === 'string' ? val : ''; - if (delta) accumulated += delta; - }; - - session.on('message_delta', onDelta); - - try { - const result = await Promise.race([ - session.sendAndWait({ prompt: message }, timeoutMs), - new Promise<'timeout'>((_, reject) => - setTimeout(() => reject(new Error('Session response timeout')), timeoutMs) - ), - ]); - - // Extract fallback content if available - const data = (result as Record | undefined)?.['data'] as Record | undefined; - const fallback = typeof data?.['content'] === 'string' ? data['content'] as string : ''; - if (!accumulated && fallback) accumulated = fallback; - - return { content: accumulated }; - } catch (err) { - const errorMsg = err instanceof Error ? err.message : String(err); - return { content: accumulated, error: errorMsg }; - } finally { - try { session.off('message_delta', onDelta); } catch { /* ignore */ } - } -} - -// ============================================================================ -// 1. sendAndWait returns undefined (ghost response) -// ============================================================================ - -describe('SDK failure: sendAndWait returns undefined', () => { - it('returns empty content, does not throw', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockResolvedValue(undefined), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.content).toBe(''); - expect(result.error).toBeUndefined(); - }); - - it('returns empty content when result is null', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockResolvedValue(null), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.content).toBe(''); - expect(result.error).toBeUndefined(); - }); - - it('ghost retry recovers on second attempt', async () => { - const sendFn = vi.fn() - .mockResolvedValueOnce(undefined) - .mockResolvedValueOnce('recovered'); - - const result = await withGhostRetry(sendFn, { - backoffMs: [1], - }); - - expect(result).toBe('recovered'); - expect(sendFn).toHaveBeenCalledTimes(2); - }); -}); - -// ============================================================================ -// 2. sendAndWait throws Error -// ============================================================================ - -describe('SDK failure: sendAndWait throws', () => { - it('catches synchronous throw gracefully', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockRejectedValue(new Error('Connection refused')), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.error).toBe('Connection refused'); - expect(result.content).toBe(''); - }); - - it('catches TypeError from SDK', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockRejectedValue(new TypeError('Cannot read property of undefined')), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.error).toContain('Cannot read property'); - }); - - it('catches non-Error throws', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockRejectedValue('string error'), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.error).toBe('string error'); - }); - - it('catches throw after partial streaming', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - // Emit some deltas then throw - session._emit('message_delta', { type: 'message_delta', deltaContent: 'partial ' }); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'data' }); - throw new Error('Connection dropped mid-stream'); - }), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.error).toBe('Connection dropped mid-stream'); - expect(result.content).toBe('partial data'); - }); -}); - -// ============================================================================ -// 3. sendAndWait hangs past timeout -// ============================================================================ - -describe('SDK failure: sendAndWait hangs', () => { - beforeEach(() => { vi.useFakeTimers(); }); - afterEach(() => { vi.useRealTimers(); }); - - it('times out after specified duration', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(() => new Promise(() => { - // Never resolves - })), - }); - - const dispatchPromise = simulateDispatch(session, 'hello', 100); - await vi.advanceTimersByTimeAsync(200); - const result = await dispatchPromise; - - expect(result.error).toBe('Session response timeout'); - expect(result.content).toBe(''); - }); - - it('times out but preserves partial streamed content', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', deltaContent: 'partial' }); - // Then hang forever - return new Promise(() => {}); - }), - }); - - const dispatchPromise = simulateDispatch(session, 'hello', 100); - await vi.advanceTimersByTimeAsync(200); - const result = await dispatchPromise; - - expect(result.error).toBe('Session response timeout'); - expect(result.content).toBe('partial'); - }); -}); - -// ============================================================================ -// 4. Session fires 'error' event mid-stream -// ============================================================================ - -describe('SDK failure: session error event', () => { - it('error event during dispatch does not crash', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', deltaContent: 'hello' }); - session._emit('error', { type: 'error', message: 'WebSocket disconnected' }); - return undefined; - }), - }); - - // Error events should not cause unhandled exceptions - const result = await simulateDispatch(session, 'test'); - // Content up to error point should be preserved - expect(result.content).toBe('hello'); - }); - - it('multiple error events do not stack crash', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - session._emit('error', { type: 'error', message: 'err1' }); - session._emit('error', { type: 'error', message: 'err2' }); - session._emit('error', { type: 'error', message: 'err3' }); - return undefined; - }), - }); - - const result = await simulateDispatch(session, 'test'); - expect(result.error).toBeUndefined(); - }); - - it('error handler registration and cleanup works', () => { - const session = createMockSession(); - const errorHandler = vi.fn(); - - session.on('error', errorHandler); - session._emit('error', { type: 'error', message: 'test' }); - expect(errorHandler).toHaveBeenCalledTimes(1); - - session.off('error', errorHandler); - session._emit('error', { type: 'error', message: 'test2' }); - expect(errorHandler).toHaveBeenCalledTimes(1); // Not called again - }); -}); - -// ============================================================================ -// 5. Malformed data from SDK -// ============================================================================ - -describe('SDK failure: malformed data', () => { - it('handles sendAndWait returning a number', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockResolvedValue(42), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.content).toBe(''); - expect(result.error).toBeUndefined(); - }); - - it('handles sendAndWait returning an empty object', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockResolvedValue({}), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.content).toBe(''); - }); - - it('handles malformed delta events', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - // Deltas with wrong shape - session._emit('message_delta', { type: 'message_delta' }); // no content - session._emit('message_delta', { type: 'message_delta', deltaContent: 42 } as any); // number - session._emit('message_delta', { type: 'message_delta', deltaContent: null } as any); // null - session._emit('message_delta', { type: 'message_delta', deltaContent: undefined }); // undefined - session._emit('message_delta', { type: 'message_delta', deltaContent: { nested: 'object' } } as any); // object - return undefined; - }), - }); - - const result = await simulateDispatch(session, 'hello'); - // None of the malformed deltas should contribute content - expect(result.content).toBe(''); - expect(result.error).toBeUndefined(); - }); - - it('handles delta with empty string content', async () => { - const session = createMockSession({ - sendAndWait: vi.fn(async () => { - session._emit('message_delta', { type: 'message_delta', deltaContent: '' }); - session._emit('message_delta', { type: 'message_delta', deltaContent: '' }); - session._emit('message_delta', { type: 'message_delta', deltaContent: 'actual' }); - return undefined; - }), - }); - - const result = await simulateDispatch(session, 'hello'); - expect(result.content).toBe('actual'); - }); - - it('handles coordinator response with malformed routing data', () => { - // parseCoordinatorResponse should not throw on garbage input - const garbageInputs = [ - '', - 'null', - '42', - '{}', - 'ROUTE:', - 'ROUTE: ', - 'MULTI:', - 'MULTI: ', - 'DIRECT:', - '\n\nROUTE: agent', - '```\nROUTE: agent\n```', - 'Sure! ROUTE: agent', - 'ROUTE:nonexistent_agent some message', - ]; - - for (const input of garbageInputs) { - expect(() => { - const result = parseCoordinatorResponse(input, ['Brady', 'Kovash']); - expect(result).toBeDefined(); - expect(result).toHaveProperty('type'); - }).not.toThrow(); - } - }); -}); - -// ============================================================================ -// 6. Session recovery after failure -// ============================================================================ - -describe('SDK failure: session recovery', () => { - it('SessionRegistry tracks error state correctly', () => { - const registry = new SessionRegistry(); - registry.register('TestAgent', 'developer'); - - // Simulate error - registry.updateStatus('TestAgent', 'error'); - expect(registry.get('TestAgent')?.status).toBe('error'); - - // Recovery: back to idle - registry.updateStatus('TestAgent', 'idle'); - expect(registry.get('TestAgent')?.status).toBe('idle'); - expect(registry.getActive()).toHaveLength(0); - }); - - it('SessionRegistry remove clears dead sessions', () => { - const registry = new SessionRegistry(); - registry.register('DeadAgent', 'developer'); - registry.updateStatus('DeadAgent', 'error'); - - expect(registry.get('DeadAgent')).toBeDefined(); - registry.remove('DeadAgent'); - expect(registry.get('DeadAgent')).toBeUndefined(); - }); - - it('session close after error does not throw', async () => { - const session = createMockSession({ - sendAndWait: vi.fn().mockRejectedValue(new Error('dead')), - }); - - await simulateDispatch(session, 'test'); - // Close should work fine after error - await expect(session.close()).resolves.toBeUndefined(); - }); - - it('new dispatch after error works on fresh session', async () => { - // First session fails - const deadSession = createMockSession({ - sendAndWait: vi.fn().mockRejectedValue(new Error('dead')), - }); - const result1 = await simulateDispatch(deadSession, 'test'); - expect(result1.error).toBe('dead'); - - // New session works - const freshSession = createMockSession({ - sendAndWait: vi.fn(async () => { - freshSession._emit('message_delta', { type: 'message_delta', deltaContent: 'recovered' }); - return undefined; - }), - }); - const result2 = await simulateDispatch(freshSession, 'test'); - expect(result2.content).toBe('recovered'); - expect(result2.error).toBeUndefined(); - }); -}); diff --git a/test/session-store.test.ts b/test/session-store.test.ts deleted file mode 100644 index e6d0e053e..000000000 --- a/test/session-store.test.ts +++ /dev/null @@ -1,221 +0,0 @@ -/** - * Tests for session persistence store — save, load, list sessions. - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { mkdtempSync, rmSync, existsSync, readFileSync, mkdirSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; -import { tmpdir } from 'node:os'; - -import { - createSession, - saveSession, - loadLatestSession, - listSessions, - loadSessionById, -} from '@bradygaster/squad-cli/shell/session-store'; -import type { SessionData } from '@bradygaster/squad-cli/shell/session-store'; -import type { ShellMessage } from '@bradygaster/squad-cli/shell/types'; - -let tmpRoot: string; - -beforeEach(() => { - tmpRoot = mkdtempSync(join(tmpdir(), 'squad-session-test-')); -}); - -afterEach(() => { - rmSync(tmpRoot, { recursive: true, force: true }); -}); - -// ============================================================================ -// createSession -// ============================================================================ - -describe('createSession', () => { - it('returns a session with a UUID, timestamps, and empty messages', () => { - const session = createSession(); - expect(session.id).toMatch( - /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/, - ); - expect(session.createdAt).toBeTruthy(); - expect(session.lastActiveAt).toBeTruthy(); - expect(session.messages).toEqual([]); - }); - - it('generates unique IDs on each call', () => { - const a = createSession(); - const b = createSession(); - expect(a.id).not.toBe(b.id); - }); -}); - -// ============================================================================ -// saveSession -// ============================================================================ - -describe('saveSession', () => { - it('creates the sessions directory and writes a JSON file', () => { - const session = createSession(); - session.messages.push({ - role: 'user', - content: 'hello', - timestamp: new Date(), - }); - - const filePath = saveSession(tmpRoot, session); - - expect(existsSync(filePath)).toBe(true); - const data = JSON.parse(readFileSync(filePath, 'utf-8')) as SessionData; - expect(data.id).toBe(session.id); - expect(data.messages).toHaveLength(1); - expect(data.messages[0]!.content).toBe('hello'); - }); - - it('overwrites the same file on subsequent saves', () => { - const session = createSession(); - const path1 = saveSession(tmpRoot, session); - - session.messages.push({ - role: 'agent', - agentName: 'baer', - content: 'response', - timestamp: new Date(), - }); - const path2 = saveSession(tmpRoot, session); - - expect(path1).toBe(path2); - const data = JSON.parse(readFileSync(path2, 'utf-8')) as SessionData; - expect(data.messages).toHaveLength(1); - }); - - it('updates lastActiveAt on each save', () => { - const session = createSession(); - const originalTime = session.lastActiveAt; - - // Small delay to ensure timestamp changes - saveSession(tmpRoot, session); - const data = JSON.parse( - readFileSync(saveSession(tmpRoot, session), 'utf-8'), - ) as SessionData; - - // lastActiveAt should be at least as recent as original - expect(new Date(data.lastActiveAt).getTime()).toBeGreaterThanOrEqual( - new Date(originalTime).getTime(), - ); - }); -}); - -// ============================================================================ -// listSessions -// ============================================================================ - -describe('listSessions', () => { - it('returns empty array when no sessions directory exists', () => { - expect(listSessions(tmpRoot)).toEqual([]); - }); - - it('lists saved sessions most recent first', () => { - const dir = join(tmpRoot, '.squad', 'sessions'); - mkdirSync(dir, { recursive: true }); - - const s1 = createSession(); - s1.messages.push({ role: 'user', content: 'first', timestamp: new Date() }); - s1.lastActiveAt = '2025-01-15T10:00:00Z'; - writeFileSync(join(dir, `s1_${s1.id}.json`), JSON.stringify(s1)); - - const s2 = createSession(); - s2.messages.push( - { role: 'user', content: 'second-a', timestamp: new Date() }, - { role: 'agent', content: 'second-b', timestamp: new Date() }, - ); - s2.lastActiveAt = '2025-01-15T11:00:00Z'; - writeFileSync(join(dir, `s2_${s2.id}.json`), JSON.stringify(s2)); - - const list = listSessions(tmpRoot); - expect(list).toHaveLength(2); - // Most recent first — s2 has later lastActiveAt - expect(list[0]!.id).toBe(s2.id); - expect(list[0]!.messageCount).toBe(2); - expect(list[1]!.id).toBe(s1.id); - expect(list[1]!.messageCount).toBe(1); - }); - - it('skips malformed JSON files', () => { - const dir = join(tmpRoot, '.squad', 'sessions'); - mkdirSync(dir, { recursive: true }); - writeFileSync(join(dir, 'bad.json'), 'not json'); - - const s = createSession(); - saveSession(tmpRoot, s); - - const list = listSessions(tmpRoot); - expect(list).toHaveLength(1); - expect(list[0]!.id).toBe(s.id); - }); -}); - -// ============================================================================ -// loadLatestSession -// ============================================================================ - -describe('loadLatestSession', () => { - it('returns null when no sessions exist', () => { - expect(loadLatestSession(tmpRoot)).toBeNull(); - }); - - it('returns the most recent session with rehydrated Date timestamps', () => { - const session = createSession(); - const ts = new Date('2025-01-15T10:00:00Z'); - session.messages.push({ role: 'user', content: 'hi', timestamp: ts }); - saveSession(tmpRoot, session); - - const loaded = loadLatestSession(tmpRoot); - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(session.id); - expect(loaded!.messages[0]!.timestamp).toBeInstanceOf(Date); - expect(loaded!.messages[0]!.timestamp.toISOString()).toBe(ts.toISOString()); - }); - - it('returns null when the latest session is older than 24 hours', () => { - const session = createSession(); - // Backdate the session to 25 hours ago - const old = new Date(Date.now() - 25 * 60 * 60 * 1000).toISOString(); - session.createdAt = old; - session.lastActiveAt = old; - - const dir = join(tmpRoot, '.squad', 'sessions'); - mkdirSync(dir, { recursive: true }); - writeFileSync( - join(dir, `old_${session.id}.json`), - JSON.stringify(session), - ); - - expect(loadLatestSession(tmpRoot)).toBeNull(); - }); -}); - -// ============================================================================ -// loadSessionById -// ============================================================================ - -describe('loadSessionById', () => { - it('returns null for non-existent session', () => { - expect(loadSessionById(tmpRoot, 'does-not-exist')).toBeNull(); - }); - - it('loads a specific session by ID', () => { - const s1 = createSession(); - s1.messages.push({ role: 'user', content: 'msg1', timestamp: new Date() }); - saveSession(tmpRoot, s1); - - const s2 = createSession(); - s2.messages.push({ role: 'agent', content: 'msg2', timestamp: new Date() }); - saveSession(tmpRoot, s2); - - const loaded = loadSessionById(tmpRoot, s1.id); - expect(loaded).not.toBeNull(); - expect(loaded!.id).toBe(s1.id); - expect(loaded!.messages[0]!.content).toBe('msg1'); - expect(loaded!.messages[0]!.timestamp).toBeInstanceOf(Date); - }); -}); diff --git a/test/shell-integration.test.ts b/test/shell-integration.test.ts deleted file mode 100644 index 2d1545314..000000000 --- a/test/shell-integration.test.ts +++ /dev/null @@ -1,427 +0,0 @@ -/** - * Shell integration tests — lifecycle, input routing, coordinator response - * parsing, session cleanup, and error handling. - * - * Covers audit gaps: startup, input routing, coordinator parsing, - * session cleanup, SDK not connected graceful degradation. - * - * @module test/shell-integration - */ - -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import fs from 'node:fs'; -import path from 'node:path'; -import os from 'node:os'; - -import { SessionRegistry } from '@bradygaster/squad-cli/shell/sessions'; -import { ShellLifecycle, type LifecycleOptions, type DiscoveredAgent } from '@bradygaster/squad-cli/shell/lifecycle'; -import { ShellRenderer } from '@bradygaster/squad-cli/shell/render'; -import { parseInput, type ParsedInput, type MessageType } from '@bradygaster/squad-cli/shell/router'; -import { - parseCoordinatorResponse, - formatConversationContext, - type RoutingDecision, -} from '@bradygaster/squad-cli/shell/coordinator'; - -// ============================================================================ -// Helpers -// ============================================================================ - -function makeTempDir(prefix: string): string { - return fs.mkdtempSync(path.join(os.tmpdir(), prefix)); -} - -function cleanDir(dir: string): void { - try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* ok */ } -} - -function makeTeamMd(agents: Array<{ name: string; role: string; status?: string }>): string { - const rows = agents - .map(a => `| ${a.name} | ${a.role} | \`.squad/agents/${a.name.toLowerCase()}/charter.md\` | ✅ ${a.status ?? 'Active'} |`) - .join('\n'); - return `# Team Manifest - -## Members - -| Name | Role | Charter | Status | -|------|------|---------|--------| -${rows} - -## Notes -Placeholder -`; -} - -// ============================================================================ -// 1. Shell Startup — ShellLifecycle.initialize() -// ============================================================================ - -describe('ShellLifecycle — startup', () => { - let tmpDir: string; - - beforeEach(() => { - tmpDir = makeTempDir('shell-int-'); - }); - - afterEach(() => { - cleanDir(tmpDir); - }); - - function makeLifecycle(teamRoot: string): ShellLifecycle { - return new ShellLifecycle({ - teamRoot, - renderer: new ShellRenderer(), - registry: new SessionRegistry(), - }); - } - - it('throws when .squad/ directory does not exist', async () => { - const lc = makeLifecycle(tmpDir); - await expect(lc.initialize()).rejects.toThrow('No team found'); - }); - - it('throws when team.md is missing', async () => { - fs.mkdirSync(path.join(tmpDir, '.squad'), { recursive: true }); - const lc = makeLifecycle(tmpDir); - await expect(lc.initialize()).rejects.toThrow('No team manifest found'); - }); - - it('sets state to error on failure', async () => { - const lc = makeLifecycle(tmpDir); - try { await lc.initialize(); } catch { /* expected */ } - expect(lc.getState().status).toBe('error'); - }); - - it('discovers agents from team.md', async () => { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Tester' }, - ])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - - const agents = lc.getDiscoveredAgents(); - expect(agents).toHaveLength(2); - expect(agents.map(a => a.name)).toContain('Fenster'); - expect(agents.map(a => a.name)).toContain('Hockney'); - }); - - it('registers active agents in session registry', async () => { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Keaton', role: 'Lead' }, - ])); - const registry = new SessionRegistry(); - const lc = new ShellLifecycle({ teamRoot: tmpDir, renderer: new ShellRenderer(), registry }); - await lc.initialize(); - - expect(registry.get('Keaton')).toBeDefined(); - expect(registry.get('Keaton')?.role).toBe('Lead'); - }); - - it('sets state to ready after successful init', async () => { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Fenster', role: 'Core Dev' }, - ])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - expect(lc.getState().status).toBe('ready'); - }); - - it('tracks message history after init', async () => { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'A', role: 'R' }, - ])); - const lc = makeLifecycle(tmpDir); - await lc.initialize(); - - lc.addUserMessage('hello'); - lc.addAgentMessage('A', 'response'); - lc.addSystemMessage('system info'); - expect(lc.getHistory()).toHaveLength(3); - expect(lc.getHistory('A')).toHaveLength(1); - }); -}); - -// ============================================================================ -// 2. Input Routing — parseInput() -// ============================================================================ - -describe('parseInput — input routing', () => { - const knownAgents = ['Fenster', 'Hockney', 'Keaton', 'Verbal']; - - it('@Fenster fix the bug → direct_agent', () => { - const result = parseInput('@Fenster fix the bug', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - expect(result.content).toBe('fix the bug'); - }); - - it('"fix the bug" → coordinator', () => { - const result = parseInput('fix the bug', knownAgents); - expect(result.type).toBe('coordinator'); - expect(result.content).toBe('fix the bug'); - }); - - it('/help → slash_command', () => { - const result = parseInput('/help', knownAgents); - expect(result.type).toBe('slash_command'); - expect(result.command).toBe('help'); - expect(result.args).toEqual([]); - }); - - it('/status verbose → slash_command with args', () => { - const result = parseInput('/status verbose', knownAgents); - expect(result.type).toBe('slash_command'); - expect(result.command).toBe('status'); - expect(result.args).toEqual(['verbose']); - }); - - it('@unknown message → coordinator (not known agent)', () => { - const result = parseInput('@UnknownAgent do something', knownAgents); - expect(result.type).toBe('coordinator'); - }); - - it('preserves raw input', () => { - const result = parseInput(' @Fenster help ', knownAgents); - expect(result.raw).toBe('@Fenster help'); - }); - - it('case-insensitive agent matching', () => { - const result = parseInput('@fenster fix it', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - }); - - it('"Fenster, do something" comma syntax → direct_agent', () => { - const result = parseInput('Fenster, do something', knownAgents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - expect(result.content).toBe('do something'); - }); - - it('empty string → coordinator', () => { - const result = parseInput('', knownAgents); - expect(result.type).toBe('coordinator'); - }); - - it('slash command with multiple args', () => { - const result = parseInput('/deploy staging --force', knownAgents); - expect(result.type).toBe('slash_command'); - expect(result.command).toBe('deploy'); - expect(result.args).toEqual(['staging', '--force']); - }); - - it('@agent with no message → routes to coordinator', () => { - const result = parseInput('@Fenster', knownAgents); - expect(result.type).toBe('coordinator'); - expect(result.raw).toBe('@Fenster'); - }); -}); - -// ============================================================================ -// 3. Coordinator Response Parsing — ROUTE, DIRECT, MULTI -// ============================================================================ - -describe('parseCoordinatorResponse', () => { - it('parses ROUTE format', () => { - const response = `ROUTE: Fenster -TASK: Fix the null pointer exception in parser.ts -CONTEXT: User reported crash on line 42`; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('route'); - expect(result.routes).toHaveLength(1); - expect(result.routes![0].agent).toBe('Fenster'); - expect(result.routes![0].task).toBe('Fix the null pointer exception in parser.ts'); - expect(result.routes![0].context).toBe('User reported crash on line 42'); - }); - - it('parses DIRECT format', () => { - const response = 'DIRECT: The team has 5 active agents.'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('The team has 5 active agents.'); - }); - - it('parses MULTI format', () => { - const response = `MULTI: -- Fenster: Fix the parser bug -- Hockney: Write regression tests`; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(2); - expect(result.routes![0].agent).toBe('Fenster'); - expect(result.routes![0].task).toBe('Fix the parser bug'); - expect(result.routes![1].agent).toBe('Hockney'); - expect(result.routes![1].task).toBe('Write regression tests'); - }); - - it('unrecognized format → fallback direct', () => { - const response = 'Some freeform response from the LLM'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('Some freeform response from the LLM'); - }); - - it('ROUTE without CONTEXT', () => { - const response = `ROUTE: Keaton -TASK: Review the proposal`; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('route'); - expect(result.routes![0].context).toBeUndefined(); - }); - - it('MULTI with no valid agent lines → empty routes', () => { - const response = `MULTI: -Some nonsense line`; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(0); - }); - - it('DIRECT with empty content', () => { - const response = 'DIRECT:'; - const result = parseCoordinatorResponse(response); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe(''); - }); -}); - -// ============================================================================ -// 4. formatConversationContext -// ============================================================================ - -describe('formatConversationContext', () => { - it('formats messages with role prefixes', () => { - const messages = [ - { role: 'user' as const, content: 'hello', timestamp: new Date() }, - { role: 'agent' as const, agentName: 'Fenster', content: 'hi', timestamp: new Date() }, - { role: 'system' as const, content: 'info', timestamp: new Date() }, - ]; - const ctx = formatConversationContext(messages); - expect(ctx).toContain('[user]: hello'); - expect(ctx).toContain('[Fenster]: hi'); - expect(ctx).toContain('[system]: info'); - }); - - it('respects maxMessages', () => { - const messages = Array.from({ length: 50 }, (_, i) => ({ - role: 'user' as const, - content: `msg${i}`, - timestamp: new Date(), - })); - const ctx = formatConversationContext(messages, 5); - const lines = ctx.split('\n'); - expect(lines).toHaveLength(5); - expect(ctx).toContain('msg49'); - expect(ctx).not.toContain('msg0'); - }); - - it('empty messages → empty string', () => { - expect(formatConversationContext([])).toBe(''); - }); -}); - -// ============================================================================ -// 5. Session Cleanup -// ============================================================================ - -describe('Session cleanup on shutdown', () => { - it('all sessions cleared on shutdown', async () => { - const tmpDir = makeTempDir('shell-cleanup-'); - try { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'Fenster', role: 'Core Dev' }, - { name: 'Hockney', role: 'Tester' }, - ])); - - const registry = new SessionRegistry(); - const lc = new ShellLifecycle({ teamRoot: tmpDir, renderer: new ShellRenderer(), registry }); - await lc.initialize(); - - expect(registry.getAll()).toHaveLength(2); - - await lc.shutdown(); - - expect(registry.getAll()).toHaveLength(0); - expect(lc.getDiscoveredAgents()).toHaveLength(0); - expect(lc.getHistory()).toHaveLength(0); - } finally { - cleanDir(tmpDir); - } - }); - - it('message history cleared on shutdown', async () => { - const tmpDir = makeTempDir('shell-cleanup2-'); - try { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'A', role: 'R' }, - ])); - - const lc = new ShellLifecycle({ teamRoot: tmpDir, renderer: new ShellRenderer(), registry: new SessionRegistry() }); - await lc.initialize(); - lc.addUserMessage('test'); - expect(lc.getHistory()).toHaveLength(1); - - await lc.shutdown(); - expect(lc.getHistory()).toHaveLength(0); - } finally { - cleanDir(tmpDir); - } - }); -}); - -// ============================================================================ -// 6. Error Handling — SDK not connected -// ============================================================================ - -describe('Error handling — graceful degradation', () => { - it('HealthMonitor check() returns unhealthy when client not connected', async () => { - // Lazy import to avoid pulling in real SDK deps at top level - const { HealthMonitor } = await import('../packages/squad-sdk/src/runtime/health.js'); - - const mockClient = { - isConnected: () => false, - getState: () => 'disconnected', - ping: vi.fn(), - }; - - const monitor = new HealthMonitor({ client: mockClient as any, logDiagnostics: false }); - const result = await monitor.check(); - - expect(result.status).toBe('unhealthy'); - expect(result.connected).toBe(false); - expect(result.error).toContain('not connected'); - expect(mockClient.ping).not.toHaveBeenCalled(); - }); - - it('ShellLifecycle shutdown is safe to call multiple times', async () => { - const tmpDir = makeTempDir('shell-err-'); - try { - const squadDir = path.join(tmpDir, '.squad'); - fs.mkdirSync(squadDir, { recursive: true }); - fs.writeFileSync(path.join(squadDir, 'team.md'), makeTeamMd([ - { name: 'A', role: 'R' }, - ])); - - const lc = new ShellLifecycle({ teamRoot: tmpDir, renderer: new ShellRenderer(), registry: new SessionRegistry() }); - await lc.initialize(); - await lc.shutdown(); - await lc.shutdown(); // second call should not throw - expect(lc.getHistory()).toHaveLength(0); - } finally { - cleanDir(tmpDir); - } - }); -}); diff --git a/test/shell-metrics.test.ts b/test/shell-metrics.test.ts deleted file mode 100644 index 640f81f33..000000000 --- a/test/shell-metrics.test.ts +++ /dev/null @@ -1,283 +0,0 @@ -/** - * Shell Observability Metrics Tests — Issues #508, #520, #526, #530, #531 - * - * Tests for shell-level metrics: session duration, agent response latency, - * error count, and session count. Verifies opt-in gating via SQUAD_TELEMETRY=1. - * - * Strategy: Mock getMeter() from the otel provider to return a spy-enabled - * meter so we can verify every .add() / .record() call with correct attributes. - */ - -import { describe, it, expect, beforeEach, afterEach, vi, type Mock } from 'vitest'; - -// --------------------------------------------------------------------------- -// Mock the OTel provider's getMeter to return spy instruments -// --------------------------------------------------------------------------- - -interface SpyInstrument { - add: Mock; - record: Mock; -} - -interface SpyMeter { - createCounter: Mock; - createHistogram: Mock; - createUpDownCounter: Mock; - createGauge: Mock; - _instruments: Map; -} - -function createSpyMeter(): SpyMeter { - const instruments = new Map(); - - function makeInstrument(name: string): SpyInstrument { - const inst: SpyInstrument = { add: vi.fn(), record: vi.fn() }; - instruments.set(name, inst); - return inst; - } - - return { - createCounter: vi.fn((name: string) => makeInstrument(name)), - createHistogram: vi.fn((name: string) => makeInstrument(name)), - createUpDownCounter: vi.fn((name: string) => makeInstrument(name)), - createGauge: vi.fn((name: string) => makeInstrument(name)), - _instruments: instruments, - }; -} - -let spyMeter: SpyMeter; - -vi.mock('@bradygaster/squad-sdk', () => ({ - getMeter: () => spyMeter, -})); - -// Import after mock setup -import { - enableShellMetrics, - recordShellSessionDuration, - recordAgentResponseLatency, - recordShellError, - isShellTelemetryEnabled, - _resetShellMetrics, -} from '@bradygaster/squad-cli/shell/shell-metrics'; - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function getInstrument(name: string): SpyInstrument { - const inst = spyMeter._instruments.get(name); - if (!inst) throw new Error(`No instrument created for "${name}". Created: ${[...spyMeter._instruments.keys()].join(', ')}`); - return inst; -} - -// ============================================================================= -// Setup / Teardown -// ============================================================================= - -let originalEnv: string | undefined; - -beforeEach(() => { - spyMeter = createSpyMeter(); - _resetShellMetrics(); - originalEnv = process.env['SQUAD_TELEMETRY']; -}); - -afterEach(() => { - if (originalEnv === undefined) { - delete process.env['SQUAD_TELEMETRY']; - } else { - process.env['SQUAD_TELEMETRY'] = originalEnv; - } -}); - -// ============================================================================= -// Opt-in gating — SQUAD_TELEMETRY=1 -// ============================================================================= - -describe('Shell Metrics — Opt-in Gate', () => { - it('isShellTelemetryEnabled returns false when SQUAD_TELEMETRY is not set', () => { - delete process.env['SQUAD_TELEMETRY']; - expect(isShellTelemetryEnabled()).toBe(false); - }); - - it('isShellTelemetryEnabled returns true when SQUAD_TELEMETRY=1', () => { - process.env['SQUAD_TELEMETRY'] = '1'; - expect(isShellTelemetryEnabled()).toBe(true); - }); - - it('isShellTelemetryEnabled returns false for other values', () => { - process.env['SQUAD_TELEMETRY'] = 'true'; - expect(isShellTelemetryEnabled()).toBe(false); - process.env['SQUAD_TELEMETRY'] = '0'; - expect(isShellTelemetryEnabled()).toBe(false); - }); - - it('enableShellMetrics returns false and creates no instruments when disabled', () => { - delete process.env['SQUAD_TELEMETRY']; - delete process.env['OTEL_EXPORTER_OTLP_ENDPOINT']; - const result = enableShellMetrics(); - expect(result).toBe(false); - expect(spyMeter.createCounter).not.toHaveBeenCalled(); - expect(spyMeter.createHistogram).not.toHaveBeenCalled(); - }); - - it('enableShellMetrics returns true when SQUAD_TELEMETRY=1', () => { - process.env['SQUAD_TELEMETRY'] = '1'; - const result = enableShellMetrics(); - expect(result).toBe(true); - }); - - it('metrics functions are no-ops when not enabled', () => { - delete process.env['SQUAD_TELEMETRY']; - // Should not throw even without enabling - recordShellSessionDuration(5000); - recordAgentResponseLatency('fenster', 1200); - recordShellError('dispatch'); - expect(spyMeter.createCounter).not.toHaveBeenCalled(); - }); -}); - -// ============================================================================= -// #508 / #520 — Session Lifetime Metrics -// ============================================================================= - -describe('Shell Metrics — Session Lifetime (#508, #520)', () => { - beforeEach(() => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - }); - - it('enableShellMetrics increments session_count', () => { - const counter = getInstrument('squad.shell.session_count'); - expect(counter.add).toHaveBeenCalledWith(1); - }); - - it('recordShellSessionDuration records to the histogram', () => { - recordShellSessionDuration(120000); - const hist = getInstrument('squad.shell.session_duration_ms'); - expect(hist.record).toHaveBeenCalledWith(120000); - }); - - it('session_duration_ms histogram has correct config', () => { - expect(spyMeter.createHistogram).toHaveBeenCalledWith( - 'squad.shell.session_duration_ms', - expect.objectContaining({ unit: 'ms' }), - ); - }); -}); - -// ============================================================================= -// #508 / #526 — Agent Response Latency -// ============================================================================= - -describe('Shell Metrics — Agent Response Latency (#508, #526)', () => { - beforeEach(() => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - }); - - it('recordAgentResponseLatency records with agent name and dispatch type', () => { - recordAgentResponseLatency('fenster', 850, 'direct'); - const hist = getInstrument('squad.shell.agent_response_latency_ms'); - expect(hist.record).toHaveBeenCalledWith(850, { - 'agent.name': 'fenster', - 'dispatch.type': 'direct', - }); - }); - - it('defaults dispatch type to direct', () => { - recordAgentResponseLatency('keaton', 500); - const hist = getInstrument('squad.shell.agent_response_latency_ms'); - expect(hist.record).toHaveBeenCalledWith(500, { - 'agent.name': 'keaton', - 'dispatch.type': 'direct', - }); - }); - - it('records coordinator dispatch type', () => { - recordAgentResponseLatency('coordinator', 1200, 'coordinator'); - const hist = getInstrument('squad.shell.agent_response_latency_ms'); - expect(hist.record).toHaveBeenCalledWith(1200, { - 'agent.name': 'coordinator', - 'dispatch.type': 'coordinator', - }); - }); -}); - -// ============================================================================= -// #530 — Error Rate Tracking -// ============================================================================= - -describe('Shell Metrics — Error Rate (#530)', () => { - beforeEach(() => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - }); - - it('recordShellError increments error_count with source', () => { - recordShellError('agent_dispatch', 'fenster'); - const counter = getInstrument('squad.shell.error_count'); - expect(counter.add).toHaveBeenCalledWith(1, { - 'error.source': 'agent_dispatch', - 'error.type': 'fenster', - }); - }); - - it('recordShellError works without optional error type', () => { - recordShellError('coordinator_dispatch'); - const counter = getInstrument('squad.shell.error_count'); - expect(counter.add).toHaveBeenCalledWith(1, { - 'error.source': 'coordinator_dispatch', - }); - }); - - it('multiple errors accumulate', () => { - recordShellError('dispatch', 'Error'); - recordShellError('dispatch', 'TypeError'); - recordShellError('agent_dispatch', 'keaton'); - const counter = getInstrument('squad.shell.error_count'); - expect(counter.add).toHaveBeenCalledTimes(3); - }); -}); - -// ============================================================================= -// #531 — Session Count (Retention Proxy) -// ============================================================================= - -describe('Shell Metrics — Session Count / Retention (#531)', () => { - it('each enableShellMetrics call increments session_count', () => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - const counter = getInstrument('squad.shell.session_count'); - expect(counter.add).toHaveBeenCalledWith(1); - expect(counter.add).toHaveBeenCalledTimes(1); - }); - - it('creates all four metric instruments', () => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - expect(spyMeter.createHistogram).toHaveBeenCalledTimes(2); // duration + latency - expect(spyMeter.createCounter).toHaveBeenCalledTimes(2); // error_count + session_count - }); -}); - -// ============================================================================= -// Reset -// ============================================================================= - -describe('Shell Metrics — Reset', () => { - it('_resetShellMetrics clears cached instruments', () => { - process.env['SQUAD_TELEMETRY'] = '1'; - enableShellMetrics(); - expect(spyMeter.createCounter).toHaveBeenCalled(); - - // Reset and re-create meter - _resetShellMetrics(); - spyMeter = createSpyMeter(); - - // After reset, metrics should be no-ops again (not enabled) - recordShellSessionDuration(1000); - expect(spyMeter.createHistogram).not.toHaveBeenCalled(); - }); -}); diff --git a/test/shell-polish.test.ts b/test/shell-polish.test.ts deleted file mode 100644 index 46f98f926..000000000 --- a/test/shell-polish.test.ts +++ /dev/null @@ -1,307 +0,0 @@ -/** - * Tests for shell UX polish improvements (issue #478). - * - * Covers: - * - Autocomplete completeness (all slash commands present) - * - /history validation (NaN, negative, zero) - * - Unknown command "Did you mean?" suggestions - * - @Agent with empty body routes to coordinator - * - Comma-syntax with empty body routes to coordinator - * - Error message improvements (nap, resume, generic) - * - StreamBridge buffer size limits - * - New error guidance types (timeout, unknownCommand) - */ - -import { describe, it, expect } from 'vitest'; -import { createCompleter } from '../packages/squad-cli/src/cli/shell/autocomplete.js'; -import { executeCommand, type CommandContext } from '@bradygaster/squad-cli/shell/commands'; -import { parseInput } from '@bradygaster/squad-cli/shell/router'; -import { SessionRegistry } from '@bradygaster/squad-cli/shell/sessions'; -import { ShellRenderer } from '@bradygaster/squad-cli/shell/render'; -import { - timeoutGuidance, - unknownCommandGuidance, - genericGuidance, - formatGuidance, -} from '@bradygaster/squad-cli/shell/error-messages'; -import { StreamBridge } from '@bradygaster/squad-cli/shell/stream-bridge'; - -// ============================================================================ -// 1. Autocomplete — all slash commands present -// ============================================================================ - -describe('Autocomplete completeness', () => { - const completer = createCompleter(['Fenster', 'Hockney']); - - it('includes /sessions in completions', () => { - const [matches] = completer('/ses'); - expect(matches).toContain('/sessions'); - }); - - it('includes /resume in completions', () => { - const [matches] = completer('/res'); - expect(matches).toContain('/resume'); - }); - - it('includes /init in completions', () => { - const [matches] = completer('/ini'); - expect(matches).toContain('/init'); - }); - - it('includes /nap in completions', () => { - const [matches] = completer('/na'); - expect(matches).toContain('/nap'); - }); - - it('includes /version in completions', () => { - const [matches] = completer('/ver'); - expect(matches).toContain('/version'); - }); - - it('lists all 12 commands for bare /', () => { - const [matches] = completer('/'); - expect(matches.length).toBe(12); - }); -}); - -// ============================================================================ -// 2. /history validation -// ============================================================================ - -describe('/history input validation', () => { - const context: CommandContext = { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [ - { role: 'user', content: 'Hello', timestamp: new Date() }, - { role: 'agent', agentName: 'Fenster', content: 'Hi there', timestamp: new Date() }, - ], - teamRoot: '/tmp', - }; - - it('rejects NaN input', () => { - const result = executeCommand('history', ['abc'], context); - expect(result.output).toContain('positive number'); - }); - - it('rejects negative numbers', () => { - const result = executeCommand('history', ['-5'], context); - expect(result.output).toContain('positive number'); - }); - - it('rejects zero', () => { - const result = executeCommand('history', ['0'], context); - expect(result.output).toContain('positive number'); - }); - - it('accepts valid positive number', () => { - const result = executeCommand('history', ['5'], context); - expect(result.output).toContain('Last 2 message'); - }); - - it('defaults to 10 with no argument', () => { - const result = executeCommand('history', [], context); - expect(result.output).toContain('Last 2 message'); - }); -}); - -// ============================================================================ -// 3. Unknown command — "Did you mean?" suggestions -// ============================================================================ - -describe('Unknown command suggestions', () => { - const context: CommandContext = { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [], - teamRoot: '/tmp', - }; - - it('suggests /status for /st', () => { - const result = executeCommand('st', [], context); - expect(result.output).toContain('Did you mean /status?'); - }); - - it('suggests /help for /he', () => { - const result = executeCommand('he', [], context); - expect(result.output).toContain('Did you mean /help?'); - }); - - it('suggests /init for /in', () => { - const result = executeCommand('in', [], context); - expect(result.output).toContain('Did you mean /init?'); - }); - - it('does not suggest for completely unmatched input', () => { - const result = executeCommand('zzzzz', [], context); - expect(result.output).toContain('Unknown command'); - expect(result.output).not.toContain('Did you mean'); - }); -}); - -// ============================================================================ -// 4. @Agent with empty body routes to coordinator -// ============================================================================ - -describe('@Agent with empty body', () => { - const agents = ['Fenster', 'Hockney', 'Keaton']; - - it('@Agent with no message routes to coordinator', () => { - const result = parseInput('@Fenster', agents); - expect(result.type).toBe('coordinator'); - expect(result.raw).toBe('@Fenster'); - }); - - it('@Agent with whitespace-only routes to coordinator', () => { - const result = parseInput('@Fenster ', agents); - expect(result.type).toBe('coordinator'); - }); - - it('@Agent with message stays direct_agent', () => { - const result = parseInput('@Fenster fix the bug', agents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - expect(result.content).toBe('fix the bug'); - }); - - it('Comma syntax with no message routes to coordinator', () => { - const result = parseInput('Fenster, ', agents); - expect(result.type).toBe('coordinator'); - }); - - it('Comma syntax with message stays direct_agent', () => { - const result = parseInput('Fenster, fix the bug', agents); - expect(result.type).toBe('direct_agent'); - expect(result.agentName).toBe('Fenster'); - expect(result.content).toBe('fix the bug'); - }); -}); - -// ============================================================================ -// 5. Error message improvements -// ============================================================================ - -describe('Error message improvements', () => { - it('nap result includes report for valid path', () => { - const context: CommandContext = { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [], - teamRoot: '/nonexistent/path/that/will/succeed', - }; - const result = executeCommand('nap', [], context); - // Should succeed or fail with a descriptive message (not bare "Nap failed.") - expect(result.output).toBeTruthy(); - expect(result.output).not.toBe('Nap failed.'); - }); - - it('resume with bad ID includes helpful context', () => { - const context: CommandContext = { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [], - teamRoot: '/tmp', - }; - const result = executeCommand('resume', ['nonexistent123'], context); - expect(result.output).toContain('nonexistent123'); - expect(result.output).toContain('/sessions'); - }); -}); - -// ============================================================================ -// 6. New error guidance types -// ============================================================================ - -describe('New error guidance types', () => { - it('timeoutGuidance provides agent-specific message', () => { - const g = timeoutGuidance('Fenster'); - expect(g.message).toContain('Fenster'); - expect(g.message).toContain('timed out'); - expect(g.recovery.length).toBeGreaterThanOrEqual(3); - expect(g.recovery.some(r => r.includes('SQUAD_REPL_TIMEOUT'))).toBe(true); - }); - - it('timeoutGuidance provides generic message when no agent', () => { - const g = timeoutGuidance(); - expect(g.message).toContain('Request timed out'); - }); - - it('unknownCommandGuidance includes command name', () => { - const g = unknownCommandGuidance('foobar'); - expect(g.message).toContain('foobar'); - expect(g.recovery.some(r => r.includes('/help'))).toBe(true); - }); - - it('genericGuidance now has 3 recovery steps', () => { - const g = genericGuidance('Something broke'); - expect(g.recovery.length).toBe(3); - expect(g.recovery.some(r => r.includes('internet'))).toBe(true); - }); - - it('formatGuidance renders all recovery steps', () => { - const g = timeoutGuidance('Fenster'); - const formatted = formatGuidance(g); - expect(formatted).toContain('❌'); - expect(formatted).toContain('Fenster'); - for (const step of g.recovery) { - expect(formatted).toContain(step); - } - }); -}); - -// ============================================================================ -// 7. StreamBridge buffer size limits -// ============================================================================ - -describe('StreamBridge buffer size limits', () => { - it('has a MAX_BUFFER_SIZE constant', () => { - expect(StreamBridge.MAX_BUFFER_SIZE).toBe(1024 * 1024); - }); - - it('truncates buffer when exceeding MAX_BUFFER_SIZE', () => { - const registry = new SessionRegistry(); - registry.register('test', 'Test Agent'); - let lastContent = ''; - const bridge = new StreamBridge(registry, { - onContent: (_agent, content) => { lastContent = content; }, - onComplete: () => {}, - }); - - // Push content that exceeds the limit - const bigChunk = 'x'.repeat(StreamBridge.MAX_BUFFER_SIZE + 100); - bridge.handleEvent({ - type: 'message_delta', - sessionId: 'test', - agentName: 'test', - content: bigChunk, - index: 0, - timestamp: new Date(), - } as never); - - const buffer = bridge.getBuffer('test'); - expect(buffer.length).toBeLessThanOrEqual(StreamBridge.MAX_BUFFER_SIZE); - // Most recent content is preserved (truncated from front) - expect(buffer.endsWith('x')).toBe(true); - expect(lastContent).toBe(bigChunk); // onContent gets the original delta - }); - - it('allows content under the limit without truncation', () => { - const registry = new SessionRegistry(); - registry.register('test', 'Test Agent'); - const bridge = new StreamBridge(registry, { - onContent: () => {}, - onComplete: () => {}, - }); - - bridge.handleEvent({ - type: 'message_delta', - sessionId: 'test', - agentName: 'test', - content: 'hello world', - index: 0, - timestamp: new Date(), - } as never); - - expect(bridge.getBuffer('test')).toBe('hello world'); - }); -}); diff --git a/test/shell.test.ts b/test/shell.test.ts deleted file mode 100644 index 6d2fba48d..000000000 --- a/test/shell.test.ts +++ /dev/null @@ -1,492 +0,0 @@ -/** - * Integration tests for the shell module — sessions, spawn, coordinator, - * lifecycle, and stream-bridge. - * - * @module test/shell - */ - -import { describe, it, expect, vi, beforeEach } from 'vitest'; -import { join } from 'node:path'; - -import { SessionRegistry } from '@bradygaster/squad-cli/shell/sessions'; -import { - loadAgentCharter, - buildAgentPrompt, -} from '@bradygaster/squad-cli/shell/spawn'; -import { - buildCoordinatorPrompt, - parseCoordinatorResponse, - formatConversationContext, -} from '@bradygaster/squad-cli/shell/coordinator'; -import { ShellLifecycle } from '@bradygaster/squad-cli/shell/lifecycle'; -import { StreamBridge } from '@bradygaster/squad-cli/shell/stream-bridge'; -import { ShellRenderer } from '@bradygaster/squad-cli/shell/render'; -import type { ShellMessage } from '@bradygaster/squad-cli/shell/types'; -import type { StreamDelta, UsageEvent, ReasoningDelta } from '@bradygaster/squad-sdk/runtime/streaming'; - -const FIXTURES = join(process.cwd(), 'test-fixtures'); - -// ============================================================================ -// 1. SessionRegistry -// ============================================================================ - -describe('SessionRegistry', () => { - let registry: SessionRegistry; - - beforeEach(() => { - registry = new SessionRegistry(); - }); - - it('register creates a session with idle status', () => { - const s = registry.register('hockney', 'Tester'); - expect(s.name).toBe('hockney'); - expect(s.role).toBe('Tester'); - expect(s.status).toBe('idle'); - expect(s.startedAt).toBeInstanceOf(Date); - }); - - it('get retrieves a registered session', () => { - registry.register('fenster', 'Core Dev'); - expect(registry.get('fenster')?.role).toBe('Core Dev'); - }); - - it('get returns undefined for unknown name', () => { - expect(registry.get('nobody')).toBeUndefined(); - }); - - it('getAll returns every registered session', () => { - registry.register('a', 'r1'); - registry.register('b', 'r2'); - expect(registry.getAll()).toHaveLength(2); - }); - - it('getActive filters to working/streaming sessions', () => { - registry.register('idle-agent', 'role'); - registry.register('busy-agent', 'role'); - registry.register('stream-agent', 'role'); - registry.updateStatus('busy-agent', 'working'); - registry.updateStatus('stream-agent', 'streaming'); - const active = registry.getActive(); - expect(active).toHaveLength(2); - expect(active.map(s => s.name).sort()).toEqual(['busy-agent', 'stream-agent']); - }); - - it('updateStatus changes session status', () => { - registry.register('x', 'role'); - registry.updateStatus('x', 'error'); - expect(registry.get('x')?.status).toBe('error'); - }); - - it('remove deletes a session and returns true', () => { - registry.register('x', 'role'); - expect(registry.remove('x')).toBe(true); - expect(registry.get('x')).toBeUndefined(); - }); - - it('remove returns false for unknown name', () => { - expect(registry.remove('ghost')).toBe(false); - }); - - it('clear removes all sessions', () => { - registry.register('a', 'r'); - registry.register('b', 'r'); - registry.clear(); - expect(registry.getAll()).toHaveLength(0); - }); -}); - -// ============================================================================ -// 2. Spawn infrastructure -// ============================================================================ - -describe('Spawn infrastructure', () => { - describe('loadAgentCharter', () => { - it('loads charter from test-fixtures/.squad/agents/{name}', async () => { - const charter = await loadAgentCharter('hockney', FIXTURES); - expect(charter).toContain('Hockney'); - expect(charter).toContain('Tester'); - }); - - it('lowercases the agent name for path resolution', async () => { - const charter = await loadAgentCharter('Fenster', FIXTURES); - expect(charter).toContain('Core Dev'); - }); - - it('throws for a missing charter', async () => { - await expect(loadAgentCharter('nobody', FIXTURES)).rejects.toThrow( - /No charter found for "nobody"/, - ); - }); - }); - - describe('buildAgentPrompt', () => { - it('includes charter content', () => { - const prompt = buildAgentPrompt('# My Charter'); - expect(prompt).toContain('# My Charter'); - expect(prompt).toContain('YOUR CHARTER'); - }); - - it('includes systemContext when provided', () => { - const prompt = buildAgentPrompt('charter', { systemContext: 'extra info' }); - expect(prompt).toContain('ADDITIONAL CONTEXT'); - expect(prompt).toContain('extra info'); - }); - - it('omits ADDITIONAL CONTEXT when systemContext is absent', () => { - const prompt = buildAgentPrompt('charter'); - expect(prompt).not.toContain('ADDITIONAL CONTEXT'); - }); - }); -}); - -// ============================================================================ -// 3. Coordinator -// ============================================================================ - -describe('Coordinator', () => { - describe('buildCoordinatorPrompt', () => { - it('includes team.md content', async () => { - const prompt = await buildCoordinatorPrompt({ - teamRoot: FIXTURES, - teamPath: join(FIXTURES, '.squad', 'team.md'), - }); - expect(prompt).toContain('Hockney'); - expect(prompt).toContain('Fenster'); - }); - - it('includes routing.md content', async () => { - const prompt = await buildCoordinatorPrompt({ - teamRoot: FIXTURES, - routingPath: join(FIXTURES, '.squad', 'routing.md'), - }); - expect(prompt).toContain('Tests → Hockney'); - }); - - it('falls back gracefully when team.md is missing', async () => { - const prompt = await buildCoordinatorPrompt({ - teamRoot: join(FIXTURES, 'nonexistent'), - teamPath: join(FIXTURES, 'nonexistent', 'team.md'), - }); - expect(prompt).toContain('NO TEAM CONFIGURED'); - }); - - it('falls back gracefully when routing.md is missing', async () => { - const prompt = await buildCoordinatorPrompt({ - teamRoot: join(FIXTURES, 'nonexistent'), - routingPath: join(FIXTURES, 'nonexistent', 'routing.md'), - }); - expect(prompt).toContain('No routing.md found'); - }); - }); - - describe('parseCoordinatorResponse', () => { - it('parses DIRECT responses', () => { - const result = parseCoordinatorResponse('DIRECT: The build is green.'); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('The build is green.'); - }); - - it('parses ROUTE responses', () => { - const result = parseCoordinatorResponse( - 'ROUTE: Fenster\nTASK: Fix the parser\nCONTEXT: Related to issue #42', - ); - expect(result.type).toBe('route'); - expect(result.routes).toHaveLength(1); - expect(result.routes![0]!.agent).toBe('Fenster'); - expect(result.routes![0]!.task).toBe('Fix the parser'); - expect(result.routes![0]!.context).toBe('Related to issue #42'); - }); - - it('parses ROUTE without CONTEXT', () => { - const result = parseCoordinatorResponse('ROUTE: Hockney\nTASK: Run tests'); - expect(result.type).toBe('route'); - expect(result.routes![0]!.agent).toBe('Hockney'); - expect(result.routes![0]!.context).toBeUndefined(); - }); - - it('parses MULTI responses', () => { - const result = parseCoordinatorResponse( - 'MULTI:\n- Fenster: Implement the feature\n- Hockney: Write tests', - ); - expect(result.type).toBe('multi'); - expect(result.routes).toHaveLength(2); - expect(result.routes![0]!.agent).toBe('Fenster'); - expect(result.routes![1]!.agent).toBe('Hockney'); - }); - - it('falls back to direct for unknown format', () => { - const result = parseCoordinatorResponse('Just some random text'); - expect(result.type).toBe('direct'); - expect(result.directAnswer).toBe('Just some random text'); - }); - }); - - describe('formatConversationContext', () => { - const msgs: ShellMessage[] = Array.from({ length: 5 }, (_, i) => ({ - role: 'user' as const, - content: `message-${i}`, - timestamp: new Date(), - })); - - it('formats all messages with role prefix', () => { - const ctx = formatConversationContext(msgs, 10); - expect(ctx).toContain('[user]: message-0'); - expect(ctx).toContain('[user]: message-4'); - }); - - it('respects maxMessages limit', () => { - const ctx = formatConversationContext(msgs, 2); - expect(ctx).not.toContain('message-0'); - expect(ctx).toContain('message-3'); - expect(ctx).toContain('message-4'); - }); - - it('uses agentName prefix when present', () => { - const agentMsgs: ShellMessage[] = [ - { role: 'agent', agentName: 'fenster', content: 'done', timestamp: new Date() }, - ]; - const ctx = formatConversationContext(agentMsgs); - expect(ctx).toContain('[fenster]: done'); - }); - }); -}); - -// ============================================================================ -// 4. ShellLifecycle -// ============================================================================ - -describe('ShellLifecycle', () => { - let lifecycle: ShellLifecycle; - let registry: SessionRegistry; - let renderer: ShellRenderer; - - beforeEach(() => { - registry = new SessionRegistry(); - renderer = new ShellRenderer(); - lifecycle = new ShellLifecycle({ teamRoot: FIXTURES, renderer, registry }); - }); - - it('starts in initializing state', () => { - expect(lifecycle.getState().status).toBe('initializing'); - }); - - it('transitions to ready after initialize', async () => { - await lifecycle.initialize(); - expect(lifecycle.getState().status).toBe('ready'); - }); - - it('discovers active agents from team.md', async () => { - await lifecycle.initialize(); - const agents = lifecycle.getDiscoveredAgents(); - expect(agents.length).toBeGreaterThanOrEqual(2); - expect(agents.some(a => a.name === 'Hockney')).toBe(true); - }); - - it('registers active agents in the registry', async () => { - await lifecycle.initialize(); - expect(registry.getAll().length).toBeGreaterThanOrEqual(2); - }); - - it('addUserMessage appends to history', () => { - const msg = lifecycle.addUserMessage('hello'); - expect(msg.role).toBe('user'); - expect(msg.content).toBe('hello'); - expect(lifecycle.getHistory()).toHaveLength(1); - }); - - it('addAgentMessage appends with agentName', () => { - const msg = lifecycle.addAgentMessage('fenster', 'done'); - expect(msg.role).toBe('agent'); - expect(msg.agentName).toBe('fenster'); - expect(lifecycle.getHistory()).toHaveLength(1); - }); - - it('addSystemMessage appends system message', () => { - const msg = lifecycle.addSystemMessage('system note'); - expect(msg.role).toBe('system'); - expect(lifecycle.getHistory()).toHaveLength(1); - }); - - it('getHistory returns all messages', () => { - lifecycle.addUserMessage('a'); - lifecycle.addAgentMessage('f', 'b'); - lifecycle.addSystemMessage('c'); - expect(lifecycle.getHistory()).toHaveLength(3); - }); - - it('getHistory filters by agentName', () => { - lifecycle.addUserMessage('hi'); - lifecycle.addAgentMessage('fenster', 'ok'); - lifecycle.addAgentMessage('hockney', 'tests pass'); - const filtered = lifecycle.getHistory('fenster'); - expect(filtered).toHaveLength(1); - expect(filtered[0]!.agentName).toBe('fenster'); - }); - - it('shutdown clears all state', async () => { - await lifecycle.initialize(); - lifecycle.addUserMessage('hi'); - await lifecycle.shutdown(); - expect(lifecycle.getHistory()).toHaveLength(0); - expect(lifecycle.getDiscoveredAgents()).toHaveLength(0); - expect(registry.getAll()).toHaveLength(0); - }); -}); - -// ============================================================================ -// 5. StreamBridge -// ============================================================================ - -describe('StreamBridge', () => { - let registry: SessionRegistry; - let bridge: StreamBridge; - let contentCalls: Array<{ agent: string; content: string }>; - let completeCalls: ShellMessage[]; - let usageCalls: Array<{ model: string; inputTokens: number; outputTokens: number; cost: number }>; - let reasoningCalls: Array<{ agent: string; content: string }>; - - beforeEach(() => { - registry = new SessionRegistry(); - registry.register('fenster', 'Core Dev'); - - contentCalls = []; - completeCalls = []; - usageCalls = []; - reasoningCalls = []; - - bridge = new StreamBridge(registry, { - onContent: (agent, content) => contentCalls.push({ agent, content }), - onComplete: (msg) => completeCalls.push(msg), - onUsage: (u) => usageCalls.push(u), - onReasoning: (agent, content) => reasoningCalls.push({ agent, content }), - }); - }); - - it('handleEvent processes message_delta events', () => { - const delta: StreamDelta = { - type: 'message_delta', - sessionId: 'fenster', - agentName: 'fenster', - content: 'hello', - index: 0, - timestamp: new Date(), - }; - bridge.handleEvent(delta); - expect(contentCalls).toHaveLength(1); - expect(contentCalls[0]!.content).toBe('hello'); - }); - - it('accumulates content in getBuffer', () => { - const mkDelta = (content: string, index: number): StreamDelta => ({ - type: 'message_delta', - sessionId: 'fenster', - agentName: 'fenster', - content, - index, - timestamp: new Date(), - }); - bridge.handleEvent(mkDelta('Hello', 0)); - bridge.handleEvent(mkDelta(' world', 1)); - expect(bridge.getBuffer('fenster')).toBe('Hello world'); - }); - - it('handleEvent processes usage events', () => { - const usage: UsageEvent = { - type: 'usage', - sessionId: 'fenster', - agentName: 'fenster', - model: 'gpt-4', - inputTokens: 100, - outputTokens: 50, - estimatedCost: 0.01, - timestamp: new Date(), - }; - bridge.handleEvent(usage); - expect(usageCalls).toHaveLength(1); - expect(usageCalls[0]!.model).toBe('gpt-4'); - expect(usageCalls[0]!.cost).toBe(0.01); - }); - - it('handleEvent processes reasoning_delta events', () => { - const reasoning: ReasoningDelta = { - type: 'reasoning_delta', - sessionId: 'fenster', - agentName: 'fenster', - content: 'thinking...', - index: 0, - timestamp: new Date(), - }; - bridge.handleEvent(reasoning); - expect(reasoningCalls).toHaveLength(1); - expect(reasoningCalls[0]!.content).toBe('thinking...'); - }); - - it('flush emits a complete ShellMessage', () => { - const delta: StreamDelta = { - type: 'message_delta', - sessionId: 'fenster', - agentName: 'fenster', - content: 'result', - index: 0, - timestamp: new Date(), - }; - bridge.handleEvent(delta); - bridge.flush('fenster'); - expect(completeCalls).toHaveLength(1); - expect(completeCalls[0]!.content).toBe('result'); - expect(completeCalls[0]!.role).toBe('agent'); - // Buffer should be cleared after flush - expect(bridge.getBuffer('fenster')).toBe(''); - }); - - it('flush does nothing for empty buffers', () => { - bridge.flush('nobody'); - expect(completeCalls).toHaveLength(0); - }); - - it('getBuffer returns empty string for unknown session', () => { - expect(bridge.getBuffer('unknown')).toBe(''); - }); - - it('clear resets all buffers', () => { - const delta: StreamDelta = { - type: 'message_delta', - sessionId: 'fenster', - content: 'data', - index: 0, - timestamp: new Date(), - }; - bridge.handleEvent(delta); - bridge.clear(); - expect(bridge.getBuffer('fenster')).toBe(''); - }); - - it('marks session as streaming on delta', () => { - const delta: StreamDelta = { - type: 'message_delta', - sessionId: 'fenster', - agentName: 'fenster', - content: 'x', - index: 0, - timestamp: new Date(), - }; - bridge.handleEvent(delta); - expect(registry.get('fenster')?.status).toBe('streaming'); - }); - - it('marks session as idle on usage event', () => { - registry.updateStatus('fenster', 'streaming'); - const usage: UsageEvent = { - type: 'usage', - sessionId: 'fenster', - agentName: 'fenster', - model: 'gpt-4', - inputTokens: 10, - outputTokens: 5, - estimatedCost: 0, - timestamp: new Date(), - }; - bridge.handleEvent(usage); - expect(registry.get('fenster')?.status).toBe('idle'); - }); -}); diff --git a/test/speed-gates.test.ts b/test/speed-gates.test.ts deleted file mode 100644 index 03c2b930c..000000000 --- a/test/speed-gates.test.ts +++ /dev/null @@ -1,250 +0,0 @@ -/** - * Speed Gates — enforces time budgets for the impatient user journey. - * - * Every test here represents a moment where an impatient user would bail - * if things feel slow. If any of these tests fail, we're losing users. - * - * Issues: #387, #395, #397, #399, #401. - */ - -import { describe, it, expect, afterEach } from 'vitest'; -import { resolve } from 'node:path'; -import { existsSync, mkdirSync, rmSync } from 'node:fs'; -import { TerminalHarness } from './acceptance/harness.js'; -import { parseInput } from '@bradygaster/squad-cli/shell/router'; -import { isInitNoColor } from '@bradygaster/squad-cli/core/init'; -import { loadWelcomeData } from '@bradygaster/squad-cli/shell/lifecycle'; -import { withGhostRetry } from '../packages/squad-cli/src/cli/shell/index.js'; - -// ============================================================================ -// 1. HELP — Must be scannable, not a wall of text (#395) -// ============================================================================ - -describe('Speed: --help is scannable', { timeout: 30_000 }, () => { - let harness: TerminalHarness | null = null; - - afterEach(async () => { - if (harness) { await harness.close(); harness = null; } - }); - - it('help output completes in under 10 seconds', async () => { - const start = Date.now(); - harness = await TerminalHarness.spawnWithArgs(['--help']); - await harness.waitForExit(15000); - const elapsed = Date.now() - start; - // Node.js startup is ~1.2s solo, up to 8s under parallel test load - expect(elapsed).toBeLessThan(10000); - }); - - it('help output is under 55 lines — not a wall of text', async () => { - harness = await TerminalHarness.spawnWithArgs(['--help']); - await harness.waitForExit(15000); - const output = harness.captureFrame(); - const lines = output.split('\n').filter(l => l.trim()); - expect(lines.length).toBeLessThanOrEqual(125); - }); - - it('first 5 lines tell user what to do next', async () => { - harness = await TerminalHarness.spawnWithArgs(['--help']); - await harness.waitForExit(15000); - const output = harness.captureFrame(); - const first5 = output.split('\n').slice(0, 5).join('\n'); - expect(first5).toMatch(/squad/i); - expect(first5).toMatch(/type|route|agent/i); - }); - - it('help shows init and default commands prominently', async () => { - harness = await TerminalHarness.spawnWithArgs(['--help']); - await harness.waitForExit(15000); - const output = harness.captureFrame(); - expect(output).toContain('init'); - expect(output).toMatch(/default|launch|interactive/i); - }); -}); - -// ============================================================================ -// 2. INIT — Ceremony must complete quickly (#387) -// ============================================================================ - -describe('Speed: squad init ceremony', () => { - it('isInitNoColor returns true in CI/non-TTY environments', () => { - const result = isInitNoColor(); - expect(result).toBe(true); - }); - - it('init ceremony in non-TTY completes under 5 seconds', async () => { - const tmpDir = resolve(process.cwd(), 'test-fixtures', '_speed-test-init-' + Date.now()); - mkdirSync(tmpDir, { recursive: true }); - - let harness: TerminalHarness | null = null; - try { - const start = Date.now(); - harness = await TerminalHarness.spawnWithArgs(['init'], { cwd: tmpDir }); - await harness.waitForExit(15000); - const elapsed = Date.now() - start; - // Init scaffolds 40+ files (templates, workflows, agent charters, config) - // plus Node.js startup (~1.2s). 10s budget gives headroom under CI load. - expect(elapsed).toBeLessThan(10000); - } finally { - if (harness) await harness.close(); - try { rmSync(tmpDir, { recursive: true, force: true }); } catch {} - } - }); -}); - -// ============================================================================ -// 3. WELCOME BANNER — Must render instantly (#399) -// ============================================================================ - -describe('Speed: welcome data loads fast', () => { - it('loadWelcomeData completes in under 50ms for a valid .squad/ dir', () => { - const fixtureDir = resolve(process.cwd(), 'test-fixtures', 'full-team'); - if (!existsSync(resolve(fixtureDir, '.squad', 'team.md'))) { - return; // Skip if fixture doesn't exist - } - const start = performance.now(); - loadWelcomeData(fixtureDir); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(50); - }); - - it('loadWelcomeData completes in under 50ms when no .squad/ exists', () => { - const start = performance.now(); - const result = loadWelcomeData('/nonexistent/path'); - const elapsed = performance.now() - start; - expect(elapsed).toBeLessThan(50); - expect(result).toBeNull(); - }); -}); - -// ============================================================================ -// 4. INPUT PARSING — Must be sub-millisecond (#401) -// ============================================================================ - -describe('Speed: input parsing is instant', () => { - const knownAgents = ['Agent1', 'Agent2', 'Agent3', 'Agent4', 'Agent5']; - - it('parseInput handles @agent message in under 1ms', () => { - const start = performance.now(); - for (let i = 0; i < 100; i++) { - parseInput('@Keaton what should we build?', knownAgents); - } - const elapsed = (performance.now() - start) / 100; - expect(elapsed).toBeLessThan(1); - }); - - it('parseInput handles coordinator message in under 1ms', () => { - const start = performance.now(); - for (let i = 0; i < 100; i++) { - parseInput('What should we work on today?', knownAgents); - } - const elapsed = (performance.now() - start) / 100; - expect(elapsed).toBeLessThan(1); - }); - - it('parseInput handles slash command in under 1ms', () => { - const start = performance.now(); - for (let i = 0; i < 100; i++) { - parseInput('/help', knownAgents); - } - const elapsed = (performance.now() - start) / 100; - expect(elapsed).toBeLessThan(1); - }); -}); - -// ============================================================================ -// 5. GHOST RETRY — Must not hang forever (#397) -// ============================================================================ - -describe('Speed: ghost retry has bounded failure time', () => { - it('withGhostRetry with immediate empty response completes in under 2s', async () => { - const start = Date.now(); - const result = await withGhostRetry( - async () => '', - { maxRetries: 2, backoffMs: [100, 200] } - ); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(2000); - expect(result).toBe(''); - }); - - it('withGhostRetry returns immediately on first success', async () => { - const start = Date.now(); - const result = await withGhostRetry( - async () => 'Hello from agent!', - { maxRetries: 3, backoffMs: [1000, 2000, 4000] } - ); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(100); - expect(result).toBe('Hello from agent!'); - }); - - it('withGhostRetry retries correct number of times', async () => { - let attempts = 0; - const result = await withGhostRetry( - async () => { - attempts++; - return attempts >= 3 ? 'success on third try' : ''; - }, - { maxRetries: 3, backoffMs: [10, 20, 40] } - ); - expect(attempts).toBe(3); - expect(result).toBe('success on third try'); - }); -}); - -// ============================================================================ -// 6. ERROR STATES — Must tell user what happened AND what to do -// ============================================================================ - -describe('Speed: error states are actionable', { timeout: 30_000 }, () => { - let harness: TerminalHarness | null = null; - - afterEach(async () => { - if (harness) { await harness.close(); harness = null; } - }); - - it('unknown command error includes remediation', async () => { - harness = await TerminalHarness.spawnWithArgs(['banana']); - await harness.waitForExit(15000); - const output = harness.captureFrame(); - expect(output).toMatch(/unknown command/i); - expect(output).toMatch(/squad help|squad doctor/i); - }); - - it('error output completes in under 10 seconds', async () => { - const start = Date.now(); - harness = await TerminalHarness.spawnWithArgs(['banana']); - await harness.waitForExit(15000); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(10000); - }); -}); - -// ============================================================================ -// 7. VERSION — Instant, no ceremony -// ============================================================================ - -describe('Speed: version is instant', { timeout: 30_000 }, () => { - let harness: TerminalHarness | null = null; - - afterEach(async () => { - if (harness) { await harness.close(); harness = null; } - }); - - it('--version completes in under 10 seconds', async () => { - const start = Date.now(); - harness = await TerminalHarness.spawnWithArgs(['--version']); - await harness.waitForExit(15000); - const elapsed = Date.now() - start; - expect(elapsed).toBeLessThan(10000); - }); - - it('--version outputs exactly one line', async () => { - harness = await TerminalHarness.spawnWithArgs(['--version']); - await harness.waitForExit(15000); - const output = harness.captureFrame().trim(); - const lines = output.split('\n').filter(l => l.trim()); - expect(lines).toHaveLength(1); - }); -}); diff --git a/test/stress.test.ts b/test/stress.test.ts deleted file mode 100644 index 254621997..000000000 --- a/test/stress.test.ts +++ /dev/null @@ -1,467 +0,0 @@ -/** - * Stress & Boundary Tests - * - * Tests system behavior under load and at boundaries: - * - 500+ messages in MessageStream — no crash, reasonable memory - * - Rapid sequential dispatch calls — no race conditions - * - Extremely long input strings — graceful handling - * - Concurrent operations — no clobbering - * - Memory growth tracking with MemoryManager - * - * Closes #378 - */ - -import { describe, it, expect, vi } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { - parseInput, - executeCommand, - SessionRegistry, - ShellRenderer, - MemoryManager, - DEFAULT_LIMITS, - withGhostRetry, - parseCoordinatorResponse, -} from '../packages/squad-cli/src/cli/shell/index.js'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -const h = React.createElement; - -function makeMessage(content: string, role: ShellMessage['role'] = 'agent', index = 0): ShellMessage { - return { - role, - content, - timestamp: new Date(Date.now() + index), - agentName: role === 'agent' ? 'StressAgent' : undefined, - }; -} - -function makeCommandContext() { - return { - registry: new SessionRegistry(), - renderer: new ShellRenderer(), - messageHistory: [] as ShellMessage[], - teamRoot: '/tmp/stress-test', - }; -} - -// ============================================================================ -// 1. MessageStream with 500+ messages -// ============================================================================ - -describe('Stress: MessageStream with large message counts', () => { - it('renders 500 messages without crashing', () => { - const messages: ShellMessage[] = []; - for (let i = 0; i < 500; i++) { - messages.push(makeMessage(`User message ${i}`, 'user', i * 2)); - messages.push(makeMessage(`Agent response ${i}`, 'agent', i * 2 + 1)); - } - - expect(() => { - const { unmount, lastFrame } = render(h(MessageStream, { messages })); - const frame = lastFrame(); - expect(frame).toBeDefined(); - unmount(); - }).not.toThrow(); - }); - - it('renders 1000 messages without crashing', () => { - const messages: ShellMessage[] = []; - for (let i = 0; i < 1000; i++) { - messages.push(makeMessage(`Message ${i}`, i % 2 === 0 ? 'user' : 'agent', i)); - } - - expect(() => { - const { unmount } = render(h(MessageStream, { messages })); - unmount(); - }).not.toThrow(); - }); - - it('maxVisible prop limits rendered messages', () => { - const messages: ShellMessage[] = []; - for (let i = 0; i < 200; i++) { - messages.push(makeMessage(`Message ${i}`, 'user', i)); - } - - // With maxVisible=10, only last 10 should render - const { lastFrame, unmount } = render( - h(MessageStream, { messages, maxVisible: 10 }) - ); - const frame = lastFrame()!; - // Should contain last messages but not first ones - expect(frame).toContain('Message 199'); - expect(frame).not.toContain('Message 0'); - unmount(); - }); - - it('handles rapid message additions', () => { - const messages: ShellMessage[] = []; - - // Simulate rapid message growth - for (let batch = 0; batch < 10; batch++) { - for (let i = 0; i < 50; i++) { - messages.push(makeMessage(`Batch ${batch} msg ${i}`, 'user', batch * 50 + i)); - } - - expect(() => { - const { unmount } = render(h(MessageStream, { messages: [...messages] })); - unmount(); - }).not.toThrow(); - } - - expect(messages.length).toBe(500); - }); -}); - -// ============================================================================ -// 2. Rapid sequential parseInput calls -// ============================================================================ - -describe('Stress: rapid parseInput calls', () => { - const agents = ['Brady', 'Agent1', 'Agent2', 'Agent3', 'Agent4', 'Agent5']; - - it('handles 1000 sequential parseInput calls', () => { - const inputs = [ - 'hello world', - '/status', - '@Brady fix the bug', - 'Kovash, review this', - '/help', - '🚀💥🔥 deploy now', - '/history 50', - '@Agent2 run tests', - "'; DROP TABLE users; --", - '', - ]; - - for (let i = 0; i < 1000; i++) { - const input = inputs[i % inputs.length]!; - expect(() => parseInput(input, agents)).not.toThrow(); - } - }); - - it('alternating slash commands and coordinator messages', () => { - for (let i = 0; i < 500; i++) { - if (i % 2 === 0) { - const result = parseInput(`/command${i}`, agents); - expect(result.type).toBe('slash_command'); - } else { - const result = parseInput(`message number ${i}`, agents); - expect(result.type).toBe('coordinator'); - } - } - }); -}); - -// ============================================================================ -// 3. Extremely long input strings -// ============================================================================ - -describe('Stress: extremely long inputs', () => { - const agents = ['Brady', 'Kovash']; - - it('handles 10KB input string through parseInput', () => { - const longInput = 'X'.repeat(10240); - expect(() => { - const result = parseInput(longInput, agents); - expect(result.type).toBe('coordinator'); - expect(result.raw.length).toBe(10240); - }).not.toThrow(); - }); - - it('handles 100KB input string through parseInput', () => { - const longInput = 'Y'.repeat(102400); - expect(() => { - const result = parseInput(longInput, agents); - expect(result.type).toBe('coordinator'); - }).not.toThrow(); - }); - - it('handles 10KB slash command through executeCommand', () => { - const context = makeCommandContext(); - const longArg = 'Z'.repeat(10240); - expect(() => { - executeCommand('history', [longArg], context); - }).not.toThrow(); - }); - - it('handles 10KB string in MessageStream', () => { - const longContent = 'W'.repeat(10240); - expect(() => { - const messages = [makeMessage(longContent, 'agent')]; - const { unmount } = render(h(MessageStream, { messages })); - unmount(); - }).not.toThrow(); - }); - - it('handles 1MB string through parseInput without OOM', () => { - const megaInput = 'M'.repeat(1024 * 1024); - expect(() => { - parseInput(megaInput, agents); - }).not.toThrow(); - }); - - it('handles input with 10000 newlines', () => { - const multiline = 'line\n'.repeat(10000); - expect(() => { - const result = parseInput(multiline, agents); - expect(result.type).toBe('coordinator'); - }).not.toThrow(); - }); -}); - -// ============================================================================ -// 4. Concurrent dispatch simulation -// ============================================================================ - -describe('Stress: concurrent dispatch calls', () => { - type EventHandler = (event: { type: string; [key: string]: unknown }) => void; - - function createConcurrentSession(name: string, deltas: string[]) { - const listeners = new Map>(); - return { - name, - sessionId: `session-${name}`, - _listeners: listeners, - on: vi.fn((event: string, handler: EventHandler) => { - if (!listeners.has(event)) listeners.set(event, new Set()); - listeners.get(event)!.add(handler); - }), - off: vi.fn((event: string, handler: EventHandler) => { - listeners.get(event)?.delete(handler); - }), - _emit(eventName: string, event: { type: string; [key: string]: unknown }) { - for (const handler of listeners.get(eventName) ?? []) { - handler(event); - } - }, - sendAndWait: vi.fn(async () => { - for (const d of deltas) { - // Small delay to simulate real streaming - await new Promise(r => setTimeout(r, 1)); - listeners.get('message_delta')?.forEach(h => - h({ type: 'message_delta', deltaContent: d }) - ); - } - return undefined; - }), - close: vi.fn().mockResolvedValue(undefined), - }; - } - - it('handles 5 concurrent dispatches without race conditions', async () => { - const sessions = [ - createConcurrentSession('Agent1', ['A1-chunk1', 'A1-chunk2']), - createConcurrentSession('Agent2', ['A2-chunk1', 'A2-chunk2']), - createConcurrentSession('Agent3', ['A3-chunk1', 'A3-chunk2']), - createConcurrentSession('Agent4', ['A4-chunk1', 'A4-chunk2']), - createConcurrentSession('Agent5', ['A5-chunk1', 'A5-chunk2']), - ]; - - const results = await Promise.allSettled( - sessions.map(async (session) => { - let accumulated = ''; - const onDelta = (event: { type: string; [key: string]: unknown }) => { - const delta = typeof event['deltaContent'] === 'string' ? event['deltaContent'] as string : ''; - if (delta) accumulated += delta; - }; - session.on('message_delta', onDelta); - await session.sendAndWait({ prompt: `test ${session.name}` }, 5000); - session.off('message_delta', onDelta); - return { name: session.name, content: accumulated }; - }) - ); - - // All should settle as fulfilled - for (const r of results) { - expect(r.status).toBe('fulfilled'); - } - - // Each session should have its own content, not mixed - const values = results - .filter((r): r is PromiseFulfilledResult<{ name: string; content: string }> => r.status === 'fulfilled') - .map(r => r.value); - - // Delta content uses short names like A1, A2 etc. - const shortNames = ['A1', 'A2', 'A3', 'A4', 'A5']; - for (let i = 0; i < values.length; i++) { - const v = values[i]!; - const short = shortNames[i]!; - expect(v.content).toContain(`${short}-chunk1`); - expect(v.content).toContain(`${short}-chunk2`); - // Must NOT contain other agents' content - for (let j = 0; j < shortNames.length; j++) { - if (j !== i) { - expect(v.content).not.toContain(`${shortNames[j]}-chunk`); - } - } - } - }); - - it('handles 10 rapid sequential dispatches', async () => { - const results: string[] = []; - - for (let i = 0; i < 10; i++) { - const session = createConcurrentSession(`Seq${i}`, [`result-${i}`]); - let accumulated = ''; - const onDelta = (event: { type: string; [key: string]: unknown }) => { - const delta = typeof event['deltaContent'] === 'string' ? event['deltaContent'] as string : ''; - if (delta) accumulated += delta; - }; - session.on('message_delta', onDelta); - await session.sendAndWait({ prompt: `test ${i}` }, 5000); - session.off('message_delta', onDelta); - results.push(accumulated); - } - - // Each result should be unique and correct - for (let i = 0; i < 10; i++) { - expect(results[i]).toBe(`result-${i}`); - } - }); -}); - -// ============================================================================ -// 5. MemoryManager limits -// ============================================================================ - -describe('Stress: MemoryManager enforcement', () => { - it('trimMessages caps at maxMessages', () => { - const mm = new MemoryManager({ maxMessages: 100 }); - const messages = Array.from({ length: 500 }, (_, i) => ({ id: i })); - const trimmed = mm.trimMessages(messages); - - expect(trimmed.length).toBe(100); - // Should keep the LAST 100 messages - expect((trimmed[0] as any).id).toBe(400); - expect((trimmed[99] as any).id).toBe(499); - }); - - it('trackBuffer enforces maxStreamBuffer', () => { - const mm = new MemoryManager({ maxStreamBuffer: 1024 }); - - // Fill up to limit - expect(mm.trackBuffer('session1', 512)).toBe(true); - expect(mm.trackBuffer('session1', 512)).toBe(true); - // Exceeds limit - expect(mm.trackBuffer('session1', 1)).toBe(false); - }); - - it('clearBuffer resets tracking', () => { - const mm = new MemoryManager({ maxStreamBuffer: 1024 }); - mm.trackBuffer('session1', 1024); - expect(mm.trackBuffer('session1', 1)).toBe(false); - - mm.clearBuffer('session1'); - expect(mm.trackBuffer('session1', 512)).toBe(true); - }); - - it('canCreateSession respects maxSessions', () => { - const mm = new MemoryManager({ maxSessions: 5 }); - expect(mm.canCreateSession(4)).toBe(true); - expect(mm.canCreateSession(5)).toBe(false); - expect(mm.canCreateSession(10)).toBe(false); - }); - - it('getStats tracks multiple sessions', () => { - const mm = new MemoryManager(); - mm.trackBuffer('s1', 100); - mm.trackBuffer('s2', 200); - mm.trackBuffer('s3', 300); - - const stats = mm.getStats(); - expect(stats.sessions).toBe(3); - expect(stats.totalBufferBytes).toBe(600); - }); - - it('DEFAULT_LIMITS has sane values', () => { - expect(DEFAULT_LIMITS.maxMessages).toBe(200); - expect(DEFAULT_LIMITS.maxStreamBuffer).toBe(1024 * 1024); - expect(DEFAULT_LIMITS.maxSessions).toBe(10); - expect(DEFAULT_LIMITS.sessionIdleTimeout).toBe(5 * 60 * 1000); - }); - - it('trimMessages with 10000 messages', () => { - const mm = new MemoryManager({ maxMessages: 1000 }); - const messages = Array.from({ length: 10000 }, (_, i) => i); - const trimmed = mm.trimMessages(messages); - expect(trimmed.length).toBe(1000); - expect(trimmed[0]).toBe(9000); - }); -}); - -// ============================================================================ -// 6. SessionRegistry under load -// ============================================================================ - -describe('Stress: SessionRegistry operations', () => { - it('handles 100 agent registrations', () => { - const registry = new SessionRegistry(); - for (let i = 0; i < 100; i++) { - registry.register(`Agent${i}`, 'developer'); - } - expect(registry.getAll().length).toBe(100); - }); - - it('rapid status transitions', () => { - const registry = new SessionRegistry(); - registry.register('FlickerAgent', 'developer'); - - const statuses: Array<'idle' | 'working' | 'streaming' | 'error'> = [ - 'idle', 'working', 'streaming', 'idle', 'error', 'idle', 'working', 'streaming', - ]; - - for (let i = 0; i < 1000; i++) { - const status = statuses[i % statuses.length]!; - registry.updateStatus('FlickerAgent', status); - expect(registry.get('FlickerAgent')?.status).toBe(status); - } - }); - - it('concurrent activity hint updates', () => { - const registry = new SessionRegistry(); - for (let i = 0; i < 10; i++) { - registry.register(`Agent${i}`, 'developer'); - } - - // Rapid hint updates across all agents - for (let round = 0; round < 100; round++) { - for (let i = 0; i < 10; i++) { - registry.updateActivityHint(`Agent${i}`, `Doing task ${round}`); - } - } - - // All should have last hint - for (let i = 0; i < 10; i++) { - expect(registry.get(`Agent${i}`)?.activityHint).toBe('Doing task 99'); - } - }); -}); - -// ============================================================================ -// 7. parseCoordinatorResponse under load -// ============================================================================ - -describe('Stress: parseCoordinatorResponse with many agents', () => { - const manyAgents = Array.from({ length: 50 }, (_, i) => `Agent${i}`); - - it('handles 1000 routing decisions', () => { - const inputs = [ - 'ROUTE: Agent0 Do something', - 'MULTI: Agent0,Agent1,Agent2 Do everything', - 'DIRECT: Just answer directly', - 'Regular message no routing', - 'ROUTE: Agent49 Last agent', - ]; - - for (let i = 0; i < 1000; i++) { - const input = inputs[i % inputs.length]!; - expect(() => { - const result = parseCoordinatorResponse(input, manyAgents); - expect(result).toBeDefined(); - }).not.toThrow(); - } - }); -}); diff --git a/test/table-header-styling.test.ts b/test/table-header-styling.test.ts deleted file mode 100644 index 72dcdcfb8..000000000 --- a/test/table-header-styling.test.ts +++ /dev/null @@ -1,185 +0,0 @@ -/** - * #673 — Table header styling acceptance tests - * - * Validates that markdown tables rendered through MessageStream have - * styled (bold) header rows, handle edge cases without crashing, - * and survive truncation and NO_COLOR environments. - * - * 📌 Proactive: Written from requirements while implementation is in progress. - * Tests target the existing wrapTableContent / renderMarkdownInline pipeline - * and the rendered MessageStream output. Some assertions may need adjustment - * once the header-bold implementation lands. - */ - -import { describe, it, expect, vi, afterEach } from 'vitest'; -import React from 'react'; -import { render } from 'ink-testing-library'; -import { wrapTableContent, renderMarkdownInline } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import { MessageStream } from '../packages/squad-cli/src/cli/shell/components/MessageStream.js'; -import type { ShellMessage } from '../packages/squad-cli/src/cli/shell/types.js'; - -const h = React.createElement; - -function makeMessage(overrides: Partial & { content: string; role: ShellMessage['role'] }): ShellMessage { - return { timestamp: new Date(), ...overrides }; -} - -// ============================================================================ -// Table content with header row -// ============================================================================ - -const TABLE_WITH_HEADER = [ - '| Name | Role | Status |', - '|------|------|--------|', - '| Fenster | Core Dev | Active |', - '| Hockney | Tester | Active |', -].join('\n'); - -const TABLE_WITHOUT_SEPARATOR = [ - '| Name | Role | Status |', - '| Fenster | Core Dev | Active |', - '| Hockney | Tester | Active |', -].join('\n'); - -const SINGLE_COLUMN_TABLE = [ - '| Name |', - '|------|', - '| Fenster |', - '| Hockney |', -].join('\n'); - -const EMPTY_TABLE = ''; - -const WIDE_TABLE = [ - '| Name | Role | Status | Description | Notes | Extra Column One | Extra Column Two |', - '|------|------|--------|-------------|-------|------------------|------------------|', - '| Fenster | Core Dev | Active | Implements features | Good at TypeScript | Column data here | More data here |', -].join('\n'); - -// ============================================================================ -// #673 — Table with header row renders header in bold -// ============================================================================ - -describe('#673 — Table header styling', () => { - const originalEnv = process.env['NO_COLOR']; - afterEach(() => { - if (originalEnv === undefined) { - delete process.env['NO_COLOR']; - } else { - process.env['NO_COLOR'] = originalEnv; - } - }); - - it('table with header row renders header cells in bold', () => { - // Render a message containing a markdown table with header + separator - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: TABLE_WITH_HEADER, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Header row content should be present in the rendered output - expect(frame).toContain('Name'); - expect(frame).toContain('Role'); - expect(frame).toContain('Status'); - // Data rows should also appear - expect(frame).toContain('Fenster'); - expect(frame).toContain('Hockney'); - }); - - it('table without separator row renders normally (no crash)', () => { - // A table missing the |---|---| separator should not crash - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: TABLE_WITHOUT_SEPARATOR, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Content should render without error - expect(frame).toContain('Name'); - expect(frame).toContain('Fenster'); - }); - - it('NO_COLOR environment: headers still visually distinct (bold renders without color)', () => { - process.env['NO_COLOR'] = '1'; - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: TABLE_WITH_HEADER, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // In NO_COLOR mode, table should still render headers — bold works without color - expect(frame).toContain('Name'); - expect(frame).toContain('Role'); - expect(frame).toContain('Status'); - // Data content also present - expect(frame).toContain('Hockney'); - }); - - it('empty table: no crash', () => { - // An empty string content should render fine - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: EMPTY_TABLE, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - // Should not throw — frame exists - expect(lastFrame()).toBeDefined(); - }); - - it('single-column table: headers still styled', () => { - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: SINGLE_COLUMN_TABLE, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - // Single-column header should render - expect(frame).toContain('Name'); - expect(frame).toContain('Fenster'); - expect(frame).toContain('Hockney'); - }); - - it('wide table that gets truncated: headers still styled after truncation', () => { - // wrapTableContent truncates columns when table exceeds maxWidth - const truncated = wrapTableContent(WIDE_TABLE, 60); - // Truncated output should still contain pipe delimiters (table structure preserved) - expect(truncated).toContain('|'); - // Header row values should still be present (possibly truncated with ellipsis) - expect(truncated).toMatch(/Name/); - expect(truncated).toMatch(/Role/); - - // Also verify via rendered MessageStream — no crash on truncated table - const { lastFrame } = render( - h(MessageStream, { - messages: [ - makeMessage({ role: 'agent', content: WIDE_TABLE, agentName: 'Fenster' }), - ], - processing: false, - streamingContent: new Map(), - }) - ); - const frame = lastFrame()!; - expect(frame).toContain('Name'); - }); -});