From 9231f9b166f7a02f9a52bec54aa12c651c31716c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 16 Jan 2026 17:18:53 -0800 Subject: [PATCH 0001/1143] Include which endpoint failed in error --- cli/src/utils/codebuff-api.ts | 10 +++++++++- packages/agent-runtime/src/llm-api/codebuff-web-api.ts | 2 ++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/cli/src/utils/codebuff-api.ts b/cli/src/utils/codebuff-api.ts index 78ad9c3f6c..f2e78f6156 100644 --- a/cli/src/utils/codebuff-api.ts +++ b/cli/src/utils/codebuff-api.ts @@ -393,7 +393,15 @@ export function createCodebuffApiClient( continue } - // Don't retry, throw the error + // Don't retry, throw the error with URL context + if (error instanceof Error) { + const enhancedError = new Error( + `${error.message} (${method} ${url})`, + ) + enhancedError.name = error.name + enhancedError.cause = error + throw enhancedError + } throw error } } diff --git a/packages/agent-runtime/src/llm-api/codebuff-web-api.ts b/packages/agent-runtime/src/llm-api/codebuff-web-api.ts index 1b0b98c977..05bec04585 100644 --- a/packages/agent-runtime/src/llm-api/codebuff-web-api.ts +++ b/packages/agent-runtime/src/llm-api/codebuff-web-api.ts @@ -68,6 +68,7 @@ const callCodebuffV1 = async (params: { body: JSON.stringify(payload), }), FETCH_TIMEOUT_MS, + `Request to ${endpoint} timed out after ${FETCH_TIMEOUT_MS}ms`, ) const text = await res.text() @@ -259,6 +260,7 @@ export async function callTokenCountAPI(params: { body: JSON.stringify(payload), }), FETCH_TIMEOUT_MS, + `Request to /api/v1/token-count timed out after ${FETCH_TIMEOUT_MS}ms`, ) const text = await res.text() From b63ed4488c70f5d134f8dadc3b3599ad0e9fffea Mon Sep 17 00:00:00 2001 From: James Grugett Date: Fri, 16 Jan 2026 18:43:02 -0800 Subject: [PATCH 0002/1143] fix(sdk): handle AI SDK APICallError status property for Claude OAuth errors The AI SDK uses `status` instead of `statusCode` for HTTP status codes. Updated getErrorStatusCode() to check both properties, ensuring proper error handling and fallback behavior for Claude OAuth rate limits and auth errors. --- sdk/src/error-utils.ts | 21 ++++++++++++++++----- sdk/src/impl/llm.ts | 23 +++++++++++------------ 2 files changed, 27 insertions(+), 17 deletions(-) diff --git a/sdk/src/error-utils.ts b/sdk/src/error-utils.ts index f2e9ec84bb..92c400cf9e 100644 --- a/sdk/src/error-utils.ts +++ b/sdk/src/error-utils.ts @@ -73,13 +73,24 @@ export function isRetryableStatusCode(statusCode: number | undefined): boolean { } /** - * Extracts the statusCode from an error if available + * Extracts the statusCode from an error if available. + * Checks both 'statusCode' (our convention) and 'status' (AI SDK's APICallError convention). */ export function getErrorStatusCode(error: unknown): number | undefined { - if (error && typeof error === 'object' && 'statusCode' in error) { - const statusCode = (error as { statusCode: unknown }).statusCode - if (typeof statusCode === 'number') { - return statusCode + if (error && typeof error === 'object') { + // Check 'statusCode' first (our convention) + if ('statusCode' in error) { + const statusCode = (error as { statusCode: unknown }).statusCode + if (typeof statusCode === 'number') { + return statusCode + } + } + // Check 'status' (AI SDK's APICallError uses this) + if ('status' in error) { + const status = (error as { status: unknown }).status + if (typeof status === 'number') { + return status + } } } return undefined diff --git a/sdk/src/impl/llm.ts b/sdk/src/impl/llm.ts index ced57812a1..77c6b50d5f 100644 --- a/sdk/src/impl/llm.ts +++ b/sdk/src/impl/llm.ts @@ -18,6 +18,7 @@ import { import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' import { getModelForRequest, markClaudeOAuthRateLimited, fetchClaudeOAuthResetTime } from './model-provider' import { getValidClaudeOAuthCredentials } from '../credentials' +import { getErrorStatusCode } from '../error-utils' import type { ModelRequestParams } from './model-provider' import type { OpenRouterProviderRoutingOptions } from '@codebuff/common/types/agent-template' @@ -116,17 +117,15 @@ type OpenRouterUsageAccounting = { function isClaudeOAuthRateLimitError(error: unknown): boolean { if (!error || typeof error !== 'object') return false - // Check for APICallError from AI SDK + // Check status code (handles both 'status' from AI SDK and 'statusCode' from our errors) + const statusCode = getErrorStatusCode(error) + if (statusCode === 429) return true + + // Check error message for rate limit indicators const err = error as { - statusCode?: number message?: string responseBody?: string } - - // Check status code - if (err.statusCode === 429) return true - - // Check error message for rate limit indicators const message = (err.message || '').toLowerCase() const responseBody = (err.responseBody || '').toLowerCase() @@ -149,15 +148,15 @@ function isClaudeOAuthRateLimitError(error: unknown): boolean { function isClaudeOAuthAuthError(error: unknown): boolean { if (!error || typeof error !== 'object') return false + // Check status code (handles both 'status' from AI SDK and 'statusCode' from our errors) + const statusCode = getErrorStatusCode(error) + if (statusCode === 401 || statusCode === 403) return true + + // Check error message for auth indicators const err = error as { - statusCode?: number message?: string responseBody?: string } - - // 401 Unauthorized or 403 Forbidden typically indicate auth issues - if (err.statusCode === 401 || err.statusCode === 403) return true - const message = (err.message || '').toLowerCase() const responseBody = (err.responseBody || '').toLowerCase() From 852e3e3f6ec19b92286ed4b9e5fc66760a40085a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 16 Jan 2026 18:38:06 -0800 Subject: [PATCH 0003/1143] refactor(.agents): extract CLI agent factory into modular files - Create lib/cli-agent-types.ts with CliAgentConfig interface - Create lib/cli-agent-prompts.ts with prompt template functions and REVIEW_CRITERIA - Create lib/cli-agent-schemas.ts with shared outputSchema - Create lib/create-cli-agent.ts with factory function and validation - Add shortName and cliCommand config fields (replaces parsing from startCommand) - Add optional model field with DEFAULT_MODEL fallback - Add runtime validation for required config fields and shortName format - Make testResults optional in outputSchema (review mode uses reviewFindings) - Move CODEX_REVIEW_MODE_INSTRUCTIONS back to codex-cli.ts - Remove unnecessary re-exports for cleaner imports - Fix bug: use startCommand instead of cliCommand in Individual Scripts example - Fix inconsistent newline handling in getSystemPrompt - Add as const to inputSchema type fields for proper type inference - Refactor all 4 CLI agents to use the factory (claude-code-cli, codex-cli, gemini-cli, codebuff-local-cli) --- .agents/claude-code-cli.ts | 454 +----------------------------- .agents/codebuff-local-cli.ts | 457 +----------------------------- .agents/codex-cli.ts | 400 ++------------------------- .agents/gemini-cli.ts | 459 +------------------------------ .agents/lib/cli-agent-prompts.ts | 286 +++++++++++++++++++ .agents/lib/cli-agent-schemas.ts | 72 +++++ .agents/lib/cli-agent-types.ts | 23 ++ .agents/lib/create-cli-agent.ts | 58 ++++ 8 files changed, 493 insertions(+), 1716 deletions(-) create mode 100644 .agents/lib/cli-agent-prompts.ts create mode 100644 .agents/lib/cli-agent-schemas.ts create mode 100644 .agents/lib/cli-agent-types.ts create mode 100644 .agents/lib/create-cli-agent.ts diff --git a/.agents/claude-code-cli.ts b/.agents/claude-code-cli.ts index 72342110fd..f81f0e6f3d 100644 --- a/.agents/claude-code-cli.ts +++ b/.agents/claude-code-cli.ts @@ -1,450 +1,12 @@ -import type { AgentDefinition } from './types/agent-definition' +import { createCliAgent } from './lib/create-cli-agent' -const definition: AgentDefinition = { +export default createCliAgent({ id: 'claude-code-cli', displayName: 'Claude Code CLI', + cliName: 'Claude Code', + shortName: 'claude-code', + startCommand: 'claude --dangerously-skip-permissions', + permissionNote: + 'Always use `--dangerously-skip-permissions` when testing to avoid permission prompts that would block automated tests.', model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing Claude Code CLI functionality using tmux, or performing code reviews via Claude Code. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Claude Code CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Claude Code CLI to perform code reviews on specified files or directories. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Claude Code', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing Claude Code CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Claude Code Startup - -For testing Claude Code, use the \`--command\` flag with permission bypass: - -\`\`\`bash -# Start Claude Code CLI (with permission bypass for testing) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions --help") -\`\`\` - -**Important:** Always use \`--dangerously-skip-permissions\` when testing to avoid permission prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Claude Code test session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "claude" --name claude-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh claude-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh claude-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh claude-test --key Escape -./scripts/tmux/tmux-send.sh claude-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh claude-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh claude-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Claude Code due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Claude Code session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Claude Code test session** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Claude Code. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Claude Code** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "claude --dangerously-skip-permissions") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, -} - -export default definition +}) diff --git a/.agents/codebuff-local-cli.ts b/.agents/codebuff-local-cli.ts index 57d21ecaa0..79a6df5e37 100644 --- a/.agents/codebuff-local-cli.ts +++ b/.agents/codebuff-local-cli.ts @@ -1,455 +1,18 @@ -import type { AgentDefinition } from './types/agent-definition' +import { createCliAgent } from './lib/create-cli-agent' -const definition: AgentDefinition = { +export default createCliAgent({ id: 'codebuff-local-cli', displayName: 'Codebuff Local CLI', + cliName: 'Codebuff', + shortName: 'codebuff-local', + startCommand: 'bun --cwd=cli run dev', + permissionNote: + 'No permission flags needed for Codebuff local dev server.', model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing Codebuff CLI functionality using tmux, or performing code reviews via Codebuff. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to the Codebuff CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Codebuff CLI to perform code reviews on specified files or directories. - -**Use this agent after modifying:** + spawnerPromptExtras: `**Use this agent after modifying:** - \`cli/src/components/\` - UI components, layouts, rendering - \`cli/src/hooks/\` - hooks that affect what users see - Any CLI visual elements: borders, colors, spacing, text formatting -**When to use:** After implementing CLI UI changes, use this to verify the visual output actually renders correctly. Unit tests and typechecks cannot catch layout bugs, rendering issues, or visual regressions. This agent captures real terminal output including colors and layout. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Codebuff', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing the Codebuff CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Codebuff-Specific Startup - -For testing Codebuff, use the \`--command\` flag with the Codebuff dev server: - -\`\`\`bash -# Start Codebuff CLI dev server -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - -# Or test a compiled binary -SESSION=$(./scripts/tmux/tmux-cli.sh start --binary) -\`\`\` - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Codebuff test session -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "bun --cwd=cli run dev" --name my-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh my-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh my-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh my-test --key Escape -./scripts/tmux/tmux-send.sh my-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh my-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh my-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Codebuff due to how OpenTUI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Codebuff session -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Codebuff test session** with the explicit command: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Codebuff. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Codebuff**: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "bun --cwd=cli run dev") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, -} - -export default definition +**When to use:** After implementing CLI UI changes, use this to verify the visual output actually renders correctly. Unit tests and typechecks cannot catch layout bugs, rendering issues, or visual regressions. This agent captures real terminal output including colors and layout.`, +}) diff --git a/.agents/codex-cli.ts b/.agents/codex-cli.ts index 95efbff7dd..43afef22a9 100644 --- a/.agents/codex-cli.ts +++ b/.agents/codex-cli.ts @@ -1,353 +1,10 @@ -import type { AgentDefinition } from './types/agent-definition' +import { createCliAgent } from './lib/create-cli-agent' -const definition: AgentDefinition = { - id: 'codex-cli', - displayName: 'Codex CLI', - model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing OpenAI Codex CLI functionality using tmux, or performing code reviews via Codex. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Codex CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Codex CLI to perform code reviews on specified files or directories. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Codex', - }, - reviewType: { - type: 'string', - enum: ['pr', 'uncommitted', 'commit', 'custom'], - description: - 'For review mode: "pr" = Review against base branch (PR style), "uncommitted" = Review uncommitted changes, "commit" = Review a specific commit, "custom" = Custom review instructions. Defaults to "uncommitted".', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing OpenAI Codex CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Codex Startup - -For testing Codex, use the \`--command\` flag with permission bypass: - -\`\`\`bash -# Start Codex CLI (with full access and no approval prompts) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access --help") -\`\`\` - -**Important:** Always use \`-a never -s danger-full-access\` when testing to avoid approval prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Codex test session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "codex" --name codex-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh codex-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh codex-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh codex-test --key Escape -./scripts/tmux/tmux-send.sh codex-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh codex-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh codex-test -o output.txt -\`\`\` - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Codex due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Codex session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Codex test session** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions +/** + * Codex-specific review mode instructions. + * Codex CLI has a built-in /review command with an interactive questionnaire. + */ +const CODEX_REVIEW_MODE_INSTRUCTIONS = `## Review Mode Instructions Codex CLI has a built-in \`/review\` command that presents an interactive questionnaire. You must navigate it using arrow keys and Enter. @@ -415,29 +72,24 @@ The \`reviewType\` param maps to menu options (1-indexed from top): 8. **Clean up**: \`\`\`bash ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) + \`\`\`` -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, -} - -export default definition +export default createCliAgent({ + id: 'codex-cli', + displayName: 'Codex CLI', + cliName: 'Codex', + shortName: 'codex', + startCommand: 'codex -a never -s danger-full-access', + permissionNote: + 'Always use `-a never -s danger-full-access` when testing to avoid approval prompts that would block automated tests.', + model: 'anthropic/claude-opus-4.5', + extraInputParams: { + reviewType: { + type: 'string', + enum: ['pr', 'uncommitted', 'commit', 'custom'], + description: + 'For review mode: "pr" = Review against base branch (PR style), "uncommitted" = Review uncommitted changes, "commit" = Review a specific commit, "custom" = Custom review instructions. Defaults to "uncommitted".', + }, + }, + reviewModeInstructions: CODEX_REVIEW_MODE_INSTRUCTIONS, +}) diff --git a/.agents/gemini-cli.ts b/.agents/gemini-cli.ts index 43ecaf7d27..03e8283d82 100644 --- a/.agents/gemini-cli.ts +++ b/.agents/gemini-cli.ts @@ -1,457 +1,18 @@ -import type { AgentDefinition } from './types/agent-definition' +import { createCliAgent } from './lib/create-cli-agent' -const definition: AgentDefinition = { +export default createCliAgent({ id: 'gemini-cli', displayName: 'Gemini CLI', + cliName: 'Gemini', + shortName: 'gemini', + startCommand: 'gemini --yolo', + permissionNote: + 'Always use `--yolo` (or `--approval-mode yolo`) when testing to auto-approve all tool actions and avoid prompts that would block automated tests.', model: 'anthropic/claude-opus-4.5', - - spawnerPrompt: `Expert at testing Google Gemini CLI functionality using tmux, or performing code reviews via Gemini. - -**Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to Gemini CLI, captures terminal output, and validates behavior. -- \`review\`: Uses Gemini CLI to perform code reviews on specified files or directories. - -**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. - -**Your responsibilities as the parent agent:** -1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes -2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues`, - - inputSchema: { - prompt: { - type: 'string', - description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', - }, - params: { - type: 'object', - properties: { - mode: { - type: 'string', - enum: ['test', 'review'], - description: - 'Operation mode - "test" for CLI testing (default), "review" for code review via Gemini', - }, - }, - }, - }, - - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - overallStatus: { - type: 'string', - enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', - }, - summary: { - type: 'string', - description: 'Brief summary of what was tested and the outcome', - }, - testResults: { - type: 'array', - items: { - type: 'object', - properties: { - testName: { - type: 'string', - description: 'Name/description of the test', - }, - passed: { type: 'boolean', description: 'Whether the test passed' }, - details: { - type: 'string', - description: 'Details about what happened', - }, - capturedOutput: { - type: 'string', - description: 'Relevant output captured from the CLI', - }, - }, - required: ['testName', 'passed'], - }, - description: 'Array of individual test results', - }, - scriptIssues: { - type: 'array', - items: { - type: 'object', - properties: { - script: { - type: 'string', - description: - 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")', - }, - issue: { - type: 'string', - description: 'What went wrong when using the script', - }, - errorOutput: { - type: 'string', - description: 'The actual error message or unexpected output', - }, - suggestedFix: { - type: 'string', - description: - 'Suggested fix or improvement for the parent agent to implement', - }, - }, - required: ['script', 'issue', 'suggestedFix'], - }, - description: - 'Issues encountered with the helper scripts that the parent agent should fix', - }, - captures: { - type: 'array', - items: { - type: 'object', - properties: { - path: { - type: 'string', - description: - 'Path to the capture file (relative to project root)', - }, - label: { - type: 'string', - description: - 'What this capture shows (e.g., "initial-cli-state", "after-help-command")', - }, - timestamp: { - type: 'string', - description: 'When the capture was taken', - }, - }, - required: ['path', 'label'], - }, - description: - 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', - }, - reviewFindings: { - type: 'array', - items: { - type: 'object', - properties: { - file: { - type: 'string', - description: 'File path where the issue was found', - }, - severity: { - type: 'string', - enum: ['critical', 'warning', 'suggestion', 'info'], - description: 'Severity level of the finding', - }, - line: { - type: 'number', - description: 'Line number (if applicable)', - }, - finding: { - type: 'string', - description: 'Description of the issue or suggestion', - }, - suggestion: { - type: 'string', - description: 'Suggested fix or improvement', - }, - }, - required: ['file', 'severity', 'finding'], - }, - description: - 'Code review findings (only populated in review mode)', - }, - }, - required: [ - 'overallStatus', - 'summary', - 'testResults', - 'scriptIssues', - 'captures', - ], - }, - includeMessageHistory: false, - - toolNames: [ - 'run_terminal_command', - 'read_files', - 'code_search', - 'set_output', - ], - - systemPrompt: `You are an expert at testing Google Gemini CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. - -## Gemini CLI Startup - -For testing Gemini, use the \`--command\` flag with YOLO mode (auto-approve all actions): - -\`\`\`bash -# Start Gemini CLI (with YOLO mode - auto-approves all actions) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo --help") -\`\`\` - -**Important:** Always use \`--yolo\` (or \`--approval-mode yolo\`) when testing to auto-approve all tool actions and avoid prompts that would block automated tests. - -## Helper Scripts - -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: - -### Unified Script (Recommended) - -\`\`\`bash -# Start a Gemini test session (with YOLO mode) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - -# Send input to the CLI -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" - -# Capture output (optionally wait first) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - -# Stop the session when done -./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all -\`\`\` - -### Individual Scripts (More Options) - -\`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "gemini --yolo" --name gemini-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh gemini-test "your prompt here" - -# Send without pressing Enter -./scripts/tmux/tmux-send.sh gemini-test "partial" --no-enter - -# Send special keys -./scripts/tmux/tmux-send.sh gemini-test --key Escape -./scripts/tmux/tmux-send.sh gemini-test --key C-c - -# Capture with colors -./scripts/tmux/tmux-capture.sh gemini-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh gemini-test -o output.txt -\`\`\` - -## Gemini CLI Commands + cliSpecificDocs: `## Gemini CLI Commands Gemini CLI uses slash commands for navigation: - \`/help\` - Show help information - \`/tools\` - List available tools -- \`/quit\` - Exit the CLI (or Ctrl-C twice) - -## Why These Scripts? - -The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like Gemini CLI due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. - -## Typical Test Workflow - -\`\`\`bash -# 1. Start a Gemini session (with YOLO mode) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") -echo "Testing in session: $SESSION" - -# 2. Verify CLI started -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 3. Run your test -./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" -sleep 2 -./scripts/tmux/tmux-cli.sh capture "$SESSION" - -# 4. Clean up -./scripts/tmux/tmux-cli.sh stop "$SESSION" -\`\`\` - -## Session Logs (Paper Trail) - -All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: - -- \`session-info.yaml\` - Session metadata (start time, dimensions, status) -- \`commands.yaml\` - YAML array of all commands sent with timestamps -- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter - -\`\`\`bash -# Capture with a descriptive label (recommended) -./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - -# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt -\`\`\` - -Each capture file has YAML front-matter with metadata: -\`\`\`yaml ---- -sequence: 1 -label: after-help-command -timestamp: 2025-01-01T12:00:30Z -after_command: "/help" -dimensions: - width: 120 - height: 30 ---- -[terminal content] -\`\`\` - -The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed. - -## Debugging Tips - -- **Attach interactively**: \`tmux attach -t SESSION_NAME\` -- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` -- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` -- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\``, - - instructionsPrompt: `Instructions: - -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review" or the prompt mentions reviewing/analyzing code: follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) - ---- - -## Test Mode Instructions - -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically - -2. **Start a Gemini test session** with YOLO mode: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - \`\`\` - -3. **Verify the CLI started** by capturing initial output: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" - \`\`\` - -4. **Send commands** and capture responses: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 - \`\`\` - -5. **Always clean up** when done: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - -6. **Use labels when capturing** to create a clear paper trail: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 - \`\`\` - ---- - -## Review Mode Instructions - -In review mode, you send a detailed review prompt to Gemini. The prompt MUST start with the word "review" and include specific areas of concern. - -### What We're Looking For - -The review should focus on these key areas: - -1. **Code Organization Issues** - - Poor file/module structure - - Unclear separation of concerns - - Functions/classes that do too many things - - Missing or inconsistent abstractions - -2. **Over-Engineering & Complexity** - - Unnecessarily abstract or generic code - - Premature optimization - - Complex patterns where simple solutions would suffice - - "Enterprise" patterns in small codebases - -3. **AI-Generated Code Patterns ("AI Slop")** - - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") - - Excessive disclaimers and hedging in documentation - - Inconsistent coding style within the same file - - Overly generic variable/function names - - Redundant explanatory comments that just restate the code - - Sudden shifts between formal and casual tone - - Filler phrases that add no value - -4. **Lack of Systems-Level Thinking** - - Missing error handling strategy - - No consideration for scaling or performance - - Ignoring edge cases and failure modes - - Lack of observability (logging, metrics, tracing) - - Missing or incomplete type definitions - -### Workflow - -1. **Start Gemini** with YOLO mode: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "gemini --yolo") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: - \`\`\`bash - sleep 3 - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - \`\`\` - -3. **Send a detailed review prompt** (MUST start with "review"): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: - - 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much - 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work - 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations - 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability - - For each issue found, specify the file, line number, what\\'s wrong, and how to fix it. Be direct and specific." - \`\`\` - -4. **Wait for and capture the review output** (reviews take longer): - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 - \`\`\` - - If the review is still in progress, wait and capture again: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 - \`\`\` - -5. **Parse the review output** and populate \`reviewFindings\` with: - - \`file\`: Path to the file with the issue - - \`severity\`: "critical", "warning", "suggestion", or "info" - - \`line\`: Line number if mentioned - - \`finding\`: Description of the issue - - \`suggestion\`: How to fix it - -6. **Clean up**: - \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" - \`\`\` - ---- - -## Output (Both Modes) - -**Report results using set_output** - You MUST call set_output with structured results: -- \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) -- \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels -- \`reviewFindings\`: Array of code review findings (for review mode) - -**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: -- \`script\`: Which script failed -- \`issue\`: What went wrong -- \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script - -**Always include captures** in your output so the parent agent can see what you saw. - -For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.`, -} - -export default definition +- \`/quit\` - Exit the CLI (or Ctrl-C twice)`, +}) diff --git a/.agents/lib/cli-agent-prompts.ts b/.agents/lib/cli-agent-prompts.ts new file mode 100644 index 0000000000..c2cd73ef1f --- /dev/null +++ b/.agents/lib/cli-agent-prompts.ts @@ -0,0 +1,286 @@ +import type { CliAgentConfig } from './cli-agent-types' + +const TMUX_SESSION_DOCS = `## Session Logs (Paper Trail) + +All session data is stored in **YAML format** in \`debug/tmux-sessions/{session-name}/\`: + +- \`session-info.yaml\` - Session metadata (start time, dimensions, status) +- \`commands.yaml\` - YAML array of all commands sent with timestamps +- \`capture-{sequence}-{label}.txt\` - Captures with YAML front-matter + +\`\`\`bash +# Capture with a descriptive label (recommended) +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 + +# Capture saved to: debug/tmux-sessions/{session}/capture-001-after-help-command.txt +\`\`\` + +Each capture file has YAML front-matter with metadata: +\`\`\`yaml +--- +sequence: 1 +label: after-help-command +timestamp: 2025-01-01T12:00:30Z +after_command: "/help" +dimensions: + width: 120 + height: 30 +--- +[terminal content] +\`\`\` + +The capture path is printed to stderr. Both you and the parent agent can read these files to see exactly what the CLI displayed.` + +const TMUX_DEBUG_TIPS = `## Debugging Tips + +- **Attach interactively**: \`tmux attach -t SESSION_NAME\` +- **List sessions**: \`./scripts/tmux/tmux-cli.sh list\` +- **View session logs**: \`ls debug/tmux-sessions/{session-name}/\` +- **Get help**: \`./scripts/tmux/tmux-cli.sh help\` or \`./scripts/tmux/tmux-start.sh --help\`` + +const REVIEW_CRITERIA = `### What We're Looking For + +The review should focus on these key areas: + +1. **Code Organization Issues** + - Poor file/module structure + - Unclear separation of concerns + - Functions/classes that do too many things + - Missing or inconsistent abstractions + +2. **Over-Engineering & Complexity** + - Unnecessarily abstract or generic code + - Premature optimization + - Complex patterns where simple solutions would suffice + - "Enterprise" patterns in small codebases + +3. **AI-Generated Code Patterns ("AI Slop")** + - Verbose, flowery language in comments ("It's important to note...", "Worth mentioning...") + - Excessive disclaimers and hedging in documentation + - Inconsistent coding style within the same file + - Overly generic variable/function names + - Redundant explanatory comments that just restate the code + - Sudden shifts between formal and casual tone + - Filler phrases that add no value + +4. **Lack of Systems-Level Thinking** + - Missing error handling strategy + - No consideration for scaling or performance + - Ignoring edge cases and failure modes + - Lack of observability (logging, metrics, tracing) + - Missing or incomplete type definitions` + +export function getSpawnerPrompt(config: CliAgentConfig): string { + const base = `Expert at testing ${config.cliName} CLI functionality using tmux, or performing code reviews via ${config.cliName}. + +**Modes:** +- \`test\` (default): Spawns tmux sessions, sends input to ${config.cliName} CLI, captures terminal output, and validates behavior. +- \`review\`: Uses ${config.cliName} CLI to perform code reviews on specified files or directories. + +**Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. + +**Your responsibilities as the parent agent:** +1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes +2. Use \`read_files\` on the capture paths to see what the CLI displayed +3. Re-run the test after fixing any script issues` + + return config.spawnerPromptExtras ? `${base}\n\n${config.spawnerPromptExtras}` : base +} + +export function getSystemPrompt(config: CliAgentConfig): string { + const cliSpecificSection = config.cliSpecificDocs ? `\n${config.cliSpecificDocs}\n` : '\n' + + return `You are an expert at testing ${config.cliName} CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. + +## ${config.cliName} Startup + +For testing ${config.cliName}, use the \`--command\` flag with permission bypass: + +\`\`\`bash +# Start ${config.cliName} CLI (with permission bypass for testing) +SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + +# Or with specific options +SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand} --help") +\`\`\` + +**Important:** ${config.permissionNote} +${cliSpecificSection} +## Helper Scripts + +Use these scripts in \`scripts/tmux/\` for reliable CLI testing: + +### Unified Script (Recommended) + +\`\`\`bash +# Start a ${config.cliName} test session (with permission bypass) +SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + +# Send input to the CLI +./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" + +# Capture output (optionally wait first) +./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 + +# Stop the session when done +./scripts/tmux/tmux-cli.sh stop "$SESSION" + +# Stop all test sessions +./scripts/tmux/tmux-cli.sh stop --all +\`\`\` + +### Individual Scripts (More Options) + +\`\`\`bash +# Start with custom settings +./scripts/tmux/tmux-start.sh --command "${config.startCommand}" --name ${config.shortName}-test --width 160 --height 40 + +# Send text (auto-presses Enter) +./scripts/tmux/tmux-send.sh ${config.shortName}-test "your prompt here" + +# Send without pressing Enter +./scripts/tmux/tmux-send.sh ${config.shortName}-test "partial" --no-enter + +# Send special keys +./scripts/tmux/tmux-send.sh ${config.shortName}-test --key Escape +./scripts/tmux/tmux-send.sh ${config.shortName}-test --key C-c + +# Capture with colors +./scripts/tmux/tmux-capture.sh ${config.shortName}-test --colors + +# Save capture to file +./scripts/tmux/tmux-capture.sh ${config.shortName}-test -o output.txt +\`\`\` + +## Why These Scripts? + +The scripts handle **bracketed paste mode** automatically. Standard \`tmux send-keys\` drops characters with TUI apps like ${config.cliName} due to how the CLI processes keyboard input. The helper scripts wrap input in escape sequences (\`\\e[200~...\\e[201~\`) so you don't have to. + +${TMUX_SESSION_DOCS} + +${TMUX_DEBUG_TIPS}` +} + +export function getDefaultReviewModeInstructions(config: CliAgentConfig): string { + return `## Review Mode Instructions + +In review mode, you send a detailed review prompt to ${config.cliName}. The prompt MUST start with the word "review" and include specific areas of concern. + +${REVIEW_CRITERIA} + +### Workflow + +1. **Start ${config.cliName}** with permission bypass: + \`\`\`bash + SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + \`\`\` + +2. **Wait for CLI to initialize**, then capture: + \`\`\`bash + sleep 3 + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" + \`\`\` + +3. **Send a detailed review prompt** (MUST start with "review"): + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: + + 1. CODE ORGANIZATION: Poor structure, unclear separation of concerns, functions doing too much + 2. OVER-ENGINEERING: Unnecessary abstractions, premature optimization, complex patterns where simple would work + 3. AI SLOP: Verbose comments ('it\\'s important to note'), excessive disclaimers, inconsistent style, generic names, redundant explanations + 4. SYSTEMS THINKING: Missing error handling strategy, no scaling consideration, ignored edge cases, lack of observability + + For each issue found, specify the file, line number, what's wrong, and how to fix it. Be direct and specific." + \`\`\` + +4. **Wait for and capture the review output** (reviews take longer): + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 + \`\`\` + + If the review is still in progress, wait and capture again: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 + \`\`\` + +5. **Parse the review output** and populate \`reviewFindings\` with: + - \`file\`: Path to the file with the issue + - \`severity\`: "critical", "warning", "suggestion", or "info" + - \`line\`: Line number if mentioned + - \`finding\`: Description of the issue + - \`suggestion\`: How to fix it + +6. **Clean up**: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh stop "$SESSION" + \`\`\`` +} + +export function getInstructionsPrompt(config: CliAgentConfig): string { + const reviewModeInstructions = config.reviewModeInstructions ?? getDefaultReviewModeInstructions(config) + + return `Instructions: + +Check the \`mode\` parameter to determine your operation: +- If \`mode\` is "review": follow **Review Mode** instructions +- Otherwise: follow **Test Mode** instructions (default) + +--- + +## Test Mode Instructions + +1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically + +2. **Start a ${config.cliName} test session** with permission bypass: + \`\`\`bash + SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + \`\`\` + +3. **Verify the CLI started** by capturing initial output: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" + \`\`\` + +4. **Send commands** and capture responses: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 + \`\`\` + +5. **Always clean up** when done: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh stop "$SESSION" + \`\`\` + +6. **Use labels when capturing** to create a clear paper trail: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 + \`\`\` + +--- + +${reviewModeInstructions} + +--- + +## Output (Both Modes) + +**Report results using set_output** - You MUST call set_output with structured results: +- \`overallStatus\`: "success", "failure", or "partial" +- \`summary\`: Brief description of what was tested/reviewed +- \`testResults\`: Array of test outcomes (for test mode) +- \`scriptIssues\`: Array of any problems with the helper scripts +- \`captures\`: Array of capture paths with labels +- \`reviewFindings\`: Array of code review findings (for review mode) + +**If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: +- \`script\`: Which script failed +- \`issue\`: What went wrong +- \`errorOutput\`: The actual error message +- \`suggestedFix\`: How the parent agent should fix the script + +**Always include captures** in your output so the parent agent can see what you saw. + +For advanced options, run \`./scripts/tmux/tmux-cli.sh help\` or check individual scripts with \`--help\`.` +} diff --git a/.agents/lib/cli-agent-schemas.ts b/.agents/lib/cli-agent-schemas.ts new file mode 100644 index 0000000000..c5cde7e1cb --- /dev/null +++ b/.agents/lib/cli-agent-schemas.ts @@ -0,0 +1,72 @@ +// Shared output schema for CLI tester agents. testResults for test mode, reviewFindings for review mode. +export const outputSchema = { + type: 'object' as const, + properties: { + overallStatus: { + type: 'string' as const, + enum: ['success', 'failure', 'partial'], + description: 'Overall test outcome', + }, + summary: { + type: 'string' as const, + description: 'Brief summary of what was tested and the outcome', + }, + testResults: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + testName: { type: 'string' as const, description: 'Name/description of the test' }, + passed: { type: 'boolean' as const, description: 'Whether the test passed' }, + details: { type: 'string' as const, description: 'Details about what happened' }, + capturedOutput: { type: 'string' as const, description: 'Relevant output captured from the CLI' }, + }, + required: ['testName', 'passed'], + }, + description: 'Array of individual test results', + }, + scriptIssues: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + script: { type: 'string' as const, description: 'Which script had the issue (e.g., "tmux-start.sh", "tmux-send.sh")' }, + issue: { type: 'string' as const, description: 'What went wrong when using the script' }, + errorOutput: { type: 'string' as const, description: 'The actual error message or unexpected output' }, + suggestedFix: { type: 'string' as const, description: 'Suggested fix or improvement for the parent agent to implement' }, + }, + required: ['script', 'issue', 'suggestedFix'], + }, + description: 'Issues encountered with the helper scripts that the parent agent should fix', + }, + captures: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + path: { type: 'string' as const, description: 'Path to the capture file (relative to project root)' }, + label: { type: 'string' as const, description: 'What this capture shows (e.g., "initial-cli-state", "after-help-command")' }, + timestamp: { type: 'string' as const, description: 'When the capture was taken' }, + }, + required: ['path', 'label'], + }, + description: 'Paths to saved terminal captures for debugging - check debug/tmux-sessions/{session}/', + }, + reviewFindings: { + type: 'array' as const, + items: { + type: 'object' as const, + properties: { + file: { type: 'string' as const, description: 'File path where the issue was found' }, + severity: { type: 'string' as const, enum: ['critical', 'warning', 'suggestion', 'info'], description: 'Severity level of the finding' }, + line: { type: 'number' as const, description: 'Line number (if applicable)' }, + finding: { type: 'string' as const, description: 'Description of the issue or suggestion' }, + suggestion: { type: 'string' as const, description: 'Suggested fix or improvement' }, + }, + required: ['file', 'severity', 'finding'], + }, + description: 'Code review findings (only populated in review mode)', + }, + }, + required: ['overallStatus', 'summary', 'scriptIssues', 'captures'], +} diff --git a/.agents/lib/cli-agent-types.ts b/.agents/lib/cli-agent-types.ts new file mode 100644 index 0000000000..4912b36c0a --- /dev/null +++ b/.agents/lib/cli-agent-types.ts @@ -0,0 +1,23 @@ +export interface InputParamDefinition { + type: 'string' | 'number' | 'boolean' | 'array' | 'object' + description?: string + enum?: string[] +} + +// Prevent extraInputParams from overriding 'mode' at compile time +export type ExtraInputParams = Omit, 'mode'> + +export interface CliAgentConfig { + id: string + displayName: string + cliName: string + /** Used for session naming, e.g., 'claude-code' -> sessions named 'claude-code-test' */ + shortName: string + startCommand: string + permissionNote: string + model: string + spawnerPromptExtras?: string + extraInputParams?: ExtraInputParams + reviewModeInstructions?: string + cliSpecificDocs?: string +} diff --git a/.agents/lib/create-cli-agent.ts b/.agents/lib/create-cli-agent.ts new file mode 100644 index 0000000000..d982a24b71 --- /dev/null +++ b/.agents/lib/create-cli-agent.ts @@ -0,0 +1,58 @@ +import type { AgentDefinition } from '../types/agent-definition' +import type { CliAgentConfig } from './cli-agent-types' +import { outputSchema } from './cli-agent-schemas' +import { + getSpawnerPrompt, + getSystemPrompt, + getInstructionsPrompt, +} from './cli-agent-prompts' + +export function createCliAgent(config: CliAgentConfig): AgentDefinition { + // Simple validation for shortName since it's used in file paths + if (!/^[a-z0-9-]+$/.test(config.shortName)) { + throw new Error( + `CliAgentConfig '${config.id}': shortName must be lowercase alphanumeric with hyphens, got '${config.shortName}'` + ) + } + + const baseInputParams = { + mode: { + type: 'string' as const, + enum: ['test', 'review'], + description: `Operation mode - "test" for CLI testing (default), "review" for code review via ${config.cliName}`, + }, + } + + const inputParams = config.extraInputParams + ? { ...baseInputParams, ...config.extraInputParams } + : baseInputParams + + return { + id: config.id, + displayName: config.displayName, + model: config.model, + + spawnerPrompt: getSpawnerPrompt(config), + + inputSchema: { + prompt: { + type: 'string' as const, + description: + 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', + }, + params: { + type: 'object' as const, + properties: inputParams, + }, + }, + + outputMode: 'structured_output', + outputSchema, + includeMessageHistory: false, + + toolNames: ['run_terminal_command', 'read_files', 'code_search', 'set_output'], + + systemPrompt: getSystemPrompt(config), + instructionsPrompt: getInstructionsPrompt(config), + } +} From f299e00da29baef1a6c8ee2413828bd51f9129f6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Fri, 16 Jan 2026 21:59:22 -0800 Subject: [PATCH 0004/1143] fix(cli): process queued messages one at a time Add ref-based lock to prevent race condition where React batching caused all queued messages to send simultaneously when stream ended. --- cli/src/chat.tsx | 2 + .../helpers/__tests__/send-message.test.ts | 217 +++++++++++++++++- cli/src/hooks/helpers/send-message.ts | 94 ++++++-- cli/src/hooks/use-message-queue.ts | 42 +++- cli/src/hooks/use-send-message.ts | 40 ++-- cli/src/utils/yield-to-event-loop.ts | 9 + 6 files changed, 358 insertions(+), 46 deletions(-) create mode 100644 cli/src/utils/yield-to-event-loop.ts diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index b1ab238ae0..73fcd0ca86 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -585,6 +585,7 @@ export const Chat = ({ resumeQueue, clearQueue, isQueuePausedRef, + isProcessingQueueRef, } = useMessageQueue( (message: QueuedMessage) => sendMessageRef.current?.({ @@ -682,6 +683,7 @@ export const Chat = ({ scrollToLatest, onTimerEvent: () => {}, // No-op for now isQueuePausedRef, + isProcessingQueueRef, resumeQueue, continueChat, continueChatId, diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 32ac67ebca..1c71472cc3 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -28,7 +28,7 @@ ensureEnv() const { useChatStore } = await import('../../../state/chat-store') const { createStreamController } = await import('../../stream-state') -const { setupStreamingContext, handleRunError } = await import( +const { setupStreamingContext, handleRunError, finalizeQueueState } = await import( '../send-message' ) const { createBatchedMessageUpdater } = await import( @@ -172,6 +172,94 @@ describe('setupStreamingContext', () => { expect(canProcessQueue).toBe(false) }) + test('abort resets isProcessingQueueRef to false', () => { + let messages = createBaseMessages() + const streamRefs = createStreamController() + const timerController = createMockTimerController() + const abortControllerRef = { current: null as AbortController | null } + const isProcessingQueueRef = { current: true } + + const { abortController } = setupStreamingContext({ + aiMessageId: 'ai-1', + timerController, + setMessages: (fn: any) => { + messages = fn(messages) + }, + streamRefs, + abortControllerRef, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + isProcessingQueueRef, + updateChainInProgress: () => {}, + setIsRetrying: () => {}, + }) + + // Verify ref starts as true + expect(isProcessingQueueRef.current).toBe(true) + + // Trigger abort + abortController.abort() + + // Verify isProcessingQueueRef is reset to false after abort + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('abort with both isProcessingQueueRef and isQueuePausedRef handles correctly', () => { + let messages = createBaseMessages() + const streamRefs = createStreamController() + const timerController = createMockTimerController() + const abortControllerRef = { current: null as AbortController | null } + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = true + let chainInProgress = true + let isRetrying = true + + const { abortController } = setupStreamingContext({ + aiMessageId: 'ai-1', + timerController, + setMessages: (fn: any) => { + messages = fn(messages) + }, + streamRefs, + abortControllerRef, + setStreamStatus: (status) => { + streamStatus = status + }, + setCanProcessQueue: (can) => { + canProcessQueue = can + }, + isQueuePausedRef, + isProcessingQueueRef, + updateChainInProgress: (value) => { + chainInProgress = value + }, + setIsRetrying: (value) => { + isRetrying = value + }, + }) + + // Sanity check initial state + expect(isProcessingQueueRef.current).toBe(true) + expect(isQueuePausedRef.current).toBe(true) + expect(streamStatus).toBe('streaming') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(true) + expect(isRetrying).toBe(true) + + // Trigger abort + abortController.abort() + + // After abort, lock should be released, queue should respect pause state, + // chain and retry flags should be cleared, and stream should be idle. + expect(isProcessingQueueRef.current).toBe(false) + expect(canProcessQueue).toBe(false) + expect(chainInProgress).toBe(false) + expect(isRetrying).toBe(false) + expect(streamStatus).toBe('idle') + }) + test('abort handler stores abortController in ref', () => { let messages = createBaseMessages() const streamRefs = createStreamController() @@ -230,6 +318,61 @@ describe('setupStreamingContext', () => { }) }) +describe('finalizeQueueState', () => { + test('sets stream status to idle and resets queue state', () => { + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = false + let chainInProgress = true + const isProcessingQueueRef = { current: true } + + finalizeQueueState({ + setStreamStatus: (status) => { streamStatus = status }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + updateChainInProgress: (value) => { chainInProgress = value }, + isProcessingQueueRef, + }) + + expect(streamStatus).toBe('idle') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(false) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('calls resumeQueue instead of setCanProcessQueue when provided', () => { + let streamStatus = 'streaming' as StreamStatus + let canProcessQueueCalled = false + let resumeQueueCalled = false + let chainInProgress = true + + finalizeQueueState({ + setStreamStatus: (status) => { streamStatus = status }, + setCanProcessQueue: () => { canProcessQueueCalled = true }, + updateChainInProgress: (value) => { chainInProgress = value }, + resumeQueue: () => { resumeQueueCalled = true }, + }) + + expect(streamStatus).toBe('idle') + expect(resumeQueueCalled).toBe(true) + expect(canProcessQueueCalled).toBe(false) + expect(chainInProgress).toBe(false) + }) + + test('respects isQueuePausedRef when no resumeQueue provided', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + + finalizeQueueState({ + setStreamStatus: () => {}, + setCanProcessQueue: (can) => { canProcessQueue = can }, + updateChainInProgress: () => {}, + isQueuePausedRef, + }) + + // When queue is paused, canProcessQueue should be false + expect(canProcessQueue).toBe(false) + }) +}) + describe('handleRunError', () => { let originalGetState: typeof useChatStore.getState @@ -376,6 +519,78 @@ describe('handleRunError', () => { expect(setInputModeMock).not.toHaveBeenCalled() }) + test('resets isProcessingQueueRef to false on error', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: '', + blocks: [], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + const isProcessingQueueRef = { current: true } + + // Verify ref starts as true + expect(isProcessingQueueRef.current).toBe(true) + + handleRunError({ + error: new Error('Some error'), + aiMessageId: 'ai-1', + timerController, + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: () => {}, + updateChainInProgress: () => {}, + isProcessingQueueRef, + }) + + // Verify isProcessingQueueRef is reset to false + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('respects isQueuePausedRef when setting canProcessQueue on error', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: '', + blocks: [], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + const isQueuePausedRef = { current: true } + let canProcessQueue = true + + handleRunError({ + error: new Error('Some error'), + aiMessageId: 'ai-1', + timerController, + updater, + setIsRetrying: () => {}, + setStreamStatus: () => {}, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: () => {}, + isQueuePausedRef, + }) + + // When queue is paused, canProcessQueue should be false + expect(canProcessQueue).toBe(false) + }) + test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => { let messages: ChatMessage[] = [ { diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 39e209cfad..8637aee9c1 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -18,6 +18,8 @@ import { type BatchedMessageUpdater, } from '../../utils/message-updater' import { createModeDividerMessage } from '../../utils/send-message-helpers' +import { yieldToEventLoop } from '../../utils/yield-to-event-loop' +import { getErrorObject } from '@codebuff/common/util/error' import type { PendingAttachment, @@ -32,12 +34,40 @@ import type { StreamController } from '../stream-state' import type { StreamStatus } from '../use-message-queue' import type { MessageContent, RunState } from '@codebuff/sdk' import type { MutableRefObject, SetStateAction } from 'react' -import { getErrorObject } from '@codebuff/common/util/error' -const yieldToEventLoop = () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }) +/** Resets queue state after streaming completes, aborts, or errors. */ +export type FinalizeQueueStateParams = { + setStreamStatus: (status: StreamStatus) => void + setCanProcessQueue: (can: boolean) => void + updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject + resumeQueue?: () => void +} + +export const finalizeQueueState = (params: FinalizeQueueStateParams): void => { + const { + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + resumeQueue, + } = params + + setStreamStatus('idle') + // Release lock here as part of normal completion flow. + // Also released in finally block and .catch() as safety nets (idempotent). + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } + if (resumeQueue) { + resumeQueue() + } else { + setCanProcessQueue(!isQueuePausedRef?.current) + } + updateChainInProgress(false) +} export type PrepareUserMessageDeps = { setMessages: (update: SetStateAction) => void @@ -158,6 +188,7 @@ export const setupStreamingContext = (params: { setStreamStatus: (status: StreamStatus) => void setCanProcessQueue: (can: boolean) => void isQueuePausedRef?: MutableRefObject + isProcessingQueueRef?: MutableRefObject updateChainInProgress: (value: boolean) => void setIsRetrying: (value: boolean) => void }) => { @@ -170,6 +201,7 @@ export const setupStreamingContext = (params: { setStreamStatus, setCanProcessQueue, isQueuePausedRef, + isProcessingQueueRef, updateChainInProgress, setIsRetrying, } = params @@ -184,9 +216,13 @@ export const setupStreamingContext = (params: { abortController.signal.addEventListener('abort', () => { // Abort means the user stopped streaming; finalize with an interruption notice. streamRefs.setters.setWasAbortedByUser(true) - setStreamStatus('idle') - setCanProcessQueue(!isQueuePausedRef?.current) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) setIsRetrying(false) timerController.stop('aborted') @@ -210,6 +246,8 @@ export const handleRunCompletion = (params: { updateChainInProgress: (value: boolean) => void setHasReceivedPlanResponse: (value: boolean) => void resumeQueue?: () => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject }) => { const { runState, @@ -224,13 +262,19 @@ export const handleRunCompletion = (params: { updateChainInProgress, setHasReceivedPlanResponse, resumeQueue, + isProcessingQueueRef, + isQueuePausedRef, } = params const output = runState.output const finalizeAfterError = () => { - setStreamStatus('idle') - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) timerController.stop('error') } @@ -267,12 +311,14 @@ export const handleRunCompletion = (params: { invalidateActivityQuery(usageQueryKeys.current()) - setStreamStatus('idle') - if (resumeQueue) { - resumeQueue() - } - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + resumeQueue, + }) const timerResult = timerController.stop('success') if (agentMode === 'PLAN') { @@ -304,6 +350,8 @@ export const handleRunError = (params: { setStreamStatus: (status: StreamStatus) => void setCanProcessQueue: (can: boolean) => void updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject }) => { const { error, @@ -314,6 +362,8 @@ export const handleRunError = (params: { setStreamStatus, setCanProcessQueue, updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, } = params const partial = createErrorMessage(error, aiMessageId) @@ -323,9 +373,13 @@ export const handleRunError = (params: { 'SDK client.run() failed', ) setIsRetrying(false) - setStreamStatus('idle') - setCanProcessQueue(true) - updateChainInProgress(false) + finalizeQueueState({ + setStreamStatus, + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) timerController.stop('error') if (isOutOfCreditsError(error)) { diff --git a/cli/src/hooks/use-message-queue.ts b/cli/src/hooks/use-message-queue.ts index 4250edc31a..6b0e02b835 100644 --- a/cli/src/hooks/use-message-queue.ts +++ b/cli/src/hooks/use-message-queue.ts @@ -12,7 +12,7 @@ export type QueuedMessage = { } export const useMessageQueue = ( - sendMessage: (message: QueuedMessage) => void, + sendMessage: (message: QueuedMessage) => Promise, isChainInProgressRef: React.MutableRefObject, activeAgentStreamsRef: React.MutableRefObject, ) => { @@ -26,6 +26,7 @@ export const useMessageQueue = ( const streamIntervalRef = useRef | null>(null) const streamMessageIdRef = useRef(null) const isQueuePausedRef = useRef(false) + const isProcessingQueueRef = useRef(false) useEffect(() => { queuedMessagesRef.current = queuedMessages @@ -98,20 +99,35 @@ export const useMessageQueue = ( return } + if (isProcessingQueueRef.current) { + logger.debug( + { queueLength }, + '[message-queue] Queue blocked: already processing', + ) + return + } + logger.info( { queueLength }, '[message-queue] Processing next message from queue', ) - const timeoutId = setTimeout(() => { - const nextMessage = queuedList[0] - const remainingMessages = queuedList.slice(1) - queuedMessagesRef.current = remainingMessages - setQueuedMessages(remainingMessages) - sendMessage(nextMessage) - }, 100) - - return () => clearTimeout(timeoutId) + isProcessingQueueRef.current = true + + const nextMessage = queuedList[0] + const remainingMessages = queuedList.slice(1) + queuedMessagesRef.current = remainingMessages + setQueuedMessages(remainingMessages) + // Add .catch() to prevent unhandled promise rejections. + // Safety net: release lock here in case sendMessage failed before its own error handling. + // Lock is also released in finalizeQueueState and sendMessage's finally block (idempotent). + sendMessage(nextMessage).catch((err: unknown) => { + logger.warn( + { error: err }, + '[message-queue] sendMessage promise rejected - releasing lock', + ) + isProcessingQueueRef.current = false + }) }, [ canProcessQueue, queuePaused, @@ -159,8 +175,9 @@ export const useMessageQueue = ( const stopStreaming = useCallback(() => { setStreamStatus('idle') - setCanProcessQueue(!queuePaused) - }, [queuePaused]) + // Use ref instead of queuePaused state to avoid stale closure issues + setCanProcessQueue(!isQueuePausedRef.current) + }, []) return { queuedMessages, @@ -178,5 +195,6 @@ export const useMessageQueue = ( resumeQueue, clearQueue, isQueuePausedRef, + isProcessingQueueRef, } } diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 042b26c209..1170fd8381 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -27,6 +27,7 @@ import { setupStreamingContext, } from './helpers/send-message' import { NETWORK_ERROR_ID } from '../utils/validation-error-helpers' +import { yieldToEventLoop } from '../utils/yield-to-event-loop' import type { ElapsedTimeTracker } from './use-elapsed-time' import type { StreamStatus } from './use-message-queue' @@ -37,12 +38,6 @@ import type { AgentMode } from '../utils/constants' import type { SendMessageTimerEvent } from '../utils/send-message-timer' import type { AgentDefinition, MessageContent, RunState } from '@codebuff/sdk' -// Main chat send hook: orchestrates prep, streaming, and completion. -const yieldToEventLoop = () => - new Promise((resolve) => { - setTimeout(resolve, 0) - }) - interface UseSendMessageOptions { inputRef: React.MutableRefObject activeSubagentsRef: React.MutableRefObject> @@ -59,6 +54,7 @@ interface UseSendMessageOptions { scrollToLatest: () => void onTimerEvent?: (event: SendMessageTimerEvent) => void isQueuePausedRef?: React.MutableRefObject + isProcessingQueueRef?: React.MutableRefObject resumeQueue?: () => void continueChat: boolean continueChatId?: string @@ -108,6 +104,7 @@ export const useSendMessage = ({ scrollToLatest, onTimerEvent = () => {}, isQueuePausedRef, + isProcessingQueueRef, resumeQueue, continueChat, continueChatId, @@ -212,8 +209,6 @@ export const useSendMessage = ({ }, }) }, - // Note: lastMessageMode is accessed via getState() inside the callback, - // so it always gets the fresh value - no need to include in deps [ setMessages, setLastMessageMode, @@ -313,6 +308,19 @@ export const useSendMessage = ({ {}, '[send-message] No Codebuff client available. Please ensure you are authenticated.', ) + // Show error to user instead of silently failing + setMessages((prev) => [ + ...prev, + createErrorChatMessage( + '⚠️ Unable to connect to Codebuff. Please check your authentication and try again.', + ), + ]) + await yieldToEventLoop() + setTimeout(() => scrollToLatest(), 0) + // Release the queue processing lock since we're returning early (before try block) + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } return } @@ -332,6 +340,7 @@ export const useSendMessage = ({ setStreamStatus, setCanProcessQueue, isQueuePausedRef, + isProcessingQueueRef, updateChainInProgress, setIsRetrying, }) @@ -409,6 +418,8 @@ export const useSendMessage = ({ updateChainInProgress, setHasReceivedPlanResponse, resumeQueue, + isProcessingQueueRef, + isQueuePausedRef, }) } catch (error) { handleRunError({ @@ -420,10 +431,10 @@ export const useSendMessage = ({ setStreamStatus, setCanProcessQueue, updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, }) } finally { - // Defensive reset: ensure chain state is always cleared even if handlers throw. - // This prevents the system from getting stuck in "chain in progress" state. if (isChainInProgressRef.current) { logger.warn( {}, @@ -433,9 +444,11 @@ export const useSendMessage = ({ setStreamStatus('idle') setCanProcessQueue(!isQueuePausedRef?.current) } - // Ensure the batched updater's flush interval is always cleaned up, - // even if handleRunCompletion or handleRunError throw unexpectedly. - // dispose() is safe to call multiple times. + // Safety net: ensure lock is always released even if handleRunCompletion/handleRunError + // didn't run (e.g., due to unexpected early return). Redundant releases are safe (idempotent). + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } updater.dispose() } }, @@ -444,6 +457,7 @@ export const useSendMessage = ({ addSessionCredits, agentId, inputRef, + isProcessingQueueRef, isQueuePausedRef, mainAgentTimer, onBeforeMessageSend, diff --git a/cli/src/utils/yield-to-event-loop.ts b/cli/src/utils/yield-to-event-loop.ts new file mode 100644 index 0000000000..8b13f4d460 --- /dev/null +++ b/cli/src/utils/yield-to-event-loop.ts @@ -0,0 +1,9 @@ +/** + * Yield to the event loop so pending React state updates and microtasks can flush + * before continuing. Useful after enqueuing UI changes that should render + * before the next step of an async flow. + */ +export const yieldToEventLoop = (): Promise => + new Promise((resolve) => { + setTimeout(resolve, 0) + }) From 9aa428447db0c5c9b8887fd4b83cc133e94001ce Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:30:36 -0800 Subject: [PATCH 0005/1143] feat(cli): add block-processor utilities --- .../utils/__tests__/block-processor.test.ts | 705 ++++++++++++++++++ cli/src/utils/block-processor.ts | 170 +++++ 2 files changed, 875 insertions(+) create mode 100644 cli/src/utils/__tests__/block-processor.test.ts create mode 100644 cli/src/utils/block-processor.ts diff --git a/cli/src/utils/__tests__/block-processor.test.ts b/cli/src/utils/__tests__/block-processor.test.ts new file mode 100644 index 0000000000..4c3fe574e7 --- /dev/null +++ b/cli/src/utils/__tests__/block-processor.test.ts @@ -0,0 +1,705 @@ +import { describe, expect, test } from 'bun:test' +import { + processBlocks, + isReasoningTextBlock, + type BlockProcessorHandlers, +} from '../block-processor' +import type { + ContentBlock, + TextContentBlock, + ToolContentBlock, + AgentContentBlock, + ImageContentBlock, +} from '../../types/chat' + +// ============================================================================ +// Test Helpers - Block Factories +// ============================================================================ + +function createTextBlock( + content: string, + textType?: 'reasoning' | 'text', +): TextContentBlock { + return { + type: 'text', + content, + textType, + } as TextContentBlock +} + +function createReasoningBlock(content: string): TextContentBlock { + return createTextBlock(content, 'reasoning') +} + +function createToolBlock( + toolName: string, + toolCallId: string = `tool-${toolName}`, +): ToolContentBlock { + return { + type: 'tool', + toolCallId, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + } +} + +function createImageBlock( + mediaType: string = 'image/png', + image: string = 'base64data', +): ImageContentBlock { + return { + type: 'image', + mediaType, + image, + } as ImageContentBlock +} + +function createImplementorAgent( + agentId: string, + agentType: string = 'editor-implementor', +): AgentContentBlock { + return { + type: 'agent', + agentId, + agentName: `Implementor ${agentId}`, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock +} + +function createNonImplementorAgent( + agentId: string, + agentType: string = 'file-picker', +): AgentContentBlock { + return { + type: 'agent', + agentId, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock +} + +// ============================================================================ +// Test Helpers - Mock Handlers +// ============================================================================ + +interface MockCallRecord { + handler: string + args: unknown[] +} + +function createMockHandlers(): { + handlers: BlockProcessorHandlers + calls: MockCallRecord[] +} { + const calls: MockCallRecord[] = [] + + const handlers: BlockProcessorHandlers = { + onReasoningGroup: (blocks, startIndex) => { + calls.push({ handler: 'onReasoningGroup', args: [blocks, startIndex] }) + return `reasoning-${startIndex}` + }, + onImageBlock: (block, index) => { + calls.push({ handler: 'onImageBlock', args: [block, index] }) + return `image-${index}` + }, + onToolGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onToolGroup', + args: [blocks, startIndex, nextIndex], + }) + return `tools-${startIndex}-${nextIndex}` + }, + onImplementorGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onImplementorGroup', + args: [blocks, startIndex, nextIndex], + }) + return `implementors-${startIndex}-${nextIndex}` + }, + onAgentGroup: (blocks, startIndex, nextIndex) => { + calls.push({ + handler: 'onAgentGroup', + args: [blocks, startIndex, nextIndex], + }) + return `agents-${startIndex}-${nextIndex}` + }, + onSingleBlock: (block, index) => { + calls.push({ handler: 'onSingleBlock', args: [block, index] }) + return `single-${index}` + }, + } + + return { handlers, calls } +} + +// ============================================================================ +// Tests: isReasoningTextBlock +// ============================================================================ + +describe('isReasoningTextBlock', () => { + test('returns true for text block with textType "reasoning"', () => { + const block = createReasoningBlock('thinking...') + expect(isReasoningTextBlock(block)).toBe(true) + }) + + test('returns false for text block without textType', () => { + const block = createTextBlock('normal text') + expect(isReasoningTextBlock(block)).toBe(false) + }) + + test('returns false for text block with textType "text"', () => { + const block = createTextBlock('normal text', 'text') + expect(isReasoningTextBlock(block)).toBe(false) + }) + + test('returns false for non-text blocks', () => { + expect(isReasoningTextBlock(createToolBlock('str_replace'))).toBe(false) + expect(isReasoningTextBlock(createImageBlock())).toBe(false) + expect(isReasoningTextBlock(createNonImplementorAgent('a1'))).toBe(false) + }) +}) + +// ============================================================================ +// Tests: processBlocks - Basic Cases +// ============================================================================ + +describe('processBlocks', () => { + describe('basic cases', () => { + test('returns empty array for empty blocks', () => { + const { handlers, calls } = createMockHandlers() + const result = processBlocks([], handlers) + + expect(result).toEqual([]) + expect(calls).toHaveLength(0) + }) + + test('processes single text block with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createTextBlock('hello')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onSingleBlock') + expect((calls[0].args[0] as TextContentBlock).content).toBe('hello') + expect(calls[0].args[1]).toBe(0) + }) + }) + + // ========================================================================== + // Tests: Reasoning Block Grouping + // ========================================================================== + + describe('reasoning block grouping', () => { + test('groups single reasoning block', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createReasoningBlock('thinking')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onReasoningGroup') + expect((calls[0].args[0] as TextContentBlock[]).length).toBe(1) + expect(calls[0].args[1]).toBe(0) + }) + + test('groups consecutive reasoning blocks together', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thought 1'), + createReasoningBlock('thought 2'), + createReasoningBlock('thought 3'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onReasoningGroup') + const reasoningBlocks = calls[0].args[0] as TextContentBlock[] + expect(reasoningBlocks).toHaveLength(3) + expect(reasoningBlocks[0].content).toBe('thought 1') + expect(reasoningBlocks[1].content).toBe('thought 2') + expect(reasoningBlocks[2].content).toBe('thought 3') + }) + + test('separates reasoning groups interrupted by other blocks', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thought 1'), + createTextBlock('response'), + createReasoningBlock('thought 2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['reasoning-0', 'single-1', 'reasoning-2']) + expect(calls).toHaveLength(3) + expect(calls[0].handler).toBe('onReasoningGroup') + expect(calls[1].handler).toBe('onSingleBlock') + expect(calls[2].handler).toBe('onReasoningGroup') + }) + }) + + // ========================================================================== + // Tests: Image Block Handling + // ========================================================================== + + describe('image block handling', () => { + test('handles image block with onImageBlock handler', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createImageBlock('image/png', 'data123')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['image-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImageBlock') + expect((calls[0].args[0] as ImageContentBlock).image).toBe('data123') + expect(calls[0].args[1]).toBe(0) + }) + + test('skips image blocks when onImageBlock is not provided', () => { + const calls: MockCallRecord[] = [] + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + // onImageBlock intentionally omitted + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: (block, index) => { + calls.push({ handler: 'onSingleBlock', args: [block, index] }) + return `single-${index}` + }, + } + + const blocks: ContentBlock[] = [ + createTextBlock('before'), + createImageBlock(), + createTextBlock('after'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-2']) + expect(calls).toHaveLength(2) + // Image at index 1 was skipped, not passed to onSingleBlock + }) + + test('handles multiple consecutive images', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImageBlock('image/png', 'img1'), + createImageBlock('image/jpeg', 'img2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['image-0', 'image-1']) + expect(calls).toHaveLength(2) + expect(calls[0].handler).toBe('onImageBlock') + expect(calls[1].handler).toBe('onImageBlock') + }) + }) + + // ========================================================================== + // Tests: Tool Block Grouping + // ========================================================================== + + describe('tool block grouping', () => { + test('groups single tool block', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [createToolBlock('str_replace', 'tool-1')] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onToolGroup') + expect((calls[0].args[0] as ToolContentBlock[]).length).toBe(1) + expect(calls[0].args[1]).toBe(0) // startIndex + expect(calls[0].args[2]).toBe(1) // nextIndex + }) + + test('groups consecutive tool blocks with correct indices', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onToolGroup') + const toolBlocks = calls[0].args[0] as ToolContentBlock[] + expect(toolBlocks).toHaveLength(3) + expect(toolBlocks[0].toolCallId).toBe('tool-1') + expect(toolBlocks[1].toolCallId).toBe('tool-2') + expect(toolBlocks[2].toolCallId).toBe('tool-3') + expect(calls[0].args[1]).toBe(0) // startIndex + expect(calls[0].args[2]).toBe(3) // nextIndex + }) + + test('separates tool groups interrupted by text', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('middle'), + createToolBlock('write_file', 'tool-2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['tools-0-1', 'single-1', 'tools-2-3']) + expect(calls).toHaveLength(3) + expect(calls[0].handler).toBe('onToolGroup') + expect(calls[0].args[1]).toBe(0) + expect(calls[0].args[2]).toBe(1) + expect(calls[1].handler).toBe('onSingleBlock') + expect(calls[2].handler).toBe('onToolGroup') + expect(calls[2].args[1]).toBe(2) + expect(calls[2].args[2]).toBe(3) + }) + }) + + // ========================================================================== + // Tests: Implementor Agent Grouping + // ========================================================================== + + describe('implementor agent grouping', () => { + test('groups single implementor agent', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'editor-implementor'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['implementors-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImplementorGroup') + }) + + test('groups consecutive implementor agents of different types', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1', 'editor-implementor'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createImplementorAgent('impl-3', 'editor-implementor-gpt-5'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['implementors-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onImplementorGroup') + const implBlocks = calls[0].args[0] as AgentContentBlock[] + expect(implBlocks).toHaveLength(3) + }) + + test('separates implementor groups from non-implementor agents', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-2'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'implementors-0-1', + 'agents-1-2', + 'implementors-2-3', + ]) + expect(calls).toHaveLength(3) + }) + }) + + // ========================================================================== + // Tests: Non-Implementor Agent Grouping + // ========================================================================== + + describe('non-implementor agent grouping', () => { + test('groups single non-implementor agent', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-1']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onAgentGroup') + }) + + test('groups consecutive non-implementor agents', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cmd-1', 'commander'), + createNonImplementorAgent('cs-1', 'code-searcher'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-3']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onAgentGroup') + const agentBlocks = calls[0].args[0] as AgentContentBlock[] + expect(agentBlocks).toHaveLength(3) + expect(agentBlocks[0].agentType).toBe('file-picker') + expect(agentBlocks[1].agentType).toBe('commander') + expect(agentBlocks[2].agentType).toBe('code-searcher') + }) + + test('separates non-implementor groups from other block types', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createTextBlock('commentary'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['agents-0-1', 'single-1', 'agents-2-3']) + expect(calls).toHaveLength(3) + }) + }) + + // ========================================================================== + // Tests: Single Block Fallback + // ========================================================================== + + describe('single block fallback', () => { + test('handles regular text blocks with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createTextBlock('hello'), + createTextBlock('world'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-1']) + expect(calls).toHaveLength(2) + expect(calls[0].handler).toBe('onSingleBlock') + expect(calls[1].handler).toBe('onSingleBlock') + }) + + test('handles html blocks with onSingleBlock', () => { + const { handlers, calls } = createMockHandlers() + const htmlBlock: ContentBlock = { + type: 'html', + render: () => null, + } as ContentBlock + + const blocks: ContentBlock[] = [htmlBlock] + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0']) + expect(calls).toHaveLength(1) + expect(calls[0].handler).toBe('onSingleBlock') + }) + }) + + // ========================================================================== + // Tests: Null Filtering + // ========================================================================== + + describe('null filtering', () => { + test('filters out null returns from handlers', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onImageBlock: () => null, + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: (block, index) => + index % 2 === 0 ? `single-${index}` : null, + } + + const blocks: ContentBlock[] = [ + createTextBlock('keep'), // index 0, should be kept + createTextBlock('skip'), // index 1, should be filtered + createTextBlock('keep'), // index 2, should be kept + createTextBlock('skip'), // index 3, should be filtered + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single-0', 'single-2']) + }) + + test('filters null from reasoning groups', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onToolGroup: () => 'tool', + onImplementorGroup: () => 'impl', + onAgentGroup: () => 'agent', + onSingleBlock: () => 'single', + } + + const blocks: ContentBlock[] = [ + createReasoningBlock('thought'), + createTextBlock('visible'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual(['single']) + }) + + test('filters null from all handler types', () => { + const handlers: BlockProcessorHandlers = { + onReasoningGroup: () => null, + onImageBlock: () => null, + onToolGroup: () => null, + onImplementorGroup: () => null, + onAgentGroup: () => null, + onSingleBlock: () => null, + } + + const blocks: ContentBlock[] = [ + createReasoningBlock('thought'), + createImageBlock(), + createToolBlock('str_replace'), + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1'), + createTextBlock('text'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([]) + }) + }) + + // ========================================================================== + // Tests: Mixed Block Combinations + // ========================================================================== + + describe('mixed block combinations', () => { + test('processes typical message flow', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createReasoningBlock('thinking about the problem'), + createReasoningBlock('considering options'), + createTextBlock('I will search for files first'), + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cs-1', 'code-searcher'), + createTextBlock('Now I will make changes'), + createImplementorAgent('impl-1', 'editor-implementor'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createTextBlock('Changes complete'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'reasoning-0', + 'single-2', + 'agents-3-5', + 'single-5', + 'implementors-6-8', + 'single-8', + ]) + expect(calls).toHaveLength(6) + }) + + test('handles interleaved tools and agents', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createToolBlock('read_files', 'tool-1'), + createToolBlock('code_search', 'tool-2'), + createNonImplementorAgent('fp-1', 'file-picker'), + createToolBlock('str_replace', 'tool-3'), + createImplementorAgent('impl-1'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'tools-0-2', + 'agents-2-3', + 'tools-3-4', + 'implementors-4-5', + ]) + }) + + test('processes complex real-world scenario', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + // Assistant thinking + createReasoningBlock('Let me analyze this...'), + createReasoningBlock('I see the issue'), + // Assistant response with tool usage + createTextBlock('I found the issue. Let me fix it.'), + createToolBlock('str_replace', 'fix-1'), + createToolBlock('str_replace', 'fix-2'), + // More thinking + createReasoningBlock('Checking if more changes needed'), + // Final response + createTextBlock('Done! The bug is fixed.'), + // Image attachment + createImageBlock('image/png', 'screenshot'), + ] + + const result = processBlocks(blocks, handlers) + + expect(result).toEqual([ + 'reasoning-0', + 'single-2', + 'tools-3-5', + 'reasoning-5', + 'single-6', + 'image-7', + ]) + expect(calls).toHaveLength(6) + }) + }) + + // ========================================================================== + // Tests: Index Correctness + // ========================================================================== + + describe('index correctness', () => { + test('maintains correct indices after grouping', () => { + const { handlers, calls } = createMockHandlers() + const blocks: ContentBlock[] = [ + createTextBlock('text at 0'), + createToolBlock('tool-1', 't1'), // group starts at 1 + createToolBlock('tool-2', 't2'), + createToolBlock('tool-3', 't3'), // group ends, nextIndex = 4 + createTextBlock('text at 4'), + createNonImplementorAgent('a1'), // group starts at 5 + createNonImplementorAgent('a2'), // group ends, nextIndex = 7 + createTextBlock('text at 7'), + ] + + processBlocks(blocks, handlers) + + // Verify startIndex and nextIndex for each group + expect(calls[0].args[1]).toBe(0) // single text at 0 + expect(calls[1].args[1]).toBe(1) // tools start at 1 + expect(calls[1].args[2]).toBe(4) // tools next at 4 + expect(calls[2].args[1]).toBe(4) // single text at 4 + expect(calls[3].args[1]).toBe(5) // agents start at 5 + expect(calls[3].args[2]).toBe(7) // agents next at 7 + expect(calls[4].args[1]).toBe(7) // single text at 7 + }) + }) +}) diff --git a/cli/src/utils/block-processor.ts b/cli/src/utils/block-processor.ts new file mode 100644 index 0000000000..daee53888f --- /dev/null +++ b/cli/src/utils/block-processor.ts @@ -0,0 +1,170 @@ +import type { ReactNode } from 'react' + +import { + isImplementorAgent, + groupConsecutiveImplementors, + groupConsecutiveNonImplementorAgents, + groupConsecutiveToolBlocks, +} from './implementor-helpers' +import { isImageBlock } from '../types/chat' +import type { + ContentBlock, + AgentContentBlock, + ToolContentBlock, + TextContentBlock, + ImageContentBlock, +} from '../types/chat' + +/** + * Type guard for reasoning text blocks (thinking blocks) + */ +export function isReasoningTextBlock( + block: ContentBlock, +): block is Extract { + return block.type === 'text' && block.textType === 'reasoning' +} + +/** + * Handler callbacks for processing different block types. + * Each handler receives the block(s) and relevant indices, and returns a ReactNode. + */ +export interface BlockProcessorHandlers { + /** Handle a group of consecutive reasoning text blocks */ + onReasoningGroup: ( + blocks: TextContentBlock[], + startIndex: number, + ) => ReactNode + + /** Handle an image block (optional - if not provided, images are skipped) */ + onImageBlock?: (block: ImageContentBlock, index: number) => ReactNode + + /** Handle a group of consecutive tool blocks */ + onToolGroup: ( + blocks: ToolContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a group of consecutive implementor agent blocks */ + onImplementorGroup: ( + blocks: AgentContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a group of consecutive non-implementor agent blocks */ + onAgentGroup: ( + blocks: AgentContentBlock[], + startIndex: number, + nextIndex: number, + ) => ReactNode + + /** Handle a single block that doesn't fit into any group category */ + onSingleBlock: (block: ContentBlock, index: number) => ReactNode +} + +/** + * Process a list of content blocks, grouping consecutive blocks of the same type + * and calling the appropriate handler for each group or single block. + * + * This utility abstracts the common iteration pattern used by BlocksRenderer and AgentBody. + * + * @param blocks - The array of content blocks to process + * @param handlers - Callback handlers for each block type + * @returns An array of ReactNode elements + */ +export function processBlocks( + blocks: ContentBlock[], + handlers: BlockProcessorHandlers, +): ReactNode[] { + const nodes: ReactNode[] = [] + + for (let i = 0; i < blocks.length; ) { + const block = blocks[i] + + // Handle reasoning text blocks (thinking) + if (isReasoningTextBlock(block)) { + const start = i + const reasoningBlocks: TextContentBlock[] = [] + while (i < blocks.length) { + const currentBlock = blocks[i] + if (!isReasoningTextBlock(currentBlock)) break + reasoningBlocks.push(currentBlock) + i++ + } + + const node = handlers.onReasoningGroup(reasoningBlocks, start) + if (node !== null) { + nodes.push(node) + } + continue + } + + // Handle image blocks + if (isImageBlock(block)) { + if (handlers.onImageBlock) { + const node = handlers.onImageBlock(block, i) + if (node !== null) { + nodes.push(node) + } + } + i++ + continue + } + + // Handle tool blocks + if (block.type === 'tool') { + const start = i + const { group: toolBlocks, nextIndex } = groupConsecutiveToolBlocks( + blocks, + i, + ) + i = nextIndex + + const node = handlers.onToolGroup(toolBlocks, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + continue + } + + // Handle agent blocks + if (block.type === 'agent') { + if (isImplementorAgent(block)) { + // Implementor agents + const start = i + const { group: implementors, nextIndex } = groupConsecutiveImplementors( + blocks, + i, + ) + i = nextIndex + + const node = handlers.onImplementorGroup(implementors, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + } else { + // Non-implementor agents + const start = i + const { group: agentBlocks, nextIndex } = + groupConsecutiveNonImplementorAgents(blocks, i) + i = nextIndex + + const node = handlers.onAgentGroup(agentBlocks, start, nextIndex) + if (node !== null) { + nodes.push(node) + } + } + continue + } + + // Handle all other block types (text, html, etc.) + const node = handlers.onSingleBlock(block, i) + if (node !== null) { + nodes.push(node) + } + i++ + } + + return nodes +} From 3347d68ba6bf51648b8e38ded57d685bf1ac5dfd Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:30:45 -0800 Subject: [PATCH 0006/1143] feat(cli): add useGridLayout hook --- .../hooks/__tests__/use-grid-layout.test.ts | 347 ++++++++++++++++++ cli/src/hooks/use-grid-layout.ts | 50 +++ 2 files changed, 397 insertions(+) create mode 100644 cli/src/hooks/__tests__/use-grid-layout.test.ts create mode 100644 cli/src/hooks/use-grid-layout.ts diff --git a/cli/src/hooks/__tests__/use-grid-layout.test.ts b/cli/src/hooks/__tests__/use-grid-layout.test.ts new file mode 100644 index 0000000000..daf4db53b4 --- /dev/null +++ b/cli/src/hooks/__tests__/use-grid-layout.test.ts @@ -0,0 +1,347 @@ +import { describe, test, expect } from 'bun:test' + +import { + computeGridLayout, + WIDTH_MD_THRESHOLD, + WIDTH_LG_THRESHOLD, + WIDTH_XL_THRESHOLD, +} from '../use-grid-layout' +import { MIN_COLUMN_WIDTH } from '../../utils/layout-helpers' + +describe('computeGridLayout', () => { + describe('threshold constants', () => { + test('thresholds are in ascending order', () => { + expect(WIDTH_MD_THRESHOLD).toBeLessThan(WIDTH_LG_THRESHOLD) + expect(WIDTH_LG_THRESHOLD).toBeLessThan(WIDTH_XL_THRESHOLD) + }) + + test('WIDTH_MD_THRESHOLD is 100', () => { + expect(WIDTH_MD_THRESHOLD).toBe(100) + }) + + test('WIDTH_LG_THRESHOLD is 150', () => { + expect(WIDTH_LG_THRESHOLD).toBe(150) + }) + + test('WIDTH_XL_THRESHOLD is 200', () => { + expect(WIDTH_XL_THRESHOLD).toBe(200) + }) + }) + + describe('maxColumns based on availableWidth', () => { + test('narrow width (< 100) gets 1 column max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 80) + expect(result.columns).toBe(1) + }) + + test('medium width (100-149) gets 2 columns max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 120) + expect(result.columns).toBe(2) + }) + + test('large width (150-199) gets 3 columns max', () => { + const items = ['a', 'b', 'c', 'd', 'e', 'f'] + const result = computeGridLayout(items, 180) + expect(result.columns).toBe(3) + }) + + test('extra large width (>= 200) gets 4 columns max', () => { + const items = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h'] + const result = computeGridLayout(items, 250) + expect(result.columns).toBe(4) + }) + }) + + describe('threshold boundaries', () => { + test('width 99 gives 1 column max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 99) + expect(result.columns).toBe(1) + }) + + test('width 100 gives 2 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 100) + expect(result.columns).toBe(2) + }) + + test('width 149 gives 2 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 149) + expect(result.columns).toBe(2) + }) + + test('width 150 gives 3 columns max', () => { + const items = ['a', 'b', 'c'] + const result = computeGridLayout(items, 150) + expect(result.columns).toBe(3) + }) + + test('width 199 gives 3 columns max (but 4 items prefer 2x2)', () => { + // 4 items with maxColumns=3 prefers 2 columns (2x2 grid) via computeSmartColumns + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 199) + expect(result.columns).toBe(2) + + // 3 items actually uses 3 columns + const threeItems = ['a', 'b', 'c'] + const result3 = computeGridLayout(threeItems, 199) + expect(result3.columns).toBe(3) + }) + + test('width 200 gives 4 columns max', () => { + const items = ['a', 'b', 'c', 'd'] + const result = computeGridLayout(items, 200) + expect(result.columns).toBe(4) + }) + }) + + describe('column count based on item count', () => { + test('0 items gives 1 column', () => { + const result = computeGridLayout([], 200) + expect(result.columns).toBe(1) + }) + + test('1 item gives 1 column', () => { + const result = computeGridLayout(['a'], 200) + expect(result.columns).toBe(1) + }) + + test('2 items on wide screen gives 2 columns', () => { + const result = computeGridLayout(['a', 'b'], 200) + expect(result.columns).toBe(2) + }) + + test('3 items on wide screen gives 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 200) + expect(result.columns).toBe(3) + }) + + test('4 items on 3-column max gives 2 columns (2x2 grid)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 180) + expect(result.columns).toBe(2) + }) + + test('6 items on 3-column max gives 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180) + expect(result.columns).toBe(3) + }) + }) + + describe('columnWidth calculation', () => { + test('single column uses full availableWidth', () => { + const result = computeGridLayout(['a'], 120) + expect(result.columnWidth).toBe(120) + }) + + test('2 columns splits width with 1 char gap', () => { + const result = computeGridLayout(['a', 'b'], 121) + // 121 - 1 gap = 120, divided by 2 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('3 columns splits width with 2 char gaps', () => { + const result = computeGridLayout(['a', 'b', 'c'], 182) + // 182 - 2 gaps = 180, divided by 3 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('4 columns splits width with 3 char gaps', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 243) + // 243 - 3 gaps = 240, divided by 4 = 60 + expect(result.columnWidth).toBe(60) + }) + + test('columnWidth respects MIN_COLUMN_WIDTH', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 200) + expect(result.columnWidth).toBeGreaterThanOrEqual(MIN_COLUMN_WIDTH) + }) + + test('very narrow width with multiple items clamps to MIN_COLUMN_WIDTH', () => { + // Force 2 columns with narrow width + const result = computeGridLayout(['a', 'b'], 105) + // 105 - 1 gap = 104, divided by 2 = 52 + expect(result.columnWidth).toBe(52) + }) + }) + + describe('columnGroups distribution (round-robin)', () => { + test('empty items gives single empty column', () => { + const result = computeGridLayout([], 200) + expect(result.columnGroups).toEqual([[]]) + }) + + test('1 item in 1 column', () => { + const result = computeGridLayout(['a'], 200) + expect(result.columnGroups).toEqual([['a']]) + }) + + test('2 items distributed across 2 columns', () => { + const result = computeGridLayout(['a', 'b'], 200) + expect(result.columnGroups).toEqual([['a'], ['b']]) + }) + + test('3 items distributed across 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 200) + expect(result.columnGroups).toEqual([['a'], ['b'], ['c']]) + }) + + test('4 items in 2 columns (round-robin)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd'], 120) + expect(result.columnGroups).toEqual([ + ['a', 'c'], + ['b', 'd'], + ]) + }) + + test('5 items in 2 columns (uneven distribution)', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e'], 120) + expect(result.columnGroups).toEqual([ + ['a', 'c', 'e'], + ['b', 'd'], + ]) + }) + + test('6 items in 3 columns', () => { + const result = computeGridLayout(['a', 'b', 'c', 'd', 'e', 'f'], 180) + expect(result.columnGroups).toEqual([ + ['a', 'd'], + ['b', 'e'], + ['c', 'f'], + ]) + }) + + test('7 items in 3 columns (uneven)', () => { + const result = computeGridLayout( + ['a', 'b', 'c', 'd', 'e', 'f', 'g'], + 180, + ) + expect(result.columnGroups).toEqual([ + ['a', 'd', 'g'], + ['b', 'e'], + ['c', 'f'], + ]) + }) + }) + + describe('return value structure', () => { + test('returns all expected properties', () => { + const result = computeGridLayout(['a', 'b'], 120) + expect(result).toHaveProperty('columns') + expect(result).toHaveProperty('columnWidth') + expect(result).toHaveProperty('columnGroups') + }) + + test('columns is a positive integer', () => { + const result = computeGridLayout(['a', 'b', 'c'], 150) + expect(Number.isInteger(result.columns)).toBe(true) + expect(result.columns).toBeGreaterThan(0) + }) + + test('columnWidth is a positive number', () => { + const result = computeGridLayout(['a', 'b'], 120) + expect(result.columnWidth).toBeGreaterThan(0) + }) + + test('columnGroups length matches columns', () => { + const result = computeGridLayout(['a', 'b', 'c'], 150) + expect(result.columnGroups.length).toBe(result.columns) + }) + + test('total items in columnGroups equals input items', () => { + const items = ['a', 'b', 'c', 'd', 'e'] + const result = computeGridLayout(items, 120) + const totalItems = result.columnGroups.flat().length + expect(totalItems).toBe(items.length) + }) + }) + + describe('generic type support', () => { + test('works with number items', () => { + const result = computeGridLayout([1, 2, 3, 4], 120) + expect(result.columnGroups).toEqual([ + [1, 3], + [2, 4], + ]) + }) + + test('works with object items', () => { + const items = [{ id: 1 }, { id: 2 }, { id: 3 }] + const result = computeGridLayout(items, 150) + expect(result.columnGroups[0][0]).toEqual({ id: 1 }) + expect(result.columnGroups[1][0]).toEqual({ id: 2 }) + expect(result.columnGroups[2][0]).toEqual({ id: 3 }) + }) + + test('preserves item references', () => { + const obj1 = { id: 1 } + const obj2 = { id: 2 } + const result = computeGridLayout([obj1, obj2], 120) + expect(result.columnGroups[0][0]).toBe(obj1) + expect(result.columnGroups[1][0]).toBe(obj2) + }) + }) + + describe('edge cases', () => { + test('very small availableWidth (< MIN_COLUMN_WIDTH)', () => { + const result = computeGridLayout(['a', 'b'], 5) + expect(result.columns).toBe(1) + expect(result.columnWidth).toBe(5) + }) + + test('zero availableWidth', () => { + const result = computeGridLayout(['a'], 0) + expect(result.columns).toBe(1) + expect(result.columnWidth).toBe(0) + }) + + test('negative availableWidth', () => { + const result = computeGridLayout(['a'], -10) + expect(result.columns).toBe(1) + expect(result.columnWidth).toBe(-10) + }) + + test('large number of items', () => { + const items = Array.from({ length: 100 }, (_, i) => i) + const result = computeGridLayout(items, 250) + expect(result.columns).toBe(4) + expect(result.columnGroups.length).toBe(4) + expect(result.columnGroups.flat().length).toBe(100) + }) + + test('fractional availableWidth is floored for columnWidth', () => { + const result = computeGridLayout(['a', 'b'], 121) + // (121 - 1) / 2 = 60 + expect(result.columnWidth).toBe(60) + }) + }) + + describe('consistency', () => { + test('same input always produces same output', () => { + const items = ['a', 'b', 'c', 'd'] + const width = 150 + + const result1 = computeGridLayout(items, width) + const result2 = computeGridLayout(items, width) + const result3 = computeGridLayout(items, width) + + expect(result1.columns).toBe(result2.columns) + expect(result2.columns).toBe(result3.columns) + expect(result1.columnWidth).toBe(result2.columnWidth) + expect(result1.columnGroups).toEqual(result2.columnGroups) + }) + + test('deterministic across all threshold boundaries', () => { + const items = ['a', 'b', 'c', 'd'] + const boundaries = [99, 100, 149, 150, 199, 200, 250] + + for (const width of boundaries) { + const result1 = computeGridLayout(items, width) + const result2 = computeGridLayout(items, width) + expect(result1.columns).toBe(result2.columns) + } + }) + }) +}) diff --git a/cli/src/hooks/use-grid-layout.ts b/cli/src/hooks/use-grid-layout.ts new file mode 100644 index 0000000000..3ab63b9d7b --- /dev/null +++ b/cli/src/hooks/use-grid-layout.ts @@ -0,0 +1,50 @@ +import { useMemo } from 'react' + +import { computeSmartColumns, MIN_COLUMN_WIDTH } from '../utils/layout-helpers' + +export const WIDTH_MD_THRESHOLD = 100 +export const WIDTH_LG_THRESHOLD = 150 +export const WIDTH_XL_THRESHOLD = 200 + +const WIDTH_THRESHOLDS = [WIDTH_MD_THRESHOLD, WIDTH_LG_THRESHOLD, WIDTH_XL_THRESHOLD] as const + +export interface GridLayoutResult { + columns: number + columnWidth: number + columnGroups: T[][] +} + +export function computeGridLayout( + items: T[], + availableWidth: number, +): GridLayoutResult { + const maxColumns = WIDTH_THRESHOLDS.filter(t => availableWidth >= t).length + 1 + + const columns = computeSmartColumns(items.length, maxColumns) + + let columnWidth: number + if (columns === 1) { + columnWidth = availableWidth + } else { + const totalGap = columns - 1 + const rawWidth = Math.floor((availableWidth - totalGap) / columns) + columnWidth = Math.max(MIN_COLUMN_WIDTH, rawWidth) + } + + const columnGroups: T[][] = Array.from({ length: columns }, () => []) + items.forEach((item, idx) => { + columnGroups[idx % columns].push(item) + }) + + return { columns, columnWidth, columnGroups } +} + +export function useGridLayout( + items: T[], + availableWidth: number, +): GridLayoutResult { + return useMemo( + () => computeGridLayout(items, availableWidth), + [items, availableWidth], + ) +} From 261133b72da106274736d7691d551fb7305c2a89 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:30:53 -0800 Subject: [PATCH 0007/1143] feat(cli): add implementor-helpers utilities --- .../__tests__/implementor-helpers.test.ts | 493 ++++++++++++++++++ cli/src/utils/implementor-helpers.ts | 104 ++-- 2 files changed, 534 insertions(+), 63 deletions(-) diff --git a/cli/src/utils/__tests__/implementor-helpers.test.ts b/cli/src/utils/__tests__/implementor-helpers.test.ts index 97dd00b359..fe1213d975 100644 --- a/cli/src/utils/__tests__/implementor-helpers.test.ts +++ b/cli/src/utils/__tests__/implementor-helpers.test.ts @@ -10,6 +10,10 @@ import { isImplementorAgent, getImplementorDisplayName, getImplementorIndex, + groupConsecutiveBlocks, + groupConsecutiveImplementors, + groupConsecutiveNonImplementorAgents, + groupConsecutiveToolBlocks, } from '../implementor-helpers' import type { ToolContentBlock, ContentBlock, AgentContentBlock, TextContentBlock } from '../../types/chat' @@ -396,3 +400,492 @@ describe('getImplementorIndex', () => { expect(getImplementorIndex(filePicker, siblings)).toBeUndefined() }) }) + +describe('groupConsecutiveBlocks', () => { + const createTextBlock = (content: string): TextContentBlock => ({ + type: 'text', + content, + } as TextContentBlock) + + const createToolBlock = (toolName: string): ToolContentBlock => ({ + type: 'tool', + toolCallId: `tool-${toolName}`, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + }) + + const createAgentBlock = (agentType: string, agentId: string): AgentContentBlock => ({ + type: 'agent', + agentId, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + test('groups consecutive matching blocks from start', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createTextBlock('text2'), + createToolBlock('str_replace'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(2) + expect(result.group[0].content).toBe('text1') + expect(result.group[1].content).toBe('text2') + expect(result.nextIndex).toBe(2) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createToolBlock('read_files'), + createTextBlock('text1'), + createTextBlock('text2'), + createTextBlock('text3'), + createToolBlock('write_file'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 1, isText) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(4) + }) + + test('returns empty group when first block does not match', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace'), + createTextBlock('text1'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles empty blocks array', () => { + const blocks: ContentBlock[] = [] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles startIndex at end of array', () => { + const blocks: ContentBlock[] = [createTextBlock('text1')] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 1, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(1) + }) + + test('handles startIndex beyond array length', () => { + const blocks: ContentBlock[] = [createTextBlock('text1')] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 10, isText) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(10) + }) + + test('groups all blocks when all match', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createTextBlock('text2'), + createTextBlock('text3'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('groups single matching block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('text1'), + createToolBlock('str_replace'), + ] + const isText = (b: ContentBlock): b is TextContentBlock => b.type === 'text' + const result = groupConsecutiveBlocks(blocks, 0, isText) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('works with complex predicates', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace'), + createToolBlock('write_file'), + createToolBlock('read_files'), + createTextBlock('done'), + ] + const isEditTool = (b: ContentBlock): b is ToolContentBlock => + b.type === 'tool' && ['str_replace', 'write_file'].includes(b.toolName as string) + const result = groupConsecutiveBlocks(blocks, 0, isEditTool) + + expect(result.group).toHaveLength(2) + expect(result.group[0].toolName).toBe('str_replace') + expect(result.group[1].toolName).toBe('write_file') + expect(result.nextIndex).toBe(2) + }) +}) + +describe('groupConsecutiveImplementors', () => { + const createImplementorAgent = (id: string, agentType = 'editor-implementor'): AgentContentBlock => ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + const createNonImplementorAgent = (id: string, agentType: string): AgentContentBlock => ({ + type: 'agent', + agentId: id, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + const createTextBlock = (content: string): TextContentBlock => ({ + type: 'text', + content, + } as TextContentBlock) + + test('groups consecutive implementor agents', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createImplementorAgent('impl-2', 'editor-implementor-opus'), + createImplementorAgent('impl-3', 'editor-implementor-gpt-5'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].agentId).toBe('impl-1') + expect(result.group[1].agentId).toBe('impl-2') + expect(result.group[2].agentId).toBe('impl-3') + expect(result.nextIndex).toBe(3) + }) + + test('stops at non-implementor agent', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('cmd-1', 'commander'), + createImplementorAgent('impl-2'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at non-agent block', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createTextBlock('some text'), + createImplementorAgent('impl-2'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at non-implementor', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('handles agents with proposed tools as implementors', () => { + const agentWithProposedTools: AgentContentBlock = { + type: 'agent', + agentId: 'custom-1', + agentName: 'Custom Agent', + agentType: 'custom-agent', + content: '', + status: 'complete', + blocks: [ + { + type: 'tool', + toolCallId: 'tool-1', + toolName: 'propose_str_replace', + input: {}, + }, + ], + } as AgentContentBlock + + const blocks: ContentBlock[] = [ + agentWithProposedTools, + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveImplementors(blocks, 0) + + expect(result.group).toHaveLength(2) + expect(result.group[0].agentId).toBe('custom-1') + expect(result.group[1].agentId).toBe('impl-1') + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveImplementors([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) +}) + +describe('groupConsecutiveNonImplementorAgents', () => { + const createImplementorAgent = (id: string): AgentContentBlock => ({ + type: 'agent', + agentId: id, + agentName: 'Implementor', + agentType: 'editor-implementor', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + const createNonImplementorAgent = (id: string, agentType: string): AgentContentBlock => ({ + type: 'agent', + agentId: id, + agentName: agentType, + agentType, + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + const createTextBlock = (content: string): TextContentBlock => ({ + type: 'text', + content, + } as TextContentBlock) + + test('groups consecutive non-implementor agents', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cmd-1', 'commander'), + createNonImplementorAgent('cs-1', 'code-searcher'), + createImplementorAgent('impl-1'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].agentType).toBe('file-picker') + expect(result.group[1].agentType).toBe('commander') + expect(result.group[2].agentType).toBe('code-searcher') + expect(result.nextIndex).toBe(3) + }) + + test('stops at implementor agent', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createImplementorAgent('impl-1'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at non-agent block', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createTextBlock('some text'), + createNonImplementorAgent('cmd-1', 'commander'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at implementor', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('returns empty group when starting at text block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('some text'), + createNonImplementorAgent('fp-1', 'file-picker'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createImplementorAgent('impl-1'), + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('cmd-1', 'commander'), + createTextBlock('done'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 1) + + expect(result.group).toHaveLength(2) + expect(result.group[0].agentType).toBe('file-picker') + expect(result.group[1].agentType).toBe('commander') + expect(result.nextIndex).toBe(3) + }) + + test('handles mixed agent types', () => { + const blocks: ContentBlock[] = [ + createNonImplementorAgent('fp-1', 'file-picker'), + createNonImplementorAgent('think-1', 'thinker'), + createNonImplementorAgent('rev-1', 'reviewer'), + ] + const result = groupConsecutiveNonImplementorAgents(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveNonImplementorAgents([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) +}) + +describe('groupConsecutiveToolBlocks', () => { + const createToolBlock = (toolName: string, id: string): ToolContentBlock => ({ + type: 'tool', + toolCallId: id, + toolName: toolName as ToolContentBlock['toolName'], + input: {}, + }) + + const createTextBlock = (content: string): TextContentBlock => ({ + type: 'text', + content, + } as TextContentBlock) + + const createAgentBlock = (id: string): AgentContentBlock => ({ + type: 'agent', + agentId: id, + agentName: 'Test Agent', + agentType: 'file-picker', + content: '', + status: 'complete', + blocks: [], + } as AgentContentBlock) + + test('groups consecutive tool blocks', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + createTextBlock('done'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.group[0].toolCallId).toBe('tool-1') + expect(result.group[1].toolCallId).toBe('tool-2') + expect(result.group[2].toolCallId).toBe('tool-3') + expect(result.nextIndex).toBe(3) + }) + + test('stops at non-tool block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('some text'), + createToolBlock('write_file', 'tool-2'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('stops at agent block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createAgentBlock('agent-1'), + createToolBlock('write_file', 'tool-2'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) + + test('returns empty group when starting at non-tool block', () => { + const blocks: ContentBlock[] = [ + createTextBlock('some text'), + createToolBlock('str_replace', 'tool-1'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups from middle of array', () => { + const blocks: ContentBlock[] = [ + createTextBlock('start'), + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createTextBlock('end'), + ] + const result = groupConsecutiveToolBlocks(blocks, 1) + + expect(result.group).toHaveLength(2) + expect(result.group[0].toolCallId).toBe('tool-1') + expect(result.group[1].toolCallId).toBe('tool-2') + expect(result.nextIndex).toBe(3) + }) + + test('handles empty blocks array', () => { + const result = groupConsecutiveToolBlocks([], 0) + expect(result.group).toHaveLength(0) + expect(result.nextIndex).toBe(0) + }) + + test('groups all tool blocks when all match', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createToolBlock('write_file', 'tool-2'), + createToolBlock('read_files', 'tool-3'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(3) + expect(result.nextIndex).toBe(3) + }) + + test('handles single tool block', () => { + const blocks: ContentBlock[] = [ + createToolBlock('str_replace', 'tool-1'), + createTextBlock('done'), + ] + const result = groupConsecutiveToolBlocks(blocks, 0) + + expect(result.group).toHaveLength(1) + expect(result.nextIndex).toBe(1) + }) +}) diff --git a/cli/src/utils/implementor-helpers.ts b/cli/src/utils/implementor-helpers.ts index cc031f3596..5bfaf9dfbb 100644 --- a/cli/src/utils/implementor-helpers.ts +++ b/cli/src/utils/implementor-helpers.ts @@ -11,8 +11,8 @@ export const IMPLEMENTOR_AGENT_IDS = [ 'editor-implementor-gpt-5', ] as const -// Edit tool names that count as edits (proposed versions too) -const PROPOSED_EDIT_TOOL_NAMES = ['propose_str_replace', 'propose_write_file'] as const +const EDIT_TOOL_NAMES = ['str_replace', 'write_file'] as const +const PROPOSED_EDIT_TOOL_NAMES = EDIT_TOOL_NAMES.map(n => `propose_${n}` as const) const isProposedToolName = (toolName: ToolContentBlock['toolName']): boolean => typeof toolName === 'string' && toolName.startsWith('propose_') @@ -28,10 +28,6 @@ const hasProposedTools = (blocks?: ContentBlock[]): boolean => { ) } -/** - * Check if an agent is an implementor agent - * These agents are rendered differently (as simple status lines instead of full agent blocks) - */ export const isImplementorAgent = ( agentBlock: Pick, ): boolean => { @@ -42,9 +38,6 @@ export const isImplementorAgent = ( return IMPLEMENTOR_AGENT_IDS.some((id) => agentBlock.agentType.includes(id)) } -/** - * Get the display name for an implementor agent - */ export const getImplementorDisplayName = ( agentType: string, index?: number, @@ -66,10 +59,6 @@ export const getImplementorDisplayName = ( return baseName } -/** - * Get the index of an implementor agent among its siblings - * Returns the 0-based index among all implementor agents of the same type - */ export const getImplementorIndex = ( currentAgent: AgentContentBlock, siblingBlocks: ContentBlock[], @@ -95,20 +84,17 @@ export const getImplementorIndex = ( ) } -/** - * Group consecutive implementor agents from a blocks array - * Returns the group of implementors and the next index to process - */ -export function groupConsecutiveImplementors( +export function groupConsecutiveBlocks( blocks: ContentBlock[], startIndex: number, -): { group: AgentContentBlock[]; nextIndex: number } { - const group: AgentContentBlock[] = [] + predicate: (block: ContentBlock) => block is T, +): { group: T[]; nextIndex: number } { + const group: T[] = [] let i = startIndex while (i < blocks.length) { const block = blocks[i] - if (block.type !== 'agent' || !isImplementorAgent(block)) { + if (!predicate(block)) { break } group.push(block) @@ -118,16 +104,43 @@ export function groupConsecutiveImplementors( return { group, nextIndex: i } } -// Edit tool names that count as edits -const EDIT_TOOL_NAMES = ['str_replace', 'write_file'] as const +export function groupConsecutiveImplementors( + blocks: ContentBlock[], + startIndex: number, +): { group: AgentContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is AgentContentBlock => + block.type === 'agent' && isImplementorAgent(block), + ) +} + +export function groupConsecutiveNonImplementorAgents( + blocks: ContentBlock[], + startIndex: number, +): { group: AgentContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is AgentContentBlock => + block.type === 'agent' && !isImplementorAgent(block), + ) +} + +export function groupConsecutiveToolBlocks( + blocks: ContentBlock[], + startIndex: number, +): { group: ToolContentBlock[]; nextIndex: number } { + return groupConsecutiveBlocks( + blocks, + startIndex, + (block): block is ToolContentBlock => block.type === 'tool', + ) +} -// All edit tool names (executed and proposed) const ALL_EDIT_TOOL_NAMES = [...EDIT_TOOL_NAMES, ...PROPOSED_EDIT_TOOL_NAMES] as const -/** - * Extract a value for a key from tool output (key: value format) - * Supports multi-line values with pipe delimiter - */ export function extractValueForKey(output: string, key: string): string | null { if (!output) return null const lines = output.split('\n') @@ -162,9 +175,6 @@ export function extractValueForKey(output: string, key: string): string | null { return null } -/** - * Extract file path from tool block - */ export function extractFilePath(toolBlock: ToolContentBlock): string | null { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' const input = toolBlock.input as Record @@ -176,11 +186,6 @@ export function extractFilePath(toolBlock: ToolContentBlock): string | null { ) } -/** - * Extract unified diff from tool output, or construct from input - * For executed tools: use outputRaw/output with unifiedDiff - * For proposed tools (implementors): construct diff from input replacements - */ export function extractDiff(toolBlock: ToolContentBlock): string | null { // First try to get from outputRaw (for executed tool results) // outputRaw is typically an array like [{type: "json", value: {unifiedDiff: "..."}}] @@ -232,9 +237,6 @@ export function extractDiff(toolBlock: ToolContentBlock): string | null { return null } -/** - * Construct a simple diff view from str_replace replacements - */ function constructDiffFromReplacements( replacements: { old: string; new: string }[], ): string { @@ -260,17 +262,11 @@ function constructDiffFromReplacements( return lines.join('\n') } -/** - * Construct a diff view from write_file content - */ function constructDiffFromWriteFile(content: string): string { const lines = content.split('\n') return lines.map((line) => `+ ${line}`).join('\n') } -/** - * Check if a tool is a "create new file" operation - */ export function isCreateFile(toolBlock: ToolContentBlock): boolean { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' const message = extractValueForKey(outputStr, 'message') @@ -303,9 +299,6 @@ export interface FileStats { stats: DiffStats } -/** - * Parse diff text and extract statistics - */ export function parseDiffStats(diff: string | undefined): DiffStats { if (!diff) return { linesAdded: 0, linesRemoved: 0, hunks: 0 } @@ -337,9 +330,6 @@ export function parseDiffStats(diff: string | undefined): DiffStats { return { linesAdded, linesRemoved, hunks } } -/** - * Determine file change type based on tool and context - */ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { const baseToolName = getBaseToolName(toolBlock.toolName) // write_file creating new file = Added @@ -357,10 +347,6 @@ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { return 'M' } -/** - * Get aggregated file stats from all edit blocks - * Groups by file path and sums up the stats - */ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): FileStats[] { if (!blocks || blocks.length === 0) return [] @@ -397,11 +383,6 @@ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): File return Array.from(fileMap.values()) } -/** - * Build an activity timeline from agent blocks - * Interleaves commentary (text blocks) and edits (tool calls) - * Includes both executed tools (str_replace, write_file) and proposed tools - */ export function buildActivityTimeline( blocks: ContentBlock[] | undefined, ): TimelineItem[] { @@ -435,9 +416,6 @@ export function buildActivityTimeline( return timeline } -/** - * Truncate text to fit within maxWidth, adding ellipsis if needed - */ export function truncateWithEllipsis(text: string, maxWidth: number): string { if (text.length <= maxWidth) return text if (maxWidth <= 3) return text.slice(0, maxWidth) From e3e0c45fe6504d4bc0fcfd71febcc2108196f271 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:02 -0800 Subject: [PATCH 0008/1143] feat(cli): add GridLayout and ErrorBoundary components --- .../components/__tests__/grid-layout.test.tsx | 628 ++++++++++++++++++ cli/src/components/error-boundary.tsx | 44 ++ cli/src/components/grid-layout.tsx | 97 +++ 3 files changed, 769 insertions(+) create mode 100644 cli/src/components/__tests__/grid-layout.test.tsx create mode 100644 cli/src/components/error-boundary.tsx create mode 100644 cli/src/components/grid-layout.tsx diff --git a/cli/src/components/__tests__/grid-layout.test.tsx b/cli/src/components/__tests__/grid-layout.test.tsx new file mode 100644 index 0000000000..243ca0ddc8 --- /dev/null +++ b/cli/src/components/__tests__/grid-layout.test.tsx @@ -0,0 +1,628 @@ +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { GridLayout } from '../grid-layout' + +interface TestItem { + id: string + name: string +} + +const createTestItem = (id: string, name: string): TestItem => ({ id, name }) + +const defaultGetItemKey = (item: TestItem): string => item.id + +const defaultRenderItem = ( + item: TestItem, + _idx: number, + _columnWidth: number, +): React.ReactNode => {item.name} + +describe('GridLayout', () => { + describe('empty state', () => { + test('returns null for empty items array', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBe('') + }) + }) + + describe('single item rendering', () => { + test('renders a single item', () => { + const items = [createTestItem('item-1', 'First Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('First Item') + }) + + test('uses single column layout for one item', () => { + const items = [createTestItem('item-1', 'Only Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Only Item') + }) + }) + + describe('multiple items rendering', () => { + test('renders all items', () => { + const items = [ + createTestItem('item-1', 'Item One'), + createTestItem('item-2', 'Item Two'), + createTestItem('item-3', 'Item Three'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item One') + expect(markup).toContain('Item Two') + expect(markup).toContain('Item Three') + }) + + test('renders items in correct order', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + + const markup = renderToStaticMarkup( + , + ) + + const alphaPos = markup.indexOf('Alpha') + const betaPos = markup.indexOf('Beta') + const gammaPos = markup.indexOf('Gamma') + + expect(alphaPos).toBeLessThan(betaPos) + expect(betaPos).toBeLessThan(gammaPos) + }) + }) + + describe('getItemKey function', () => { + test('uses getItemKey for React keys', () => { + const items = [ + createTestItem('unique-key-1', 'Item 1'), + createTestItem('unique-key-2', 'Item 2'), + ] + + const markup = renderToStaticMarkup( + `custom-${item.id}`} + renderItem={defaultRenderItem} + />, + ) + + expect(markup).toContain('Item 1') + expect(markup).toContain('Item 2') + }) + + test('handles numeric keys', () => { + interface NumericItem { + index: number + label: string + } + + const items: NumericItem[] = [ + { index: 0, label: 'Zero' }, + { index: 1, label: 'One' }, + ] + + const markup = renderToStaticMarkup( + String(item.index)} + renderItem={(item) => {item.label}} + />, + ) + + expect(markup).toContain('Zero') + expect(markup).toContain('One') + }) + }) + + describe('renderItem function', () => { + test('passes correct item to renderItem', () => { + const items = [createTestItem('test-id', 'Test Name')] + const renderedItems: TestItem[] = [] + + renderToStaticMarkup( + { + renderedItems.push(item) + return {item.name} + }} + />, + ) + + expect(renderedItems).toHaveLength(1) + expect(renderedItems[0]).toEqual({ id: 'test-id', name: 'Test Name' }) + }) + + test('passes correct index to renderItem', () => { + const items = [ + createTestItem('a', 'A'), + createTestItem('b', 'B'), + createTestItem('c', 'C'), + ] + const indices: number[] = [] + + renderToStaticMarkup( + { + indices.push(idx) + return {item.name} + }} + />, + ) + + expect(indices).toEqual([0, 1, 2]) + }) + + test('passes columnWidth to renderItem for single column', () => { + const items = [createTestItem('a', 'A')] + const widths: number[] = [] + + renderToStaticMarkup( + { + widths.push(width) + return {item.name} + }} + />, + ) + + expect(widths[0]).toBe(120) + }) + + test('passes calculated columnWidth to renderItem for multi-column', () => { + const items = [ + createTestItem('a', 'A'), + createTestItem('b', 'B'), + ] + const widths: number[] = [] + + renderToStaticMarkup( + { + widths.push(width) + return {item.name} + }} + />, + ) + + // 2 columns: (121 - 1 gap) / 2 = 60 + expect(widths[0]).toBe(60) + expect(widths[1]).toBe(60) + }) + }) + + describe('footer prop', () => { + test('renders footer when provided', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + Footer Content} + />, + ) + + expect(markup).toContain('Footer Content') + }) + + test('renders footer after items in single column', () => { + const items = [createTestItem('item-1', 'Main Item')] + + const markup = renderToStaticMarkup( + The Footer} + />, + ) + + const itemPos = markup.indexOf('Main Item') + const footerPos = markup.indexOf('The Footer') + + expect(itemPos).toBeLessThan(footerPos) + }) + + test('renders footer after items in multi-column', () => { + const items = [ + createTestItem('a', 'Item A'), + createTestItem('b', 'Item B'), + ] + + const markup = renderToStaticMarkup( + Multi-col Footer} + />, + ) + + expect(markup).toContain('Item A') + expect(markup).toContain('Item B') + expect(markup).toContain('Multi-col Footer') + }) + + test('does not render footer when not provided', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).not.toContain('Footer') + }) + + test('renders complex footer elements', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + + Status: + Complete + + } + />, + ) + + expect(markup).toContain('Status:') + expect(markup).toContain('Complete') + }) + }) + + describe('marginTop prop', () => { + test('applies default marginTop of 0', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBeDefined() + }) + + test('applies custom marginTop', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item') + }) + }) + + describe('column layout based on width', () => { + test('narrow width (< 100) uses single column', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Alpha') + expect(markup).toContain('Beta') + expect(markup).toContain('Gamma') + }) + + test('medium width (100-149) uses up to 2 columns', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Alpha') + expect(markup).toContain('Beta') + }) + + test('large width (150-199) uses up to 3 columns', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Alpha') + expect(markup).toContain('Beta') + expect(markup).toContain('Gamma') + }) + + test('extra large width (>= 200) uses up to 4 columns', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + createTestItem('d', 'Delta'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Alpha') + expect(markup).toContain('Beta') + expect(markup).toContain('Gamma') + expect(markup).toContain('Delta') + }) + }) + + describe('generic type support', () => { + test('works with string items', () => { + const items = ['one', 'two', 'three'] + + const markup = renderToStaticMarkup( + item} + renderItem={(item) => {item.toUpperCase()}} + />, + ) + + expect(markup).toContain('ONE') + expect(markup).toContain('TWO') + expect(markup).toContain('THREE') + }) + + test('works with number items', () => { + const items = [1, 2, 3] + + const markup = renderToStaticMarkup( + String(item)} + renderItem={(item) => Number: {item}} + />, + ) + + expect(markup).toContain('Number: 1') + expect(markup).toContain('Number: 2') + expect(markup).toContain('Number: 3') + }) + + test('works with complex object items', () => { + interface ComplexItem { + id: string + data: { + title: string + count: number + } + } + + const items: ComplexItem[] = [ + { id: 'c1', data: { title: 'First', count: 10 } }, + { id: 'c2', data: { title: 'Second', count: 20 } }, + ] + + const markup = renderToStaticMarkup( + item.id} + renderItem={(item) => ( + + {item.data.title}: {item.data.count} + + )} + />, + ) + + expect(markup).toContain('First: 10') + expect(markup).toContain('Second: 20') + }) + }) + + describe('edge cases', () => { + test('handles very narrow width', () => { + const items = [createTestItem('item-1', 'Narrow')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Narrow') + }) + + test('handles many items', () => { + const items = Array.from({ length: 50 }, (_, i) => + createTestItem(`item-${i}`, `Item ${i}`), + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item 0') + expect(markup).toContain('Item 49') + }) + + test('handles items with special characters in names', () => { + const items = [ + createTestItem('special-1', ''), + createTestItem('special-2', 'Item & More'), + ] + + const markup = renderToStaticMarkup( + , + ) + + // React escapes HTML entities + expect(markup).toContain('<script>') + expect(markup).toContain('&') + }) + + test('handles undefined footer gracefully', () => { + const items = [createTestItem('item-1', 'Item')] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item') + }) + }) + + describe('memoization', () => { + test('component is memoized', () => { + // MasonryGrid is wrapped in memo(), verify it renders consistently + const items = [createTestItem('memo-test', 'Memoized')] + + const markup1 = renderToStaticMarkup( + , + ) + + const markup2 = renderToStaticMarkup( + , + ) + + expect(markup1).toBe(markup2) + }) + }) +}) diff --git a/cli/src/components/error-boundary.tsx b/cli/src/components/error-boundary.tsx new file mode 100644 index 0000000000..040d8c68de --- /dev/null +++ b/cli/src/components/error-boundary.tsx @@ -0,0 +1,44 @@ +import { memo, type ReactNode } from 'react' + +interface ErrorBoundaryProps { + children: ReactNode + fallback: ReactNode + componentName?: string +} + +/** + * A wrapper component that provides error boundary-like behavior. + * Since OpenTUI's JSX types don't support React class components, + * this uses a memo wrapper. Errors that occur during render will + * be caught by React's error boundary mechanism if one exists higher + * in the tree, or will propagate normally. + * + * For true error boundary behavior in OpenTUI, wrap at the application + * root level using React's native error boundary support. + */ +export const ErrorBoundary = memo( + ({ children, fallback, componentName }: ErrorBoundaryProps) => { + // Note: This is a structural wrapper. True error catching requires + // a class component, but OpenTUI's JSX types don't support them. + // The fallback is available for parent components to use when they + // detect errors through other means. + return <>{children} + }, +) + +/** + * Helper to safely render content with error handling. + * Use this when you need to catch render errors in a functional context. + */ +export function withErrorFallback( + renderFn: () => T, + fallback: T, + componentName?: string, +): T { + try { + return renderFn() + } catch (error) { + console.error(`[${componentName ?? 'withErrorFallback'}] Error caught:`, error) + return fallback + } +} diff --git a/cli/src/components/grid-layout.tsx b/cli/src/components/grid-layout.tsx new file mode 100644 index 0000000000..1897782f6d --- /dev/null +++ b/cli/src/components/grid-layout.tsx @@ -0,0 +1,97 @@ +import React, { memo, type ReactNode } from 'react' + +import { useGridLayout } from '../hooks/use-grid-layout' + +export interface GridLayoutProps { + items: T[] + availableWidth: number + getItemKey: (item: T) => string + renderItem: (item: T, index: number, columnWidth: number) => ReactNode + footer?: ReactNode + marginTop?: number +} + +function GridLayoutInner({ + items, + availableWidth, + getItemKey, + renderItem, + footer, + marginTop = 0, +}: GridLayoutProps): ReactNode { + const { columns, columnWidth, columnGroups } = useGridLayout(items, availableWidth) + + if (items.length === 0) return null + + // Single column layout + if (columns === 1) { + return ( + + + {items.map((item, idx) => ( + + {renderItem(item, idx, availableWidth)} + + ))} + + {footer} + + ) + } + + // Multi-column layout + return ( + + + {columnGroups.map((columnItems, colIdx) => { + const columnKey = columnItems[0] + ? getItemKey(columnItems[0]) + : `col-${colIdx}` + return ( + + {columnItems.map((item, idx) => ( + + {renderItem(item, idx, columnWidth)} + + ))} + + ) + })} + + {footer} + + ) +} + +export const GridLayout = memo(GridLayoutInner) as typeof GridLayoutInner From 8656f77ac86bd98a598dcc1dca88480077a097a2 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:26 -0800 Subject: [PATCH 0009/1143] feat(cli): add agent-block-grid and tool-block-group components --- .../components/__tests__/agent-grid.test.tsx | 567 ++++++++++++++++++ cli/src/components/agent-block-grid.tsx | 93 +++ cli/src/components/tool-block-group.tsx | 97 +++ 3 files changed, 757 insertions(+) create mode 100644 cli/src/components/__tests__/agent-grid.test.tsx create mode 100644 cli/src/components/agent-block-grid.tsx create mode 100644 cli/src/components/tool-block-group.tsx diff --git a/cli/src/components/__tests__/agent-grid.test.tsx b/cli/src/components/__tests__/agent-grid.test.tsx new file mode 100644 index 0000000000..6e6fc2776a --- /dev/null +++ b/cli/src/components/__tests__/agent-grid.test.tsx @@ -0,0 +1,567 @@ +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' +import { MessageBlock } from '../message-block' +import { MessageWithAgents } from '../message-with-agents' + +import type { MarkdownPalette } from '../../utils/markdown-renderer' +import type { AgentContentBlock, ContentBlock, ChatMessage } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.dark +const basePalette = createMarkdownPalette(theme) + +const palette: MarkdownPalette = { + ...basePalette, + inlineCodeFg: theme.foreground, + codeTextFg: theme.foreground, +} + +const createAgentBlock = ( + agentId: string, + agentName: string, + agentType: string, + status: 'running' | 'complete' | 'failed' = 'complete', +): AgentContentBlock => ({ + type: 'agent', + agentId, + agentName, + agentType, + content: `Content for ${agentName}`, + status, + blocks: [], +}) + +const createImplementorAgent = ( + agentId: string, + index: number, +): AgentContentBlock => ({ + type: 'agent', + agentId, + agentName: `Implementor ${index}`, + agentType: 'editor-implementor', + content: '', + status: 'complete', + blocks: [ + { + type: 'tool', + toolCallId: `tool-${agentId}`, + toolName: 'propose_str_replace', + input: { path: 'file.ts', replacements: [{ old: 'a', new: 'b' }] }, + }, + ], +}) + +const baseMessageBlockProps = { + messageId: 'test-message', + content: '', + isUser: false, + isAi: true, + isLoading: false, + timestamp: '12:00', + isComplete: true, + completionTime: undefined, + credits: undefined, + timerStartTime: null, + textColor: theme.foreground, + timestampColor: theme.muted, + markdownOptions: { + codeBlockWidth: 72, + palette, + }, + availableWidth: 120, + markdownPalette: basePalette, + collapsedAgents: new Set(), + autoCollapsedAgents: new Set(), + streamingAgents: new Set(), + onToggleCollapsed: () => {}, + onBuildFast: () => {}, + onBuildMax: () => {}, + setCollapsedAgents: () => {}, + addAutoCollapsedAgent: () => {}, +} + +const createAgentMessage = ( + id: string, + agentName: string, + parentId?: string, +): ChatMessage => ({ + id, + variant: 'agent', + content: `Agent ${agentName} content`, + timestamp: '12:00', + isComplete: true, + agent: { + agentName, + agentType: 'file-picker', + responseCount: 0, + }, + parentId, +}) + +const baseMessageWithAgentsProps = { + depth: 0, + isLastMessage: false, + theme, + markdownPalette: basePalette, + streamingAgents: new Set(), + messages: [] as ChatMessage[], + availableWidth: 120, + setFocusedAgentId: () => {}, + isWaitingForResponse: false, + timerStartTime: null, + onToggleCollapsed: () => {}, + onBuildFast: () => {}, + onBuildMax: () => {}, + onFeedback: () => {}, + onCloseFeedback: () => {}, +} + +describe('AgentBlockGrid (via MessageBlock)', () => { + describe('single agent rendering', () => { + test('renders a single agent without header', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('File Picker') + // Single agent should not show "1 agent completed" header + expect(markup).not.toContain('1 agent') + }) + }) + + describe('multiple agents rendering', () => { + test('renders multiple agents with count header', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker'), + createAgentBlock('agent-2', 'Code Searcher', 'code-searcher'), + createAgentBlock('agent-3', 'Commander', 'commander'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('File Picker') + expect(markup).toContain('Code Searcher') + expect(markup).toContain('Commander') + expect(markup).toContain('3 agents completed') + }) + + test('shows running count when agents are running', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker', 'running'), + createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'running'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('2 agents running') + }) + + test('shows running when at least one agent is running', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker', 'complete'), + createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'running'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('2 agents running') + }) + + test('shows running when agent is in streamingAgents set', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker', 'complete'), + createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'complete'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('2 agents running') + }) + }) + + describe('implementor agents (should use ImplementorGroup instead)', () => { + test('renders implementor agents separately from regular agents', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker', 'file-picker'), + createImplementorAgent('impl-1', 1), + createImplementorAgent('impl-2', 2), + ] + + const markup = renderToStaticMarkup( + , + ) + + // Regular agent should be rendered + expect(markup).toContain('File Picker') + // Implementor agents should be grouped separately and show model names + // ImplementorGroup renders "Sonnet #1", "Sonnet #2" etc. for editor-implementor agents + expect(markup).toContain('Sonnet') + }) + }) + + describe('mixed block types', () => { + test('renders agents interspersed with text blocks', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'Before agents' }, + createAgentBlock('agent-1', 'File Picker', 'file-picker'), + createAgentBlock('agent-2', 'Code Searcher', 'code-searcher'), + { type: 'text', content: 'After agents' }, + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Before agents') + expect(markup).toContain('File Picker') + expect(markup).toContain('Code Searcher') + expect(markup).toContain('After agents') + expect(markup).toContain('2 agents completed') + }) + + test('groups only consecutive non-implementor agents', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'File Picker 1', 'file-picker'), + createAgentBlock('agent-2', 'File Picker 2', 'file-picker'), + { type: 'text', content: 'Separator' }, + createAgentBlock('agent-3', 'Commander', 'commander'), + ] + + const markup = renderToStaticMarkup( + , + ) + + // First group of 2 agents + expect(markup).toContain('2 agents completed') + // Single agent after separator shouldn't have header + expect(markup).toContain('Commander') + }) + }) + + describe('empty and edge cases', () => { + test('handles empty blocks array', () => { + const markup = renderToStaticMarkup( + , + ) + + // Should render without errors + expect(markup).toBeDefined() + }) + + test('handles blocks with no agents', () => { + const blocks: ContentBlock[] = [ + { type: 'text', content: 'Just text' }, + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Just text') + expect(markup).not.toContain('agent') + }) + }) +}) + +describe('AgentChildrenGrid (via MessageWithAgents)', () => { + describe('single child agent', () => { + test('renders a single child agent', () => { + const parentMessage: ChatMessage = { + id: 'parent-1', + variant: 'ai', + content: 'Parent message', + timestamp: '12:00', + isComplete: true, + } + + const childAgent = createAgentMessage('child-1', 'Child Agent', 'parent-1') + + const messageTree = new Map([ + ['parent-1', [childAgent]], + ]) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Child Agent') + }) + }) + + describe('multiple child agents', () => { + test('renders multiple child agents', () => { + const parentMessage: ChatMessage = { + id: 'parent-1', + variant: 'ai', + content: 'Parent message', + timestamp: '12:00', + isComplete: true, + } + + const children = [ + createAgentMessage('child-1', 'Agent One', 'parent-1'), + createAgentMessage('child-2', 'Agent Two', 'parent-1'), + createAgentMessage('child-3', 'Agent Three', 'parent-1'), + ] + + const messageTree = new Map([ + ['parent-1', children], + ]) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Agent One') + expect(markup).toContain('Agent Two') + expect(markup).toContain('Agent Three') + }) + }) + + describe('nested agent hierarchy', () => { + test('renders nested child agents', () => { + const parentMessage: ChatMessage = { + id: 'parent-1', + variant: 'ai', + content: 'Parent message', + timestamp: '12:00', + isComplete: true, + } + + const child1 = createAgentMessage('child-1', 'Level 1 Agent', 'parent-1') + const grandchild = createAgentMessage('grandchild-1', 'Level 2 Agent', 'child-1') + + const messageTree = new Map([ + ['parent-1', [child1]], + ['child-1', [grandchild]], + ]) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Level 1 Agent') + expect(markup).toContain('Level 2 Agent') + }) + }) + + describe('depth limiting', () => { + test('respects MAX_AGENT_DEPTH limit', () => { + // Create a deeply nested hierarchy (11 levels) + const messages: ChatMessage[] = [] + const messageTree = new Map() + + const rootMessage: ChatMessage = { + id: 'root', + variant: 'ai', + content: 'Root', + timestamp: '12:00', + isComplete: true, + } + messages.push(rootMessage) + + let parentId = 'root' + for (let i = 1; i <= 12; i++) { + const agent = createAgentMessage(`agent-${i}`, `Agent Level ${i}`, parentId) + messages.push(agent) + messageTree.set(parentId, [agent]) + parentId = agent.id + } + + const markup = renderToStaticMarkup( + , + ) + + // Should render agents up to MAX_AGENT_DEPTH (10) + expect(markup).toContain('Agent Level 1') + expect(markup).toContain('Agent Level 9') + // Agent Level 11 and 12 should be cut off by depth limit + expect(markup).not.toContain('Agent Level 11') + expect(markup).not.toContain('Agent Level 12') + }) + }) + + describe('empty children', () => { + test('handles message with no children', () => { + const message: ChatMessage = { + id: 'msg-1', + variant: 'ai', + content: 'No children', + timestamp: '12:00', + isComplete: true, + } + + const messageTree = new Map() + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('No children') + }) + + test('handles empty children array in messageTree', () => { + const message: ChatMessage = { + id: 'msg-1', + variant: 'ai', + content: 'Empty children', + timestamp: '12:00', + isComplete: true, + } + + const messageTree = new Map([ + ['msg-1', []], + ]) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Empty children') + }) + }) + + describe('streaming agents', () => { + test('passes streaming state to child agents', () => { + const parentMessage: ChatMessage = { + id: 'parent-1', + variant: 'ai', + content: 'Parent', + timestamp: '12:00', + isComplete: true, + } + + const streamingChild: ChatMessage = { + id: 'streaming-agent', + variant: 'agent', + content: 'Processing...', + timestamp: '12:00', + isComplete: false, + agent: { + agentName: 'Streaming Agent', + agentType: 'file-picker', + responseCount: 0, + }, + parentId: 'parent-1', + } + + const messageTree = new Map([ + ['parent-1', [streamingChild]], + ]) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Streaming Agent') + }) + }) +}) + +describe('Grid layout width handling', () => { + test('renders with narrow width (single column)', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'Agent 1', 'file-picker'), + createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), + ] + + // Width below SM_THRESHOLD (60) should force single column + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Agent 1') + expect(markup).toContain('Agent 2') + expect(markup).toContain('2 agents completed') + }) + + test('renders with medium width (up to 2 columns)', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'Agent 1', 'file-picker'), + createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), + ] + + // Width between MD_THRESHOLD (100) should allow 2 columns + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Agent 1') + expect(markup).toContain('Agent 2') + }) + + test('renders with wide width (up to 3 columns)', () => { + const blocks: ContentBlock[] = [ + createAgentBlock('agent-1', 'Agent 1', 'file-picker'), + createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), + createAgentBlock('agent-3', 'Agent 3', 'commander'), + ] + + // Width above LG_THRESHOLD (140) should allow 3 columns + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Agent 1') + expect(markup).toContain('Agent 2') + expect(markup).toContain('Agent 3') + expect(markup).toContain('3 agents completed') + }) +}) diff --git a/cli/src/components/agent-block-grid.tsx b/cli/src/components/agent-block-grid.tsx new file mode 100644 index 0000000000..5909c8faac --- /dev/null +++ b/cli/src/components/agent-block-grid.tsx @@ -0,0 +1,93 @@ +import { pluralize } from '@codebuff/common/util/string' +import { TextAttributes } from '@opentui/core' +import React, { memo, useCallback } from 'react' + +import { GridLayout } from './grid-layout' +import { useTheme } from '../hooks/use-theme' +import type { AgentContentBlock } from '../types/chat' + +export interface AgentBlockGridProps { + agentBlocks: AgentContentBlock[] + keyPrefix: string + availableWidth: number + streamingAgents: Set + renderAgentBranch: ( + agentBlock: AgentContentBlock, + keyPrefix: string, + availableWidth: number, + ) => React.ReactNode +} + +export function getAgentStatusSummary( + agentBlocks: AgentContentBlock[], + streamingAgents: Set, +): string { + const running = agentBlocks.filter( + (agent) => agent.status === 'running' || streamingAgents.has(agent.agentId), + ).length + const failed = agentBlocks.filter((agent) => agent.status === 'failed').length + const completed = agentBlocks.filter((agent) => agent.status === 'complete').length + + if (running > 0) { + return `${pluralize(agentBlocks.length, 'agent')} running` + } + + if (failed > 0 && completed > 0) { + return `${failed} failed, ${completed} completed` + } + + if (failed > 0) { + return `${pluralize(failed, 'agent')} failed` + } + + return `${pluralize(agentBlocks.length, 'agent')} completed` +} + +export const AgentBlockGrid = memo( + ({ + agentBlocks, + keyPrefix, + availableWidth, + streamingAgents, + renderAgentBranch, + }: AgentBlockGridProps) => { + const theme = useTheme() + + const getItemKey = useCallback( + (agentBlock: AgentContentBlock) => agentBlock.agentId, + [], + ) + + const renderItem = useCallback( + (agentBlock: AgentContentBlock, idx: number, columnWidth: number) => + renderAgentBranch(agentBlock, `${keyPrefix}-agent-${idx}`, columnWidth), + [keyPrefix, renderAgentBranch], + ) + + if (agentBlocks.length === 0) return null + + const headerText = getAgentStatusSummary(agentBlocks, streamingAgents) + const hasFailed = agentBlocks.some((agent) => agent.status === 'failed') + const showHeader = agentBlocks.length > 1 + + const footer = showHeader ? ( + + {headerText} + + ) : undefined + + return ( + + ) + }, +) diff --git a/cli/src/components/tool-block-group.tsx b/cli/src/components/tool-block-group.tsx new file mode 100644 index 0000000000..35c4929b62 --- /dev/null +++ b/cli/src/components/tool-block-group.tsx @@ -0,0 +1,97 @@ +import React, { memo, type ReactNode } from 'react' + +import { ToolBranch } from './blocks/tool-branch' +import type { ContentBlock } from '../types/chat' +import type { MarkdownPalette } from '../utils/markdown-renderer' + +interface ToolBlockGroupProps { + toolBlocks: Extract[] + keyPrefix: string + startIndex: number + nextIndex: number + siblingBlocks: ContentBlock[] + availableWidth: number + streamingAgents: Set + onToggleCollapsed: (id: string) => void + markdownPalette: MarkdownPalette +} + +const isRenderableTimelineBlock = ( + block: ContentBlock | null | undefined, +): boolean => { + if (!block) { + return false + } + + if (block.type === 'tool') { + return block.toolName !== 'end_turn' + } + + switch (block.type) { + case 'text': + case 'html': + case 'agent': + case 'agent-list': + case 'plan': + case 'mode-divider': + case 'ask-user': + case 'image': + return true + default: + return false + } +} + +export const ToolBlockGroup = memo( + ({ + toolBlocks, + keyPrefix, + startIndex, + nextIndex, + siblingBlocks, + availableWidth, + streamingAgents, + onToggleCollapsed, + markdownPalette, + }: ToolBlockGroupProps): ReactNode => { + const groupNodes = toolBlocks + .map((toolBlock) => ( + + )) + .filter(Boolean) + + if (groupNodes.length === 0) return null + + const hasRenderableBefore = + startIndex > 0 && isRenderableTimelineBlock(siblingBlocks[startIndex - 1]) + let hasRenderableAfter = false + for (let i = nextIndex; i < siblingBlocks.length; i++) { + if (isRenderableTimelineBlock(siblingBlocks[i])) { + hasRenderableAfter = true + break + } + } + + return ( + + {groupNodes} + + ) + }, +) From 4a3692b9a18990c242dfaae19e90156872508262 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:15 -0800 Subject: [PATCH 0010/1143] refactor(cli): add decomposed blocks/ directory components --- .../{ => blocks}/agent-block-grid.tsx | 6 +- .../{ => blocks}/agent-branch-item.tsx | 12 +- .../blocks/agent-branch-wrapper.tsx | 374 ++++++++++++++++++ cli/src/components/blocks/block-helpers.ts | 12 + cli/src/components/blocks/blocks-renderer.tsx | 167 ++++++++ .../{ => blocks}/implementor-row.tsx | 24 +- cli/src/components/blocks/single-block.tsx | 208 ++++++++++ .../{ => blocks}/tool-block-group.tsx | 6 +- .../components/blocks/user-content-copy.tsx | 150 +++++++ 9 files changed, 935 insertions(+), 24 deletions(-) rename cli/src/components/{ => blocks}/agent-block-grid.tsx (94%) rename cli/src/components/{ => blocks}/agent-branch-item.tsx (95%) create mode 100644 cli/src/components/blocks/agent-branch-wrapper.tsx create mode 100644 cli/src/components/blocks/block-helpers.ts create mode 100644 cli/src/components/blocks/blocks-renderer.tsx rename cli/src/components/{ => blocks}/implementor-row.tsx (95%) create mode 100644 cli/src/components/blocks/single-block.tsx rename cli/src/components/{ => blocks}/tool-block-group.tsx (93%) create mode 100644 cli/src/components/blocks/user-content-copy.tsx diff --git a/cli/src/components/agent-block-grid.tsx b/cli/src/components/blocks/agent-block-grid.tsx similarity index 94% rename from cli/src/components/agent-block-grid.tsx rename to cli/src/components/blocks/agent-block-grid.tsx index 5909c8faac..56e7ad3f27 100644 --- a/cli/src/components/agent-block-grid.tsx +++ b/cli/src/components/blocks/agent-block-grid.tsx @@ -2,9 +2,9 @@ import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { memo, useCallback } from 'react' -import { GridLayout } from './grid-layout' -import { useTheme } from '../hooks/use-theme' -import type { AgentContentBlock } from '../types/chat' +import { GridLayout } from '../grid-layout' +import { useTheme } from '../../hooks/use-theme' +import type { AgentContentBlock } from '../../types/chat' export interface AgentBlockGridProps { agentBlocks: AgentContentBlock[] diff --git a/cli/src/components/agent-branch-item.tsx b/cli/src/components/blocks/agent-branch-item.tsx similarity index 95% rename from cli/src/components/agent-branch-item.tsx rename to cli/src/components/blocks/agent-branch-item.tsx index 59f35d1580..15fb908b24 100644 --- a/cli/src/components/agent-branch-item.tsx +++ b/cli/src/components/blocks/agent-branch-item.tsx @@ -1,12 +1,12 @@ import { TextAttributes } from '@opentui/core' import React, { memo, type ReactNode } from 'react' -import { Button } from './button' -import { CollapseButton } from './collapse-button' -import { useTheme } from '../hooks/use-theme' -import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update' -import { getCliEnv } from '../utils/env' -import { BORDER_CHARS } from '../utils/ui-constants' +import { Button } from '../button' +import { CollapseButton } from '../collapse-button' +import { useTheme } from '../../hooks/use-theme' +import { useWhyDidYouUpdateById } from '../../hooks/use-why-did-you-update' +import { getCliEnv } from '../../utils/env' +import { BORDER_CHARS } from '../../utils/ui-constants' interface AgentBranchItemProps { name: string diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx new file mode 100644 index 0000000000..ea7d1b956a --- /dev/null +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -0,0 +1,374 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo, useCallback, useMemo, type ReactNode } from 'react' + +import { AgentBlockGrid } from './agent-block-grid' +import { AgentBranchItem } from './agent-branch-item' +import { ImplementorGroup } from './implementor-row' +import { ToolBlockGroup } from './tool-block-group' +import { ContentWithMarkdown } from './content-with-markdown' +import { ThinkingBlock } from './thinking-block' +import { trimTrailingNewlines, sanitizePreview } from './block-helpers' +import { useTheme } from '../../hooks/use-theme' +import { AGENT_CONTENT_HORIZONTAL_PADDING } from '../../utils/layout-helpers' +import { shouldRenderAsSimpleText } from '../../utils/constants' +import { isImplementorAgent, getImplementorIndex } from '../../utils/implementor-helpers' +import { processBlocks, type BlockProcessorHandlers } from '../../utils/block-processor' +import { getAgentStatusInfo } from '../../utils/agent-helpers' +import { isTextBlock } from '../../types/chat' +import type { + AgentContentBlock, + ContentBlock, + TextContentBlock, + HtmlContentBlock, +} from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface AgentBodyProps { + agentBlock: Extract + keyPrefix: string + parentIsStreaming: boolean + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + isLastMessage?: boolean +} + +const AgentBody = memo( + ({ + agentBlock, + keyPrefix, + parentIsStreaming, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + }: AgentBodyProps): ReactNode[] => { + const theme = useTheme() + const nestedBlocks = agentBlock.blocks ?? [] + + const getAgentMarkdownOptions = useCallback( + (indent: number) => { + const indentationOffset = indent * 2 + return { + codeBlockWidth: Math.max( + 10, + availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING - indentationOffset, + ), + palette: { + ...markdownPalette, + codeTextFg: theme.foreground, + }, + } + }, + [availableWidth, markdownPalette, theme.foreground], + ) + + const handlers: BlockProcessorHandlers = useMemo( + () => ({ + onReasoningGroup: (reasoningBlocks, startIndex) => ( + + ), + + onToolGroup: (toolBlocks, startIndex, nextIndex) => ( + + ), + + onImplementorGroup: (implementors, startIndex) => ( + + ), + + onAgentGroup: (agentBlocks, startIndex) => ( + ( + + )} + /> + ), + + onSingleBlock: (block, index) => { + if (block.type === 'text') { + const textBlock = block as TextContentBlock + const nestedStatus = textBlock.status + const isNestedStreamingText = parentIsStreaming || nestedStatus === 'running' + const filteredNestedContent = isNestedStreamingText + ? trimTrailingNewlines(textBlock.content) + : textBlock.content.trim() + const markdownOptionsForLevel = getAgentMarkdownOptions(0) + const marginTop = textBlock.marginTop ?? 0 + const marginBottom = textBlock.marginBottom ?? 0 + const explicitColor = textBlock.color + const nestedTextColor = explicitColor ?? theme.foreground + + return ( + + + + ) + } + + if (block.type === 'html') { + const htmlBlock = block as HtmlContentBlock + const marginTop = htmlBlock.marginTop ?? 0 + const marginBottom = htmlBlock.marginBottom ?? 0 + + return ( + + {htmlBlock.render({ + textColor: theme.foreground, + theme, + })} + + ) + } + + // Fallback for unknown block types + return null + }, + }), + [ + keyPrefix, + nestedBlocks, + parentIsStreaming, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + theme, + getAgentMarkdownOptions, + ], + ) + + return processBlocks(nestedBlocks, handlers) as ReactNode[] + }, +) + +export interface AgentBranchWrapperProps { + agentBlock: Extract + keyPrefix: string + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + siblingBlocks?: ContentBlock[] + isLastMessage?: boolean +} + +export const AgentBranchWrapper = memo( + ({ + agentBlock, + keyPrefix, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + siblingBlocks, + isLastMessage, + }: AgentBranchWrapperProps) => { + const theme = useTheme() + + if (shouldRenderAsSimpleText(agentBlock.agentType)) { + const isStreaming = + agentBlock.status === 'running' || + streamingAgents.has(agentBlock.agentId) + + const effectiveStatus = isStreaming ? 'running' : agentBlock.status + const { indicator: statusIndicator, color: statusColor } = + getAgentStatusInfo(effectiveStatus, theme) + + let statusText = 'Selecting best' + let reason: string | undefined + + const isComplete = agentBlock.status === 'complete' + if (isComplete && siblingBlocks) { + const blocks = agentBlock.blocks ?? [] + const lastBlock = blocks[blocks.length - 1] as + | { input: { implementationId: string; reason: string } } + | undefined + const implementationId = lastBlock?.input?.implementationId + if (implementationId) { + const letterIndex = implementationId.charCodeAt(0) - 65 + const implementors = siblingBlocks.filter( + (b): b is AgentContentBlock => + b.type === 'agent' && isImplementorAgent(b), + ) + + const selectedAgent = implementors[letterIndex] + if (selectedAgent) { + const index = getImplementorIndex(selectedAgent, siblingBlocks) + statusText = + index !== undefined + ? `Selected Strategy #${index + 1}` + : 'Selected' + reason = lastBlock?.input?.reason + } + } + } + + return ( + + + {statusIndicator} + + {' '} + {statusText} + + + {reason && ( + + {reason} + + )} + + ) + } + + const isCollapsed = agentBlock.isCollapsed ?? false + const isStreaming = + agentBlock.status === 'running' || streamingAgents.has(agentBlock.agentId) + + const allTextContent = + agentBlock.blocks + ?.filter(isTextBlock) + .map((nested) => nested.content) + .join('') || '' + + const lines = allTextContent.split('\n').filter((line) => line.trim()) + const firstLine = lines[0] || '' + + const streamingPreview = isStreaming + ? agentBlock.initialPrompt + ? sanitizePreview(agentBlock.initialPrompt) + : `${sanitizePreview(firstLine)}...` + : '' + + const finishedPreview = + !isStreaming && isCollapsed && agentBlock.initialPrompt + ? sanitizePreview(agentBlock.initialPrompt) + : '' + + const isActive = isStreaming || agentBlock.status === 'running' + const { indicator: statusIndicator, label: statusLabel, color: statusColor } = + getAgentStatusInfo(isActive ? 'running' : agentBlock.status, theme) + + const onToggle = useCallback(() => { + onToggleCollapsed(agentBlock.agentId) + }, [onToggleCollapsed, agentBlock.agentId]) + + return ( + + + + + + ) + }, +) diff --git a/cli/src/components/blocks/block-helpers.ts b/cli/src/components/blocks/block-helpers.ts new file mode 100644 index 0000000000..cea741f649 --- /dev/null +++ b/cli/src/components/blocks/block-helpers.ts @@ -0,0 +1,12 @@ +import type { ContentBlock } from '../../types/chat' + +export function trimTrailingNewlines(str: string): string { + return str.replace(/\n+$/, '') +} + +export function sanitizePreview(text: string): string { + return text.replace(/[#*_`~\[\]()]/g, '').trim() +} + +// Re-export from block-processor for backwards compatibility +export { isReasoningTextBlock } from '../../utils/block-processor' diff --git a/cli/src/components/blocks/blocks-renderer.tsx b/cli/src/components/blocks/blocks-renderer.tsx new file mode 100644 index 0000000000..f8ae818a9c --- /dev/null +++ b/cli/src/components/blocks/blocks-renderer.tsx @@ -0,0 +1,167 @@ +import React, { memo, useMemo } from 'react' + +import { AgentBlockGrid } from './agent-block-grid' +import { ImplementorGroup } from './implementor-row' +import { ToolBlockGroup } from './tool-block-group' +import { AgentBranchWrapper } from './agent-branch-wrapper' +import { ImageBlock } from './image-block' +import { ThinkingBlock } from './thinking-block' +import { SingleBlock } from './single-block' +import { processBlocks, type BlockProcessorHandlers } from '../../utils/block-processor' +import type { ContentBlock } from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface BlocksRendererProps { + sourceBlocks: ContentBlock[] + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + isLastMessage?: boolean + contentToCopy?: string +} + +export const BlocksRenderer = memo( + ({ + sourceBlocks, + messageId, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + contentToCopy, + }: BlocksRendererProps) => { + const lastTextBlockIndex = contentToCopy + ? sourceBlocks.reduceRight( + (acc, block, idx) => + acc === -1 && block.type === 'text' ? idx : acc, + -1, + ) + : -1 + + const handlers: BlockProcessorHandlers = useMemo( + () => ({ + onReasoningGroup: (reasoningBlocks, startIndex) => ( + + ), + + onImageBlock: (block, index) => ( + + ), + + onToolGroup: (toolBlocks, startIndex, nextIndex) => ( + + ), + + onImplementorGroup: (implementors, startIndex) => ( + + ), + + onAgentGroup: (agentBlocks, startIndex) => ( + ( + + )} + /> + ), + + onSingleBlock: (block, index) => ( + + ), + }), + [ + messageId, + sourceBlocks, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + contentToCopy, + lastTextBlockIndex, + ], + ) + + return processBlocks(sourceBlocks, handlers) + }, +) diff --git a/cli/src/components/implementor-row.tsx b/cli/src/components/blocks/implementor-row.tsx similarity index 95% rename from cli/src/components/implementor-row.tsx rename to cli/src/components/blocks/implementor-row.tsx index dacaf65a9d..4a787c7a47 100644 --- a/cli/src/components/implementor-row.tsx +++ b/cli/src/components/blocks/implementor-row.tsx @@ -1,8 +1,8 @@ import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' -import React, { memo, useMemo, useState, useCallback } from 'react' +import React, { memo, useCallback, useMemo, useState } from 'react' -import { getAgentStatusInfo } from '../utils/agent-helpers' +import { getAgentStatusInfo } from '../../utils/agent-helpers' import { buildActivityTimeline, getImplementorDisplayName, @@ -10,16 +10,16 @@ import { getFileStatsFromBlocks, truncateWithEllipsis, type FileStats, -} from '../utils/implementor-helpers' -import { useTheme } from '../hooks/use-theme' -import { useTerminalLayout } from '../hooks/use-terminal-layout' -import { computeSmartColumns } from '../utils/layout-helpers' -import { getRelativePath } from '../utils/path-helpers' -import { PROPOSAL_BORDER_CHARS } from '../utils/ui-constants' -import { Button } from './button' -import { CollapseButton } from './collapse-button' -import { DiffViewer } from './tools/diff-viewer' -import type { AgentContentBlock, ContentBlock } from '../types/chat' +} from '../../utils/implementor-helpers' +import { useTheme } from '../../hooks/use-theme' +import { useTerminalLayout } from '../../hooks/use-terminal-layout' +import { computeSmartColumns } from '../../utils/layout-helpers' +import { getRelativePath } from '../../utils/path-helpers' +import { PROPOSAL_BORDER_CHARS } from '../../utils/ui-constants' +import { Button } from '../button' +import { CollapseButton } from '../collapse-button' +import { DiffViewer } from '../tools/diff-viewer' +import type { AgentContentBlock, ContentBlock } from '../../types/chat' interface ImplementorGroupProps { implementors: AgentContentBlock[] diff --git a/cli/src/components/blocks/single-block.tsx b/cli/src/components/blocks/single-block.tsx new file mode 100644 index 0000000000..4453f08be6 --- /dev/null +++ b/cli/src/components/blocks/single-block.tsx @@ -0,0 +1,208 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo, type ReactNode } from 'react' + +import { AgentBranchWrapper } from './agent-branch-wrapper' +import { AgentListBranch } from './agent-list-branch' +import { AskUserBranch } from './ask-user-branch' +import { ContentWithMarkdown } from './content-with-markdown' +import { ImageBlock } from './image-block' +import { UserBlockTextWithInlineCopy } from './user-content-copy' +import { trimTrailingNewlines, isReasoningTextBlock } from './block-helpers' +import { PlanBox } from '../renderers/plan-box' +import { useTheme } from '../../hooks/use-theme' +import type { + ContentBlock, + TextContentBlock, + ImageContentBlock, +} from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface SingleBlockProps { + block: ContentBlock + idx: number + messageId: string + blocks?: ContentBlock[] + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + isLastMessage?: boolean + contentToCopy?: string +} + +export const SingleBlock = memo( + ({ + block, + idx, + messageId, + blocks, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + contentToCopy, + }: SingleBlockProps): ReactNode => { + const theme = useTheme() + const codeBlockWidth = Math.max(10, availableWidth - 8) + + switch (block.type) { + case 'text': { + if (isReasoningTextBlock(block)) { + return null + } + const textBlock = block as TextContentBlock + const isStreamingText = isLoading || !isComplete + const filteredContent = isStreamingText + ? trimTrailingNewlines(textBlock.content) + : textBlock.content.trim() + const renderKey = `${messageId}-text-${idx}` + const prevBlock = idx > 0 && blocks ? blocks[idx - 1] : null + const marginTop = + prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') + ? 0 + : textBlock.marginTop ?? 0 + const marginBottom = textBlock.marginBottom ?? 0 + const explicitColor = textBlock.color + const blockTextColor = explicitColor ?? textColor + + if (contentToCopy) { + return ( + + ) + } + + return ( + + + + ) + } + + case 'plan': { + return ( + + + + ) + } + + case 'html': { + const marginTop = block.marginTop ?? 0 + const marginBottom = block.marginBottom ?? 0 + return ( + + {block.render({ textColor, theme })} + + ) + } + + case 'tool': { + return null + } + + case 'ask-user': { + return ( + + ) + } + + case 'image': { + return ( + + ) + } + + case 'agent': { + return ( + + ) + } + + case 'agent-list': { + return ( + + ) + } + + default: + return null + } + }, +) diff --git a/cli/src/components/tool-block-group.tsx b/cli/src/components/blocks/tool-block-group.tsx similarity index 93% rename from cli/src/components/tool-block-group.tsx rename to cli/src/components/blocks/tool-block-group.tsx index 35c4929b62..2c0508c9d7 100644 --- a/cli/src/components/tool-block-group.tsx +++ b/cli/src/components/blocks/tool-block-group.tsx @@ -1,8 +1,8 @@ import React, { memo, type ReactNode } from 'react' -import { ToolBranch } from './blocks/tool-branch' -import type { ContentBlock } from '../types/chat' -import type { MarkdownPalette } from '../utils/markdown-renderer' +import { ToolBranch } from './tool-branch' +import type { ContentBlock } from '../../types/chat' +import type { MarkdownPalette } from '../../utils/markdown-renderer' interface ToolBlockGroupProps { toolBlocks: Extract[] diff --git a/cli/src/components/blocks/user-content-copy.tsx b/cli/src/components/blocks/user-content-copy.tsx new file mode 100644 index 0000000000..04d4e15503 --- /dev/null +++ b/cli/src/components/blocks/user-content-copy.tsx @@ -0,0 +1,150 @@ +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { CopyButton } from '../copy-button' +import { ContentWithMarkdown } from './content-with-markdown' +import { trimTrailingNewlines } from './block-helpers' +import type { MarkdownPalette } from '../../utils/markdown-renderer' + +interface UserContentWithCopyButtonProps { + content: string + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette + showCopyButton: boolean +} + +export const UserContentWithCopyButton = memo( + ({ + content, + messageId, + isLoading, + isComplete, + isUser, + textColor, + codeBlockWidth, + palette, + showCopyButton, + }: UserContentWithCopyButtonProps) => { + const isStreamingMessage = isLoading || !isComplete + const normalizedContent = isStreamingMessage + ? trimTrailingNewlines(content) + : content.trim() + + if (!showCopyButton) { + return ( + + + + ) + } + + return ( + + ) + }, +) + +interface UserTextWithInlineCopyProps { + messageId: string + content: string + normalizedContent: string + isStreamingMessage: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette +} + +const UserTextWithInlineCopy = memo( + ({ + messageId, + content, + normalizedContent, + isStreamingMessage, + textColor, + codeBlockWidth, + palette, + }: UserTextWithInlineCopyProps) => { + return ( + + + + + + ) + }, +) + +interface UserBlockTextWithInlineCopyProps { + content: string + contentToCopy: string + isStreaming: boolean + textColor: string + codeBlockWidth: number + palette: MarkdownPalette + marginTop: number + marginBottom: number +} + +export const UserBlockTextWithInlineCopy = memo( + ({ + content, + contentToCopy, + isStreaming, + textColor, + codeBlockWidth, + palette, + marginTop, + marginBottom, + }: UserBlockTextWithInlineCopyProps) => { + return ( + + + + + + ) + }, +) From b651b46e72ff56e84af119b5482709d0ad69ca21 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:37 -0800 Subject: [PATCH 0011/1143] refactor(cli): extract message-block internals and wire up parallel agent display --- cli/src/components/blocks/implementor-row.tsx | 117 +- cli/src/components/message-block.tsx | 1012 +---------------- cli/src/components/message-with-agents.tsx | 251 ++-- cli/src/types/chat.ts | 6 + cli/src/utils/layout-helpers.ts | 9 +- 5 files changed, 241 insertions(+), 1154 deletions(-) diff --git a/cli/src/components/blocks/implementor-row.tsx b/cli/src/components/blocks/implementor-row.tsx index 4a787c7a47..77e72692e4 100644 --- a/cli/src/components/blocks/implementor-row.tsx +++ b/cli/src/components/blocks/implementor-row.tsx @@ -12,8 +12,7 @@ import { type FileStats, } from '../../utils/implementor-helpers' import { useTheme } from '../../hooks/use-theme' -import { useTerminalLayout } from '../../hooks/use-terminal-layout' -import { computeSmartColumns } from '../../utils/layout-helpers' +import { useGridLayout } from '../../hooks/use-grid-layout' import { getRelativePath } from '../../utils/path-helpers' import { PROPOSAL_BORDER_CHARS } from '../../utils/ui-constants' import { Button } from '../button' @@ -24,13 +23,9 @@ import type { AgentContentBlock, ContentBlock } from '../../types/chat' interface ImplementorGroupProps { implementors: AgentContentBlock[] siblingBlocks: ContentBlock[] - onToggleCollapsed: (id: string) => void availableWidth: number } -/** - * Responsive card grid for comparing implementor proposals - */ export const ImplementorGroup = memo( ({ implementors, @@ -38,36 +33,7 @@ export const ImplementorGroup = memo( availableWidth, }: ImplementorGroupProps) => { const theme = useTheme() - const { width } = useTerminalLayout() - - // Determine max columns based on terminal width - const maxColumns = useMemo(() => { - if (width.is('xs')) return 1 - if (width.is('sm')) return 1 - if (width.is('md')) return 2 - return 3 // lg - }, [width]) - - // Smart column selection based on item count - const columns = useMemo(() => - computeSmartColumns(implementors.length, maxColumns), - [implementors.length, maxColumns]) - - // Calculate card width based on columns and available space - const cardWidth = useMemo(() => { - // No gap between columns - cards are flush - return Math.floor(availableWidth / columns) - }, [availableWidth, columns]) - - // Masonry layout: distribute items to columns round-robin style - // (simpler than height-based, but still gives masonry effect) - const columnGroups = useMemo(() => { - const result: AgentContentBlock[][] = Array.from({ length: columns }, () => []) - implementors.forEach((impl, idx) => { - result[idx % columns].push(impl) - }) - return result - }, [implementors, columns]) + const { columns, columnWidth: cardWidth, columnGroups } = useGridLayout(implementors, availableWidth) // Check if any implementors are still running const anyRunning = implementors.some(impl => impl.status === 'running') @@ -84,52 +50,55 @@ export const ImplementorGroup = memo( marginTop: 1, }} > - - {headerText} - - {/* Masonry layout: columns side by side, cards stack vertically in each */} - {columnGroups.map((columnItems, colIdx) => ( - { + // Use first agent's ID as stable column key + const columnKey = columnItems[0]?.agentId ?? `col-${colIdx}` + return ( + - {columnItems.map((agentBlock) => { - const implementorIndex = getImplementorIndex( - agentBlock, - siblingBlocks, - ) - - return ( - - ) - })} - - ))} + {columnItems.map((agentBlock) => { + const implementorIndex = getImplementorIndex( + agentBlock, + siblingBlocks, + ) + + return ( + + ) + })} + + ) + })} + + {headerText} + ) }, @@ -141,10 +110,6 @@ interface ImplementorCardProps { cardWidth: number } -/** - * Individual proposal card with dashed border - * Click file rows to view their diffs - */ const ImplementorCard = memo( ({ agentBlock, @@ -274,10 +239,6 @@ const ImplementorCard = memo( }, ) -// ============================================================================ -// COMPACT FILE STATS VIEW -// ============================================================================ - interface CompactFileStatsProps { fileStats: FileStats[] availableWidth: number @@ -287,12 +248,6 @@ interface CompactFileStatsProps { fileDiffs: Map } -/** - * Compact view showing file changes with full-width, center-aligned addition/deletion bars. - * The left side is a green bar (additions) and the right side is a red bar (deletions), - * both extending to the center with their +N / -N counts rendered in white inside the bars. - * Click a file name to view its diff inline below that row. - */ const CompactFileStats = memo(({ fileStats, availableWidth, @@ -354,10 +309,6 @@ interface CompactFileRowProps { diff?: string } -/** - * Single file row with full-width colored bars meeting at center. - * File name is underlined on hover, clickable to show diff inline below. - */ const CompactFileRow = memo(({ file, availableWidth, diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index 48439318f8..b3df59ea7b 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -1,43 +1,24 @@ import { TextAttributes } from '@opentui/core' -import React, { memo, useCallback, useState, type ReactNode } from 'react' +import React, { useState } from 'react' -import { AgentBranchItem } from './agent-branch-item' import { Button } from './button' -import { CopyButton } from './copy-button' import { ImageCard } from './image-card' import { TextAttachmentCard } from './text-attachment-card' -import { ImplementorGroup } from './implementor-row' import { MessageFooter } from './message-footer' +import { UserErrorBanner } from './user-error-banner' import { ValidationErrorPopover } from './validation-error-popover' +import { BlocksRenderer } from './blocks/blocks-renderer' +import { UserContentWithCopyButton } from './blocks/user-content-copy' import { useTheme } from '../hooks/use-theme' import { useWhyDidYouUpdateById } from '../hooks/use-why-did-you-update' import { getCliEnv } from '../utils/env' -import { isTextBlock, isToolBlock, isImageBlock } from '../types/chat' -import { shouldRenderAsSimpleText } from '../utils/constants' -import { - isImplementorAgent, - getImplementorIndex, - groupConsecutiveImplementors, -} from '../utils/implementor-helpers' -import { getAgentStatusInfo } from '../utils/agent-helpers' import { type MarkdownPalette } from '../utils/markdown-renderer' import { formatCwd } from '../utils/path-helpers' -import { AgentListBranch } from './blocks/agent-list-branch' -import { AskUserBranch } from './blocks/ask-user-branch' -import { ContentWithMarkdown } from './blocks/content-with-markdown' -import { ImageBlock } from './blocks/image-block' -import { ThinkingBlock } from './blocks/thinking-block' -import { ToolBranch } from './blocks/tool-branch' -import { PlanBox } from './renderers/plan-box' import type { ContentBlock, - TextContentBlock, - HtmlContentBlock, - AgentContentBlock, ImageAttachment, TextAttachment, - ImageContentBlock, ChatMessageMetadata, } from '../types/chat' import type { ThemeColor } from '../types/theme-system' @@ -66,6 +47,8 @@ interface MessageBlockProps { onFeedback?: (messageId: string) => void onCloseFeedback?: () => void validationErrors?: Array<{ id: string; message: string }> + /** Runtime error to display in UI but NOT send to LLM */ + userError?: string onOpenFeedback?: (options?: { category?: string footerMessage?: string @@ -139,6 +122,7 @@ export const MessageBlock: React.FC = ({ onFeedback, onCloseFeedback, validationErrors, + userError, onOpenFeedback, attachments, textAttachments, @@ -314,12 +298,17 @@ export const MessageBlock: React.FC = ({ /> )} {/* Show attachments for user messages */} - {isUser && ((attachments && attachments.length > 0) || (textAttachments && textAttachments.length > 0)) && ( - - )} + {isUser && + ((attachments && attachments.length > 0) || + (textAttachments && textAttachments.length > 0)) && ( + + )} + + {/* Display runtime error banner for AI messages */} + {isAi && userError && } {isAi && ( = ({ ) } - -const trimTrailingNewlines = (value: string): string => - value.replace(/[\r\n]+$/g, '') - -const sanitizePreview = (value: string): string => - value.replace(/[#*_`~\[\]()]/g, '').trim() - -// Extract all text content from blocks recursively - -const isReasoningTextBlock = ( - b: ContentBlock | null | undefined, -): b is TextContentBlock => { - if (!b || b.type !== 'text') return false - return b.textType === 'reasoning' -} - -const isRenderableTimelineBlock = ( - block: ContentBlock | null | undefined, -): boolean => { - if (!block) { - return false - } - - if (block.type === 'tool') { - return block.toolName !== 'end_turn' - } - - switch (block.type) { - case 'text': - case 'html': - case 'agent': - case 'agent-list': - case 'plan': - case 'mode-divider': - case 'ask-user': - case 'image': - return true - default: - return false - } -} - -interface AgentBodyProps { - agentBlock: Extract - keyPrefix: string - parentIsStreaming: boolean - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean -} - -const AgentBody = memo( - ({ - agentBlock, - keyPrefix, - parentIsStreaming, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - }: AgentBodyProps): ReactNode[] => { - const theme = useTheme() - const nestedBlocks = agentBlock.blocks ?? [] - const nodes: React.ReactNode[] = [] - - const getAgentMarkdownOptions = useCallback( - (indent: number) => { - const indentationOffset = indent * 2 - return { - codeBlockWidth: Math.max(10, availableWidth - 12 - indentationOffset), - palette: { - ...markdownPalette, - codeTextFg: theme.foreground, - }, - } - }, - [availableWidth, markdownPalette, theme.foreground], - ) - - for (let nestedIdx = 0; nestedIdx < nestedBlocks.length; ) { - const nestedBlock = nestedBlocks[nestedIdx] - - // Handle reasoning text blocks first - if (isReasoningTextBlock(nestedBlock)) { - const start = nestedIdx - const reasoningBlocks: Extract[] = [] - while (nestedIdx < nestedBlocks.length) { - const block = nestedBlocks[nestedIdx] - if (!isReasoningTextBlock(block)) break - reasoningBlocks.push(block) - nestedIdx++ - } - - nodes.push( - , - ) - continue - } - - switch ((nestedBlock as ContentBlock).type) { - case 'text': { - const textBlock = nestedBlock as unknown as TextContentBlock - const nestedStatus = textBlock.status - const isNestedStreamingText = - parentIsStreaming || nestedStatus === 'running' - const filteredNestedContent = isNestedStreamingText - ? trimTrailingNewlines(textBlock.content) - : textBlock.content.trim() - const renderKey = `${keyPrefix}-text-${nestedIdx}` - const markdownOptionsForLevel = getAgentMarkdownOptions(0) - const marginTop = textBlock.marginTop ?? 0 - const marginBottom = textBlock.marginBottom ?? 0 - const explicitColor = textBlock.color - const nestedTextColor = explicitColor ?? theme.foreground - nodes.push( - - - , - ) - nestedIdx++ - break - } - - case 'html': { - const htmlBlock = nestedBlock as HtmlContentBlock - const marginTop = htmlBlock.marginTop ?? 0 - const marginBottom = htmlBlock.marginBottom ?? 0 - nodes.push( - - {htmlBlock.render({ - textColor: theme.foreground, - theme, - })} - , - ) - nestedIdx++ - break - } - - case 'tool': { - const start = nestedIdx - const toolGroup: Extract[] = [] - while (nestedIdx < nestedBlocks.length) { - const block = nestedBlocks[nestedIdx] - if (!isToolBlock(block)) break - toolGroup.push(block) - nestedIdx++ - } - - const groupNodes = toolGroup.map((toolBlock) => ( - - )) - - const nonNullGroupNodes = groupNodes.filter( - Boolean, - ) as React.ReactNode[] - if (nonNullGroupNodes.length > 0) { - const hasRenderableBefore = - start > 0 && isRenderableTimelineBlock(nestedBlocks[start - 1]) - let hasRenderableAfter = false - for (let i = nestedIdx; i < nestedBlocks.length; i++) { - if (isRenderableTimelineBlock(nestedBlocks[i])) { - hasRenderableAfter = true - break - } - } - nodes.push( - - {nonNullGroupNodes} - , - ) - } - break - } - - case 'agent': { - const agentBlock = nestedBlock as AgentContentBlock - - // Group consecutive implementor agents and render with ImplementorGroup - if (isImplementorAgent(agentBlock)) { - const start = nestedIdx - const { group: implementors, nextIndex } = groupConsecutiveImplementors(nestedBlocks, nestedIdx) - nestedIdx = nextIndex - - nodes.push( - , - ) - break - } - - nodes.push( - , - ) - nestedIdx++ - break - } - } - } - - return nodes - }, -) - -interface AgentBranchWrapperProps { - agentBlock: Extract - keyPrefix: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - siblingBlocks?: ContentBlock[] - isLastMessage?: boolean -} - -const AgentBranchWrapper = memo( - ({ - agentBlock, - keyPrefix, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - siblingBlocks, - isLastMessage, - }: AgentBranchWrapperProps) => { - const theme = useTheme() - - if (shouldRenderAsSimpleText(agentBlock.agentType)) { - const isStreaming = - agentBlock.status === 'running' || - streamingAgents.has(agentBlock.agentId) - - // Get base status info, but override if streaming - const effectiveStatus = isStreaming ? 'running' : agentBlock.status - const { indicator: statusIndicator, color: statusColor } = getAgentStatusInfo( - effectiveStatus, - theme, - ) - - let statusText = 'Selecting best' - let reason: string | undefined - - // If complete, try to show which implementation was selected - const isComplete = agentBlock.status === 'complete' - if (isComplete && siblingBlocks) { - const blocks = agentBlock.blocks ?? [] - const lastBlock = blocks[blocks.length - 1] as - | { input: { implementationId: string; reason: string } } - | undefined - const implementationId = lastBlock?.input?.implementationId - if (implementationId) { - // Convert letter to index: 'A' -> 0, 'B' -> 1, etc. - const letterIndex = implementationId.charCodeAt(0) - 65 - const implementors = siblingBlocks.filter( - (b): b is AgentContentBlock => b.type === 'agent' && isImplementorAgent(b), - ) - - const selectedAgent = implementors[letterIndex] - if (selectedAgent) { - const index = getImplementorIndex(selectedAgent, siblingBlocks) - // Just show "Selected Prompt #N" without repeating the prompt text - statusText = index !== undefined ? `Selected Strategy #${index + 1}` : 'Selected' - reason = lastBlock?.input?.reason - } - } - } - - return ( - - - {statusIndicator} - - {' '} - {statusText} - - - {reason && ( - - {reason} - - )} - - ) - } - - const isCollapsed = agentBlock.isCollapsed ?? false - const isStreaming = - agentBlock.status === 'running' || streamingAgents.has(agentBlock.agentId) - - const allTextContent = - agentBlock.blocks - ?.filter(isTextBlock) - .map((nested) => nested.content) - .join('') || '' - - const lines = allTextContent.split('\n').filter((line) => line.trim()) - const firstLine = lines[0] || '' - - const streamingPreview = isStreaming - ? agentBlock.initialPrompt - ? sanitizePreview(agentBlock.initialPrompt) - : `${sanitizePreview(firstLine)}...` - : '' - - const finishedPreview = - !isStreaming && isCollapsed && agentBlock.initialPrompt - ? sanitizePreview(agentBlock.initialPrompt) - : '' - - const isActive = isStreaming || agentBlock.status === 'running' - const effectiveStatus = isActive ? 'running' : agentBlock.status - const { indicator: statusIndicator, label: statusLabel, color: statusColor } = getAgentStatusInfo( - effectiveStatus, - theme, - ) - - const onToggle = useCallback(() => { - onToggleCollapsed(agentBlock.agentId) - }, [onToggleCollapsed, agentBlock.agentId]) - - return ( - - - - - - ) - }, -) - -interface UserContentWithCopyButtonProps { - content: string - messageId: string - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette - showCopyButton: boolean -} - -/** - * Renders user content with an inline copy button. - * The text flows naturally with word wrapping, and the copy button appears inline after the content. - */ -const UserContentWithCopyButton = memo( - ({ - content, - messageId, - isLoading, - isComplete, - isUser, - textColor, - codeBlockWidth, - palette, - showCopyButton, - }: UserContentWithCopyButtonProps) => { - const isStreamingMessage = isLoading || !isComplete - const normalizedContent = isStreamingMessage - ? trimTrailingNewlines(content) - : content.trim() - - if (!showCopyButton) { - return ( - - - - ) - } - - // Render text content with inline copy icon - clicking the icon copies the text - return ( - - ) - }, -) - -interface UserTextWithInlineCopyProps { - messageId: string - content: string - normalizedContent: string - isStreamingMessage: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette -} - -/** - * Renders user text content with an inline copy icon at the end. - * Clicking the copy icon copies the text to clipboard. - */ -const UserTextWithInlineCopy = memo( - ({ - messageId, - content, - normalizedContent, - isStreamingMessage, - textColor, - codeBlockWidth, - palette, - }: UserTextWithInlineCopyProps) => { - return ( - - - - - - ) - }, -) - -interface UserBlockTextWithInlineCopyProps { - content: string - contentToCopy: string - isStreaming: boolean - textColor: string - codeBlockWidth: number - palette: MarkdownPalette - marginTop: number - marginBottom: number -} - -/** - * Renders a text block for user messages with an inline copy icon at the end. - */ -const UserBlockTextWithInlineCopy = memo( - ({ - content, - contentToCopy, - isStreaming, - textColor, - codeBlockWidth, - palette, - marginTop, - marginBottom, - }: UserBlockTextWithInlineCopyProps) => { - return ( - - - - - - ) - }, -) - -interface SingleBlockProps { - block: ContentBlock - idx: number - messageId: string - blocks?: ContentBlock[] - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean - contentToCopy?: string -} - -const SingleBlock = memo( - ({ - block, - idx, - messageId, - blocks, - isLoading, - isComplete, - isUser, - textColor, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - contentToCopy, - }: SingleBlockProps): ReactNode => { - const theme = useTheme() - const codeBlockWidth = Math.max(10, availableWidth - 8) - - switch (block.type) { - case 'text': { - // Skip raw rendering for reasoning; grouped above into - if (isReasoningTextBlock(block)) { - return null - } - const textBlock = block as TextContentBlock - const isStreamingText = isLoading || !isComplete - const filteredContent = isStreamingText - ? trimTrailingNewlines(textBlock.content) - : textBlock.content.trim() - const renderKey = `${messageId}-text-${idx}` - const prevBlock = idx > 0 && blocks ? blocks[idx - 1] : null - const marginTop = - prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') - ? 0 - : textBlock.marginTop ?? 0 - const marginBottom = textBlock.marginBottom ?? 0 - const explicitColor = textBlock.color - const blockTextColor = explicitColor ?? textColor - - // If this block should have an inline copy icon, use the special component - if (contentToCopy) { - return ( - - ) - } - - return ( - - - - ) - } - - case 'plan': { - return ( - - - - ) - } - - case 'html': { - const marginTop = block.marginTop ?? 0 - const marginBottom = block.marginBottom ?? 0 - return ( - - {block.render({ textColor, theme })} - - ) - } - - case 'tool': { - // Handled in BlocksRenderer grouping logic - return null - } - - case 'ask-user': { - return ( - - ) - } - - case 'image': { - return ( - - ) - } - - case 'agent': { - return ( - - ) - } - - case 'agent-list': { - return ( - - ) - } - - default: - return null - } - }, -) - -interface BlocksRendererProps { - sourceBlocks: ContentBlock[] - messageId: string - isLoading: boolean - isComplete?: boolean - isUser: boolean - textColor: string - availableWidth: number - markdownPalette: MarkdownPalette - streamingAgents: Set - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - isLastMessage?: boolean - contentToCopy?: string -} - -const BlocksRenderer = memo( - ({ - sourceBlocks, - messageId, - isLoading, - isComplete, - isUser, - textColor, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - contentToCopy, - }: BlocksRendererProps) => { - const nodes: React.ReactNode[] = [] - - // Find the index of the last text block for inline copy icon - const lastTextBlockIndex = contentToCopy - ? sourceBlocks.reduceRight( - (acc, block, idx) => (acc === -1 && block.type === 'text' ? idx : acc), - -1, - ) - : -1 - - for (let i = 0; i < sourceBlocks.length; ) { - const block = sourceBlocks[i] - // Handle reasoning text blocks - if (isReasoningTextBlock(block)) { - const start = i - const reasoningBlocks: Extract[] = [] - while (i < sourceBlocks.length) { - const currentBlock = sourceBlocks[i] - if (!isReasoningTextBlock(currentBlock)) break - reasoningBlocks.push(currentBlock) - i++ - } - - nodes.push( - , - ) - continue - } - // Handle image blocks - if (isImageBlock(block)) { - nodes.push( - , - ) - i++ - continue - } - - if (block.type === 'tool') { - const start = i - const group: Extract[] = [] - while (i < sourceBlocks.length) { - const currentBlock = sourceBlocks[i] - if (!isToolBlock(currentBlock)) break - group.push(currentBlock) - i++ - } - - const groupNodes = group.map((toolBlock) => ( - - )) - - const nonNullGroupNodes = groupNodes.filter( - Boolean, - ) as React.ReactNode[] - if (nonNullGroupNodes.length > 0) { - const hasRenderableBefore = - start > 0 && isRenderableTimelineBlock(sourceBlocks[start - 1]) - // Check for any subsequent renderable blocks without allocating a slice - let hasRenderableAfter = false - for (let j = i; j < sourceBlocks.length; j++) { - if (isRenderableTimelineBlock(sourceBlocks[j])) { - hasRenderableAfter = true - break - } - } - nodes.push( - - {nonNullGroupNodes} - , - ) - } - continue - } - - // Group consecutive implementor agents and render with ImplementorGroup - if (block.type === 'agent' && isImplementorAgent(block)) { - const start = i - const { group: implementors, nextIndex } = groupConsecutiveImplementors(sourceBlocks, i) - i = nextIndex - - nodes.push( - , - ) - continue - } - - nodes.push( - , - ) - i++ - } - return nodes - }, -) diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index cb3af6abcb..adf08c1b38 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -3,6 +3,8 @@ import { memo, useCallback, useMemo, type ReactNode } from 'react' import React from 'react' import { Button } from './button' +import { ErrorBoundary } from './error-boundary' +import { GridLayout } from './grid-layout' import { MessageBlock } from './message-block' import { ModeDivider } from './mode-divider' import { @@ -10,10 +12,133 @@ import { hasMarkdown, type MarkdownPalette, } from '../utils/markdown-renderer' +import { AGENT_CONTENT_HORIZONTAL_PADDING, MAX_AGENT_DEPTH } from '../utils/layout-helpers' +import { getCliEnv } from '../utils/env' import type { ChatMessage } from '../types/chat' import type { ChatTheme } from '../types/theme-system' +interface AgentChildrenGridProps { + agentChildren: ChatMessage[] + depth: number + theme: ChatTheme + markdownPalette: MarkdownPalette + streamingAgents: Set + messageTree: Map + messages: ChatMessage[] + availableWidth: number + setFocusedAgentId: React.Dispatch> + isWaitingForResponse: boolean + timerStartTime: number | null + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onFeedback: ( + messageId: string, + options?: { + category?: string + footerMessage?: string + errors?: Array<{ id: string; message: string }> + }, + ) => void + onCloseFeedback: () => void +} + +const AgentChildrenGrid = memo( + ({ + agentChildren, + depth, + theme, + markdownPalette, + streamingAgents, + messageTree, + messages, + availableWidth, + setFocusedAgentId, + isWaitingForResponse, + timerStartTime, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onFeedback, + onCloseFeedback, + }: AgentChildrenGridProps) => { + const getItemKey = useCallback((agent: ChatMessage) => agent.id, []) + + const renderAgentChild = useCallback( + (agent: ChatMessage, _idx: number, columnWidth: number) => ( + + ), + [ + depth, + theme, + markdownPalette, + streamingAgents, + messageTree, + messages, + setFocusedAgentId, + isWaitingForResponse, + timerStartTime, + onToggleCollapsed, + onBuildFast, + onBuildMax, + onFeedback, + onCloseFeedback, + ], + ) + + if (agentChildren.length === 0) return null + + if (depth >= MAX_AGENT_DEPTH) { + if (getCliEnv().NODE_ENV === 'development') { + console.warn( + `[AgentChildrenGrid] Depth limit (${MAX_AGENT_DEPTH}) reached, truncating agent tree`, + ) + } + return ( + + {`${agentChildren.length} nested agent${ + agentChildren.length > 1 ? 's' : '' + } not shown (depth limit)`} + + ) + } + + const errorFallback = ( + Error rendering agent children + ) + + return ( + + + + ) + }, +) + interface MessageWithAgentsProps { message: ChatMessage depth: number @@ -134,11 +259,7 @@ export const MessageWithAgents = memo( ) } const lineColor = isError ? 'red' : isAi ? theme.aiLine : theme.userLine - const textColor = isError - ? theme.foreground - : isAi - ? theme.foreground - : theme.foreground + const textColor = theme.foreground const timestampColor = isError ? 'red' : isAi ? theme.muted : theme.muted const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) @@ -221,6 +342,7 @@ export const MessageWithAgents = memo( onFeedback={onFeedback} onCloseFeedback={onCloseFeedback} validationErrors={message.validationErrors} + userError={message.userError} onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} @@ -254,6 +376,9 @@ export const MessageWithAgents = memo( onBuildMax={onBuildMax} onFeedback={onFeedback} onCloseFeedback={onCloseFeedback} + validationErrors={message.validationErrors} + userError={message.userError} + onOpenFeedback={onOpenFeedback} attachments={message.attachments} textAttachments={message.textAttachments} metadata={message.metadata} @@ -264,31 +389,24 @@ export const MessageWithAgents = memo( {hasAgentChildren && ( - - {agentChildren.map((agent) => ( - - - - ))} - + )} ) @@ -340,7 +458,15 @@ const AgentMessage = memo( onFeedback, onCloseFeedback, }: AgentMessageProps): ReactNode => { - const agentInfo = message.agent! + // Guard against missing agent info (should not happen for agent variant messages) + if (!message.agent) { + return ( + + Error: Missing agent info for agent message + + ) + } + const agentInfo = message.agent // Get or initialize collapse state from message metadata const isCollapsed = message.metadata?.isCollapsed ?? false @@ -365,7 +491,7 @@ const AgentMessage = memo( ? lastLine.replace(/[#*_`~\[\]()]/g, '').trim() : '' - const agentCodeBlockWidth = Math.max(10, availableWidth - 12) + const agentCodeBlockWidth = Math.max(10, availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING) const agentPalette: MarkdownPalette = { ...markdownPalette, codeTextFg: theme.foreground, @@ -378,20 +504,12 @@ const AgentMessage = memo( ? renderMarkdown(rawDisplayContent, agentMarkdownOptions) : rawDisplayContent - const handleTitleClick = (e: any): void => { - if (e && e.stopPropagation) { - e.stopPropagation() - } - + const handleTitleClick = (): void => { onToggleCollapsed(message.id) setFocusedAgentId(message.id) } - const handleContentClick = (e: any): void => { - if (e && e.stopPropagation) { - e.stopPropagation() - } - + const handleContentClick = (): void => { if (!isCollapsed) { return } @@ -475,37 +593,24 @@ const AgentMessage = memo( {agentChildren.length > 0 && ( - - {agentChildren.map((childAgent) => ( - - - - ))} - + )} ) diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index ab5c52d651..a4933f9765 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -166,6 +166,12 @@ export type ChatMessage = { isComplete?: boolean metadata?: ChatMessageMetadata validationErrors?: Array<{ id: string; message: string }> + /** + * UI-only runtime error displayed in UserErrorBanner (not sent to LLM). + * Set by setError() when an error occurs during message streaming. + * Can be cleared by clearUserError() when starting a new successful interaction. + */ + userError?: string attachments?: ImageAttachment[] textAttachments?: TextAttachment[] } diff --git a/cli/src/utils/layout-helpers.ts b/cli/src/utils/layout-helpers.ts index 70b37fa8b2..82c44dc9cd 100644 --- a/cli/src/utils/layout-helpers.ts +++ b/cli/src/utils/layout-helpers.ts @@ -1,7 +1,8 @@ -/** - * Compute the ideal number of columns for a grid layout - * Tries to create a balanced grid (e.g. 2x2 instead of 3x1 + 1) while respecting max columns - */ +export const MIN_COLUMN_WIDTH = 10 +export const MAX_AGENT_DEPTH = 10 +export const AGENT_CONTENT_HORIZONTAL_PADDING = 12 + +// Prefers balanced grids (2x2 over 3+1) export function computeSmartColumns(itemCount: number, maxColumns: number): number { if (itemCount === 0) return 1 if (itemCount <= maxColumns) return itemCount From 06b8b77fd456e3288344749c9cf32234e5869db5 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:47 -0800 Subject: [PATCH 0012/1143] feat(cli): add UserErrorBanner component --- .../__tests__/user-error-banner.test.tsx | 102 ++++++++++++++++++ cli/src/components/user-error-banner.tsx | 56 ++++++++++ 2 files changed, 158 insertions(+) create mode 100644 cli/src/components/__tests__/user-error-banner.test.tsx create mode 100644 cli/src/components/user-error-banner.tsx diff --git a/cli/src/components/__tests__/user-error-banner.test.tsx b/cli/src/components/__tests__/user-error-banner.test.tsx new file mode 100644 index 0000000000..87cf1f9b21 --- /dev/null +++ b/cli/src/components/__tests__/user-error-banner.test.tsx @@ -0,0 +1,102 @@ +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { UserErrorBanner } from '../user-error-banner' + +initializeThemeStore() + +describe('UserErrorBanner', () => { + test('renders error message', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Something went wrong') + }) + + test('renders with context length exceeded error', () => { + const errorMessage = + "This endpoint's maximum context length is 200000 tokens. However, you requested about 201209 tokens." + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('200000 tokens') + expect(markup).toContain('201209 tokens') + }) + + test('renders with network error', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Network request failed') + expect(markup).toContain('Connection refused') + }) + + test('returns null for empty error message', () => { + const markup = renderToStaticMarkup() + + // Empty error should render nothing + expect(markup).toBe('') + }) + + test('returns null for whitespace-only error message', () => { + const markup = renderToStaticMarkup() + + // Whitespace-only error should render nothing + expect(markup).toBe('') + }) + + test('renders with multiline error message', () => { + const multilineError = 'First line of error\nSecond line of error' + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('First line of error') + expect(markup).toContain('Second line of error') + }) + + test('renders with special characters in error message', () => { + const specialCharsError = 'Error with tags & "quotes"' + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + // HTML entities should be escaped in the markup + expect(markup).toContain('<html>') + expect(markup).toContain('&') + expect(markup).toContain('"quotes"') + }) + + test('renders with long error message', () => { + const longError = 'A'.repeat(500) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain(longError) + }) + + test('renders with custom title', () => { + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Network Error') + expect(markup).toContain('Something went wrong') + }) +}) diff --git a/cli/src/components/user-error-banner.tsx b/cli/src/components/user-error-banner.tsx new file mode 100644 index 0000000000..c01bcb00c3 --- /dev/null +++ b/cli/src/components/user-error-banner.tsx @@ -0,0 +1,56 @@ +import React from 'react' + +import { useTheme } from '../hooks/use-theme' +import { BORDER_CHARS } from '../utils/ui-constants' + +interface UserErrorBannerProps { + error: string + title?: string +} + +/** Displays runtime errors in the UI (not sent to LLM). */ +export const UserErrorBanner = React.memo(function UserErrorBanner({ + error, + title, +}: UserErrorBannerProps) { + const theme = useTheme() + + // Handle empty and whitespace-only errors + const trimmedError = error.trim() + if (!trimmedError) { + return null + } + + return ( + + + + {title ?? 'Error'} + + + {error} + + + + ) +}) From a94e4f213ed9625a016ee5190f34e911c1d121fb Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sat, 17 Jan 2026 11:31:57 -0800 Subject: [PATCH 0013/1143] fix(cli): @ menu with apostrophes, preserve input on dialogs, keep leading whitespace --- cli/package.json | 2 +- .../__tests__/run-terminal-command.test.ts | 188 ++++++++++++ .../components/tools/run-terminal-command.tsx | 64 +++-- cli/src/components/top-banner.tsx | 9 +- .../__tests__/use-ask-user-bridge.test.ts | 176 ++++++++++++ .../use-suggestion-engine-mention.test.ts | 271 +++++++++--------- .../helpers/__tests__/send-message.test.ts | 106 +++++-- cli/src/hooks/helpers/send-message.ts | 37 +-- cli/src/hooks/use-ask-user-bridge.ts | 7 +- cli/src/hooks/use-send-message.ts | 1 - cli/src/hooks/use-suggestion-engine.ts | 20 +- .../utils/__tests__/message-updater.test.ts | 136 ++++++++- cli/src/utils/message-updater.ts | 49 +++- 13 files changed, 809 insertions(+), 257 deletions(-) create mode 100644 cli/src/components/tools/__tests__/run-terminal-command.test.ts create mode 100644 cli/src/hooks/__tests__/use-ask-user-bridge.test.ts diff --git a/cli/package.json b/cli/package.json index 30e9258115..4f2520147f 100644 --- a/cli/package.json +++ b/cli/package.json @@ -19,7 +19,7 @@ "prebuild:agents": "bun run scripts/prebuild-agents.ts", "build:binary": "bun ./scripts/build-binary.ts codebuff $npm_package_version", "release": "bun run scripts/release.ts", - "test": "bun test", + "test": "NODE_ENV=production bun test", "test:tmux-poc": "bun run src/__tests__/tmux-poc.ts", "typecheck": "tsc --noEmit -p ." }, diff --git a/cli/src/components/tools/__tests__/run-terminal-command.test.ts b/cli/src/components/tools/__tests__/run-terminal-command.test.ts new file mode 100644 index 0000000000..d34dc32670 --- /dev/null +++ b/cli/src/components/tools/__tests__/run-terminal-command.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, test } from 'bun:test' + +import { parseTerminalOutput, RunTerminalCommandComponent } from '../run-terminal-command' + +import type { ToolBlock } from '../types' + +// Helper to create a mock tool block +const createToolBlock = ( + command: string, + output?: string, +): ToolBlock & { toolName: 'run_terminal_command' } => ({ + type: 'tool', + toolName: 'run_terminal_command', + toolCallId: 'test-tool-call-id', + input: { command }, + output, +}) + +// Helper to create JSON output in the format the component expects +const createJsonOutput = (stdout: string, stderr = ''): string => { + return JSON.stringify([ + { + type: 'json', + value: { + command: 'test', + stdout, + stderr, + exitCode: 0, + }, + }, + ]) +} + +describe('RunTerminalCommandComponent', () => { + describe('render', () => { + test('returns content and collapsedPreview', () => { + const toolBlock = createToolBlock('ls -la', createJsonOutput('file1\nfile2')) + const mockTheme = {} as any + const mockOptions = { + availableWidth: 80, + indentationOffset: 0, + labelWidth: 10, + } + + const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions) + + expect(result).toBeDefined() + expect(result.content).toBeDefined() + expect(result.collapsedPreview).toBe('$ ls -la') + }) + + test('preserves leading whitespace in stdout (tree output)', () => { + // Simulate tree command output with leading spaces for indentation + const treeOutput = `├── src +│ ├── index.ts +│ └── utils +│ └── helper.ts +└── package.json` + + const { output } = parseTerminalOutput(createJsonOutput(treeOutput)) + + expect(output).toBe(treeOutput) + // Verify leading characters are preserved (├ has no leading space, but indented lines do) + expect(output?.startsWith('├')).toBe(true) + expect(output).toContain('│ ├') + expect(output).toContain('│ └') + }) + + test('preserves leading spaces in table-like output', () => { + // Simulate output with leading spaces for alignment + const tableOutput = ` Name Size Modified + file1.txt 1.2KB 2024-01-15 + file2.txt 3.4MB 2024-01-16` + + const { output } = parseTerminalOutput(createJsonOutput(tableOutput)) + + expect(output).toBe(tableOutput) + // Verify leading spaces are preserved + expect(output?.startsWith(' ')).toBe(true) + }) + + test('preserves leading spaces in indented code output', () => { + // Simulate indented output like grep with context + const indentedOutput = ` function hello() { + console.log("world") + }` + + const { output } = parseTerminalOutput(createJsonOutput(indentedOutput)) + + expect(output).toBe(indentedOutput) + expect(output?.startsWith(' ')).toBe(true) + }) + + test('removes trailing whitespace while preserving leading whitespace', () => { + const outputWithTrailing = ' leading preserved\ntrailing removed \n\n' + const expectedOutput = ' leading preserved\ntrailing removed' + + const { output } = parseTerminalOutput(createJsonOutput(outputWithTrailing)) + + expect(output).toBe(expectedOutput) + // Leading spaces preserved + expect(output?.startsWith(' ')).toBe(true) + // Trailing whitespace removed + expect(output?.endsWith('removed')).toBe(true) + }) + + test('handles raw string output (non-JSON) and preserves leading whitespace', () => { + const rawOutput = ' indented raw output' + const { output } = parseTerminalOutput(rawOutput) + + expect(output).toBe(rawOutput) + expect(output?.startsWith(' ')).toBe(true) + }) + + test('handles combined stdout and stderr with leading whitespace', () => { + const stdout = ' stdout with leading space\n' + const stderr = ' stderr with leading space' + + const { output } = parseTerminalOutput( + JSON.stringify([ + { + type: 'json', + value: { stdout, stderr, exitCode: 0 }, + }, + ]), + ) + + expect(output).toContain(' stdout with leading space') + expect(output).toContain(' stderr with leading space') + }) + + test('handles output that is only whitespace', () => { + const whitespaceOnly = ' ' + const { output } = parseTerminalOutput(createJsonOutput(whitespaceOnly)) + + // trimEnd() on whitespace-only string returns empty string, which becomes null + expect(output).toBe(null) + }) + + test('handles empty output', () => { + const { output } = parseTerminalOutput(createJsonOutput('')) + + expect(output).toBe(null) + }) + }) + + describe('parseTerminalOutput', () => { + test('handles error messages', () => { + const errorPayload = JSON.stringify([ + { + type: 'json', + value: { + command: 'test', + errorMessage: 'Something went wrong', + stdout: '', + stderr: '', + exitCode: 1, + }, + }, + ]) + + const { output, startingCwd } = parseTerminalOutput(errorPayload) + + expect(output).toBe('Error: Something went wrong') + expect(startingCwd).toBeUndefined() + }) + + test('extracts startingCwd when present', () => { + const payloadWithCwd = JSON.stringify([ + { + type: 'json', + value: { + command: 'pwd', + stdout: '/project\n', + stderr: '', + exitCode: 0, + startingCwd: '/project', + }, + }, + ]) + + const { output, startingCwd } = parseTerminalOutput(payloadWithCwd) + + expect(output).toBe('/project') + expect(startingCwd).toBe('/project') + }) + }) +}) diff --git a/cli/src/components/tools/run-terminal-command.tsx b/cli/src/components/tools/run-terminal-command.tsx index f97d2fd5d1..6c630d39e3 100644 --- a/cli/src/components/tools/run-terminal-command.tsx +++ b/cli/src/components/tools/run-terminal-command.tsx @@ -3,6 +3,44 @@ import { TerminalCommandDisplay } from '../terminal-command-display' import type { ToolRenderConfig } from './types' +export interface ParsedTerminalOutput { + output: string | null + startingCwd?: string +} + +/** + * Parse terminal command output from JSON or raw string format. + * Exported for testing. + */ +export const parseTerminalOutput = (rawOutput: string | undefined): ParsedTerminalOutput => { + if (!rawOutput) { + return { output: null } + } + + try { + const parsed = JSON.parse(rawOutput) + // Handle array format [{ type: 'json', value: {...} }] + const value = Array.isArray(parsed) ? parsed[0]?.value : parsed + if (value) { + const startingCwd = value.startingCwd + // Handle error case + if (value.errorMessage) { + return { output: `Error: ${value.errorMessage}`, startingCwd } + } + // Combine stdout and stderr for display + // Use trimEnd() to preserve leading spaces (used for UI elements like trees/tables) + const stdout = value.stdout || '' + const stderr = value.stderr || '' + const output = (stdout + stderr).trimEnd() || null + return { output, startingCwd } + } + return { output: null } + } catch { + // If not JSON, use raw output (preserve leading spaces) + return { output: rawOutput.trimEnd() || null } + } +} + /** * UI component for run_terminal_command tool. * Displays the command in bold next to the bullet point, @@ -19,31 +57,7 @@ export const RunTerminalCommandComponent = defineToolComponent({ : '' // Extract output and startingCwd from tool result - let output: string | null = null - let startingCwd: string | undefined - - if (toolBlock.output) { - try { - const parsed = JSON.parse(toolBlock.output) - // Handle array format [{ type: 'json', value: {...} }] - const value = Array.isArray(parsed) ? parsed[0]?.value : parsed - if (value) { - startingCwd = value.startingCwd - // Handle error case - if (value.errorMessage) { - output = `Error: ${value.errorMessage}` - } else { - // Combine stdout and stderr for display - const stdout = value.stdout || '' - const stderr = value.stderr || '' - output = (stdout + stderr).trim() || null - } - } - } catch { - // If not JSON, use raw output - output = toolBlock.output.trim() || null - } - } + const { output, startingCwd } = parseTerminalOutput(toolBlock.output) // Custom content component using shared TerminalCommandDisplay const content = ( diff --git a/cli/src/components/top-banner.tsx b/cli/src/components/top-banner.tsx index 76883f8594..3a52e29495 100644 --- a/cli/src/components/top-banner.tsx +++ b/cli/src/components/top-banner.tsx @@ -42,13 +42,8 @@ const TOP_BANNER_REGISTRY: Record, BannerConfig> = { borderColorKey: 'warning', textColorKey: 'foreground', relatedInputMode: 'homeDir', - content: ( - <> - You are currently in your home directory. -
- Select a project folder to get started, or choose "Start here". - - ), + content: + 'You are currently in your home directory.\nSelect a project folder to get started, or choose "Start here".', }, gitRoot: { borderColorKey: 'warning', diff --git a/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts b/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts new file mode 100644 index 0000000000..0958d167fc --- /dev/null +++ b/cli/src/hooks/__tests__/use-ask-user-bridge.test.ts @@ -0,0 +1,176 @@ +import { describe, test, expect, beforeEach, afterEach, spyOn } from 'bun:test' + +import { AskUserBridge } from '@codebuff/common/utils/ask-user-bridge' + +import { useChatStore } from '../../state/chat-store' + +describe('useAskUserBridge', () => { + const submitAnswers = ( + answers: Array<{ + questionIndex: number + selectedOption?: string + selectedOptions?: string[] + otherText?: string + }> + ) => { + AskUserBridge.submit({ answers }) + } + + const skip = () => { + AskUserBridge.submit({ skipped: true }) + } + + let submitSpy: ReturnType + + beforeEach(() => { + // Mock AskUserBridge.submit to track calls + submitSpy = spyOn(AskUserBridge, 'submit') + + // Reset the chat store to a known state with some input + useChatStore.setState({ + inputValue: 'user input that should be preserved', + cursorPosition: 35, + lastEditDueToNav: false, + askUserState: null, + }) + }) + + afterEach(() => { + submitSpy.mockRestore() + }) + + describe('submitAnswers', () => { + test('calls AskUserBridge.submit with the provided answers', () => { + const answers = [ + { questionIndex: 0, selectedOption: 'Option A' }, + { questionIndex: 1, selectedOptions: ['Option B', 'Option C'] }, + ] + + submitAnswers(answers) + + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(submitSpy).toHaveBeenCalledWith({ answers }) + }) + + test('does NOT modify the input value in the store', () => { + const originalInputValue = useChatStore.getState().inputValue + const originalCursorPosition = useChatStore.getState().cursorPosition + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + // Verify input value was NOT changed + const currentState = useChatStore.getState() + expect(currentState.inputValue).toBe(originalInputValue) + expect(currentState.cursorPosition).toBe(originalCursorPosition) + }) + + test('preserves input value with empty answers array', () => { + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + expect(submitSpy).toHaveBeenCalledWith({ answers: [] }) + }) + + test('preserves input value with multiple question answers', () => { + const originalInputValue = useChatStore.getState().inputValue + + const answers = [ + { questionIndex: 0, selectedOption: 'First answer' }, + { questionIndex: 1, selectedOptions: ['Multi 1', 'Multi 2'] }, + { questionIndex: 2, otherText: 'Custom text input' }, + ] + + submitAnswers(answers) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + }) + + describe('skip', () => { + test('calls AskUserBridge.submit with skipped: true', () => { + skip() + + expect(submitSpy).toHaveBeenCalledTimes(1) + expect(submitSpy).toHaveBeenCalledWith({ skipped: true }) + }) + + test('does NOT modify the input value in the store', () => { + const originalInputValue = useChatStore.getState().inputValue + const originalCursorPosition = useChatStore.getState().cursorPosition + + skip() + + // Verify input value was NOT changed + const currentState = useChatStore.getState() + expect(currentState.inputValue).toBe(originalInputValue) + expect(currentState.cursorPosition).toBe(originalCursorPosition) + }) + }) + + describe('input preservation regression tests', () => { + test('input with special characters is preserved after submitAnswers', () => { + useChatStore.setState({ + inputValue: 'Input with "quotes" and `backticks` and @mentions', + cursorPosition: 48, + }) + + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('input with special characters is preserved after skip', () => { + useChatStore.setState({ + inputValue: "Don't lose this apostrophe or @file-picker mention", + cursorPosition: 51, + }) + + const originalInputValue = useChatStore.getState().inputValue + + skip() + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('multiline input is preserved after submitAnswers', () => { + useChatStore.setState({ + inputValue: 'Line 1\nLine 2\nLine 3', + cursorPosition: 20, + }) + + const originalInputValue = useChatStore.getState().inputValue + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe(originalInputValue) + }) + + test('empty input remains empty after submitAnswers', () => { + useChatStore.setState({ + inputValue: '', + cursorPosition: 0, + }) + + submitAnswers([{ questionIndex: 0, selectedOption: 'Test' }]) + + expect(useChatStore.getState().inputValue).toBe('') + expect(useChatStore.getState().cursorPosition).toBe(0) + }) + + test('empty input remains empty after skip', () => { + useChatStore.setState({ + inputValue: '', + cursorPosition: 0, + }) + + skip() + + expect(useChatStore.getState().inputValue).toBe('') + expect(useChatStore.getState().cursorPosition).toBe(0) + }) + }) +}) diff --git a/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts b/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts index dca6b7efb7..68cbd99214 100644 --- a/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts +++ b/cli/src/hooks/__tests__/use-suggestion-engine-mention.test.ts @@ -1,98 +1,27 @@ import { describe, test, expect } from 'bun:test' -// Helper function extracted from use-suggestion-engine.ts for testing -const isInsideQuotes = (text: string, position: number): boolean => { - let inSingleQuote = false - let inDoubleQuote = false - let inBacktick = false - let escaped = false - - for (let i = 0; i < position; i++) { - const char = text[i] - - if (escaped) { - escaped = false - continue - } - - if (char === '\\') { - escaped = true - continue - } - - if (char === "'" && !inDoubleQuote && !inBacktick) { - inSingleQuote = !inSingleQuote - } else if (char === '"' && !inSingleQuote && !inBacktick) { - inDoubleQuote = !inDoubleQuote - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { - inBacktick = !inBacktick - } - } - - return inSingleQuote || inDoubleQuote || inBacktick -} - -const parseAtInLine = ( - line: string, -): { active: boolean; query: string; atIndex: number } => { - const atIndex = line.lastIndexOf('@') - if (atIndex === -1) { - return { active: false, query: '', atIndex: -1 } - } - - // Check if @ is inside quotes - if (isInsideQuotes(line, atIndex)) { - return { active: false, query: '', atIndex: -1 } - } - - const beforeChar = atIndex > 0 ? line[atIndex - 1] : '' - - // Don't trigger on escaped @: \@ - if (beforeChar === '\\') { - return { active: false, query: '', atIndex: -1 } - } - - // Don't trigger on email-like patterns or URLs - if (beforeChar && /[a-zA-Z0-9.:]/.test(beforeChar)) { - return { active: false, query: '', atIndex: -1 } - } - - // Require whitespace or start of line before @ - if (beforeChar && !/\s/.test(beforeChar)) { - return { active: false, query: '', atIndex: -1 } - } - - const afterAt = line.slice(atIndex + 1) - const firstSpaceIndex = afterAt.search(/\s/) - const query = - firstSpaceIndex === -1 ? afterAt : afterAt.slice(0, firstSpaceIndex) - - if (firstSpaceIndex !== -1) { - return { active: false, query: '', atIndex: -1 } - } - - return { active: true, query, atIndex } -} +import { isInsideStringDelimiters, parseAtInLine } from '../use-suggestion-engine' describe('@ mention edge cases - quote detection', () => { - test('isInsideQuotes detects position inside double quotes', () => { - expect(isInsideQuotes('"hello @world"', 7)).toBe(true) + test('isInsideStringDelimiters detects position inside double quotes', () => { + expect(isInsideStringDelimiters('"hello @world"', 7)).toBe(true) }) - test('isInsideQuotes detects position inside single quotes', () => { - expect(isInsideQuotes("'hello @world'", 7)).toBe(true) + test('isInsideStringDelimiters does NOT detect position inside single quotes (apostrophes)', () => { + // Single quotes are ignored - they're commonly used as apostrophes + expect(isInsideStringDelimiters("'hello @world'", 7)).toBe(false) }) - test('isInsideQuotes detects position inside backticks', () => { - expect(isInsideQuotes('`hello @world`', 7)).toBe(true) + test('isInsideStringDelimiters detects position inside backticks', () => { + expect(isInsideStringDelimiters('`hello @world`', 7)).toBe(true) }) - test('isInsideQuotes returns false for position outside quotes', () => { - expect(isInsideQuotes('"hello" @world', 8)).toBe(false) + test('isInsideStringDelimiters returns false for position outside quotes', () => { + expect(isInsideStringDelimiters('"hello" @world', 8)).toBe(false) }) - test('isInsideQuotes handles escaped quotes', () => { - expect(isInsideQuotes('"hello \\" @world"', 11)).toBe(true) + test('isInsideStringDelimiters handles escaped quotes', () => { + expect(isInsideStringDelimiters('"hello \\" @world"', 11)).toBe(true) }) }) @@ -114,7 +43,8 @@ describe('parseAtInLine - @ mention trigger logic', () => { expect(result.active).toBe(false) }) - test('does NOT trigger for @ inside single quotes', () => { + test('does NOT trigger for @ immediately after single quote (whitespace still required)', () => { + // Single quotes don't create quoted regions, but whitespace before @ is still required const result = parseAtInLine("'@agent'") expect(result.active).toBe(false) }) @@ -175,44 +105,24 @@ describe('parseAtInLine - @ mention trigger logic', () => { describe('parseAtInLine - comprehensive edge cases', () => { // Email variations - test('does NOT trigger for email with subdomain', () => { - const result = parseAtInLine('user@mail.example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with numbers', () => { - const result = parseAtInLine('user123@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with underscores', () => { - const result = parseAtInLine('user_name@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with hyphens', () => { - const result = parseAtInLine('user-name@example.com') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for email with dots in username', () => { - const result = parseAtInLine('first.last@example.com') + test.each([ + ['user@mail.example.com', 'email with subdomain'], + ['user123@example.com', 'email with numbers'], + ['user_name@example.com', 'email with underscores'], + ['user-name@example.com', 'email with hyphens'], + ['first.last@example.com', 'email with dots in username'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) // URL variations - test('does NOT trigger for http URL', () => { - const result = parseAtInLine('http://example.com/@user') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for https URL', () => { - const result = parseAtInLine('https://example.com/@user') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for URL with port', () => { - const result = parseAtInLine('http://localhost:3000/@user') + test.each([ + ['http://example.com/@user', 'http URL'], + ['https://example.com/@user', 'https URL'], + ['http://localhost:3000/@user', 'URL with port'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) @@ -283,20 +193,12 @@ describe('parseAtInLine - comprehensive edge cases', () => { }) // Whitespace variations - test('triggers with tab before @', () => { - const result = parseAtInLine('\t@agent') - expect(result.active).toBe(true) - expect(result.query).toBe('agent') - }) - - test('triggers with newline before @ (in same line context)', () => { - const result = parseAtInLine(' @agent') - expect(result.active).toBe(true) - expect(result.query).toBe('agent') - }) - - test('triggers with multiple spaces before @', () => { - const result = parseAtInLine('text @agent') + test.each([ + ['\t@agent', 'tab before @'], + [' @agent', 'space before @'], + ['text @agent', 'multiple spaces before @'], + ])('triggers with %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(true) expect(result.query).toBe('agent') }) @@ -320,13 +222,11 @@ describe('parseAtInLine - comprehensive edge cases', () => { }) // Code-like contexts (where @ might appear) - test('does NOT trigger for decorator-like syntax', () => { - const result = parseAtInLine('something.@decorator') - expect(result.active).toBe(false) - }) - - test('does NOT trigger for array access', () => { - const result = parseAtInLine('array.@index') + test.each([ + ['something.@decorator', 'decorator-like syntax'], + ['array.@index', 'array access'], + ])('does NOT trigger for %s (%s)', (input) => { + const result = parseAtInLine(input) expect(result.active).toBe(false) }) @@ -360,9 +260,11 @@ describe('parseAtInLine - comprehensive edge cases', () => { expect(result.active).toBe(false) }) - test('does NOT trigger when inside unclosed single quote', () => { + test('DOES trigger when inside unclosed single quote (apostrophes dont suppress)', () => { + // Single quotes are treated as apostrophes, not string delimiters const result = parseAtInLine("'unclosed @mention") - expect(result.active).toBe(false) + expect(result.active).toBe(true) + expect(result.query).toBe('mention') }) test('does NOT trigger when inside unclosed backtick', () => { @@ -370,3 +272,90 @@ describe('parseAtInLine - comprehensive edge cases', () => { expect(result.active).toBe(false) }) }) + +describe('single quote handling - apostrophes should NOT suppress @ menu', () => { + // Common contractions with apostrophes - use test.each for repetitive cases + const contractions = [ + ["don't", 'agent'], + ["it's", 'agent'], + ["I'm", 'agent'], + ["can't", 'agent'], + ["won't", 'agent'], + ["you're", 'agent'], + ["they're", 'agent'], + ["doesn't", 'agent'], + ] as const + + test.each(contractions)( + 'triggers @ after contraction "%s"', + (contraction, expectedQuery) => { + const result = parseAtInLine(`I ${contraction} @${expectedQuery}`) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) + + // Possessives with apostrophes + const possessives = [ + ["user's", 'mention'], + ["file's", 'content'], + ] as const + + test.each(possessives)( + 'triggers @ after possessive "%s"', + (possessive, expectedQuery) => { + const result = parseAtInLine(`${possessive} @${expectedQuery}`) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) + + // Multiple apostrophes in sentence + test('triggers @ with multiple apostrophes in sentence', () => { + const result = parseAtInLine("I don't think it's working @agent") + expect(result.active).toBe(true) + expect(result.query).toBe('agent') + }) + + // Single quotes that look like string delimiters + test('triggers @ after space inside single-quoted-looking string', () => { + // The @ triggers because there's a space before it, not because of single quotes + const result = parseAtInLine("'hello @world'") + expect(result.active).toBe(true) + // Query includes the trailing quote since it's not a delimiter + expect(result.query).toBe("world'") + }) + + test('does NOT trigger @ at start of single-quoted-looking string (whitespace required)', () => { + // Single quotes don't create quoted regions, but whitespace before @ is still required + const result = parseAtInLine("'@agent'") + expect(result.active).toBe(false) + }) + + // Mixed quotes - double quotes still suppress + test('does NOT trigger when @ is inside double quotes even with apostrophes', () => { + const result = parseAtInLine('"I don\'t @agent"') + expect(result.active).toBe(false) + }) + + test('does NOT trigger when @ is inside backticks even with apostrophes', () => { + const result = parseAtInLine("`I don't @agent`") + expect(result.active).toBe(false) + }) + + // Real-world usage examples + const realWorldExamples = [ + ["Why doesn't this work? @agent", 'agent'], + ["That's what @file-picker", 'file-picker'], + ["What's @commander", 'commander'], + ] as const + + test.each(realWorldExamples)( + 'triggers in natural sentence: "%s"', + (sentence, expectedQuery) => { + const result = parseAtInLine(sentence) + expect(result.active).toBe(true) + expect(result.query).toBe(expectedQuery) + }, + ) +}) diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 1c71472cc3..e57acdb257 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -384,7 +384,7 @@ describe('handleRunError', () => { useChatStore.getState = originalGetState }) - test('appends error to existing streamed content for regular errors', () => { + test('stores error in userError field for regular errors', () => { let messages: ChatMessage[] = [ { id: 'ai-1', @@ -407,7 +407,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Network timeout'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: (value: boolean) => { @@ -424,15 +423,12 @@ describe('handleRunError', () => { }, }) - // Flush the batched updates - updater.flush() - const aiMessage = messages.find((m) => m.id === 'ai-1') expect(aiMessage).toBeDefined() - // Content should be appended, not overwritten - expect(aiMessage!.content).toContain('Partial streamed content') - expect(aiMessage!.content).toContain('Network timeout') + // Content should be preserved, error stored in userError + expect(aiMessage!.content).toBe('Partial streamed content') + expect(aiMessage!.userError).toBe('Network timeout') // Verify state resets expect(streamStatus).toBe('idle') @@ -465,7 +461,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Something failed'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -474,11 +469,9 @@ describe('handleRunError', () => { updateChainInProgress: () => {}, }) - updater.flush() - const aiMessage = messages.find((m) => m.id === 'ai-1') - // Should contain error message - expect(aiMessage!.content).toContain('Something failed') + // Error should be in userError field + expect(aiMessage!.userError).toBe('Something failed') expect(aiMessage!.isComplete).toBe(true) }) @@ -506,7 +499,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Regular error'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -541,7 +533,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Some error'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -575,7 +566,6 @@ describe('handleRunError', () => { handleRunError({ error: new Error('Some error'), - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -591,6 +581,82 @@ describe('handleRunError', () => { expect(canProcessQueue).toBe(false) }) + test('context length exceeded error (AI_APICallError) stores error in userError and preserves content', () => { + let messages: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'Partial streamed content before error', + blocks: [{ type: 'text', content: 'some block content' }], + timestamp: 'now', + }, + ] + + const timerController = createMockTimerController() + const updater = createBatchedMessageUpdater('ai-1', (fn: any) => { + messages = fn(messages) + }) + + // Create an error that matches the real AI_APICallError structure + const contextLengthError = Object.assign( + new Error( + "This endpoint's maximum context length is 200000 tokens. However, you requested about 201209 tokens (158536 of text input, 10673 of tool input, 32000 in the output). Please reduce the length of either one, or use the \"middle-out\" transform to compress your prompt automatically." + ), + { + name: 'AI_APICallError', + statusCode: 400, + } + ) + + let streamStatus = 'streaming' as StreamStatus + let canProcessQueue = false + let chainInProgress = true + let isRetrying = true + + handleRunError({ + error: contextLengthError, + timerController, + updater, + setIsRetrying: (value: boolean) => { + isRetrying = value + }, + setStreamStatus: (status: StreamStatus) => { + streamStatus = status + }, + setCanProcessQueue: (can: boolean) => { + canProcessQueue = can + }, + updateChainInProgress: (value: boolean) => { + chainInProgress = value + }, + }) + + const aiMessage = messages.find((m) => m.id === 'ai-1') + expect(aiMessage).toBeDefined() + + // Content should be preserved + expect(aiMessage!.content).toBe('Partial streamed content before error') + + // Blocks should be preserved + expect(aiMessage!.blocks).toEqual([{ type: 'text', content: 'some block content' }]) + + // Error should be stored in userError (displayed in UserErrorBanner) + expect(aiMessage!.userError).toContain('maximum context length is 200000 tokens') + expect(aiMessage!.userError).toContain('201209 tokens') + + // Message should be marked complete + expect(aiMessage!.isComplete).toBe(true) + + // State should be reset + expect(streamStatus).toBe('idle') + expect(canProcessQueue).toBe(true) + expect(chainInProgress).toBe(false) + expect(isRetrying).toBe(false) + + // Timer should be stopped with error + expect(timerController.stopCalls).toContain('error') + }) + test('Payment required error (402) uses setError, invalidates queries, and switches input mode', () => { let messages: ChatMessage[] = [ { @@ -617,7 +683,6 @@ describe('handleRunError', () => { handleRunError({ error: paymentError, - aiMessageId: 'ai-1', timerController, updater, setIsRetrying: () => {}, @@ -629,9 +694,10 @@ describe('handleRunError', () => { const aiMessage = messages.find((m) => m.id === 'ai-1') expect(aiMessage).toBeDefined() - // For PaymentRequiredError, setError is used which OVERWRITES content - expect(aiMessage!.content).not.toContain('Partial streamed content') - expect(aiMessage!.content).toContain('Out of credits') + // For PaymentRequiredError, setError sets userError (not content) + // Content is preserved, error is stored in userError field + expect(aiMessage!.content).toBe('Partial streamed content') + expect(aiMessage!.userError).toContain('Out of credits') // Blocks should be preserved for debugging context expect(aiMessage!.blocks).toEqual([{ type: 'text', content: 'some block' }]) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 8637aee9c1..4e3e0f6580 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -2,7 +2,6 @@ import { getProjectRoot } from '../../project-files' import { useChatStore } from '../../state/chat-store' import { processBashContext } from '../../utils/bash-context-processor' import { - createErrorMessage, isOutOfCreditsError, OUT_OF_CREDITS_MESSAGE, } from '../../utils/error-handling' @@ -69,6 +68,8 @@ export const finalizeQueueState = (params: FinalizeQueueStateParams): void => { updateChainInProgress(false) } +const DEFAULT_RUN_OUTPUT_ERROR_MESSAGE = 'No output from agent run' + export type PrepareUserMessageDeps = { setMessages: (update: SetStateAction) => void lastMessageMode: AgentMode | null @@ -209,6 +210,8 @@ export const setupStreamingContext = (params: { streamRefs.reset() timerController.start(aiMessageId) const updater = createBatchedMessageUpdater(aiMessageId, setMessages) + // Clear any previous UI-only error on this message when starting a new run + updater.clearUserError() const hasReceivedContentRef = { current: false } const abortController = new AbortController() abortControllerRef.current = abortController @@ -280,7 +283,7 @@ export const handleRunCompletion = (params: { if (!output) { if (!streamRefs.state.wasAbortedByUser) { - updater.setError('No output from agent run') + updater.setError(DEFAULT_RUN_OUTPUT_ERROR_MESSAGE) finalizeAfterError() } return @@ -299,11 +302,8 @@ export const handleRunCompletion = (params: { return } - const partial = createErrorMessage( - output.message ?? 'No output from agent run', - aiMessageId, - ) - updater.setError(partial.content ?? '') + // Pass the raw error message to setError (displayed in UserErrorBanner without additional wrapper formatting) + updater.setError(output.message ?? DEFAULT_RUN_OUTPUT_ERROR_MESSAGE) finalizeAfterError() return @@ -343,7 +343,6 @@ export const handleRunCompletion = (params: { export const handleRunError = (params: { error: unknown - aiMessageId: string timerController: SendMessageTimerController updater: BatchedMessageUpdater setIsRetrying: (value: boolean) => void @@ -355,7 +354,6 @@ export const handleRunError = (params: { }) => { const { error, - aiMessageId, timerController, updater, setIsRetrying, @@ -366,12 +364,9 @@ export const handleRunError = (params: { isQueuePausedRef, } = params - const partial = createErrorMessage(error, aiMessageId) + const errorInfo = getErrorObject(error, { includeRawError: true }) - logger.error( - { error: getErrorObject(error, { includeRawError: true }) }, - 'SDK client.run() failed', - ) + logger.error({ error: errorInfo }, 'SDK client.run() failed') setIsRetrying(false) finalizeQueueState({ setStreamStatus, @@ -389,15 +384,7 @@ export const handleRunError = (params: { return } - updater.updateAiMessage((msg) => { - const updatedContent = [msg.content, partial.content] - .filter(Boolean) - .join('\n\n') - return { - ...msg, - content: updatedContent, - } - }) - - updater.markComplete() + // Use setError for all errors so they display in UserErrorBanner consistently + const errorMessage = errorInfo.message || 'An unexpected error occurred' + updater.setError(errorMessage) } diff --git a/cli/src/hooks/use-ask-user-bridge.ts b/cli/src/hooks/use-ask-user-bridge.ts index b36573765e..1b4285d490 100644 --- a/cli/src/hooks/use-ask-user-bridge.ts +++ b/cli/src/hooks/use-ask-user-bridge.ts @@ -5,7 +5,6 @@ import { useChatStore } from '../state/chat-store' export function useAskUserBridge() { const setAskUserState = useChatStore((state) => state.setAskUserState) - const setInputValue = useChatStore((state) => state.setInputValue) useEffect(() => { const unsubscribe = AskUserBridge.subscribe((request) => { @@ -32,14 +31,12 @@ export function useAskUserBridge() { otherText?: string }> ) => { - // Clear input value so previous prompt doesn't appear after form closes - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + // Don't clear input value - preserve user's input from before the questionnaire AskUserBridge.submit({ answers }) } const skip = () => { - // Clear input value so previous prompt doesn't appear after form closes - setInputValue({ text: '', cursorPosition: 0, lastEditDueToNav: false }) + // Don't clear input value - preserve user's input from before the questionnaire AskUserBridge.submit({ skipped: true }) } diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index 1170fd8381..a68688b84d 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -424,7 +424,6 @@ export const useSendMessage = ({ } catch (error) { handleRunError({ error, - aiMessageId, timerController, updater, setIsRetrying, diff --git a/cli/src/hooks/use-suggestion-engine.ts b/cli/src/hooks/use-suggestion-engine.ts index da0d8fc50d..caa68345f3 100644 --- a/cli/src/hooks/use-suggestion-engine.ts +++ b/cli/src/hooks/use-suggestion-engine.ts @@ -70,9 +70,9 @@ interface MentionParseResult { atIndex: number } -// Helper to check if a position is inside quotes -const isInsideQuotes = (text: string, position: number): boolean => { - let inSingleQuote = false +// Helper to check if a position is inside string delimiters (double quotes or backticks only) +// Single quotes are excluded because they're commonly used as apostrophes (don't, it's, etc.) +export const isInsideStringDelimiters = (text: string, position: number): boolean => { let inDoubleQuote = false let inBacktick = false @@ -91,27 +91,25 @@ const isInsideQuotes = (text: string, position: number): boolean => { const isEscaped = numBackslashes % 2 === 1 if (!isEscaped) { - if (char === "'" && !inDoubleQuote && !inBacktick) { - inSingleQuote = !inSingleQuote - } else if (char === '"' && !inSingleQuote && !inBacktick) { + if (char === '"' && !inBacktick) { inDoubleQuote = !inDoubleQuote - } else if (char === '`' && !inSingleQuote && !inDoubleQuote) { + } else if (char === '`' && !inDoubleQuote) { inBacktick = !inBacktick } } } - return inSingleQuote || inDoubleQuote || inBacktick + return inDoubleQuote || inBacktick } -const parseAtInLine = (line: string): MentionParseResult => { +export const parseAtInLine = (line: string): MentionParseResult => { const atIndex = line.lastIndexOf('@') if (atIndex === -1) { return { active: false, query: '', atIndex: -1 } } - // Check if @ is inside quotes - if (isInsideQuotes(line, atIndex)) { + // Check if @ is inside string delimiters + if (isInsideStringDelimiters(line, atIndex)) { return { active: false, query: '', atIndex: -1 } } diff --git a/cli/src/utils/__tests__/message-updater.test.ts b/cli/src/utils/__tests__/message-updater.test.ts index 1c46c5e675..661aa0cf88 100644 --- a/cli/src/utils/__tests__/message-updater.test.ts +++ b/cli/src/utils/__tests__/message-updater.test.ts @@ -53,12 +53,12 @@ describe('createMessageUpdater', () => { expect((state[0].metadata as any).runState).toEqual({ id: 'run-1' }) }) - test('setError preserves blocks and marks complete', () => { + test('setError preserves content and blocks, sets userError, and marks complete', () => { let state: ChatMessage[] = [ { id: 'ai-1', variant: 'ai', - content: '', + content: 'original content', blocks: [{ type: 'text', content: 'existing block' }], timestamp: 'now', }, @@ -70,11 +70,54 @@ describe('createMessageUpdater', () => { updater.setError('boom') - expect(state[0].content).toBe('boom') + // setError stores error in userError field, preserving content + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBe('boom') expect(state[0].isComplete).toBe(true) expect(state[0].blocks).toHaveLength(1) expect((state[0].blocks![0] as any).content).toBe('existing block') }) + + test('clearUserError removes userError field from message', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'original content', + userError: 'previous error', + timestamp: 'now', + }, + ] + + const updater = createMessageUpdater('ai-1', (fn) => { + state = fn(state) + }) + + updater.clearUserError() + + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBeUndefined() + }) + + test('clearUserError is a no-op if no userError exists', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'original content', + timestamp: 'now', + }, + ] + + const updater = createMessageUpdater('ai-1', (fn) => { + state = fn(state) + }) + + updater.clearUserError() + + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBeUndefined() + }) }) describe('createBatchedMessageUpdater', () => { @@ -164,12 +207,12 @@ describe('createBatchedMessageUpdater', () => { expect(state[0].credits).toBe(0.5) }) - test('setError discards pending updates but preserves existing blocks', () => { + test('setError flushes pending updates and preserves existing content and blocks', () => { let state: ChatMessage[] = [ { id: 'ai-1', variant: 'ai', - content: '', + content: 'original content', blocks: [{ type: 'text', content: 'existing block' }], timestamp: 'now', }, @@ -185,18 +228,21 @@ describe('createBatchedMessageUpdater', () => { 1000, ) - // Queue an update (will be discarded by error) + // Queue an update that should be flushed before applying the error updater.addBlock({ type: 'text', content: 'pending block' }) updater.setError('something went wrong') - // Should have 1 call: setError (pending updates discarded, not flushed) - expect(setMessagesCallCount).toBe(1) - expect(state[0].content).toBe('something went wrong') + // Should have 2 calls: flush + setError + expect(setMessagesCallCount).toBe(2) + // setError stores error in userError field, preserving content + expect(state[0].content).toBe('original content') + expect(state[0].userError).toBe('something went wrong') expect(state[0].isComplete).toBe(true) - // Existing blocks are preserved, but pending block was discarded - expect(state[0].blocks).toHaveLength(1) + // Existing blocks are preserved and pending block was flushed + expect(state[0].blocks).toHaveLength(2) expect((state[0].blocks![0] as any).content).toBe('existing block') + expect((state[0].blocks![1] as any).content).toBe('pending block') }) test('updates after dispose are applied immediately', () => { @@ -506,6 +552,74 @@ describe('createBatchedMessageUpdater timer behavior', () => { expect(clearedIntervals).toContain(intervalId) }) + test('clearUserError applies immediately (bypasses batch queue)', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'content', + userError: 'previous error', + timestamp: 'now', + }, + ] + let setMessagesCallCount = 0 + + const updater = createBatchedMessageUpdater( + 'ai-1', + (fn) => { + setMessagesCallCount++ + state = fn(state) + }, + 1000, // Long interval so it won't auto-flush + ) + + // Queue an update (should NOT be applied yet) + updater.updateAiMessage((msg) => ({ ...msg, content: 'updated' })) + expect(setMessagesCallCount).toBe(0) + expect(state[0].content).toBe('content') + + // clearUserError should apply immediately + updater.clearUserError() + + // Should have 1 call from clearUserError (applied immediately) + expect(setMessagesCallCount).toBe(1) + expect(state[0].userError).toBeUndefined() + // Content should still be 'content' since the queued update wasn't flushed + expect(state[0].content).toBe('content') + + updater.dispose() + }) + + test('clearUserError is a no-op if no userError exists', () => { + let state: ChatMessage[] = [ + { + id: 'ai-1', + variant: 'ai', + content: 'content', + timestamp: 'now', + }, + ] + let setMessagesCallCount = 0 + + const updater = createBatchedMessageUpdater( + 'ai-1', + (fn) => { + setMessagesCallCount++ + state = fn(state) + }, + 1000, + ) + + updater.clearUserError() + + // Should have 1 call but message unchanged + expect(setMessagesCallCount).toBe(1) + expect(state[0].userError).toBeUndefined() + expect(state[0].content).toBe('content') + + updater.dispose() + }) + test('no stray timers after all termination methods', () => { // Test that each termination method properly cleans up const updater1 = createBatchedMessageUpdater('ai-1', () => {}, 100) diff --git a/cli/src/utils/message-updater.ts b/cli/src/utils/message-updater.ts index b827009687..cbeeaeeba1 100644 --- a/cli/src/utils/message-updater.ts +++ b/cli/src/utils/message-updater.ts @@ -12,6 +12,8 @@ export type MessageUpdater = { ) => void markComplete: (metadata?: Partial) => void setError: (message: string) => void + /** Clears the userError field (e.g., when a new message is sent successfully) */ + clearUserError: () => void addBlock: (block: ContentBlock) => void } @@ -73,13 +75,22 @@ export const createMessageUpdater = ( } const setError = (message: string) => { + updateAiMessage((msg) => ({ + ...msg, + userError: message, + isComplete: true, + })) + } + + /** + * Clears the userError field from the message. + * Call this when starting a new successful interaction to dismiss any previous error banners. + */ + const clearUserError = () => { updateAiMessage((msg) => { - const nextMessage: ChatMessage = { - ...msg, - content: message, - isComplete: true, - } - return nextMessage + if (!msg.userError) return msg + const { userError: _, ...rest } = msg + return rest as ChatMessage }) } @@ -88,6 +99,7 @@ export const createMessageUpdater = ( updateAiMessageBlocks, markComplete, setError, + clearUserError, addBlock, } } @@ -187,28 +199,45 @@ export const createBatchedMessageUpdater = ( } const setError = (message: string) => { - // Clear pending updates (they'll be overwritten anyway) and stop the interval - pendingUpdaters.length = 0 + // Flush any pending updates first so we don't lose streamed content + flush() + // Stop the interval dispose() - // Apply error immediately, preserving blocks for debugging context + // Apply error immediately while preserving existing content and blocks setMessages((prev) => prev.map((msg) => { if (msg.id !== aiMessageId) return msg return { ...msg, - content: message, + userError: message, isComplete: true, } }), ) } + /** + * Clears the userError field from the message immediately (bypasses batch queue). + * Call this when starting a new successful interaction to dismiss any previous error banners. + */ + const clearUserError = () => { + // Apply immediately (bypass batch queue) so error banners are dismissed instantly + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== aiMessageId || !msg.userError) return msg + const { userError: _, ...rest } = msg + return rest as ChatMessage + }), + ) + } + return { updateAiMessage, updateAiMessageBlocks, markComplete, setError, + clearUserError, addBlock, flush, dispose, From 50e4ee9506ed39972c3126b971a0fd81cc4cba7a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:06:43 -0800 Subject: [PATCH 0014/1143] context-pruner: Include tool results of most spawned agents, except blacklisted agents --- agents/context-pruner.ts | 63 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index 2a3201cac4..b414f46dc0 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -32,6 +32,18 @@ const definition: AgentDefinition = { // Target: summarized messages should be at most 10% of max context const TARGET_SUMMARY_FACTOR = 0.1 + // Blacklist of agent IDs whose output should be excluded from spawn_agents results + const SPAWN_AGENTS_OUTPUT_BLACKLIST = [ + 'file-picker', + 'code-searcher', + 'directory-lister', + 'glob-matcher', + 'researcher-web', + 'researcher-docs', + 'code-reviewer', + 'code-reviewer-multi-prompt', + ] + // Limits for truncating long messages (chars) const USER_MESSAGE_LIMIT = 15000 const ASSISTANT_MESSAGE_LIMIT = 4000 @@ -514,6 +526,57 @@ const definition: AgentDefinition = { } } } + + // Capture spawn_agents results (excluding blacklisted agents) + // The tool result value is an array of agent results at the top level + if ( + toolMessage.toolName === 'spawn_agents' && + Array.isArray(toolMessage.content) + ) { + for (const part of toolMessage.content) { + if (part.type === 'json' && Array.isArray(part.value)) { + const agentResults = part.value as Array<{ + agentName?: string + agentType?: string + value?: { + type?: string + value?: unknown + } + }> + const includedResults = agentResults.filter( + (r) => + r.agentType && + !SPAWN_AGENTS_OUTPUT_BLACKLIST.includes(r.agentType), + ) + if (includedResults.length > 0) { + const resultSummaries = includedResults.map((r) => { + let outputStr = '' + // Extract the actual output from value.value (e.g., lastMessage content) + if (r.value?.value !== undefined && r.value?.value !== null) { + if (typeof r.value.value === 'string') { + outputStr = r.value.value + } else { + outputStr = JSON.stringify(r.value.value) + } + // Remove tags and their contents to save context tokens + outputStr = outputStr + .replace(/[\s\S]*?<\/think>/g, '') + .trim() + // Truncate long outputs to ASSISTANT_MESSAGE_LIMIT chars + if (outputStr.length > ASSISTANT_MESSAGE_LIMIT) { + outputStr = + outputStr.slice(0, ASSISTANT_MESSAGE_LIMIT) + '...' + } + } + return `- ${r.agentType}: ${outputStr || '(no output)'}` + }) + summaryParts.push( + `[AGENT RESULTS]\n${resultSummaries.join('\n')}`, + ) + } + } + } + } } } From e2fa9854680cf7b6308175a41341f8fdf8bd8571 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:10:04 -0800 Subject: [PATCH 0015/1143] switch max mode to use code-reviewer-multi-prompt --- agents/base2/base2.ts | 2 +- .../code-reviewer-multi-prompt.ts | 151 ++++++++++++++++++ 2 files changed, 152 insertions(+), 1 deletion(-) create mode 100644 agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index bcc096ea30..9d25d121bb 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -78,7 +78,7 @@ export function createBase2( isMax && 'editor-multi-prompt', isMax && 'thinker-best-of-n-opus', isDefault && 'code-reviewer', - isMax && 'reviewer-editor-gpt-5', + isMax && 'code-reviewer-multi-prompt', 'context-pruner', ), diff --git a/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts new file mode 100644 index 0000000000..eed11ba48b --- /dev/null +++ b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts @@ -0,0 +1,151 @@ +import { publisher } from '../../constants' + +import type { AgentStepContext, ToolCall } from '../../types/agent-definition' +import type { SecretAgentDefinition } from '../../types/secret-agent-definition' + +/** + * Creates a multi-prompt code reviewer agent that spawns one code-reviewer per prompt. + * Each prompt specifies a slightly different review focus or perspective. + * Combines all review outputs into a single comprehensive review. + */ +export function createCodeReviewerMultiPrompt(): Omit< + SecretAgentDefinition, + 'id' +> { + return { + publisher, + model: 'anthropic/claude-opus-4.5', + displayName: 'Multi-Prompt Code Reviewer', + spawnerPrompt: + 'Reviews code by spawning multiple code-reviewer agents with different focus prompts, then combines all review outputs into a comprehensive review. Make sure to read relevant files before spawning this agent. Pass an input array of short prompts specifying several different review focuses or perspectives.', + + includeMessageHistory: true, + inheritParentSystemPrompt: true, + + toolNames: ['spawn_agents', 'set_output'], + spawnableAgents: ['code-reviewer'], + + inputSchema: { + params: { + type: 'object', + properties: { + prompts: { + type: 'array', + items: { type: 'string' }, + description: + 'Array of 3-5 short prompts, each specifying a different review focus or perspective. Example: ["api design", "frontend changes", "correctness and edge cases", "code style and readability", "performance implications", "security concerns"]', + }, + }, + required: ['prompts'], + }, + }, + outputMode: 'structured_output', + + handleSteps: handleStepsMultiPrompt, + } +} + +function* handleStepsMultiPrompt({ + params, +}: AgentStepContext): ReturnType< + NonNullable +> { + const prompts = (params?.prompts as string[] | undefined) ?? [] + + if (prompts.length === 0) { + yield { + toolName: 'set_output', + input: { + error: + 'No prompts provided. Please pass an array of review focus prompts.', + }, + } satisfies ToolCall<'set_output'> + return + } + + // Spawn one code-reviewer per prompt + const reviewerAgents: { agent_type: string; prompt: string }[] = prompts.map( + (prompt) => ({ + agent_type: 'code-reviewer', + prompt: `Review focus: ${prompt}`, + }), + ) + + // Spawn all reviewer agents + const { toolResult: reviewerResults } = yield { + toolName: 'spawn_agents', + input: { + agents: reviewerAgents, + }, + includeToolCall: false, + } satisfies ToolCall<'spawn_agents'> + + // Extract spawn results - each is last_message output (string content) + const spawnedReviews = extractSpawnResults(reviewerResults) + + // Combine all reviews with their focus areas + const combinedReviews = spawnedReviews + .map((review, index) => { + const focus = prompts[index] ?? 'unknown' + if (!review || (typeof review === 'object' && 'errorMessage' in review)) { + return `## Review Focus: ${focus}\n\nError: ${(review as { errorMessage?: string })?.errorMessage ?? 'Unknown error'}` + } + return `## Review Focus: ${focus}\n\n${review}` + }) + .join('\n\n---\n\n') + + // Set output with the combined reviews + yield { + toolName: 'set_output', + input: { + reviews: spawnedReviews, + combinedReview: combinedReviews, + promptCount: prompts.length, + }, + includeToolCall: false, + } satisfies ToolCall<'set_output'> + + /** + * Extracts the array of subagent results from spawn_agents tool output. + * For code-reviewer agents with outputMode: 'last_message', the value is the message content. + */ + function extractSpawnResults( + results: { type: string; value?: unknown }[] | undefined, + ): (T | { errorMessage: string })[] { + if (!results || results.length === 0) return [] + + const jsonResult = results.find((r) => r.type === 'json') + if (!jsonResult?.value) return [] + + const spawnedResults = Array.isArray(jsonResult.value) + ? jsonResult.value + : [jsonResult.value] + + const extracted: (T | { errorMessage: string })[] = [] + for (const result of spawnedResults) { + const innerValue = result?.value + if ( + innerValue && + typeof innerValue === 'object' && + 'value' in innerValue + ) { + extracted.push(innerValue.value as T) + } else if ( + innerValue && + typeof innerValue === 'object' && + 'errorMessage' in innerValue + ) { + extracted.push({ errorMessage: String(innerValue.errorMessage) }) + } else if (innerValue != null) { + extracted.push(innerValue as T) + } + } + return extracted + } +} + +const definition = { + ...createCodeReviewerMultiPrompt(), + id: 'code-reviewer-multi-prompt', +} +export default definition From 58fc927f06297e9211ab3da11c92dd4cd9578b5a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:24:43 -0800 Subject: [PATCH 0016/1143] Remove footer with agent status from AgentBlockGrid --- .../components/__tests__/agent-grid.test.tsx | 51 ++++--------------- .../components/blocks/agent-block-grid.tsx | 45 ---------------- 2 files changed, 11 insertions(+), 85 deletions(-) diff --git a/cli/src/components/__tests__/agent-grid.test.tsx b/cli/src/components/__tests__/agent-grid.test.tsx index 6e6fc2776a..dcf3a4e9ac 100644 --- a/cli/src/components/__tests__/agent-grid.test.tsx +++ b/cli/src/components/__tests__/agent-grid.test.tsx @@ -139,7 +139,7 @@ describe('AgentBlockGrid (via MessageBlock)', () => { }) describe('multiple agents rendering', () => { - test('renders multiple agents with count header', () => { + test('renders multiple agents without footer label', () => { const blocks: ContentBlock[] = [ createAgentBlock('agent-1', 'File Picker', 'file-picker'), createAgentBlock('agent-2', 'Code Searcher', 'code-searcher'), @@ -153,10 +153,11 @@ describe('AgentBlockGrid (via MessageBlock)', () => { expect(markup).toContain('File Picker') expect(markup).toContain('Code Searcher') expect(markup).toContain('Commander') - expect(markup).toContain('3 agents completed') + // Footer label was removed as redundant + expect(markup).not.toContain('agents completed') }) - test('shows running count when agents are running', () => { + test('renders running agents without footer label', () => { const blocks: ContentBlock[] = [ createAgentBlock('agent-1', 'File Picker', 'file-picker', 'running'), createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'running'), @@ -166,37 +167,10 @@ describe('AgentBlockGrid (via MessageBlock)', () => { , ) - expect(markup).toContain('2 agents running') - }) - - test('shows running when at least one agent is running', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker', 'complete'), - createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'running'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('2 agents running') - }) - - test('shows running when agent is in streamingAgents set', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker', 'complete'), - createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'complete'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('2 agents running') + expect(markup).toContain('File Picker') + expect(markup).toContain('Code Searcher') + // Footer label was removed as redundant + expect(markup).not.toContain('agents running') }) }) @@ -237,7 +211,6 @@ describe('AgentBlockGrid (via MessageBlock)', () => { expect(markup).toContain('File Picker') expect(markup).toContain('Code Searcher') expect(markup).toContain('After agents') - expect(markup).toContain('2 agents completed') }) test('groups only consecutive non-implementor agents', () => { @@ -252,9 +225,9 @@ describe('AgentBlockGrid (via MessageBlock)', () => { , ) - // First group of 2 agents - expect(markup).toContain('2 agents completed') - // Single agent after separator shouldn't have header + expect(markup).toContain('File Picker 1') + expect(markup).toContain('File Picker 2') + expect(markup).toContain('Separator') expect(markup).toContain('Commander') }) }) @@ -529,7 +502,6 @@ describe('Grid layout width handling', () => { expect(markup).toContain('Agent 1') expect(markup).toContain('Agent 2') - expect(markup).toContain('2 agents completed') }) test('renders with medium width (up to 2 columns)', () => { @@ -562,6 +534,5 @@ describe('Grid layout width handling', () => { expect(markup).toContain('Agent 1') expect(markup).toContain('Agent 2') expect(markup).toContain('Agent 3') - expect(markup).toContain('3 agents completed') }) }) diff --git a/cli/src/components/blocks/agent-block-grid.tsx b/cli/src/components/blocks/agent-block-grid.tsx index 56e7ad3f27..bebe3f14a3 100644 --- a/cli/src/components/blocks/agent-block-grid.tsx +++ b/cli/src/components/blocks/agent-block-grid.tsx @@ -1,9 +1,6 @@ -import { pluralize } from '@codebuff/common/util/string' -import { TextAttributes } from '@opentui/core' import React, { memo, useCallback } from 'react' import { GridLayout } from '../grid-layout' -import { useTheme } from '../../hooks/use-theme' import type { AgentContentBlock } from '../../types/chat' export interface AgentBlockGridProps { @@ -18,41 +15,13 @@ export interface AgentBlockGridProps { ) => React.ReactNode } -export function getAgentStatusSummary( - agentBlocks: AgentContentBlock[], - streamingAgents: Set, -): string { - const running = agentBlocks.filter( - (agent) => agent.status === 'running' || streamingAgents.has(agent.agentId), - ).length - const failed = agentBlocks.filter((agent) => agent.status === 'failed').length - const completed = agentBlocks.filter((agent) => agent.status === 'complete').length - - if (running > 0) { - return `${pluralize(agentBlocks.length, 'agent')} running` - } - - if (failed > 0 && completed > 0) { - return `${failed} failed, ${completed} completed` - } - - if (failed > 0) { - return `${pluralize(failed, 'agent')} failed` - } - - return `${pluralize(agentBlocks.length, 'agent')} completed` -} - export const AgentBlockGrid = memo( ({ agentBlocks, keyPrefix, availableWidth, - streamingAgents, renderAgentBranch, }: AgentBlockGridProps) => { - const theme = useTheme() - const getItemKey = useCallback( (agentBlock: AgentContentBlock) => agentBlock.agentId, [], @@ -66,26 +35,12 @@ export const AgentBlockGrid = memo( if (agentBlocks.length === 0) return null - const headerText = getAgentStatusSummary(agentBlocks, streamingAgents) - const hasFailed = agentBlocks.some((agent) => agent.status === 'failed') - const showHeader = agentBlocks.length > 1 - - const footer = showHeader ? ( - - {headerText} - - ) : undefined - return ( ) From a2d7759b9887ef7bb566fae3ab2a8cac7b3d094c Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:31:58 -0800 Subject: [PATCH 0017/1143] delete some agents --- .../decomposing-thinker.ts | 4 +- agents/base2/base2-with-planner-pro.ts | 161 -------- .../code-reviewer-best-of-n-gemini.ts | 11 - .../code-reviewer-best-of-n-gpt-5.ts | 7 - .../best-of-n/code-reviewer-best-of-n.ts | 374 ------------------ .../code-reviewer-implementor-gpt-5.ts | 7 - .../best-of-n/code-reviewer-implementor.ts | 96 ----- .../code-reviewer-selector-gemini.ts | 7 - .../best-of-n/code-reviewer-selector-gpt-5.ts | 7 - .../best-of-n/code-reviewer-selector.ts | 127 ------ agents/reviewer/reviewer-gpt-5.ts | 10 - agents/reviewer/reviewer-lite.ts | 12 - agents/reviewer/reviewer.ts | 65 --- agents/tsconfig.json | 7 +- 14 files changed, 3 insertions(+), 892 deletions(-) rename {agents/thinker => .agents-graveyard}/decomposing-thinker.ts (95%) delete mode 100644 agents/base2/base2-with-planner-pro.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-best-of-n-gemini.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-best-of-n-gpt-5.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-best-of-n.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-implementor-gpt-5.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-implementor.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-selector-gemini.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-selector-gpt-5.ts delete mode 100644 agents/reviewer/best-of-n/code-reviewer-selector.ts delete mode 100644 agents/reviewer/reviewer-gpt-5.ts delete mode 100644 agents/reviewer/reviewer-lite.ts delete mode 100644 agents/reviewer/reviewer.ts diff --git a/agents/thinker/decomposing-thinker.ts b/.agents-graveyard/decomposing-thinker.ts similarity index 95% rename from agents/thinker/decomposing-thinker.ts rename to .agents-graveyard/decomposing-thinker.ts index 3d52872cf2..c315670cf4 100644 --- a/agents/thinker/decomposing-thinker.ts +++ b/.agents-graveyard/decomposing-thinker.ts @@ -1,6 +1,6 @@ -import { publisher } from '../constants' +import { publisher } from '../agents/constants' -import type { SecretAgentDefinition } from '../types/secret-agent-definition' +import type { SecretAgentDefinition } from '../agents/types/secret-agent-definition' const definition: SecretAgentDefinition = { id: 'decomposing-thinker', diff --git a/agents/base2/base2-with-planner-pro.ts b/agents/base2/base2-with-planner-pro.ts deleted file mode 100644 index 94b7155fca..0000000000 --- a/agents/base2/base2-with-planner-pro.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { buildArray } from '@codebuff/common/util/array' - -import { publisher } from '../constants' -import { - PLACEHOLDER, - type SecretAgentDefinition, -} from '../types/secret-agent-definition' - -export const createBase2: ( - mode: 'normal' | 'max', -) => Omit = () => { - return { - publisher, - model: 'anthropic/claude-sonnet-4.5', - displayName: 'Buffy the Orchestrator', - spawnerPrompt: - 'Advanced base agent that orchestrates planning, editing, and reviewing for complex coding tasks', - inputSchema: { - prompt: { - type: 'string', - description: 'A coding task to complete', - }, - params: { - type: 'object', - properties: { - maxContextLength: { - type: 'number', - }, - }, - required: [], - }, - }, - outputMode: 'last_message', - includeMessageHistory: true, - toolNames: ['spawn_agents', 'read_files', 'str_replace', 'write_file'], - spawnableAgents: buildArray( - 'file-picker', - 'code-searcher', - 'directory-lister', - 'glob-matcher', - 'researcher-web', - 'researcher-docs', - 'commander', - 'planner-pro', - 'code-reviewer', - 'validator', - 'context-pruner', - ), - - systemPrompt: `You are Buffy, a strategic coding assistant that orchestrates complex coding tasks through specialized sub-agents. - -# Layers - -You spawn agents in "layers". Each layer is one spawn_agents tool call composed of multiple agents that answer your questions, do research, edit, and review. - -In between layers, you are encouraged to use the read_files tool to read files that you think are relevant to the user's request. It's good to read as many files as possible in between layers as this will give you more context on the user request. - -Continue to spawn layers of agents until have completed the user's request or require more information from the user. - -## Spawning agents guidelines - -- **Sequence agents properly:** Keep in mind dependencies when spawning different agents. Don't spawn agents in parallel that depend on each other. Be conservative sequencing agents so they can build on each other's insights: - - Spawn file pickers, code-searcher, directory-lister, glob-matcher, commanders, and researchers before making edits. - - Spawn planner-pro agent after you have gathered all the context you need (and not before!). - - Only make edits after generating a plan. - - Code reviewers/validators should be spawned after you have made your edits. -- **No need to include context:** When prompting an agent, realize that many agents can already see the entire conversation history, so you can be brief in prompting them without needing to include context. -- **Don't spawn code reviewers/validators for trivial changes or quick follow-ups:** You should spawn the code reviewer/validator for most changes, but not for little changes or simple follow-ups. - -# Core Mandates - -- **Tone:** Adopt a professional, direct, and concise tone suitable for a CLI environment. -- **Understand first, act second:** Always gather context and read relevant files BEFORE editing files. -- **Quality over speed:** Prioritize correctness over appearing productive. Fewer, well-informed agents are better than many rushed ones. -- **Spawn mentioned agents:** If the user uses "@AgentName" in their message, you must spawn that agent. -- **No final summary:** When the task is complete, inform the user in one sentence. -- **Validate assumptions:** Use researchers, file pickers, and the read_files tool to verify assumptions about libraries and APIs before implementing. -- **Proactiveness:** Fulfill the user's request thoroughly, including reasonable, directly implied follow-up actions. -- **Confirm Ambiguity/Expansion:** Do not take significant actions beyond the clear scope of the request without confirming with the user. If asked *how* to do something, explain first, don't just do it. -- **Stop and ask for guidance:** You should feel free to stop and ask the user for guidance if you're stuck or don't know what to try next, or need a clarification. -- **Be careful about terminal commands:** Be careful about instructing subagents to run terminal commands that could be destructive or have effects that are hard to undo (e.g. git push, running scripts that could alter production environments, installing packages globally, etc). Don't do any of these unless the user explicitly asks you to. -- **Do what the user asks:** If the user asks you to do something, even running a risky terminal command, do it. - -# Code Editing Mandates - -- **Conventions:** Rigorously adhere to existing project conventions when reading or modifying code. Analyze surrounding code, tests, and configuration first. -- **Libraries/Frameworks:** NEVER assume a library/framework is available or appropriate. Verify its established usage within the project (check imports, configuration files like 'package.json', 'Cargo.toml', 'requirements.txt', 'build.gradle', etc., or observe neighboring files) before employing it. -- **Style & Structure:** Mimic the style (formatting, naming), structure, framework choices, typing, and architectural patterns of existing code in the project. -- **Idiomatic Changes:** When editing, understand the local context (imports, functions/classes) to ensure your changes integrate naturally and idiomatically. -- **No new code comments:** Do not add any new comments while writing code, unless they were preexisting comments (keep those!) or unless the user asks you to add comments! -- **Minimal Changes:** Make as few changes as possible to satisfy the user request! Don't go beyond what the user has asked for. -- **Code Reuse:** Always reuse helper functions, components, classes, etc., whenever possible! Don't reimplement what already exists elsewhere in the codebase. -- **Front end development** We want to make the UI look as good as possible. Don't hold back. Give it your all. - - Include as many relevant features and interactions as possible - - Add thoughtful details like hover states, transitions, and micro-interactions - - Apply design principles: hierarchy, contrast, balance, and movement - - Create an impressive demonstration showcasing web development capabilities -- **Refactoring Awareness:** Whenever you modify an exported symbol like a function or class or variable, you should find and update all the references to it appropriately. -- **Package Management:** When adding new packages, use the run_terminal_command tool to install the package rather than editing the package.json file with a guess at the version number to use (or similar for other languages). This way, you will be sure to have the latest version of the package. Do not install packages globally unless asked by the user (e.g. Don't run \`npm install -g \`). Always try to use the package manager associated with the project (e.g. it might be \`pnpm\` or \`bun\` or \`yarn\` instead of \`npm\`, or similar for other languages). -- **Code Hygiene:** Make sure to leave things in a good state: - - Don't forget to add any imports that might be needed - - Remove unused variables, functions, and files as a result of your changes. - - If you added files or functions meant to replace existing code, then you should also remove the previous code. -- **Edit multiple files at once:** When you edit files, you must make as many tool calls as possible in a single message. This is faster and much more efficient than making all the tool calls in separate messages. It saves users thousands of dollars in credits if you do this! - -# Response guidelines - -- **Don't create a summary markdown file:** The user doesn't want markdown files they didn't ask for. Don't create them. -- **Don't include final summary:** Don't include any final summary in your response. Don't describe the changes you made. Just let the user know that you have completed the task briefly. - -${PLACEHOLDER.FILE_TREE_PROMPT_SMALL} -${PLACEHOLDER.KNOWLEDGE_FILES_CONTENTS} - -# Initial Git Changes - -The following is the state of the git repository at the start of the conversation. Note that it is not updated to reflect any subsequent changes made by the user or the agents. - -${PLACEHOLDER.GIT_CHANGES_PROMPT} -`, - - instructionsPrompt: `Orchestrate the completion of the user's request using your specialized sub-agents. Take your time and be comprehensive. - -## Example response - -The user asks you to implement a new feature. You respond in multiple steps: - -1. Spawn a couple different file-picker's with different prompts to find relevant files; spawn a code-searcher and glob-matcher to find more relevant files and answer questions about the codebase; spawn 1 docs researcher to find relevant docs. -1a. Read all the relevant files using the read_files tool. -2. Spawn one more file-picker and one more code-searcher with different prompts to find relevant files. -2a. Read all the relevant files using the read_files tool. -3. Important: Spawn a planner-pro agent to generate a plan for the changes. -4. Use the str_replace or write_file tool to make the changes. -5. Spawn a code-reviewer to review the changes. Consider making changes suggested by the code-reviewer. -6. Spawn a validator to run validation commands (tests, typechecks, etc.) to ensure the changes are correct. -7. Inform the user that you have completed the task in one sentence without a final summary.`, - - stepPrompt: `Don't forget to spawn agents that could help, especially: the file-picker and find-all-referencer to get codebase context, the planner-pro agent to create a plan, the code reviewer to review changes, and the validator to run validation checks.`, - - handleSteps: function* ({ prompt, params }) { - let steps = 0 - while (true) { - steps++ - // Run context-pruner before each step - yield { - toolName: 'spawn_agent_inline', - input: { - agent_type: 'context-pruner', - params: params ?? {}, - }, - includeToolCall: false, - } as any - - const { stepsComplete } = yield 'STEP' - if (stepsComplete) break - } - }, - } -} - -const definition = { ...createBase2('normal'), id: 'base2-with-planner-pro' } -export default definition diff --git a/agents/reviewer/best-of-n/code-reviewer-best-of-n-gemini.ts b/agents/reviewer/best-of-n/code-reviewer-best-of-n-gemini.ts deleted file mode 100644 index 0c6fe64b08..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-best-of-n-gemini.ts +++ /dev/null @@ -1,11 +0,0 @@ -import { createCodeReviewerBestOfN } from './code-reviewer-best-of-n' -import { publisher } from '../../constants' -import type { SecretAgentDefinition } from '../../types/secret-agent-definition' - -const definition: SecretAgentDefinition = { - id: 'code-reviewer-best-of-n-gemini', - publisher, - ...createCodeReviewerBestOfN('gemini'), -} - -export default definition diff --git a/agents/reviewer/best-of-n/code-reviewer-best-of-n-gpt-5.ts b/agents/reviewer/best-of-n/code-reviewer-best-of-n-gpt-5.ts deleted file mode 100644 index fe7e3c8725..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-best-of-n-gpt-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import type { SecretAgentDefinition } from '../../types/secret-agent-definition' -import { createCodeReviewerBestOfN } from './code-reviewer-best-of-n' - -export default { - ...createCodeReviewerBestOfN('gpt-5'), - id: 'code-reviewer-best-of-n-gpt-5', -} satisfies SecretAgentDefinition diff --git a/agents/reviewer/best-of-n/code-reviewer-best-of-n.ts b/agents/reviewer/best-of-n/code-reviewer-best-of-n.ts deleted file mode 100644 index ec906790d3..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-best-of-n.ts +++ /dev/null @@ -1,374 +0,0 @@ -import { publisher } from '../../constants' - -import type { - AgentStepContext, - StepText, - ToolCall, -} from '../../types/agent-definition' -import { - PLACEHOLDER, - type SecretAgentDefinition, -} from '../../types/secret-agent-definition' - -export function createCodeReviewerBestOfN( - model: 'sonnet' | 'gpt-5' | 'gemini', -): Omit { - const isGpt5 = model === 'gpt-5' - const isGemini = model === 'gemini' - - return { - publisher, - model: isGpt5 - ? 'openai/gpt-5.1' - : isGemini - ? 'google/gemini-3-pro-preview' - : 'anthropic/claude-sonnet-4.5', - displayName: isGpt5 - ? 'Best-of-N GPT-5 Code Reviewer' - : isGemini - ? 'Best-of-N Gemini Code Reviewer' - : 'Best-of-N Fast Code Reviewer', - spawnerPrompt: - 'Reviews code by orchestrating multiple reviewer agents to generate review proposals, selects the best one, and provides the final review. Do not specify an input prompt for this agent; it reads the context from the message history.', - - includeMessageHistory: true, - inheritParentSystemPrompt: true, - - toolNames: ['spawn_agents'], - spawnableAgents: [ - isGemini ? 'code-reviewer-selector-gemini' : 'code-reviewer-selector', - ], - - inputSchema: { - params: { - type: 'object', - properties: { - n: { - type: 'number', - description: - 'Number of parallel reviewer agents to spawn. Defaults to 5. Use fewer for simple reviews and max of 10 for complex reviews.', - }, - }, - }, - }, - outputMode: 'last_message', - - instructionsPrompt: `You are one agent within the code-reviewer-best-of-n. You were spawned to generate a comprehensive code review for the recent changes. - -Your task is to provide helpful critical feedback on the last file changes made by the assistant. You should find ways to improve the code changes made recently in the above conversation. - -Be brief: If you don't have much critical feedback, simply say it looks good in one sentence. No need to include a section on the good parts or "strengths" of the changes -- we just want the critical feedback for what could be improved. - -NOTE: You cannot make any changes directly! Nor cany you spawn any other agents, or use any tools. You can only suggest changes. - -# Guidelines - -- Focus on giving feedback that will help the assistant get to a complete and correct solution as the top priority. -- Make sure all the requirements in the user's message are addressed. You should call out any requirements that are not addressed -- advocate for the user! -- Try to keep any changes to the codebase as minimal as possible. -- Simplify any logic that can be simplified. -- Where a function can be reused, reuse it and do not create a new one. -- Make sure that no new dead code is introduced. -- Make sure there are no missing imports. -- Make sure no sections were deleted that weren't supposed to be deleted. -- Make sure the new code matches the style of the existing code. -- Make sure there are no unnecessary try/catch blocks. Prefer to remove those. -- Look for logical errors in the code. -- Look for missed cases in the code. -- Look for any other bugs. -- Look for opportunities to improve the code's readability. - -**Important**: Do not use any tools! You are only reviewing! - -For reference, here is the original user request: - -${PLACEHOLDER.USER_INPUT_PROMPT} - - -${ - isGpt5 - ? `Now, give your review. Be concise and focus on the most important issues that need to be addressed.` - : ` -You can also use tags interspersed throughout your review to think about the best way to analyze the changes. Keep these thoughts very brief. You may not need to use think tags at all. - - - - -[ Brief thoughts about the changes made ] - - -Your critical feedback here... - - -[ Thoughts about a specific issue ] - - -More feedback... - -` -} - -Be extremely concise and focus on the most important issues that need to be addressed.`, - - handleSteps: isGpt5 ? handleStepsGpt5 : isGemini ? handleStepsGemini : handleStepsSonnet, - } -} - -function* handleStepsSonnet({ - agentState, - params, -}: AgentStepContext): ReturnType< - NonNullable -> { - const selectorAgent = 'code-reviewer-selector' - const n = Math.min(10, Math.max(1, (params?.n as number | undefined) ?? 5)) - - // Use GENERATE_N to generate n review outputs - const { nResponses = [] } = yield { - type: 'GENERATE_N', - n, - } - - // Extract all the reviews - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - const reviews = nResponses.map((content, index) => ({ - id: letters[index], - content, - })) - - // Spawn selector with reviews as params - const { toolResult: selectorResult } = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: selectorAgent, - params: { reviews }, - }, - ], - }, - includeToolCall: false, - } satisfies ToolCall<'spawn_agents'> - - const selectorOutput = extractSpawnResults<{ - reviewId: string - }>(selectorResult)[0] - - function extractSpawnResults( - results: any[] | undefined, - ): (T | { errorMessage: string })[] { - if (!results) return [] - const spawnedResults = results - .filter((result) => result.type === 'json') - .map((result) => result.value) - .flat() as { - agentType: string - value: { value?: T; errorMessage?: string } - }[] - return spawnedResults.map( - (result) => - result.value.value ?? - ({ - errorMessage: - result.value.errorMessage ?? 'Error extracting spawn results', - } as { errorMessage: string }), - ) - } - - if ('errorMessage' in selectorOutput) { - yield { - type: 'STEP_TEXT', - text: selectorOutput.errorMessage, - } satisfies StepText - return - } - const { reviewId } = selectorOutput - const chosenReview = reviews.find((review) => review.id === reviewId) - if (!chosenReview) { - yield { - type: 'STEP_TEXT', - text: 'Failed to find chosen review.', - } satisfies StepText - return - } - - yield { - type: 'STEP_TEXT', - text: chosenReview.content, - } satisfies StepText -} - -function* handleStepsGemini({ - agentState, - params, -}: AgentStepContext): ReturnType< - NonNullable -> { - const selectorAgent = 'code-reviewer-selector-gemini' - const n = Math.min(10, Math.max(1, (params?.n as number | undefined) ?? 5)) - - // Use GENERATE_N to generate n review outputs - const { nResponses = [] } = yield { - type: 'GENERATE_N', - n, - } - - // Extract all the reviews - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - const reviews = nResponses.map((content, index) => ({ - id: letters[index], - content, - })) - - // Spawn selector with reviews as params - const { toolResult: selectorResult } = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: selectorAgent, - params: { reviews }, - }, - ], - }, - includeToolCall: false, - } satisfies ToolCall<'spawn_agents'> - - const selectorOutput = extractSpawnResults<{ - reviewId: string - }>(selectorResult)[0] - - function extractSpawnResults( - results: any[] | undefined, - ): (T | { errorMessage: string })[] { - if (!results) return [] - const spawnedResults = results - .filter((result) => result.type === 'json') - .map((result) => result.value) - .flat() as { - agentType: string - value: { value?: T; errorMessage?: string } - }[] - return spawnedResults.map( - (result) => - result.value.value ?? - ({ - errorMessage: - result.value.errorMessage ?? 'Error extracting spawn results', - } as { errorMessage: string }), - ) - } - - if ('errorMessage' in selectorOutput) { - yield { - type: 'STEP_TEXT', - text: selectorOutput.errorMessage, - } satisfies StepText - return - } - const { reviewId } = selectorOutput - const chosenReview = reviews.find((review) => review.id === reviewId) - if (!chosenReview) { - yield { - type: 'STEP_TEXT', - text: 'Failed to find chosen review.', - } satisfies StepText - return - } - - yield { - type: 'STEP_TEXT', - text: chosenReview.content, - } satisfies StepText -} - -function* handleStepsGpt5({ - agentState, - params, -}: AgentStepContext): ReturnType< - NonNullable -> { - const selectorAgent = 'code-reviewer-selector' - const n = Math.min(10, Math.max(1, (params?.n as number | undefined) ?? 5)) - - // Use GENERATE_N to generate n review outputs - const { nResponses = [] } = yield { - type: 'GENERATE_N', - n, - } - - // Extract all the reviews - const letters = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' - const reviews = nResponses.map((content, index) => ({ - id: letters[index], - content, - })) - - // Spawn selector with reviews as params - const { toolResult: selectorResult } = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: selectorAgent, - params: { reviews }, - }, - ], - }, - includeToolCall: false, - } satisfies ToolCall<'spawn_agents'> - - const selectorOutput = extractSpawnResults<{ - reviewId: string - reasoning: string - }>(selectorResult)[0] - - function extractSpawnResults( - results: any[] | undefined, - ): (T | { errorMessage: string })[] { - if (!results) return [] - const spawnedResults = results - .filter((result) => result.type === 'json') - .map((result) => result.value) - .flat() as { - agentType: string - value: { value?: T; errorMessage?: string } - }[] - return spawnedResults.map( - (result) => - result.value.value ?? - ({ - errorMessage: - result.value.errorMessage ?? 'Error extracting spawn results', - } as { errorMessage: string }), - ) - } - - if ('errorMessage' in selectorOutput) { - yield { - type: 'STEP_TEXT', - text: selectorOutput.errorMessage, - } satisfies StepText - return - } - const { reviewId } = selectorOutput - const chosenReview = reviews.find((review) => review.id === reviewId) - if (!chosenReview) { - yield { - type: 'STEP_TEXT', - text: 'Failed to find chosen review.', - } satisfies StepText - return - } - - yield { - type: 'STEP_TEXT', - text: chosenReview.content, - } satisfies StepText -} - -const definition = { - ...createCodeReviewerBestOfN('sonnet'), - id: 'code-reviewer-best-of-n', -} -export default definition diff --git a/agents/reviewer/best-of-n/code-reviewer-implementor-gpt-5.ts b/agents/reviewer/best-of-n/code-reviewer-implementor-gpt-5.ts deleted file mode 100644 index e98b473ff9..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-implementor-gpt-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type SecretAgentDefinition } from '../../types/secret-agent-definition' -import { createCodeReviewerImplementor } from './code-reviewer-implementor' - -export default { - ...createCodeReviewerImplementor({ model: 'gpt-5' }), - id: 'code-reviewer-implementor-gpt-5', -} satisfies SecretAgentDefinition diff --git a/agents/reviewer/best-of-n/code-reviewer-implementor.ts b/agents/reviewer/best-of-n/code-reviewer-implementor.ts deleted file mode 100644 index 9d171662c6..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-implementor.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { publisher } from '../../constants' - -import { - PLACEHOLDER, - type SecretAgentDefinition, -} from '../../types/secret-agent-definition' - -export const createCodeReviewerImplementor = (options: { - model: 'sonnet' | 'gpt-5' -}): Omit => { - const { model } = options - const isSonnet = model === 'sonnet' - const isGpt5 = model === 'gpt-5' - - return { - publisher, - model: isSonnet ? 'anthropic/claude-sonnet-4.5' : 'openai/gpt-5.1', - displayName: 'Code Review Generator', - spawnerPrompt: - 'Generates a comprehensive code review with critical feedback', - - includeMessageHistory: true, - inheritParentSystemPrompt: true, - - toolNames: [], - spawnableAgents: [], - - inputSchema: {}, - outputMode: 'last_message', - - instructionsPrompt: `You are one agent of the code reviewer best-of-n. You were spawned to generate a comprehensive code review for the recent changes. - -Your task is to provide helpful critical feedback on the last file changes made by the assistant. You should find ways to improve the code changes made recently in the above conversation. - -Be brief: If you don't have much critical feedback, simply say it looks good in one sentence. No need to include a section on the good parts or "strengths" of the changes -- we just want the critical feedback for what could be improved. - -NOTE: You cannot make any changes directly! You can only suggest changes. - -# Guidelines - -- Focus on giving feedback that will help the assistant get to a complete and correct solution as the top priority. -- Make sure all the requirements in the user's message are addressed. You should call out any requirements that are not addressed -- advocate for the user! -- Try to keep any changes to the codebase as minimal as possible. -- Simplify any logic that can be simplified. -- Where a function can be reused, reuse it and do not create a new one. -- Make sure that no new dead code is introduced. -- Make sure there are no missing imports. -- Make sure no sections were deleted that weren't supposed to be deleted. -- Make sure the new code matches the style of the existing code. -- Make sure there are no unnecessary try/catch blocks. Prefer to remove those. -- Look for logical errors in the code. -- Look for missed cases in the code. -- Look for any other bugs. -- Look for opportunities to improve the code's readability. - -For reference, here is the original user request: - -${PLACEHOLDER.USER_INPUT_PROMPT} - - -${ - isGpt5 - ? `Now, give your review. Be concise and focus on the most important issues that need to be addressed.` - : ` -You can also use tags interspersed throughout your review to think about the best way to analyze the changes. Keep these thoughts very brief. You may not need to use think tags at all. - - - - -[ Brief thoughts about the changes made ] - - -Your critical feedback here... - - -[ Thoughts about a specific issue ] - - -More feedback... - -` -} - -Be extremely concise and focus on the most important issues that need to be addressed.`, - - handleSteps: function* () { - yield 'STEP' - }, - } -} - -const definition = { - ...createCodeReviewerImplementor({ model: 'sonnet' }), - id: 'code-reviewer-implementor', -} -export default definition diff --git a/agents/reviewer/best-of-n/code-reviewer-selector-gemini.ts b/agents/reviewer/best-of-n/code-reviewer-selector-gemini.ts deleted file mode 100644 index eefb65b85c..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-selector-gemini.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type SecretAgentDefinition } from '../../types/secret-agent-definition' -import { createCodeReviewerSelector } from './code-reviewer-selector' - -export default { - ...createCodeReviewerSelector({ model: 'gemini' }), - id: 'code-reviewer-selector-gemini', -} satisfies SecretAgentDefinition diff --git a/agents/reviewer/best-of-n/code-reviewer-selector-gpt-5.ts b/agents/reviewer/best-of-n/code-reviewer-selector-gpt-5.ts deleted file mode 100644 index 1dc25b6e7d..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-selector-gpt-5.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { type SecretAgentDefinition } from '../../types/secret-agent-definition' -import { createCodeReviewerSelector } from './code-reviewer-selector' - -export default { - ...createCodeReviewerSelector({ model: 'gpt-5' }), - id: 'code-reviewer-selector-gpt-5', -} satisfies SecretAgentDefinition diff --git a/agents/reviewer/best-of-n/code-reviewer-selector.ts b/agents/reviewer/best-of-n/code-reviewer-selector.ts deleted file mode 100644 index f071e6e65d..0000000000 --- a/agents/reviewer/best-of-n/code-reviewer-selector.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { publisher } from '../../constants' -import { - PLACEHOLDER, - type SecretAgentDefinition, -} from '../../types/secret-agent-definition' - -export const createCodeReviewerSelector = (options: { - model: 'sonnet' | 'gpt-5' | 'gemini' -}): Omit => { - const { model } = options - const isSonnet = model === 'sonnet' - const isGpt5 = model === 'gpt-5' - const isGemini = model === 'gemini' - - return { - publisher, - model: isSonnet - ? 'anthropic/claude-sonnet-4.5' - : isGpt5 - ? 'openai/gpt-5.1' - : 'google/gemini-3-pro-preview', - ...((isGpt5 || isGemini) && { - reasoningOptions: { - effort: 'medium', - }, - }), - displayName: 'Best-of-N Code Review Selector', - spawnerPrompt: - 'Analyzes multiple code review proposals and selects the best one', - - includeMessageHistory: true, - inheritParentSystemPrompt: true, - - toolNames: ['set_output'], - spawnableAgents: [], - - inputSchema: { - params: { - type: 'object', - properties: { - reviews: { - type: 'array', - items: { - type: 'object', - properties: { - id: { type: 'string' }, - content: { type: 'string' }, - }, - required: ['id', 'content'], - }, - }, - }, - required: ['reviews'], - }, - }, - outputMode: 'structured_output', - outputSchema: { - type: 'object', - properties: { - reviewId: { - type: 'string', - description: 'The id of the chosen review', - }, - }, - required: ['reviewId'], - }, - - instructionsPrompt: `As part of the best-of-n code reviewer workflow, you are the review selector agent. - -## Task Instructions - -You have been provided with multiple code review proposals via params. - -The reviews are available in the params.reviews array, where each has: -- id: A unique identifier for the review -- content: The full review text with feedback - -Your task is to analyze each review proposal carefully, compare them against the original user requirements and the code changes made, and select the best review. - -Evaluate each based on (in order of importance): -- **Critical feedback quality**: How well the review identifies real issues that need to be addressed -- **Completeness**: How thoroughly the review covers all aspects of the changes -- **Actionability**: How specific and actionable the feedback is -- **User advocacy**: How well the review advocates for the user's requirements -- **Clarity and conciseness**: How clearly the feedback is communicated -- **Technical accuracy**: How accurate the technical feedback is - -Code guidelines: -- Try to keep any changes to the codebase as minimal as possible. -- Simplify any logic that can be simplified. -- Where a function can be reused, reuse it and do not create a new one. -- Make sure that no new dead code is introduced. -- Make sure there are no missing imports. -- Make sure no sections were deleted that weren't supposed to be deleted. -- Make sure the new code matches the style of the existing code. -- Make sure there are no unnecessary try/catch blocks. Prefer to remove those. -- Mak sure there are no unnecessary type casts. Prefer to remove those. - -## User Request - -For context, here is the original user request again: - -${PLACEHOLDER.USER_INPUT_PROMPT} - - -Try to select a review that provides the most valuable, actionable, and high signal feedback that will help improve the code changes. - -## Response Format - -${ - isSonnet - ? `Use tags to briefly consider the reviews as needed to pick the best one. - -If the best one is obvious or the reviews are very similar, you may not need to think very much (a few words suffice) or you may not need to use think tags at all, just pick the best one and output it. You have a dual goal of picking the best review and being fast (using as few words as possible). - -Then, do not write any other explanations AT ALL. You should directly output a single tool call to set_output with the selected reviewId and reasoning.` - : `Output a single tool call to set_output with the selected reviewId and reasoning. Do not write anything else.` -}`, - } -} - -const definition: SecretAgentDefinition = { - ...createCodeReviewerSelector({ model: 'sonnet' }), - id: 'code-reviewer-selector', -} - -export default definition diff --git a/agents/reviewer/reviewer-gpt-5.ts b/agents/reviewer/reviewer-gpt-5.ts deleted file mode 100644 index 95bb13cc6d..0000000000 --- a/agents/reviewer/reviewer-gpt-5.ts +++ /dev/null @@ -1,10 +0,0 @@ -import reviewer from './reviewer' -import type { SecretAgentDefinition } from '../types/secret-agent-definition' - -const definition: SecretAgentDefinition = { - ...reviewer, - id: 'reviewer-gpt-5', - model: 'openai/gpt-5.1', -} - -export default definition diff --git a/agents/reviewer/reviewer-lite.ts b/agents/reviewer/reviewer-lite.ts deleted file mode 100644 index e43dbc0228..0000000000 --- a/agents/reviewer/reviewer-lite.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { publisher } from '../constants' -import { createReviewer } from './reviewer' - -import type { SecretAgentDefinition } from '../types/secret-agent-definition' - -const definition: SecretAgentDefinition = { - id: 'reviewer-lite', - publisher, - ...createReviewer('x-ai/grok-4-fast'), -} - -export default definition diff --git a/agents/reviewer/reviewer.ts b/agents/reviewer/reviewer.ts deleted file mode 100644 index f003d74f5d..0000000000 --- a/agents/reviewer/reviewer.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { publisher } from '../constants' -import { - PLACEHOLDER, - type SecretAgentDefinition, -} from '../types/secret-agent-definition' -import type { Model } from '@codebuff/common/old-constants' - -export const createReviewer = ( - model: Model, -): Omit => ({ - model, - displayName: 'Nit Pick Nick', - spawnerPrompt: - 'Reviews file changes and responds with critical feedback. Use this after making any significant change to the codebase; otherwise, no need to use this agent for minor changes since it takes a second.', - inputSchema: { - prompt: { - type: 'string', - description: 'What should be reviewed. Be brief.', - }, - }, - outputMode: 'last_message', - toolNames: ['run_file_change_hooks'], - spawnableAgents: [], - - inheritParentSystemPrompt: true, - includeMessageHistory: true, - - instructionsPrompt: `For reference, here is the original user request: - -${PLACEHOLDER.USER_INPUT_PROMPT} - - -Your task is to provide helpful feedback on the last file changes made by the assistant. - -IMPORTANT: Before analyzing the file changes, you should first: -1. Run file change hooks to validate the changes using the run_file_change_hooks tool -2. Include the hook results in your feedback - if any hooks fail, mention the specific failures and suggest how to fix them -3. If hooks pass and no issues are found, mention that validation was successful -4. Always run hooks for TypeScript/JavaScript changes, test file changes, or when the changes could affect compilation/tests - -NOTE: You cannot make any changes directly! You can only suggest changes. - -Next, you should critique the code changes made recently in the above conversation. Provide specific feedback on the file changes made by the assistant, file-by-file. - -- Focus on getting to a complete and correct solution as the top priority. -- Make sure all the requirements in the user's message are addressed. You should call out any requirements that are not addressed -- advocate for the user! -- Try to keep any changes to the codebase as minimal as possible. -- Simplify any logic that can be simplified. -- Where a function can be reused, reuse it and do not create a new one. -- Make sure that no new dead code is introduced. -- Make sure there are no missing imports. -- Make sure no sections were deleted that weren't supposed to be deleted. -- Make sure the new code matches the style of the existing code. -- Make sure there are no unnecessary try/catch blocks. Prefer to remove those. - -Be concise and to the point.`, -}) - -const definition: SecretAgentDefinition = { - id: 'reviewer', - publisher, - ...createReviewer('anthropic/claude-sonnet-4.5'), -} - -export default definition diff --git a/agents/tsconfig.json b/agents/tsconfig.json index e1d142e2a4..dbb372c162 100644 --- a/agents/tsconfig.json +++ b/agents/tsconfig.json @@ -9,10 +9,5 @@ "@codebuff/common/*": ["../common/src/*"] } }, - "include": [ - "**/*.ts", - "../.agents-graveyard/charles.ts", - "../.agents/notion-agent.ts", - "../.agents/notion-researcher.ts" - ] + "include": ["**/*.ts"] } From 40ad1ff0aee76e0616e1d7fb80d5e0650f3d1fd4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:33:20 -0800 Subject: [PATCH 0018/1143] Rename .agents-graveyard to agents-graveyard --- {.agents-graveyard => agents-graveyard}/base/ask.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-experimental.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-factory.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-lite-codex.ts | 0 .../base/base-lite-grok-4-fast.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-lite.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-max.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-prompts.ts | 0 {.agents-graveyard => agents-graveyard}/base/base-quick.ts | 0 {.agents-graveyard => agents-graveyard}/base/base.ts | 0 {.agents-graveyard => agents-graveyard}/base/thinking-base.ts | 0 {.agents-graveyard => agents-graveyard}/base2-fast-subgoals.ts | 0 {.agents-graveyard => agents-graveyard}/base2-gpt-5-worker.ts | 0 .../base2-with-context-discoverer.ts | 0 .../base2-with-task-researcher.ts | 0 .../base2/alloy/base2-alloy.ts | 0 .../base2/alloy/base2-gpt-5-single-step.ts | 0 .../base2/alloy2/base2-alloy2.ts | 0 .../base2/alloy2/base2-plan-step-gpt-5.ts | 0 .../base2/alloy2/base2-plan-step.ts | 0 .../base2/task-researcher/base2-gpt-5-with-task-researcher.ts | 0 .../base2/task-researcher/base2-with-file-researcher.ts | 0 .../task-researcher/base2-with-task-researcher-planner-pro.ts | 0 .../base2/thinking/base2-fast-thinker-gpt-5.ts | 0 .../base2/thinking/base2-fast-thinker.ts | 0 .../base2/thinking/base2-fast-thinking-tags.ts | 0 .../base2/thinking/base2-fast-thinking-tool.ts | 0 .../base2/thinking/base2-fast-thinking.ts | 0 {.agents-graveyard => agents-graveyard}/brainstormer.ts | 0 {.agents-graveyard => agents-graveyard}/charles.ts | 0 {.agents-graveyard => agents-graveyard}/context-discoverer.ts | 0 {.agents-graveyard => agents-graveyard}/creative-catalyst.ts | 0 .../decision-maker/decision-maker.ts | 0 {.agents-graveyard => agents-graveyard}/decomposing-reviewer.ts | 0 {.agents-graveyard => agents-graveyard}/decomposing-thinker.ts | 0 {.agents-graveyard => agents-graveyard}/editor-lite.ts | 0 {.agents-graveyard => agents-graveyard}/editor.ts | 0 {.agents-graveyard => agents-graveyard}/file-lister-max.ts | 0 {.agents-graveyard => agents-graveyard}/knowledge-keeper.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/base.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/coder.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/file-picker.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/researcher.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/reviewer.ts | 0 {.agents-graveyard => agents-graveyard}/opensource/thinker.ts | 0 .../planners/decomposing-planner-lite.ts | 0 .../planners/decomposing-planner.ts | 0 .../planners/generate-plan-max.ts | 0 .../planners/generate-plan-thinking.ts | 0 .../planners/implementation-planner-lite.ts | 0 .../planners/implementation-planner-max.ts | 0 .../planners/implementation-planner.ts | 0 .../planners/iterative-planner.ts | 0 .../planners/plan-critiquer.ts | 0 .../planners/plan-selector-for-generate-plan.ts | 0 {.agents-graveyard => agents-graveyard}/planners/plan-selector.ts | 0 .../planners/requirements-planner.ts | 0 .../planners/two-wave-planner.ts | 0 {.agents-graveyard => agents-graveyard}/registry/etl-manager.ts | 0 {.agents-graveyard => agents-graveyard}/registry/extract-agent.ts | 0 {.agents-graveyard => agents-graveyard}/registry/load-agent.ts | 0 .../registry/transform-agent.ts | 0 {.agents-graveyard => agents-graveyard}/scout.ts | 0 63 files changed, 0 insertions(+), 0 deletions(-) rename {.agents-graveyard => agents-graveyard}/base/ask.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-experimental.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-factory.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-lite-codex.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-lite-grok-4-fast.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-lite.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-max.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-prompts.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base-quick.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/base.ts (100%) rename {.agents-graveyard => agents-graveyard}/base/thinking-base.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2-fast-subgoals.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2-gpt-5-worker.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2-with-context-discoverer.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2-with-task-researcher.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/alloy/base2-alloy.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/alloy/base2-gpt-5-single-step.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/alloy2/base2-alloy2.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/alloy2/base2-plan-step-gpt-5.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/alloy2/base2-plan-step.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/task-researcher/base2-gpt-5-with-task-researcher.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/task-researcher/base2-with-file-researcher.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/task-researcher/base2-with-task-researcher-planner-pro.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/thinking/base2-fast-thinker-gpt-5.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/thinking/base2-fast-thinker.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/thinking/base2-fast-thinking-tags.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/thinking/base2-fast-thinking-tool.ts (100%) rename {.agents-graveyard => agents-graveyard}/base2/thinking/base2-fast-thinking.ts (100%) rename {.agents-graveyard => agents-graveyard}/brainstormer.ts (100%) rename {.agents-graveyard => agents-graveyard}/charles.ts (100%) rename {.agents-graveyard => agents-graveyard}/context-discoverer.ts (100%) rename {.agents-graveyard => agents-graveyard}/creative-catalyst.ts (100%) rename {.agents-graveyard => agents-graveyard}/decision-maker/decision-maker.ts (100%) rename {.agents-graveyard => agents-graveyard}/decomposing-reviewer.ts (100%) rename {.agents-graveyard => agents-graveyard}/decomposing-thinker.ts (100%) rename {.agents-graveyard => agents-graveyard}/editor-lite.ts (100%) rename {.agents-graveyard => agents-graveyard}/editor.ts (100%) rename {.agents-graveyard => agents-graveyard}/file-lister-max.ts (100%) rename {.agents-graveyard => agents-graveyard}/knowledge-keeper.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/base.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/coder.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/file-picker.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/researcher.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/reviewer.ts (100%) rename {.agents-graveyard => agents-graveyard}/opensource/thinker.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/decomposing-planner-lite.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/decomposing-planner.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/generate-plan-max.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/generate-plan-thinking.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/implementation-planner-lite.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/implementation-planner-max.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/implementation-planner.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/iterative-planner.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/plan-critiquer.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/plan-selector-for-generate-plan.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/plan-selector.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/requirements-planner.ts (100%) rename {.agents-graveyard => agents-graveyard}/planners/two-wave-planner.ts (100%) rename {.agents-graveyard => agents-graveyard}/registry/etl-manager.ts (100%) rename {.agents-graveyard => agents-graveyard}/registry/extract-agent.ts (100%) rename {.agents-graveyard => agents-graveyard}/registry/load-agent.ts (100%) rename {.agents-graveyard => agents-graveyard}/registry/transform-agent.ts (100%) rename {.agents-graveyard => agents-graveyard}/scout.ts (100%) diff --git a/.agents-graveyard/base/ask.ts b/agents-graveyard/base/ask.ts similarity index 100% rename from .agents-graveyard/base/ask.ts rename to agents-graveyard/base/ask.ts diff --git a/.agents-graveyard/base/base-experimental.ts b/agents-graveyard/base/base-experimental.ts similarity index 100% rename from .agents-graveyard/base/base-experimental.ts rename to agents-graveyard/base/base-experimental.ts diff --git a/.agents-graveyard/base/base-factory.ts b/agents-graveyard/base/base-factory.ts similarity index 100% rename from .agents-graveyard/base/base-factory.ts rename to agents-graveyard/base/base-factory.ts diff --git a/.agents-graveyard/base/base-lite-codex.ts b/agents-graveyard/base/base-lite-codex.ts similarity index 100% rename from .agents-graveyard/base/base-lite-codex.ts rename to agents-graveyard/base/base-lite-codex.ts diff --git a/.agents-graveyard/base/base-lite-grok-4-fast.ts b/agents-graveyard/base/base-lite-grok-4-fast.ts similarity index 100% rename from .agents-graveyard/base/base-lite-grok-4-fast.ts rename to agents-graveyard/base/base-lite-grok-4-fast.ts diff --git a/.agents-graveyard/base/base-lite.ts b/agents-graveyard/base/base-lite.ts similarity index 100% rename from .agents-graveyard/base/base-lite.ts rename to agents-graveyard/base/base-lite.ts diff --git a/.agents-graveyard/base/base-max.ts b/agents-graveyard/base/base-max.ts similarity index 100% rename from .agents-graveyard/base/base-max.ts rename to agents-graveyard/base/base-max.ts diff --git a/.agents-graveyard/base/base-prompts.ts b/agents-graveyard/base/base-prompts.ts similarity index 100% rename from .agents-graveyard/base/base-prompts.ts rename to agents-graveyard/base/base-prompts.ts diff --git a/.agents-graveyard/base/base-quick.ts b/agents-graveyard/base/base-quick.ts similarity index 100% rename from .agents-graveyard/base/base-quick.ts rename to agents-graveyard/base/base-quick.ts diff --git a/.agents-graveyard/base/base.ts b/agents-graveyard/base/base.ts similarity index 100% rename from .agents-graveyard/base/base.ts rename to agents-graveyard/base/base.ts diff --git a/.agents-graveyard/base/thinking-base.ts b/agents-graveyard/base/thinking-base.ts similarity index 100% rename from .agents-graveyard/base/thinking-base.ts rename to agents-graveyard/base/thinking-base.ts diff --git a/.agents-graveyard/base2-fast-subgoals.ts b/agents-graveyard/base2-fast-subgoals.ts similarity index 100% rename from .agents-graveyard/base2-fast-subgoals.ts rename to agents-graveyard/base2-fast-subgoals.ts diff --git a/.agents-graveyard/base2-gpt-5-worker.ts b/agents-graveyard/base2-gpt-5-worker.ts similarity index 100% rename from .agents-graveyard/base2-gpt-5-worker.ts rename to agents-graveyard/base2-gpt-5-worker.ts diff --git a/.agents-graveyard/base2-with-context-discoverer.ts b/agents-graveyard/base2-with-context-discoverer.ts similarity index 100% rename from .agents-graveyard/base2-with-context-discoverer.ts rename to agents-graveyard/base2-with-context-discoverer.ts diff --git a/.agents-graveyard/base2-with-task-researcher.ts b/agents-graveyard/base2-with-task-researcher.ts similarity index 100% rename from .agents-graveyard/base2-with-task-researcher.ts rename to agents-graveyard/base2-with-task-researcher.ts diff --git a/.agents-graveyard/base2/alloy/base2-alloy.ts b/agents-graveyard/base2/alloy/base2-alloy.ts similarity index 100% rename from .agents-graveyard/base2/alloy/base2-alloy.ts rename to agents-graveyard/base2/alloy/base2-alloy.ts diff --git a/.agents-graveyard/base2/alloy/base2-gpt-5-single-step.ts b/agents-graveyard/base2/alloy/base2-gpt-5-single-step.ts similarity index 100% rename from .agents-graveyard/base2/alloy/base2-gpt-5-single-step.ts rename to agents-graveyard/base2/alloy/base2-gpt-5-single-step.ts diff --git a/.agents-graveyard/base2/alloy2/base2-alloy2.ts b/agents-graveyard/base2/alloy2/base2-alloy2.ts similarity index 100% rename from .agents-graveyard/base2/alloy2/base2-alloy2.ts rename to agents-graveyard/base2/alloy2/base2-alloy2.ts diff --git a/.agents-graveyard/base2/alloy2/base2-plan-step-gpt-5.ts b/agents-graveyard/base2/alloy2/base2-plan-step-gpt-5.ts similarity index 100% rename from .agents-graveyard/base2/alloy2/base2-plan-step-gpt-5.ts rename to agents-graveyard/base2/alloy2/base2-plan-step-gpt-5.ts diff --git a/.agents-graveyard/base2/alloy2/base2-plan-step.ts b/agents-graveyard/base2/alloy2/base2-plan-step.ts similarity index 100% rename from .agents-graveyard/base2/alloy2/base2-plan-step.ts rename to agents-graveyard/base2/alloy2/base2-plan-step.ts diff --git a/.agents-graveyard/base2/task-researcher/base2-gpt-5-with-task-researcher.ts b/agents-graveyard/base2/task-researcher/base2-gpt-5-with-task-researcher.ts similarity index 100% rename from .agents-graveyard/base2/task-researcher/base2-gpt-5-with-task-researcher.ts rename to agents-graveyard/base2/task-researcher/base2-gpt-5-with-task-researcher.ts diff --git a/.agents-graveyard/base2/task-researcher/base2-with-file-researcher.ts b/agents-graveyard/base2/task-researcher/base2-with-file-researcher.ts similarity index 100% rename from .agents-graveyard/base2/task-researcher/base2-with-file-researcher.ts rename to agents-graveyard/base2/task-researcher/base2-with-file-researcher.ts diff --git a/.agents-graveyard/base2/task-researcher/base2-with-task-researcher-planner-pro.ts b/agents-graveyard/base2/task-researcher/base2-with-task-researcher-planner-pro.ts similarity index 100% rename from .agents-graveyard/base2/task-researcher/base2-with-task-researcher-planner-pro.ts rename to agents-graveyard/base2/task-researcher/base2-with-task-researcher-planner-pro.ts diff --git a/.agents-graveyard/base2/thinking/base2-fast-thinker-gpt-5.ts b/agents-graveyard/base2/thinking/base2-fast-thinker-gpt-5.ts similarity index 100% rename from .agents-graveyard/base2/thinking/base2-fast-thinker-gpt-5.ts rename to agents-graveyard/base2/thinking/base2-fast-thinker-gpt-5.ts diff --git a/.agents-graveyard/base2/thinking/base2-fast-thinker.ts b/agents-graveyard/base2/thinking/base2-fast-thinker.ts similarity index 100% rename from .agents-graveyard/base2/thinking/base2-fast-thinker.ts rename to agents-graveyard/base2/thinking/base2-fast-thinker.ts diff --git a/.agents-graveyard/base2/thinking/base2-fast-thinking-tags.ts b/agents-graveyard/base2/thinking/base2-fast-thinking-tags.ts similarity index 100% rename from .agents-graveyard/base2/thinking/base2-fast-thinking-tags.ts rename to agents-graveyard/base2/thinking/base2-fast-thinking-tags.ts diff --git a/.agents-graveyard/base2/thinking/base2-fast-thinking-tool.ts b/agents-graveyard/base2/thinking/base2-fast-thinking-tool.ts similarity index 100% rename from .agents-graveyard/base2/thinking/base2-fast-thinking-tool.ts rename to agents-graveyard/base2/thinking/base2-fast-thinking-tool.ts diff --git a/.agents-graveyard/base2/thinking/base2-fast-thinking.ts b/agents-graveyard/base2/thinking/base2-fast-thinking.ts similarity index 100% rename from .agents-graveyard/base2/thinking/base2-fast-thinking.ts rename to agents-graveyard/base2/thinking/base2-fast-thinking.ts diff --git a/.agents-graveyard/brainstormer.ts b/agents-graveyard/brainstormer.ts similarity index 100% rename from .agents-graveyard/brainstormer.ts rename to agents-graveyard/brainstormer.ts diff --git a/.agents-graveyard/charles.ts b/agents-graveyard/charles.ts similarity index 100% rename from .agents-graveyard/charles.ts rename to agents-graveyard/charles.ts diff --git a/.agents-graveyard/context-discoverer.ts b/agents-graveyard/context-discoverer.ts similarity index 100% rename from .agents-graveyard/context-discoverer.ts rename to agents-graveyard/context-discoverer.ts diff --git a/.agents-graveyard/creative-catalyst.ts b/agents-graveyard/creative-catalyst.ts similarity index 100% rename from .agents-graveyard/creative-catalyst.ts rename to agents-graveyard/creative-catalyst.ts diff --git a/.agents-graveyard/decision-maker/decision-maker.ts b/agents-graveyard/decision-maker/decision-maker.ts similarity index 100% rename from .agents-graveyard/decision-maker/decision-maker.ts rename to agents-graveyard/decision-maker/decision-maker.ts diff --git a/.agents-graveyard/decomposing-reviewer.ts b/agents-graveyard/decomposing-reviewer.ts similarity index 100% rename from .agents-graveyard/decomposing-reviewer.ts rename to agents-graveyard/decomposing-reviewer.ts diff --git a/.agents-graveyard/decomposing-thinker.ts b/agents-graveyard/decomposing-thinker.ts similarity index 100% rename from .agents-graveyard/decomposing-thinker.ts rename to agents-graveyard/decomposing-thinker.ts diff --git a/.agents-graveyard/editor-lite.ts b/agents-graveyard/editor-lite.ts similarity index 100% rename from .agents-graveyard/editor-lite.ts rename to agents-graveyard/editor-lite.ts diff --git a/.agents-graveyard/editor.ts b/agents-graveyard/editor.ts similarity index 100% rename from .agents-graveyard/editor.ts rename to agents-graveyard/editor.ts diff --git a/.agents-graveyard/file-lister-max.ts b/agents-graveyard/file-lister-max.ts similarity index 100% rename from .agents-graveyard/file-lister-max.ts rename to agents-graveyard/file-lister-max.ts diff --git a/.agents-graveyard/knowledge-keeper.ts b/agents-graveyard/knowledge-keeper.ts similarity index 100% rename from .agents-graveyard/knowledge-keeper.ts rename to agents-graveyard/knowledge-keeper.ts diff --git a/.agents-graveyard/opensource/base.ts b/agents-graveyard/opensource/base.ts similarity index 100% rename from .agents-graveyard/opensource/base.ts rename to agents-graveyard/opensource/base.ts diff --git a/.agents-graveyard/opensource/coder.ts b/agents-graveyard/opensource/coder.ts similarity index 100% rename from .agents-graveyard/opensource/coder.ts rename to agents-graveyard/opensource/coder.ts diff --git a/.agents-graveyard/opensource/file-picker.ts b/agents-graveyard/opensource/file-picker.ts similarity index 100% rename from .agents-graveyard/opensource/file-picker.ts rename to agents-graveyard/opensource/file-picker.ts diff --git a/.agents-graveyard/opensource/researcher.ts b/agents-graveyard/opensource/researcher.ts similarity index 100% rename from .agents-graveyard/opensource/researcher.ts rename to agents-graveyard/opensource/researcher.ts diff --git a/.agents-graveyard/opensource/reviewer.ts b/agents-graveyard/opensource/reviewer.ts similarity index 100% rename from .agents-graveyard/opensource/reviewer.ts rename to agents-graveyard/opensource/reviewer.ts diff --git a/.agents-graveyard/opensource/thinker.ts b/agents-graveyard/opensource/thinker.ts similarity index 100% rename from .agents-graveyard/opensource/thinker.ts rename to agents-graveyard/opensource/thinker.ts diff --git a/.agents-graveyard/planners/decomposing-planner-lite.ts b/agents-graveyard/planners/decomposing-planner-lite.ts similarity index 100% rename from .agents-graveyard/planners/decomposing-planner-lite.ts rename to agents-graveyard/planners/decomposing-planner-lite.ts diff --git a/.agents-graveyard/planners/decomposing-planner.ts b/agents-graveyard/planners/decomposing-planner.ts similarity index 100% rename from .agents-graveyard/planners/decomposing-planner.ts rename to agents-graveyard/planners/decomposing-planner.ts diff --git a/.agents-graveyard/planners/generate-plan-max.ts b/agents-graveyard/planners/generate-plan-max.ts similarity index 100% rename from .agents-graveyard/planners/generate-plan-max.ts rename to agents-graveyard/planners/generate-plan-max.ts diff --git a/.agents-graveyard/planners/generate-plan-thinking.ts b/agents-graveyard/planners/generate-plan-thinking.ts similarity index 100% rename from .agents-graveyard/planners/generate-plan-thinking.ts rename to agents-graveyard/planners/generate-plan-thinking.ts diff --git a/.agents-graveyard/planners/implementation-planner-lite.ts b/agents-graveyard/planners/implementation-planner-lite.ts similarity index 100% rename from .agents-graveyard/planners/implementation-planner-lite.ts rename to agents-graveyard/planners/implementation-planner-lite.ts diff --git a/.agents-graveyard/planners/implementation-planner-max.ts b/agents-graveyard/planners/implementation-planner-max.ts similarity index 100% rename from .agents-graveyard/planners/implementation-planner-max.ts rename to agents-graveyard/planners/implementation-planner-max.ts diff --git a/.agents-graveyard/planners/implementation-planner.ts b/agents-graveyard/planners/implementation-planner.ts similarity index 100% rename from .agents-graveyard/planners/implementation-planner.ts rename to agents-graveyard/planners/implementation-planner.ts diff --git a/.agents-graveyard/planners/iterative-planner.ts b/agents-graveyard/planners/iterative-planner.ts similarity index 100% rename from .agents-graveyard/planners/iterative-planner.ts rename to agents-graveyard/planners/iterative-planner.ts diff --git a/.agents-graveyard/planners/plan-critiquer.ts b/agents-graveyard/planners/plan-critiquer.ts similarity index 100% rename from .agents-graveyard/planners/plan-critiquer.ts rename to agents-graveyard/planners/plan-critiquer.ts diff --git a/.agents-graveyard/planners/plan-selector-for-generate-plan.ts b/agents-graveyard/planners/plan-selector-for-generate-plan.ts similarity index 100% rename from .agents-graveyard/planners/plan-selector-for-generate-plan.ts rename to agents-graveyard/planners/plan-selector-for-generate-plan.ts diff --git a/.agents-graveyard/planners/plan-selector.ts b/agents-graveyard/planners/plan-selector.ts similarity index 100% rename from .agents-graveyard/planners/plan-selector.ts rename to agents-graveyard/planners/plan-selector.ts diff --git a/.agents-graveyard/planners/requirements-planner.ts b/agents-graveyard/planners/requirements-planner.ts similarity index 100% rename from .agents-graveyard/planners/requirements-planner.ts rename to agents-graveyard/planners/requirements-planner.ts diff --git a/.agents-graveyard/planners/two-wave-planner.ts b/agents-graveyard/planners/two-wave-planner.ts similarity index 100% rename from .agents-graveyard/planners/two-wave-planner.ts rename to agents-graveyard/planners/two-wave-planner.ts diff --git a/.agents-graveyard/registry/etl-manager.ts b/agents-graveyard/registry/etl-manager.ts similarity index 100% rename from .agents-graveyard/registry/etl-manager.ts rename to agents-graveyard/registry/etl-manager.ts diff --git a/.agents-graveyard/registry/extract-agent.ts b/agents-graveyard/registry/extract-agent.ts similarity index 100% rename from .agents-graveyard/registry/extract-agent.ts rename to agents-graveyard/registry/extract-agent.ts diff --git a/.agents-graveyard/registry/load-agent.ts b/agents-graveyard/registry/load-agent.ts similarity index 100% rename from .agents-graveyard/registry/load-agent.ts rename to agents-graveyard/registry/load-agent.ts diff --git a/.agents-graveyard/registry/transform-agent.ts b/agents-graveyard/registry/transform-agent.ts similarity index 100% rename from .agents-graveyard/registry/transform-agent.ts rename to agents-graveyard/registry/transform-agent.ts diff --git a/.agents-graveyard/scout.ts b/agents-graveyard/scout.ts similarity index 100% rename from .agents-graveyard/scout.ts rename to agents-graveyard/scout.ts From 8715216740c7ea8d9c2a44c7c628bc32ae1f641e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:37:36 -0800 Subject: [PATCH 0019/1143] Fix base2 to reference code-reviewer-multi-prompt --- agents/base2/base2.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 9d25d121bb..51bc38471f 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -206,13 +206,13 @@ ${ ${ isDefault || isMax - ? `[ You spawn a ${isDefault ? 'code-reviewer' : 'reviewer-editor-gpt-5'}, a commander to typecheck the changes, and another commander to run tests, all in parallel ]` + ? `[ You spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'}, a commander to typecheck the changes, and another commander to run tests, all in parallel ]` : '[ You spawn a commander to typecheck the changes and another commander to run tests, all in parallel ]' } ${ isDefault || isMax - ? `[ You fix the issues found by the ${isDefault ? 'code-reviewer' : 'reviewer-editor-gpt-5'} and type/test errors ]` + ? `[ You fix the issues found by the ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} and type/test errors ]` : '[ You fix the issues found by the type/test errors and spawn more commanders to confirm ]' } @@ -332,7 +332,7 @@ ${buildArray( isFast && '- Do a single typecheck targeted for your changes at most (if applicable for the project). Or skip this step if the change was small.', (isDefault || isMax) && - `- Spawn a ${isDefault ? 'code-reviewer' : 'reviewer-editor-gpt-5'} to review the changes after you have implemented the changes. (Skip this step only if the change is extremely straightforward and obvious.)`, + `- Spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented the changes. (Skip this step only if the change is extremely straightforward and obvious.)`, !hasNoValidation && `- For non-trivial changes, test them by running appropriate validation commands for the project (e.g. typechecks, tests, lints, etc.). Try to run all appropriate commands in parallel. ${isMax ? ' Typecheck and test the specific area of the project that you are editing *AND* then typecheck and test the entire project if necessary.' : ' If you can, only test the area of the project that you are editing, rather than the entire project.'} You may have to explore the project to find the appropriate commands. Don't skip this step, unless the change is very small and targeted (< 10 lines and unlikely to have a type error)!`, `- Inform the user that you have completed the task in one sentence or a few short bullet points.${isSonnet ? " Don't create any markdown summary files or example documentation files, unless asked by the user." : ''}`, @@ -363,9 +363,9 @@ function buildImplementationStepPrompt({ isMax && `Keep working until the user's request is completely satisfied${!hasNoValidation ? ' and validated' : ''}, or until you require more information from the user.`, isMax && - `You must spawn the 'editor-multi-prompt' agent to implement code changes, since it will generate the best code changes.`, + `You must spawn the 'editor-multi-prompt' agent to implement code changes rather than using the str_replace or write_file tools, since it will generate the best code changes.`, (isDefault || isMax) && - `Spawn ${isDefault ? 'code-reviewer' : 'reviewer-editor-gpt-5'} to review the changes after you have implemented the changes and in parallel with typechecking or testing.`, + `You must spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented the changes and in parallel with typechecking or testing.`, `After completing the user request, summarize your changes in a sentence${isFast ? '' : ' or a few short bullet points'}.${isSonnet ? " Don't create any summary markdown files or example documentation files, unless asked by the user." : ''} Don't repeat yourself, especially if you have already concluded and summarized the changes in a previous step -- just end your turn.`, !isFast && !noAskUser && From d5f59a4bb8533856cc6f41058261e820089f9aa8 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 22:46:21 -0800 Subject: [PATCH 0020/1143] Fix: swap out reviewer agent in agent-builder examples --- agents/agent-builder.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/agents/agent-builder.ts b/agents/agent-builder.ts index 4a4211f3f0..7fd4ab167e 100644 --- a/agents/agent-builder.ts +++ b/agents/agent-builder.ts @@ -23,11 +23,15 @@ const researcherGrok4FastExampleContent = readFileSync( 'utf8', ) const generatePlanExampleContent = readFileSync( - join(__dirname, 'planners', 'generate-plan.ts'), + join(__dirname, 'planners', 'planner-pro-with-files-input.ts'), 'utf8', ) const reviewerExampleContent = readFileSync( - join(__dirname, 'reviewer', 'reviewer.ts'), + join(__dirname, 'reviewer', 'code-reviewer.ts'), + 'utf8', +) +const reviewerMultiPromptExampleContent = readFileSync( + join(__dirname, 'reviewer', 'multi-prompt','code-reviewer-multi-prompt.ts'), 'utf8', ) const examplesAgentsContent = [ @@ -35,6 +39,7 @@ const examplesAgentsContent = [ researcherGrok4FastExampleContent, generatePlanExampleContent, reviewerExampleContent, + reviewerMultiPromptExampleContent, ] const definition: AgentDefinition = { From c0d0c374b594391bfdd1271a5c3e09fc8a98136e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 23:12:02 -0800 Subject: [PATCH 0021/1143] Tweak examples for code reviewer multi prompt --- agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts index eed11ba48b..b7382bedbf 100644 --- a/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts +++ b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts @@ -33,7 +33,12 @@ export function createCodeReviewerMultiPrompt(): Omit< type: 'array', items: { type: 'string' }, description: - 'Array of 3-5 short prompts, each specifying a different review focus or perspective. Example: ["api design", "frontend changes", "correctness and edge cases", "code style and readability", "performance implications", "security concerns"]', + `Array of 3-5 short prompts, each specifying a different review focus or perspective. Can be specific parts of the code that was changed (frontend), or angles like reviewing with an eye for simplifying the code or design or code style. +Example 1: +["api design", "correctness and edge cases", "find ways to simplify the code or reuse existing code", "security concerns", "overall review"] +Example 2: +[ "frontend changes", "backend changes", "code style, maintainability, and readability"] +`, }, }, required: ['prompts'], From 946585c2aa2f84e50480cb6b951de4e396623175 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sat, 17 Jan 2026 23:51:49 -0800 Subject: [PATCH 0022/1143] Simplify ui of multi prompt editor slightly --- cli/src/components/blocks/implementor-row.tsx | 31 ++++--------------- 1 file changed, 6 insertions(+), 25 deletions(-) diff --git a/cli/src/components/blocks/implementor-row.tsx b/cli/src/components/blocks/implementor-row.tsx index 77e72692e4..9db3179dc6 100644 --- a/cli/src/components/blocks/implementor-row.tsx +++ b/cli/src/components/blocks/implementor-row.tsx @@ -1,4 +1,3 @@ -import { pluralize } from '@codebuff/common/util/string' import { TextAttributes } from '@opentui/core' import React, { memo, useCallback, useMemo, useState } from 'react' @@ -35,11 +34,6 @@ export const ImplementorGroup = memo( const theme = useTheme() const { columns, columnWidth: cardWidth, columnGroups } = useGridLayout(implementors, availableWidth) - // Check if any implementors are still running - const anyRunning = implementors.some(impl => impl.status === 'running') - const headerText = anyRunning - ? `${pluralize(implementors.length, 'proposal')} being generated` - : `${pluralize(implementors.length, 'proposal')} generated` return ( - - {headerText} - ) }, @@ -338,11 +326,10 @@ const CompactFileRow = memo(({ const removedContent = (' ' + removedStr).padEnd(removedSectionWidth) // Calculate available width for file path - // Layout: changeType(1) + spaces(2) + filePath + spaces(2) + hunks + spaces(2) + bars - const hunkText = `${file.stats.hunks} ${file.stats.hunks === 1 ? 'hunk' : 'hunks'}` + // Layout: changeType(1) + spaces(2) + filePath + spaces(2) + bars // Total bar section width: 2*maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth (no center gap) const barWidth = 2 * maxBarWidth + maxAddedStrWidth + maxRemovedStrWidth - const fixedWidth = 1 + 2 + 2 + hunkText.length + 2 + barWidth + const fixedWidth = 1 + 2 + 2 + barWidth const maxFilePathWidth = Math.max(10, availableWidth - fixedWidth) // Get and truncate file path @@ -383,18 +370,12 @@ const CompactFileRow = memo(({ - {/* Hunk count */} - - {hunkText} - - - {/* Bar visualization: full-width bars meeting at center with numbers inside */} - {/* Added section: full green bar with +N in white inside, right-aligned to center */} - {addedContent} - {/* Removed section: full red bar with -N in white inside, left-aligned from center */} - {removedContent} + {/* Added section: muted gray-green bar with +N inside */} + {addedContent} + {/* Removed section: muted gray-red bar with -N inside */} + {removedContent} From 9ca64dac89c9cc69bf21f82d1f94e1b1149413fa Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 00:02:31 -0800 Subject: [PATCH 0023/1143] Improve code reviewer multi-prompt --- agents/reviewer/code-reviewer.ts | 6 +- .../code-reviewer-multi-prompt.ts | 71 ++++++++++++------- 2 files changed, 51 insertions(+), 26 deletions(-) diff --git a/agents/reviewer/code-reviewer.ts b/agents/reviewer/code-reviewer.ts index 5cbb7bc6b6..9be2468cd3 100644 --- a/agents/reviewer/code-reviewer.ts +++ b/agents/reviewer/code-reviewer.ts @@ -36,7 +36,7 @@ Your task is to provide helpful critical feedback on the last file changes made Be brief: If you don't have much critical feedback, simply say it looks good in one sentence. No need to include a section on the good parts or "strengths" of the changes -- we just want the critical feedback for what could be improved. -NOTE: You cannot make any changes directly! You can only suggest changes. +NOTE: You cannot make any changes directly! DO NOT CALL ANY TOOLS! You can only suggest changes. # Guidelines @@ -52,6 +52,10 @@ NOTE: You cannot make any changes directly! You can only suggest changes. - Make sure there are no unnecessary try/catch blocks. Prefer to remove those. Be extremely concise.`, + + handleSteps: function* ({ agentState, params }) { + yield 'STEP' + }, }) const definition: SecretAgentDefinition = { diff --git a/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts index b7382bedbf..126c2c6215 100644 --- a/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts +++ b/agents/reviewer/multi-prompt/code-reviewer-multi-prompt.ts @@ -32,8 +32,7 @@ export function createCodeReviewerMultiPrompt(): Omit< prompts: { type: 'array', items: { type: 'string' }, - description: - `Array of 3-5 short prompts, each specifying a different review focus or perspective. Can be specific parts of the code that was changed (frontend), or angles like reviewing with an eye for simplifying the code or design or code style. + description: `Array of 3-5 short prompts, each specifying a different review focus or perspective. Can be specific parts of the code that was changed (frontend), or angles like reviewing with an eye for simplifying the code or design or code style. Example 1: ["api design", "correctness and edge cases", "find ways to simplify the code or reuse existing code", "security concerns", "overall review"] Example 2: @@ -52,6 +51,7 @@ Example 2: function* handleStepsMultiPrompt({ params, + agentState, }: AgentStepContext): ReturnType< NonNullable > { @@ -68,11 +68,25 @@ function* handleStepsMultiPrompt({ return } + const { messageHistory } = agentState + // Remove last user messages (prompt, subagent spawn message, instructions prompt) + while (messageHistory.length > 0 && messageHistory[messageHistory.length - 1].role === 'user') { + messageHistory.pop() + } + + yield { + toolName: 'set_messages', + input: { + messages: messageHistory, + }, + includeToolCall: false, + } satisfies ToolCall<'set_messages'> + // Spawn one code-reviewer per prompt const reviewerAgents: { agent_type: string; prompt: string }[] = prompts.map( (prompt) => ({ agent_type: 'code-reviewer', - prompt: `Review focus: ${prompt}`, + prompt: `Review the above code changes with the following focus: ${prompt}`, }), ) @@ -85,38 +99,45 @@ function* handleStepsMultiPrompt({ includeToolCall: false, } satisfies ToolCall<'spawn_agents'> - // Extract spawn results - each is last_message output (string content) - const spawnedReviews = extractSpawnResults(reviewerResults) - - // Combine all reviews with their focus areas - const combinedReviews = spawnedReviews - .map((review, index) => { - const focus = prompts[index] ?? 'unknown' - if (!review || (typeof review === 'object' && 'errorMessage' in review)) { - return `## Review Focus: ${focus}\n\nError: ${(review as { errorMessage?: string })?.errorMessage ?? 'Unknown error'}` + const spawnedReviews = extractSpawnResults(reviewerResults) + + // Extract text content from each review's message content blocks + const reviewTexts: string[] = [] + for (const review of spawnedReviews) { + if ('errorMessage' in review) { + reviewTexts.push(`Error: ${review.errorMessage}`) + } else { + // Each review is an array of messages + for (const message of review) { + for (const block of message.content) { + if (block.type === 'text' && block.text) { + reviewTexts.push(block.text) + } + } } - return `## Review Focus: ${focus}\n\n${review}` - }) - .join('\n\n---\n\n') + } + } - // Set output with the combined reviews + // Set output with the simplified reviews (array of strings) yield { toolName: 'set_output', input: { - reviews: spawnedReviews, - combinedReview: combinedReviews, - promptCount: prompts.length, + reviews: reviewTexts, }, includeToolCall: false, } satisfies ToolCall<'set_output'> + type ContentBlock = { type: string; text?: string } + type ReviewMessage = { role: string; content: ContentBlock[]; sentAt?: number } + type ReviewResult = ReviewMessage[] + /** * Extracts the array of subagent results from spawn_agents tool output. - * For code-reviewer agents with outputMode: 'last_message', the value is the message content. + * For code-reviewer agents with outputMode: 'last_message', the value is an array of messages. */ - function extractSpawnResults( + function extractSpawnResults( results: { type: string; value?: unknown }[] | undefined, - ): (T | { errorMessage: string })[] { + ): (ReviewResult | { errorMessage: string })[] { if (!results || results.length === 0) return [] const jsonResult = results.find((r) => r.type === 'json') @@ -126,7 +147,7 @@ function* handleStepsMultiPrompt({ ? jsonResult.value : [jsonResult.value] - const extracted: (T | { errorMessage: string })[] = [] + const extracted: (ReviewResult | { errorMessage: string })[] = [] for (const result of spawnedResults) { const innerValue = result?.value if ( @@ -134,7 +155,7 @@ function* handleStepsMultiPrompt({ typeof innerValue === 'object' && 'value' in innerValue ) { - extracted.push(innerValue.value as T) + extracted.push(innerValue.value as ReviewResult) } else if ( innerValue && typeof innerValue === 'object' && @@ -142,7 +163,7 @@ function* handleStepsMultiPrompt({ ) { extracted.push({ errorMessage: String(innerValue.errorMessage) }) } else if (innerValue != null) { - extracted.push(innerValue as T) + extracted.push(innerValue as ReviewResult) } } return extracted From 4fc5d7e781ab3447d4aead99bf0bc1f836f6b319 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 00:12:29 -0800 Subject: [PATCH 0024/1143] In max mode, do typecheck before code review --- agents/base2/base2.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 51bc38471f..18106c41cf 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -205,9 +205,11 @@ ${ } ${ - isDefault || isMax - ? `[ You spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'}, a commander to typecheck the changes, and another commander to run tests, all in parallel ]` - : '[ You spawn a commander to typecheck the changes and another commander to run tests, all in parallel ]' + isDefault + ? `[ You spawn a code-reviewer, a commander to typecheck the changes, and another commander to run tests, all in parallel ]` + : isMax + ? `[ You spawn a commander to typecheck the changes, and another commander to run tests, in parallel. Then, you spawn a code-reviewer-multi-prompt to review the changes. ]` + : '[ You spawn a commander to typecheck the changes and another commander to run tests, all in parallel ]' } ${ @@ -331,10 +333,10 @@ ${buildArray( '- Implement the changes using the str_replace or write_file tools. Implement all the changes in one go.', isFast && '- Do a single typecheck targeted for your changes at most (if applicable for the project). Or skip this step if the change was small.', - (isDefault || isMax) && - `- Spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented the changes. (Skip this step only if the change is extremely straightforward and obvious.)`, !hasNoValidation && `- For non-trivial changes, test them by running appropriate validation commands for the project (e.g. typechecks, tests, lints, etc.). Try to run all appropriate commands in parallel. ${isMax ? ' Typecheck and test the specific area of the project that you are editing *AND* then typecheck and test the entire project if necessary.' : ' If you can, only test the area of the project that you are editing, rather than the entire project.'} You may have to explore the project to find the appropriate commands. Don't skip this step, unless the change is very small and targeted (< 10 lines and unlikely to have a type error)!`, + (isDefault || isMax) && + `- Spawn a ${isDefault ? 'code-reviewer' : 'code-reviewer-multi-prompt'} to review the changes after you have implemented changes. (Skip this step only if the change is extremely straightforward and obvious.)`, `- Inform the user that you have completed the task in one sentence or a few short bullet points.${isSonnet ? " Don't create any markdown summary files or example documentation files, unless asked by the user." : ''}`, !isFast && !noAskUser && From 3f4ceda25a58dfb35e2f7c052177d23c0b01a414 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 00:16:35 -0800 Subject: [PATCH 0025/1143] cli: collapse code reviewers within code-reviewer-multi-prompt --- cli/src/utils/constants.ts | 34 +++++++++++++++++++++++++ cli/src/utils/message-block-helpers.ts | 35 +++++++++++++++++++++++--- cli/src/utils/sdk-event-handlers.ts | 27 ++++++++++++++------ 3 files changed, 86 insertions(+), 10 deletions(-) diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 8d9310f88a..2b19d8853e 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -32,6 +32,40 @@ export const shouldCollapseByDefault = (agentType: string): boolean => { ) } +/** + * Rules for collapsing child agents when spawned by specific parent agents. + * Key: parent agent type pattern, Value: array of child agent type patterns to collapse + */ +export const PARENT_CHILD_COLLAPSE_RULES: Record = { + 'code-reviewer-multi-prompt': ['code-reviewer'], +} + +/** + * Check if a child agent should be collapsed when spawned by a specific parent + */ +export const shouldCollapseForParent = ( + childAgentType: string, + parentAgentType: string | undefined, +): boolean => { + if (!parentAgentType) { + return false + } + + for (const [parentPattern, childPatterns] of Object.entries( + PARENT_CHILD_COLLAPSE_RULES, + )) { + if (parentAgentType.includes(parentPattern)) { + for (const childPattern of childPatterns) { + if (childAgentType.includes(childPattern)) { + return true + } + } + } + } + + return false +} + // Agent IDs that should render as simple text instead of full agent boxes export const SIMPLE_TEXT_AGENT_IDS = [ 'best-of-n-selector', diff --git a/cli/src/utils/message-block-helpers.ts b/cli/src/utils/message-block-helpers.ts index c1b8cde174..3e3a1b96f8 100644 --- a/cli/src/utils/message-block-helpers.ts +++ b/cli/src/utils/message-block-helpers.ts @@ -1,7 +1,7 @@ import { isEqual } from 'lodash' import { formatToolOutput } from './codebuff-client' -import { shouldCollapseByDefault } from './constants' +import { shouldCollapseByDefault, shouldCollapseForParent } from './constants' import type { ContentBlock, @@ -250,6 +250,30 @@ export const appendInterruptionNotice = ( return [...blocks, interruptionNotice] } +/** + * Recursively finds an agent block by ID and returns its agent type. + * Returns undefined if not found. + */ +export const findAgentTypeById = ( + blocks: ContentBlock[], + agentId: string, +): string | undefined => { + for (const block of blocks) { + if (block.type === 'agent') { + if (block.agentId === agentId) { + return block.agentType + } + if (block.blocks) { + const found = findAgentTypeById(block.blocks, agentId) + if (found) { + return found + } + } + } + } + return undefined +} + /** * Options for creating an agent content block. */ @@ -262,6 +286,8 @@ export interface CreateAgentBlockOptions { spawnToolCallId?: string /** The index within the spawn_agents call */ spawnIndex?: number + /** The agent type of the parent agent that spawned this one */ + parentAgentType?: string } /** @@ -270,7 +296,10 @@ export interface CreateAgentBlockOptions { export const createAgentBlock = ( options: CreateAgentBlockOptions, ): AgentContentBlock => { - const { agentId, agentType, prompt, params, spawnToolCallId, spawnIndex } = options + const { agentId, agentType, prompt, params, spawnToolCallId, spawnIndex, parentAgentType } = options + const shouldCollapse = + shouldCollapseByDefault(agentType || '') || + shouldCollapseForParent(agentType || '', parentAgentType) return { type: 'agent', agentId, @@ -283,7 +312,7 @@ export const createAgentBlock = ( ...(params && { params }), ...(spawnToolCallId && { spawnToolCallId }), ...(spawnIndex !== undefined && { spawnIndex }), - ...(shouldCollapseByDefault(agentType || '') && { isCollapsed: true }), + ...(shouldCollapse && { isCollapsed: true }), } } diff --git a/cli/src/utils/sdk-event-handlers.ts b/cli/src/utils/sdk-event-handlers.ts index b7443d089e..13af0bdab5 100644 --- a/cli/src/utils/sdk-event-handlers.ts +++ b/cli/src/utils/sdk-event-handlers.ts @@ -10,6 +10,7 @@ import { createAgentBlock, extractPlanFromBuffer, extractSpawnAgentResultContent, + findAgentTypeById, insertPlanBlock, nestBlockUnderParent, transformAskUserBlocks, @@ -216,14 +217,20 @@ const handleSubagentStart = ( 'Creating new agent block (no spawn_agents match)', ) - const newAgentBlock = createAgentBlock({ - agentId: event.agentId, - agentType: event.agentType || '', - prompt: event.prompt, - params: event.params, - }) - state.message.updater.updateAiMessageBlocks((blocks) => { + // Look up the parent agent's type if there's a parent agent ID + const parentAgentType = event.parentAgentId + ? findAgentTypeById(blocks, event.parentAgentId) + : undefined + + const newAgentBlock = createAgentBlock({ + agentId: event.agentId, + agentType: event.agentType || '', + prompt: event.prompt, + params: event.params, + parentAgentType, + }) + if (event.parentAgentId) { const { blocks: nestedBlocks, parentFound } = nestBlockUnderParent( blocks, @@ -273,6 +280,11 @@ const handleSpawnAgentsToolCall = ( }) state.message.updater.updateAiMessageBlocks((blocks) => { + // Look up the parent agent's type if there's a parent agent ID + const parentAgentType = event.agentId + ? findAgentTypeById(blocks, event.agentId) + : undefined + const newAgentBlocks: ContentBlock[] = agents .map((agent: any, originalIndex: number) => ({ agent, originalIndex })) .filter(({ agent }) => !shouldHideAgent(agent.agent_type || '')) @@ -283,6 +295,7 @@ const handleSpawnAgentsToolCall = ( prompt: agent.prompt, spawnToolCallId: event.toolCallId, spawnIndex: originalIndex, + parentAgentType, }), ) From 999d3624323b57c7f764ba75f7d52f6c11d20df1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Jan 2026 08:25:34 +0000 Subject: [PATCH 0026/1143] Bump version to 1.0.586 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 922771e7f6..c702b1765c 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.585", + "version": "1.0.586", "description": "AI coding agent", "license": "MIT", "bin": { From 2a998e0223b9383a67b71a3fb3508e4aea27ddea Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 00:22:51 -0800 Subject: [PATCH 0027/1143] Let the code reviewer think --- agents/reviewer/code-reviewer.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agents/reviewer/code-reviewer.ts b/agents/reviewer/code-reviewer.ts index 9be2468cd3..a3751f6dc0 100644 --- a/agents/reviewer/code-reviewer.ts +++ b/agents/reviewer/code-reviewer.ts @@ -38,6 +38,8 @@ Be brief: If you don't have much critical feedback, simply say it looks good in NOTE: You cannot make any changes directly! DO NOT CALL ANY TOOLS! You can only suggest changes. +Before providing your review, use tags to think through the code changes and identify any issues or improvements. + # Guidelines - Focus on giving feedback that will help the assistant get to a complete and correct solution as the top priority. From f559866da48c06fd31931b2403cad1479a583638 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 18 Jan 2026 08:39:45 +0000 Subject: [PATCH 0028/1143] Bump version to 1.0.587 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index c702b1765c..89314ed2bd 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.586", + "version": "1.0.587", "description": "AI coding agent", "license": "MIT", "bin": { From 3a5d0545049f2f64aba57743e24527595c3233fa Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 17:47:46 -0800 Subject: [PATCH 0029/1143] Require bash on Windows. Throw error if missing (#413) --- WINDOWS.md | 52 ++++++++++---- sdk/src/run-state.ts | 2 +- sdk/src/tools/run-terminal-command.ts | 97 +++++++++++++++++++++++++-- 3 files changed, 131 insertions(+), 20 deletions(-) diff --git a/WINDOWS.md b/WINDOWS.md index 9d0414ddc3..c8c4a0d0c0 100644 --- a/WINDOWS.md +++ b/WINDOWS.md @@ -79,34 +79,60 @@ Codebuff checks GitHub for the latest release on first run. This fails when: --- +### Issue: "Bash is required but was not found" Error + +**Symptom**: +``` +Bash is required but was not found on this Windows system. +``` + +**Cause**: +Codebuff requires bash for command execution. This error appears when: +- Git for Windows is not installed +- You're not running inside WSL +- bash.exe is not in your PATH + +**Solutions**: + +1. **Install Git for Windows** (recommended): + - Download from https://git-scm.com/download/win + - This installs `bash.exe` which Codebuff will automatically detect + - Works in PowerShell, CMD, or Git Bash terminals + +2. **Use WSL (Windows Subsystem for Linux)**: + - Provides full Linux environment with native bash + - Install: `wsl --install` in PowerShell (Admin) + - Run codebuff inside WSL for best compatibility + +3. **Set custom bash path** (advanced): + - If bash.exe is installed in a non-standard location: + ```powershell + set CODEBUFF_GIT_BASH_PATH=C:\path\to\bash.exe + ``` + +**Reference**: Issue [#274](https://github.com/CodebuffAI/codebuff/issues/274) + +--- + ### Issue: Git Commands Fail on Windows **Symptom**: Git operations (commit, rebase, complex commands) fail with syntax errors or unexpected behavior. **Cause**: -Codebuff uses Windows `cmd.exe` for command execution, which: -- Does not support bash syntax (HEREDOC, process substitution) -- Has limited quote escaping compared to bash -- Cannot execute complex git commands that work in Git Bash +Complex git commands may have issues with Windows path handling or shell escaping. **Solutions**: -1. **Install Git for Windows** (if not already installed): +1. **Ensure Git for Windows is installed**: - Download from https://git-scm.com/download/win - - Ensures git commands are available in PATH - -2. **Use Git Bash terminal** instead of PowerShell: - - Git Bash provides better compatibility with bash-style commands - - Launch Git Bash and run `codebuff` from there + - Codebuff uses bash.exe from Git for Windows for command execution -3. **Or use WSL (Windows Subsystem for Linux)**: +2. **Use WSL for complex operations**: - Provides full Linux environment with native bash - Install: `wsl --install` in PowerShell (Admin) - Run codebuff inside WSL for best compatibility -**Note**: Even when running in Git Bash, Codebuff spawns commands using `cmd.exe`. Using WSL provides the most reliable experience for git operations. - **Reference**: Issue [#274](https://github.com/CodebuffAI/codebuff/issues/274) --- diff --git a/sdk/src/run-state.ts b/sdk/src/run-state.ts index 14676ea34d..12b896af70 100644 --- a/sdk/src/run-state.ts +++ b/sdk/src/run-state.ts @@ -502,7 +502,7 @@ export async function initialSessionState( shellConfigFiles: {}, systemInfo: { platform: process.platform, - shell: process.platform === 'win32' ? 'cmd.exe' : 'bash', + shell: 'bash', nodeVersion: process.version, arch: process.arch, homedir: os.homedir(), diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index dd2c974b99..87b819f282 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -1,4 +1,5 @@ import { spawn } from 'child_process' +import * as fs from 'fs' import * as os from 'os' import * as path from 'path' @@ -12,6 +13,75 @@ import type { CodebuffToolOutput } from '../../../common/src/tools/list' const COMMAND_OUTPUT_LIMIT = 50_000 +// Common locations where Git Bash might be installed on Windows +const GIT_BASH_COMMON_PATHS = [ + 'C:\\Program Files\\Git\\bin\\bash.exe', + 'C:\\Program Files (x86)\\Git\\bin\\bash.exe', + 'C:\\Git\\bin\\bash.exe', +] + +/** + * Find bash executable on Windows. + * Priority: + * 1. CODEBUFF_GIT_BASH_PATH environment variable + * 2. bash.exe in PATH (e.g., inside WSL or Git Bash terminal) + * 3. Common Git Bash installation locations + */ +function findWindowsBash(env: NodeJS.ProcessEnv): string | null { + // Check for user-specified path via environment variable + const customPath = env.CODEBUFF_GIT_BASH_PATH + if (customPath && fs.existsSync(customPath)) { + return customPath + } + + // Check if bash.exe is in PATH (works inside WSL or Git Bash) + const pathEnv = env.PATH || env.Path || '' + const pathDirs = pathEnv.split(path.delimiter) + + for (const dir of pathDirs) { + const bashPath = path.join(dir, 'bash.exe') + if (fs.existsSync(bashPath)) { + return bashPath + } + // Also check for just 'bash' (for WSL) + const bashPathNoExt = path.join(dir, 'bash') + if (fs.existsSync(bashPathNoExt)) { + return bashPathNoExt + } + } + + // Check common Git Bash installation locations + for (const commonPath of GIT_BASH_COMMON_PATHS) { + if (fs.existsSync(commonPath)) { + return commonPath + } + } + + return null +} + +/** + * Create an error message for Windows users when bash is not available. + */ +function createWindowsBashNotFoundError(): Error { + return new Error( + `Bash is required but was not found on this Windows system. + +To fix this, you have several options: + +1. Install Git for Windows (includes bash.exe): + Download from: https://git-scm.com/download/win + +2. Use WSL (Windows Subsystem for Linux): + Run in PowerShell (Admin): wsl --install + Then run Codebuff inside WSL. + +3. Set a custom bash path: + Set the CODEBUFF_GIT_BASH_PATH environment variable to your bash.exe location. + Example: set CODEBUFF_GIT_BASH_PATH=C:\\path\\to\\bash.exe`, + ) +} + export function runTerminalCommand({ command, process_type, @@ -31,18 +101,33 @@ export function runTerminalCommand({ return new Promise((resolve, reject) => { const isWindows = os.platform() === 'win32' - const shell = isWindows ? 'cmd.exe' : 'bash' - const shellArgs = isWindows ? ['/c'] : ['-c'] + const processEnv = { + ...getSystemProcessEnv(), + ...(env ?? {}), + } as NodeJS.ProcessEnv + + let shell: string + let shellArgs: string[] + + if (isWindows) { + const bashPath = findWindowsBash(processEnv) + if (!bashPath) { + reject(createWindowsBashNotFoundError()) + return + } + shell = bashPath + shellArgs = ['-c'] + } else { + shell = 'bash' + shellArgs = ['-c'] + } // Resolve cwd to absolute path const resolvedCwd = path.resolve(cwd) const childProcess = spawn(shell, [...shellArgs, command], { cwd: resolvedCwd, - env: { - ...getSystemProcessEnv(), - ...(env ?? {}), - } as NodeJS.ProcessEnv, + env: processEnv, stdio: 'pipe', }) From f36bb0056f08f2a0ca8db48232df3a3b2d5febbb Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 18:21:27 -0800 Subject: [PATCH 0030/1143] Inclue newline shortcuts in help --- cli/src/components/help-banner.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/src/components/help-banner.tsx b/cli/src/components/help-banner.tsx index eb1b1fdb8a..fdaefe5873 100644 --- a/cli/src/components/help-banner.tsx +++ b/cli/src/components/help-banner.tsx @@ -20,7 +20,7 @@ export const HelpBanner = () => { return ( setInputMode('default')} /> ) From 413ff1f82be2ee9ae73303b66e9276d8db6296f8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 02:12:18 -0800 Subject: [PATCH 0031/1143] feat(cli): add message-block-store and block-margins utilities Add zustand store for managing message block context and callbacks. Add block-margins utility for consistent spacing calculations. --- cli/src/state/message-block-store.ts | 117 +++++++++++++++++++++++++++ cli/src/utils/block-margins.ts | 35 ++++++++ 2 files changed, 152 insertions(+) create mode 100644 cli/src/state/message-block-store.ts create mode 100644 cli/src/utils/block-margins.ts diff --git a/cli/src/state/message-block-store.ts b/cli/src/state/message-block-store.ts new file mode 100644 index 0000000000..4551d481d3 --- /dev/null +++ b/cli/src/state/message-block-store.ts @@ -0,0 +1,117 @@ +import { create } from 'zustand' +import { immer } from 'zustand/middleware/immer' + +import type { ChatMessage } from '../types/chat' +import type { ChatTheme } from '../types/theme-system' +import type { MarkdownPalette } from '../utils/markdown-renderer' + +/** + * Context values that are updated by the Chat component and consumed by + * message rendering components (MessageWithAgents, AgentMessage, etc). + */ +export interface MessageBlockContext { + /** Active chat theme (colors, etc). */ + theme: ChatTheme | null + /** Palette for markdown rendering. Null until Chat component initializes it. */ + markdownPalette: MarkdownPalette | null + /** Message tree mapping parent message ID -> child agent messages. */ + messageTree: Map | null + /** Whether the main agent is currently waiting for a response. */ + isWaitingForResponse: boolean + /** Timer start time for the main agent stream, used for UI timers. */ + timerStartTime: number | null + /** Available width for rendering message content. */ + availableWidth: number +} + +/** + * Stable callback functions for message block interactions. + * These are set by the Chat component and consumed by message blocks. + */ +export interface MessageBlockCallbacks { + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + onFeedback: ( + messageId: string, + options?: { + category?: string + footerMessage?: string + errors?: Array<{ id: string; message: string }> + }, + ) => void + onCloseFeedback: () => void +} + +interface MessageBlockStoreState { + context: MessageBlockContext + callbacks: MessageBlockCallbacks +} + +interface MessageBlockStoreActions { + /** + * Batch update context values. Pass only the values you want to update. + * + * This is called from the Chat component whenever any of the dependent + * values (theme, markdownPalette, messageTree, etc) change. + */ + setContext: (context: Partial) => void + /** + * Replace all callbacks at once. These are typically stable functions set + * up once when the Chat component mounts. + */ + setCallbacks: (callbacks: MessageBlockCallbacks) => void + /** + * Reset the store to its initial state. Primarily used by tests. + */ + reset: () => void +} + +type MessageBlockStore = MessageBlockStoreState & MessageBlockStoreActions + +const noop = () => {} +const noopFeedback: MessageBlockCallbacks['onFeedback'] = () => {} + +const initialContext: MessageBlockContext = { + theme: null, + markdownPalette: null, + messageTree: null, + isWaitingForResponse: false, + timerStartTime: null, + availableWidth: 80, +} + +const initialCallbacks: MessageBlockCallbacks = { + onToggleCollapsed: noop, + onBuildFast: noop, + onBuildMax: noop, + onFeedback: noopFeedback, + onCloseFeedback: noop, +} + +const initialState: MessageBlockStoreState = { + context: initialContext, + callbacks: initialCallbacks, +} + +export const useMessageBlockStore = create()( + immer((set) => ({ + ...initialState, + + setContext: (updates) => + set((state) => { + state.context = { ...state.context, ...updates } + }), + + setCallbacks: (callbacks) => + set((state) => { + state.callbacks = callbacks + }), + + reset: () => + set((state) => { + state.context = { ...initialContext } + state.callbacks = { ...initialCallbacks } + }), + })), +) diff --git a/cli/src/utils/block-margins.ts b/cli/src/utils/block-margins.ts new file mode 100644 index 0000000000..12c36cc528 --- /dev/null +++ b/cli/src/utils/block-margins.ts @@ -0,0 +1,35 @@ +import type { ContentBlock, TextContentBlock } from '../types/chat' + +/** + * Margin calculation result for a content block. + */ +export interface BlockMargins { + marginTop: number + marginBottom: number +} + +/** Extracts margins for a text block, suppressing top margin after tool/agent blocks. */ +export function extractTextBlockMargins( + block: TextContentBlock, + prevBlock: ContentBlock | null, +): BlockMargins { + const prevBlockSuppressesMargin = + prevBlock !== null && + (prevBlock.type === 'tool' || prevBlock.type === 'agent') + + const marginTop = prevBlockSuppressesMargin ? 0 : (block.marginTop ?? 0) + const marginBottom = block.marginBottom ?? 0 + + return { marginTop, marginBottom } +} + +/** Extracts margins for an HTML block using explicit values without context adjustments. */ +export function extractHtmlBlockMargins(block: { + marginTop?: number + marginBottom?: number +}): BlockMargins { + return { + marginTop: block.marginTop ?? 0, + marginBottom: block.marginBottom ?? 0, + } +} From 7b65f1b93be73c28b87c602a8a11595b83e3a20e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 02:12:40 -0800 Subject: [PATCH 0032/1143] refactor(cli): refactor blocks-renderer and agent-branch-wrapper Refactor block rendering components to use zustand store for context. Add use-grid-layout hook and update implementor-helpers utilities. --- .../blocks/agent-branch-wrapper.tsx | 190 ++++++++------ cli/src/components/blocks/blocks-renderer.tsx | 244 ++++++++++-------- cli/src/components/blocks/implementor-row.tsx | 13 +- cli/src/components/blocks/single-block.tsx | 10 +- cli/src/hooks/use-grid-layout.ts | 20 ++ cli/src/utils/implementor-helpers.ts | 69 ++++- cli/src/utils/layout-helpers.ts | 10 +- 7 files changed, 363 insertions(+), 193 deletions(-) diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx index ea7d1b956a..f49ce665f5 100644 --- a/cli/src/components/blocks/agent-branch-wrapper.tsx +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from '@opentui/core' -import React, { memo, useCallback, useMemo, type ReactNode } from 'react' +import React, { memo, useCallback, useMemo, useRef, type ReactNode } from 'react' import { AgentBlockGrid } from './agent-block-grid' import { AgentBranchItem } from './agent-branch-item' @@ -14,6 +14,7 @@ import { shouldRenderAsSimpleText } from '../../utils/constants' import { isImplementorAgent, getImplementorIndex } from '../../utils/implementor-helpers' import { processBlocks, type BlockProcessorHandlers } from '../../utils/block-processor' import { getAgentStatusInfo } from '../../utils/agent-helpers' +import { extractHtmlBlockMargins } from '../../utils/block-margins' import { isTextBlock } from '../../types/chat' import type { AgentContentBlock, @@ -36,6 +37,22 @@ interface AgentBodyProps { isLastMessage?: boolean } +/** Props stored in ref for stable handler access in AgentBody */ +interface AgentBodyPropsRef { + keyPrefix: string + nestedBlocks: ContentBlock[] + parentIsStreaming: boolean + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + isLastMessage?: boolean + theme: ReturnType + getAgentMarkdownOptions: (indent: number) => { codeBlockWidth: number; palette: MarkdownPalette } +} + const AgentBody = memo( ({ agentBlock, @@ -69,83 +86,114 @@ const AgentBody = memo( [availableWidth, markdownPalette, theme.foreground], ) + // Store props in ref for stable handler access (avoids 12+ useMemo dependencies) + const propsRef = useRef(null!) + propsRef.current = { + keyPrefix, + nestedBlocks, + parentIsStreaming, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + theme, + getAgentMarkdownOptions, + } + + // Handlers are stable (empty deps) and read latest props from ref const handlers: BlockProcessorHandlers = useMemo( () => ({ - onReasoningGroup: (reasoningBlocks, startIndex) => ( - - ), + onReasoningGroup: (reasoningBlocks, startIndex) => { + const p = propsRef.current + return ( + + ) + }, - onToolGroup: (toolBlocks, startIndex, nextIndex) => ( - - ), + onToolGroup: (toolBlocks, startIndex, nextIndex) => { + const p = propsRef.current + return ( + + ) + }, - onImplementorGroup: (implementors, startIndex) => ( - - ), + onImplementorGroup: (implementors, startIndex) => { + const p = propsRef.current + return ( + + ) + }, - onAgentGroup: (agentBlocks, startIndex) => ( - ( - - )} - /> - ), + onAgentGroup: (agentBlocks, startIndex) => { + const p = propsRef.current + return ( + ( + + )} + /> + ) + }, onSingleBlock: (block, index) => { + const p = propsRef.current if (block.type === 'text') { const textBlock = block as TextContentBlock const nestedStatus = textBlock.status - const isNestedStreamingText = parentIsStreaming || nestedStatus === 'running' + const isNestedStreamingText = p.parentIsStreaming || nestedStatus === 'running' const filteredNestedContent = isNestedStreamingText ? trimTrailingNewlines(textBlock.content) : textBlock.content.trim() - const markdownOptionsForLevel = getAgentMarkdownOptions(0) + const markdownOptionsForLevel = p.getAgentMarkdownOptions(0) const marginTop = textBlock.marginTop ?? 0 const marginBottom = textBlock.marginBottom ?? 0 const explicitColor = textBlock.color - const nestedTextColor = explicitColor ?? theme.foreground + const nestedTextColor = explicitColor ?? p.theme.foreground return ( {htmlBlock.render({ - textColor: theme.foreground, - theme, + textColor: p.theme.foreground, + theme: p.theme, })} ) @@ -190,20 +237,7 @@ const AgentBody = memo( return null }, }), - [ - keyPrefix, - nestedBlocks, - parentIsStreaming, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - theme, - getAgentMarkdownOptions, - ], + [], // Empty deps - handlers read from propsRef.current ) return processBlocks(nestedBlocks, handlers) as ReactNode[] diff --git a/cli/src/components/blocks/blocks-renderer.tsx b/cli/src/components/blocks/blocks-renderer.tsx index f8ae818a9c..bc7ac00d03 100644 --- a/cli/src/components/blocks/blocks-renderer.tsx +++ b/cli/src/components/blocks/blocks-renderer.tsx @@ -1,4 +1,4 @@ -import React, { memo, useMemo } from 'react' +import React, { memo, useMemo, useRef } from 'react' import { AgentBlockGrid } from './agent-block-grid' import { ImplementorGroup } from './implementor-row' @@ -28,6 +28,25 @@ interface BlocksRendererProps { contentToCopy?: string } +/** Props stored in ref for stable handler access */ +interface BlocksRendererPropsRef { + sourceBlocks: ContentBlock[] + messageId: string + isLoading: boolean + isComplete?: boolean + isUser: boolean + textColor: string + availableWidth: number + markdownPalette: MarkdownPalette + streamingAgents: Set + onToggleCollapsed: (id: string) => void + onBuildFast: () => void + onBuildMax: () => void + isLastMessage?: boolean + contentToCopy?: string + lastTextBlockIndex: number +} + export const BlocksRenderer = memo( ({ sourceBlocks, @@ -53,115 +72,138 @@ export const BlocksRenderer = memo( ) : -1 + // Store props in ref for stable handler access (avoids 17 useMemo dependencies) + const propsRef = useRef(null!) + propsRef.current = { + sourceBlocks, + messageId, + isLoading, + isComplete, + isUser, + textColor, + availableWidth, + markdownPalette, + streamingAgents, + onToggleCollapsed, + onBuildFast, + onBuildMax, + isLastMessage, + contentToCopy, + lastTextBlockIndex, + } + + // Handlers are stable (empty deps) and read latest props from ref const handlers: BlockProcessorHandlers = useMemo( () => ({ - onReasoningGroup: (reasoningBlocks, startIndex) => ( - - ), + onReasoningGroup: (reasoningBlocks, startIndex) => { + const p = propsRef.current + return ( + + ) + }, - onImageBlock: (block, index) => ( - - ), + onImageBlock: (block, index) => { + const p = propsRef.current + return ( + + ) + }, - onToolGroup: (toolBlocks, startIndex, nextIndex) => ( - - ), + onToolGroup: (toolBlocks, startIndex, nextIndex) => { + const p = propsRef.current + return ( + + ) + }, - onImplementorGroup: (implementors, startIndex) => ( - - ), + onImplementorGroup: (implementors, startIndex) => { + const p = propsRef.current + return ( + + ) + }, - onAgentGroup: (agentBlocks, startIndex) => ( - ( - - )} - /> - ), + onAgentGroup: (agentBlocks, startIndex) => { + const p = propsRef.current + return ( + ( + + )} + /> + ) + }, - onSingleBlock: (block, index) => ( - - ), + onSingleBlock: (block, index) => { + const p = propsRef.current + return ( + + ) + }, }), - [ - messageId, - sourceBlocks, - isLoading, - isComplete, - isUser, - textColor, - availableWidth, - markdownPalette, - streamingAgents, - onToggleCollapsed, - onBuildFast, - onBuildMax, - isLastMessage, - contentToCopy, - lastTextBlockIndex, - ], + [], // Empty deps - handlers read from propsRef.current ) - return processBlocks(sourceBlocks, handlers) + return <>{processBlocks(sourceBlocks, handlers)} }, ) diff --git a/cli/src/components/blocks/implementor-row.tsx b/cli/src/components/blocks/implementor-row.tsx index 9db3179dc6..8705d78f74 100644 --- a/cli/src/components/blocks/implementor-row.tsx +++ b/cli/src/components/blocks/implementor-row.tsx @@ -1,6 +1,15 @@ import { TextAttributes } from '@opentui/core' import React, { memo, useCallback, useMemo, useState } from 'react' +/** Horizontal padding inside implementor cards (left + right) */ +const CARD_HORIZONTAL_PADDING = 4 +/** Fixed width for the +/- bar visualization */ +const STATS_BAR_WIDTH = 5 +/** Minimum width to display file paths */ +const MIN_FILE_PATH_WIDTH = 10 +/** Minimum inner content width */ +const MIN_INNER_WIDTH = 10 + import { getAgentStatusInfo } from '../../utils/agent-helpers' import { buildActivityTimeline, @@ -148,7 +157,7 @@ const ImplementorCard = memo( : `${statusIndicator} ${statusLabel}` // Use cardWidth for internal truncation calculations (approximate internal space) - const innerWidth = Math.max(10, cardWidth - 4) + const innerWidth = Math.max(MIN_INNER_WIDTH, cardWidth - CARD_HORIZONTAL_PADDING) // Toggle file selection - clicking same file deselects it const handleFileSelect = useCallback((filePath: string) => { @@ -254,7 +263,7 @@ const CompactFileStats = memo(({ } // Fixed bar width - keeps layout simple and predictable - const maxBarWidth = 5 + const maxBarWidth = STATS_BAR_WIDTH // Calculate max string widths for alignment (so all bars meet at center axis) // Always include +0/-0 in width calculation since we always show them diff --git a/cli/src/components/blocks/single-block.tsx b/cli/src/components/blocks/single-block.tsx index 4453f08be6..c15b0043d6 100644 --- a/cli/src/components/blocks/single-block.tsx +++ b/cli/src/components/blocks/single-block.tsx @@ -10,6 +10,7 @@ import { UserBlockTextWithInlineCopy } from './user-content-copy' import { trimTrailingNewlines, isReasoningTextBlock } from './block-helpers' import { PlanBox } from '../renderers/plan-box' import { useTheme } from '../../hooks/use-theme' +import { extractTextBlockMargins, extractHtmlBlockMargins } from '../../utils/block-margins' import type { ContentBlock, TextContentBlock, @@ -70,11 +71,7 @@ export const SingleBlock = memo( : textBlock.content.trim() const renderKey = `${messageId}-text-${idx}` const prevBlock = idx > 0 && blocks ? blocks[idx - 1] : null - const marginTop = - prevBlock && (prevBlock.type === 'tool' || prevBlock.type === 'agent') - ? 0 - : textBlock.marginTop ?? 0 - const marginBottom = textBlock.marginBottom ?? 0 + const { marginTop, marginBottom } = extractTextBlockMargins(textBlock, prevBlock) const explicitColor = textBlock.color const blockTextColor = explicitColor ?? textColor @@ -130,8 +127,7 @@ export const SingleBlock = memo( } case 'html': { - const marginTop = block.marginTop ?? 0 - const marginBottom = block.marginBottom ?? 0 + const { marginTop, marginBottom } = extractHtmlBlockMargins(block) return ( { @@ -18,6 +27,17 @@ export function computeGridLayout( items: T[], availableWidth: number, ): GridLayoutResult { + // Force single column for very narrow terminals where multi-column wouldn't fit + const COLUMN_GAP = 1 + const minWidthForTwoColumns = MIN_COLUMN_WIDTH * 2 + COLUMN_GAP + if (availableWidth < minWidthForTwoColumns) { + return { + columns: 1, + columnWidth: availableWidth, + columnGroups: [items], + } + } + const maxColumns = WIDTH_THRESHOLDS.filter(t => availableWidth >= t).length + 1 const columns = computeSmartColumns(items.length, maxColumns) diff --git a/cli/src/utils/implementor-helpers.ts b/cli/src/utils/implementor-helpers.ts index 5bfaf9dfbb..aebd35281b 100644 --- a/cli/src/utils/implementor-helpers.ts +++ b/cli/src/utils/implementor-helpers.ts @@ -11,8 +11,13 @@ export const IMPLEMENTOR_AGENT_IDS = [ 'editor-implementor-gpt-5', ] as const -const EDIT_TOOL_NAMES = ['str_replace', 'write_file'] as const -const PROPOSED_EDIT_TOOL_NAMES = EDIT_TOOL_NAMES.map(n => `propose_${n}` as const) +/** All edit tool names (both direct and proposed variants) */ +const ALL_EDIT_TOOL_NAMES = [ + 'str_replace', + 'write_file', + 'propose_str_replace', + 'propose_write_file', +] as const const isProposedToolName = (toolName: ToolContentBlock['toolName']): boolean => typeof toolName === 'string' && toolName.startsWith('propose_') @@ -28,6 +33,10 @@ const hasProposedTools = (blocks?: ContentBlock[]): boolean => { ) } +/** + * Check if an agent is an implementor agent. + * These agents are rendered differently (as simple status lines instead of full agent blocks). + */ export const isImplementorAgent = ( agentBlock: Pick, ): boolean => { @@ -38,6 +47,9 @@ export const isImplementorAgent = ( return IMPLEMENTOR_AGENT_IDS.some((id) => agentBlock.agentType.includes(id)) } +/** + * Get the display name for an implementor agent. + */ export const getImplementorDisplayName = ( agentType: string, index?: number, @@ -59,6 +71,10 @@ export const getImplementorDisplayName = ( return baseName } +/** + * Get the index of an implementor agent among its siblings. + * Returns the 0-based index among all implementor agents of the same type. + */ export const getImplementorIndex = ( currentAgent: AgentContentBlock, siblingBlocks: ContentBlock[], @@ -84,6 +100,10 @@ export const getImplementorIndex = ( ) } +/** + * Group consecutive blocks from a blocks array that match the predicate. + * Returns the group and the next index to process. + */ export function groupConsecutiveBlocks( blocks: ContentBlock[], startIndex: number, @@ -104,6 +124,10 @@ export function groupConsecutiveBlocks( return { group, nextIndex: i } } +/** + * Group consecutive implementor agents from a blocks array. + * Returns the group of implementors and the next index to process. + */ export function groupConsecutiveImplementors( blocks: ContentBlock[], startIndex: number, @@ -139,8 +163,10 @@ export function groupConsecutiveToolBlocks( ) } -const ALL_EDIT_TOOL_NAMES = [...EDIT_TOOL_NAMES, ...PROPOSED_EDIT_TOOL_NAMES] as const - +/** + * Extract a value for a key from tool output (key: value format). + * Supports multi-line values with pipe delimiter. + */ export function extractValueForKey(output: string, key: string): string | null { if (!output) return null const lines = output.split('\n') @@ -175,6 +201,9 @@ export function extractValueForKey(output: string, key: string): string | null { return null } +/** + * Extract file path from tool block. + */ export function extractFilePath(toolBlock: ToolContentBlock): string | null { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' const input = toolBlock.input as Record @@ -186,6 +215,11 @@ export function extractFilePath(toolBlock: ToolContentBlock): string | null { ) } +/** + * Extract unified diff from tool output, or construct from input. + * For executed tools: use outputRaw/output with unifiedDiff. + * For proposed tools (implementors): construct diff from input replacements. + */ export function extractDiff(toolBlock: ToolContentBlock): string | null { // First try to get from outputRaw (for executed tool results) // outputRaw is typically an array like [{type: "json", value: {unifiedDiff: "..."}}] @@ -237,6 +271,9 @@ export function extractDiff(toolBlock: ToolContentBlock): string | null { return null } +/** + * Construct a simple diff view from str_replace replacements. + */ function constructDiffFromReplacements( replacements: { old: string; new: string }[], ): string { @@ -262,11 +299,17 @@ function constructDiffFromReplacements( return lines.join('\n') } +/** + * Construct a diff view from write_file content. + */ function constructDiffFromWriteFile(content: string): string { const lines = content.split('\n') return lines.map((line) => `+ ${line}`).join('\n') } +/** + * Check if a tool is a "create new file" operation. + */ export function isCreateFile(toolBlock: ToolContentBlock): boolean { const outputStr = typeof toolBlock.output === 'string' ? toolBlock.output : '' const message = extractValueForKey(outputStr, 'message') @@ -299,6 +342,9 @@ export interface FileStats { stats: DiffStats } +/** + * Parse diff text and extract statistics. + */ export function parseDiffStats(diff: string | undefined): DiffStats { if (!diff) return { linesAdded: 0, linesRemoved: 0, hunks: 0 } @@ -330,6 +376,9 @@ export function parseDiffStats(diff: string | undefined): DiffStats { return { linesAdded, linesRemoved, hunks } } +/** + * Determine file change type based on tool and context. + */ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { const baseToolName = getBaseToolName(toolBlock.toolName) // write_file creating new file = Added @@ -347,6 +396,10 @@ export function getFileChangeType(toolBlock: ToolContentBlock): FileChangeType { return 'M' } +/** + * Get aggregated file stats from all edit blocks. + * Groups by file path and sums up the stats. + */ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): FileStats[] { if (!blocks || blocks.length === 0) return [] @@ -383,6 +436,11 @@ export function getFileStatsFromBlocks(blocks: ContentBlock[] | undefined): File return Array.from(fileMap.values()) } +/** + * Build an activity timeline from agent blocks. + * Interleaves commentary (text blocks) and edits (tool calls). + * Includes both executed tools (str_replace, write_file) and proposed tools. + */ export function buildActivityTimeline( blocks: ContentBlock[] | undefined, ): TimelineItem[] { @@ -416,6 +474,9 @@ export function buildActivityTimeline( return timeline } +/** + * Truncate text to fit within maxWidth, adding ellipsis if needed. + */ export function truncateWithEllipsis(text: string, maxWidth: number): string { if (text.length <= maxWidth) return text if (maxWidth <= 3) return text.slice(0, maxWidth) diff --git a/cli/src/utils/layout-helpers.ts b/cli/src/utils/layout-helpers.ts index 82c44dc9cd..7f6fd58785 100644 --- a/cli/src/utils/layout-helpers.ts +++ b/cli/src/utils/layout-helpers.ts @@ -1,8 +1,16 @@ +/** Minimum width (in characters) for a grid column */ export const MIN_COLUMN_WIDTH = 10 + +/** Maximum nesting depth for agent blocks */ export const MAX_AGENT_DEPTH = 10 + +/** Horizontal padding (in characters) inside agent content boxes */ export const AGENT_CONTENT_HORIZONTAL_PADDING = 12 -// Prefers balanced grids (2x2 over 3+1) +/** + * Compute the ideal number of columns for a grid layout. + * Tries to create a balanced grid (e.g. 2x2 instead of 3x1 + 1) while respecting max columns. + */ export function computeSmartColumns(itemCount: number, maxColumns: number): number { if (itemCount === 0) return 1 if (itemCount <= maxColumns) return itemCount From 4c7ead2d68061241fcde64fc59e8698beb9844df Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 02:13:02 -0800 Subject: [PATCH 0033/1143] refactor(cli): refactor message-with-agents and add chat types Refactor message-with-agents component to use zustand store. Add chat types and update error-boundary component. --- cli/src/chat.tsx | 60 ++++- cli/src/components/error-boundary.tsx | 39 ++- cli/src/components/message-with-agents.tsx | 289 ++++++--------------- cli/src/types/chat.ts | 7 + cli/src/utils/message-updater.ts | 2 + 5 files changed, 159 insertions(+), 238 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 73fcd0ca86..7cc914e054 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -57,6 +57,7 @@ import { getProjectRoot } from './project-files' import { useChatStore } from './state/chat-store' import { useChatHistoryStore } from './state/chat-history-store' import { useFeedbackStore } from './state/feedback-store' +import { useMessageBlockStore } from './state/message-block-store' import { usePublishStore } from './state/publish-store' import { addClipboardPlaceholder, @@ -1363,6 +1364,52 @@ export const Chat = ({ [messages], ) + // Sync message block context to zustand store for child components + const setMessageBlockContext = useMessageBlockStore( + (state) => state.setContext, + ) + const setMessageBlockCallbacks = useMessageBlockStore( + (state) => state.setCallbacks, + ) + + // Update context when values change + useEffect(() => { + setMessageBlockContext({ + theme, + markdownPalette, + messageTree, + isWaitingForResponse, + timerStartTime, + availableWidth: messageAvailableWidth, + }) + }, [ + theme, + markdownPalette, + messageTree, + isWaitingForResponse, + timerStartTime, + messageAvailableWidth, + setMessageBlockContext, + ]) + + // Update callbacks once (they're stable) + useEffect(() => { + setMessageBlockCallbacks({ + onToggleCollapsed: handleCollapseToggle, + onBuildFast: handleBuildFast, + onBuildMax: handleBuildMax, + onFeedback: handleMessageFeedback, + onCloseFeedback: handleCloseFeedback, + }) + }, [ + handleCollapseToggle, + handleBuildFast, + handleBuildMax, + handleMessageFeedback, + handleCloseFeedback, + setMessageBlockCallbacks, + ]) + // Compute visible messages slice (from the end) const visibleTopLevelMessages = useMemo(() => { if (topLevelMessages.length <= visibleMessageCount) { @@ -1530,20 +1577,7 @@ export const Chat = ({ message={message} depth={0} isLastMessage={isLast} - theme={theme} - markdownPalette={markdownPalette} - streamingAgents={streamingAgents} - messageTree={messageTree} - messages={messages} availableWidth={messageAvailableWidth} - setFocusedAgentId={setFocusedAgentId} - isWaitingForResponse={isWaitingForResponse} - timerStartTime={timerStartTime} - onToggleCollapsed={handleCollapseToggle} - onBuildFast={handleBuildFast} - onBuildMax={handleBuildMax} - onFeedback={handleMessageFeedback} - onCloseFeedback={handleCloseFeedback} /> ) })} diff --git a/cli/src/components/error-boundary.tsx b/cli/src/components/error-boundary.tsx index 040d8c68de..7495db4740 100644 --- a/cli/src/components/error-boundary.tsx +++ b/cli/src/components/error-boundary.tsx @@ -1,31 +1,42 @@ import { memo, type ReactNode } from 'react' -interface ErrorBoundaryProps { +interface ErrorBoundaryPlaceholderProps { children: ReactNode fallback: ReactNode componentName?: string } /** - * A wrapper component that provides error boundary-like behavior. - * Since OpenTUI's JSX types don't support React class components, - * this uses a memo wrapper. Errors that occur during render will - * be caught by React's error boundary mechanism if one exists higher - * in the tree, or will propagate normally. + * **WARNING: This component does NOT catch render errors.** * - * For true error boundary behavior in OpenTUI, wrap at the application - * root level using React's native error boundary support. + * This is a placeholder/passthrough component that exists for structural purposes. + * OpenTUI's JSX types don't support React class components, which are required + * for true error boundary functionality. + * + * For actual error catching in render functions, use `withErrorFallback()` instead. + * + * @example + * // Use withErrorFallback for catching render errors: + * const safeContent = withErrorFallback( + * () => riskyRenderFunction(), + * , + * 'MyComponent' + * ) */ -export const ErrorBoundary = memo( - ({ children, fallback, componentName }: ErrorBoundaryProps) => { - // Note: This is a structural wrapper. True error catching requires - // a class component, but OpenTUI's JSX types don't support them. - // The fallback is available for parent components to use when they - // detect errors through other means. +export const ErrorBoundaryPlaceholder = memo( + ({ children }: ErrorBoundaryPlaceholderProps) => { + // This component does NOT catch errors - it's a passthrough. + // Use withErrorFallback() for actual error catching. return <>{children} }, ) +/** + * @deprecated Use `ErrorBoundaryPlaceholder` instead. This alias exists for backward + * compatibility but the name is misleading since it doesn't actually catch errors. + */ +export const ErrorBoundary = ErrorBoundaryPlaceholder + /** * Helper to safely render content with error handling. * Use this when you need to catch render errors in a functional context. diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index adf08c1b38..8017e4df24 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -7,62 +7,31 @@ import { ErrorBoundary } from './error-boundary' import { GridLayout } from './grid-layout' import { MessageBlock } from './message-block' import { ModeDivider } from './mode-divider' +import { useChatStore } from '../state/chat-store' +import { useMessageBlockStore } from '../state/message-block-store' import { renderMarkdown, hasMarkdown, type MarkdownPalette, } from '../utils/markdown-renderer' -import { AGENT_CONTENT_HORIZONTAL_PADDING, MAX_AGENT_DEPTH } from '../utils/layout-helpers' +import { + AGENT_CONTENT_HORIZONTAL_PADDING, + MAX_AGENT_DEPTH, +} from '../utils/layout-helpers' import { getCliEnv } from '../utils/env' import type { ChatMessage } from '../types/chat' -import type { ChatTheme } from '../types/theme-system' interface AgentChildrenGridProps { agentChildren: ChatMessage[] depth: number - theme: ChatTheme - markdownPalette: MarkdownPalette - streamingAgents: Set - messageTree: Map - messages: ChatMessage[] availableWidth: number - setFocusedAgentId: React.Dispatch> - isWaitingForResponse: boolean - timerStartTime: number | null - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - onFeedback: ( - messageId: string, - options?: { - category?: string - footerMessage?: string - errors?: Array<{ id: string; message: string }> - }, - ) => void - onCloseFeedback: () => void } const AgentChildrenGrid = memo( - ({ - agentChildren, - depth, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - availableWidth, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - }: AgentChildrenGridProps) => { + ({ agentChildren, depth, availableWidth }: AgentChildrenGridProps) => { + const theme = useMessageBlockStore((state) => state.context.theme) + const getItemKey = useCallback((agent: ChatMessage) => agent.id, []) const renderAgentChild = useCallback( @@ -71,38 +40,10 @@ const AgentChildrenGrid = memo( message={agent} depth={depth + 1} isLastMessage={false} - theme={theme} - markdownPalette={markdownPalette} - streamingAgents={streamingAgents} - messageTree={messageTree} - messages={messages} availableWidth={columnWidth} - setFocusedAgentId={setFocusedAgentId} - isWaitingForResponse={isWaitingForResponse} - timerStartTime={timerStartTime} - onToggleCollapsed={onToggleCollapsed} - onBuildFast={onBuildFast} - onBuildMax={onBuildMax} - onFeedback={onFeedback} - onCloseFeedback={onCloseFeedback} /> ), - [ - depth, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - ], + [depth], ) if (agentChildren.length === 0) return null @@ -114,7 +55,7 @@ const AgentChildrenGrid = memo( ) } return ( - + {`${agentChildren.length} nested agent${ agentChildren.length > 1 ? 's' : '' } not shown (depth limit)`} @@ -123,7 +64,7 @@ const AgentChildrenGrid = memo( } const errorFallback = ( - Error rendering agent children + Error rendering agent children ) return ( @@ -143,52 +84,35 @@ interface MessageWithAgentsProps { message: ChatMessage depth: number isLastMessage: boolean - theme: ChatTheme - markdownPalette: MarkdownPalette - streamingAgents: Set - messageTree: Map - messages: ChatMessage[] availableWidth: number - setFocusedAgentId: React.Dispatch> - isWaitingForResponse: boolean - timerStartTime: number | null - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - onFeedback: ( - messageId: string, - options?: { - category?: string - footerMessage?: string - errors?: Array<{ id: string; message: string }> - }, - ) => void - onCloseFeedback: () => void } export const MessageWithAgents = memo( - ({ - message, - depth, - isLastMessage, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - availableWidth, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - }: MessageWithAgentsProps): ReactNode => { + ({ message, depth, isLastMessage, availableWidth }: MessageWithAgentsProps): ReactNode => { const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' + const context = useMessageBlockStore((state) => state.context) + const callbacks = useMessageBlockStore((state) => state.callbacks) + + const { + theme, + markdownPalette, + messageTree, + isWaitingForResponse, + timerStartTime, + } = context + + const { + onToggleCollapsed, + onBuildFast, + onBuildMax, + onFeedback, + onCloseFeedback, + } = callbacks + + const streamingAgents = useChatStore((state) => state.streamingAgents) + // Memoize onOpenFeedback to prevent unnecessary re-renders const onOpenFeedback = useCallback( (options?: { @@ -203,7 +127,7 @@ export const MessageWithAgents = memo( const contentBoxStyle = useMemo( () => ({ - backgroundColor: theme.background, + backgroundColor: theme?.background, padding: 0, paddingLeft: SIDE_GUTTER, paddingRight: SIDE_GUTTER, @@ -214,30 +138,11 @@ export const MessageWithAgents = memo( flexGrow: 1, justifyContent: 'center' as const, }), - [theme.background], + [theme?.background], ) if (isAgent) { - return ( - - ) + return } const isAi = message.variant === 'ai' @@ -258,11 +163,22 @@ export const MessageWithAgents = memo( /> ) } - const lineColor = isError ? 'red' : isAi ? theme.aiLine : theme.userLine - const textColor = theme.foreground - const timestampColor = isError ? 'red' : isAi ? theme.muted : theme.muted + + const lineColor = isError + ? 'red' + : isAi + ? theme?.aiLine ?? 'white' + : theme?.userLine ?? 'white' + const textColor = theme?.foreground ?? 'white' + const timestampColor = isError + ? 'red' + : isAi + ? theme?.muted ?? 'white' + : theme?.muted ?? 'white' + const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) + const paletteForMessage: MarkdownPalette = useMemo( () => ({ ...markdownPalette, @@ -270,6 +186,7 @@ export const MessageWithAgents = memo( }), [markdownPalette, textColor], ) + const markdownOptions = useMemo( () => ({ codeBlockWidth, palette: paletteForMessage }), [codeBlockWidth, paletteForMessage], @@ -278,7 +195,7 @@ export const MessageWithAgents = memo( const isLoading = isAi && message.content === '' && !message.blocks && isWaitingForResponse - const agentChildren = messageTree.get(message.id) ?? [] + const agentChildren = messageTree?.get(message.id) ?? [] const hasAgentChildren = agentChildren.length > 0 // Show vertical line for user messages (including bash commands which are now user messages) const showVerticalLine = isUser @@ -392,20 +309,7 @@ export const MessageWithAgents = memo( )} @@ -416,52 +320,25 @@ export const MessageWithAgents = memo( interface AgentMessageProps { message: ChatMessage depth: number - theme: ChatTheme - markdownPalette: MarkdownPalette - streamingAgents: Set - messageTree: Map - messages: ChatMessage[] availableWidth: number - setFocusedAgentId: React.Dispatch> - isWaitingForResponse: boolean - timerStartTime: number | null - onToggleCollapsed: (id: string) => void - onBuildFast: () => void - onBuildMax: () => void - onFeedback: ( - messageId: string, - options?: { - category?: string - footerMessage?: string - errors?: Array<{ id: string; message: string }> - }, - ) => void - onCloseFeedback: () => void } const AgentMessage = memo( - ({ - message, - depth, - theme, - markdownPalette, - streamingAgents, - messageTree, - messages, - availableWidth, - setFocusedAgentId, - isWaitingForResponse, - timerStartTime, - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - }: AgentMessageProps): ReactNode => { + ({ message, depth, availableWidth }: AgentMessageProps): ReactNode => { + // Get values from zustand stores + const context = useMessageBlockStore((state) => state.context) + const callbacks = useMessageBlockStore((state) => state.callbacks) + + const { theme, markdownPalette, messageTree } = context + const { onToggleCollapsed } = callbacks + + const streamingAgents = useChatStore((state) => state.streamingAgents) + const setFocusedAgentId = useChatStore((state) => state.setFocusedAgentId) + // Guard against missing agent info (should not happen for agent variant messages) if (!message.agent) { return ( - + Error: Missing agent info for agent message ) @@ -472,7 +349,7 @@ const AgentMessage = memo( const isCollapsed = message.metadata?.isCollapsed ?? false const isStreaming = streamingAgents.has(message.id) - const agentChildren = messageTree.get(message.id) ?? [] + const agentChildren = messageTree?.get(message.id) ?? [] const bulletChar = '• ' const fullPrefix = bulletChar @@ -491,10 +368,13 @@ const AgentMessage = memo( ? lastLine.replace(/[#*_`~\[\]()]/g, '').trim() : '' - const agentCodeBlockWidth = Math.max(10, availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING) + const agentCodeBlockWidth = Math.max( + 10, + availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING, + ) const agentPalette: MarkdownPalette = { ...markdownPalette, - codeTextFg: theme.foreground, + codeTextFg: theme?.foreground ?? markdownPalette.codeTextFg, } const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, @@ -534,7 +414,7 @@ const AgentMessage = memo( }} > - {fullPrefix} + {fullPrefix} - {isCollapsed ? '▸ ' : '▾ '} - + {isCollapsed ? '▸ ' : '▾ '} + {agentInfo.agentName} @@ -567,7 +447,7 @@ const AgentMessage = memo( > {isStreaming && isCollapsed && streamingPreview && ( {streamingPreview} @@ -575,7 +455,7 @@ const AgentMessage = memo( )} {!isStreaming && isCollapsed && finishedPreview && ( {finishedPreview} @@ -584,7 +464,7 @@ const AgentMessage = memo( {!isCollapsed && ( {displayContent} @@ -596,20 +476,7 @@ const AgentMessage = memo( )} diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index a4933f9765..ffba3a2d35 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -2,6 +2,12 @@ import type { ChatTheme } from './theme-system' import type { ToolName } from '@codebuff/sdk' import type { ReactNode } from 'react' +/** + * isCollapsed/userOpened are duplicated across block types intentionally - each UI + * element tracks collapse state independently for different defaults and to persist + * user intent vs programmatic state. + */ + export type ChatVariant = 'ai' | 'user' | 'agent' | 'error' export type TextContentBlock = { @@ -18,6 +24,7 @@ export type TextContentBlock = { /** True if this is a reasoning block from a tag that hasn't been closed yet */ thinkingOpen?: boolean } +/** Renders dynamic React content. NOT serializable - don't use for persistent data. */ export type HtmlContentBlock = { type: 'html' marginTop?: number diff --git a/cli/src/utils/message-updater.ts b/cli/src/utils/message-updater.ts index cbeeaeeba1..f9cfbe6300 100644 --- a/cli/src/utils/message-updater.ts +++ b/cli/src/utils/message-updater.ts @@ -134,6 +134,8 @@ export const createBatchedMessageUpdater = ( const dispose = () => { if (isDisposed) return + // Flush any pending updates before disposing to prevent data loss + flush() isDisposed = true if (intervalId !== null) { clearInterval(intervalId) From 18ced106f4da0ebcefd83569fb727e2e9dfc8121 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 02:13:23 -0800 Subject: [PATCH 0034/1143] refactor(cli): extract ask-user sub-components and rename Other to Custom Extract QuestionHeader, OptionsList, and CustomAnswerInput sub-components from AccordionQuestion. Rename all 'Other' terminology to 'Custom' for UI consistency (isOther -> isCustom, otherText -> customText, etc). --- .../__tests__/multiple-choice-form.test.ts | 90 ++++---- .../components/accordion-question.tsx | 217 +++++------------- .../components/custom-answer-input.tsx | 53 +++++ .../ask-user/components/options-list.tsx | 133 +++++++++++ .../ask-user/components/question-header.tsx | 68 ++++++ cli/src/components/ask-user/constants.ts | 4 +- cli/src/components/ask-user/index.tsx | 92 ++++---- 7 files changed, 408 insertions(+), 249 deletions(-) create mode 100644 cli/src/components/ask-user/components/custom-answer-input.tsx create mode 100644 cli/src/components/ask-user/components/options-list.tsx create mode 100644 cli/src/components/ask-user/components/question-header.tsx diff --git a/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts b/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts index fced9c0cd7..f275c1ab44 100644 --- a/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts +++ b/cli/src/components/ask-user/__tests__/multiple-choice-form.test.ts @@ -8,7 +8,7 @@ import { describe, it, expect } from 'bun:test' -import { getOptionLabel, OTHER_OPTION_INDEX } from '../constants' +import { getOptionLabel, CUSTOM_OPTION_INDEX } from '../constants' import type { AccordionAnswer } from '../components/accordion-question' import type { AskUserOption } from '../constants' @@ -40,8 +40,8 @@ function formatAnswer( : [] const customText = - answer.isOther && (answer.otherText?.trim().length ?? 0) > 0 - ? (answer.otherText ?? '').trim() + answer.isCustom && (answer.customText?.trim().length ?? 0) > 0 + ? (answer.customText ?? '').trim() : '' const parts = customText ? [...selectedOptions, customText] : selectedOptions @@ -132,10 +132,10 @@ describe('formatAnswer', () => { }) }) - it('returns custom text when isOther is true', () => { + it('returns custom text when isCustom is true', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: 'Purple', + isCustom: true, + customText: 'Purple', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -146,8 +146,8 @@ describe('formatAnswer', () => { it('trims whitespace from custom text', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: ' Purple ', + isCustom: true, + customText: ' Purple ', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -156,10 +156,10 @@ describe('formatAnswer', () => { }) }) - it('returns Skipped when isOther is true but text is empty', () => { + it('returns Skipped when isCustom is true but text is empty', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: '', + isCustom: true, + customText: '', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -168,10 +168,10 @@ describe('formatAnswer', () => { }) }) - it('returns Skipped when isOther is true but text is only whitespace', () => { + it('returns Skipped when isCustom is true but text is only whitespace', () => { const answer: AccordionAnswer = { - isOther: true, - otherText: ' ', + isCustom: true, + customText: ' ', } const result = formatAnswer(singleSelectQuestion, answer) expect(result).toEqual({ @@ -221,8 +221,8 @@ describe('formatAnswer', () => { it('includes custom text with selections', () => { const answer: AccordionAnswer = { selectedIndices: new Set([0]), - isOther: true, - otherText: 'Cooking', + isCustom: true, + customText: 'Cooking', } const result = formatAnswer(multiSelectQuestion, answer) expect(result).toEqual({ @@ -234,8 +234,8 @@ describe('formatAnswer', () => { it('returns only custom text when no other selections', () => { const answer: AccordionAnswer = { selectedIndices: new Set(), - isOther: true, - otherText: 'Cooking', + isCustom: true, + customText: 'Cooking', } const result = formatAnswer(multiSelectQuestion, answer) expect(result).toEqual({ @@ -266,67 +266,67 @@ describe('formatAnswer', () => { }) }) -describe('OTHER_OPTION_INDEX constant', () => { - it('is -1 for identifying custom/other option', () => { - expect(OTHER_OPTION_INDEX).toBe(-1) +describe('CUSTOM_OPTION_INDEX constant', () => { + it('is -1 for identifying custom option', () => { + expect(CUSTOM_OPTION_INDEX).toBe(-1) }) it('is distinct from valid option indices', () => { - expect(OTHER_OPTION_INDEX).toBeLessThan(0) + expect(CUSTOM_OPTION_INDEX).toBeLessThan(0) }) }) describe('answer state management patterns', () => { describe('single-select behavior', () => { - it('selecting an option clears isOther flag', () => { + it('selecting an option clears isCustom flag', () => { const previousAnswer: AccordionAnswer = { - isOther: true, - otherText: 'Custom text', + isCustom: true, + customText: 'Custom text', } const optionIndex: number = 1 - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX - const newAnswer: AccordionAnswer = isOtherOption + const newAnswer: AccordionAnswer = isCustomOption ? { selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: previousAnswer.otherText || '', + isCustom: true, + customText: previousAnswer.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, } expect(newAnswer.selectedIndex).toBe(1) - expect(newAnswer.isOther).toBe(false) + expect(newAnswer.isCustom).toBe(false) }) - it('selecting OTHER clears selectedIndex and enables isOther', () => { + it('selecting CUSTOM clears selectedIndex and enables isCustom', () => { const previousAnswer: AccordionAnswer = { selectedIndex: 1, } - const optionIndex = OTHER_OPTION_INDEX - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const optionIndex = CUSTOM_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX - const newAnswer: AccordionAnswer = isOtherOption + const newAnswer: AccordionAnswer = isCustomOption ? { selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: previousAnswer.otherText || '', + isCustom: true, + customText: previousAnswer.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, } expect(newAnswer.selectedIndex).toBeUndefined() - expect(newAnswer.isOther).toBe(true) + expect(newAnswer.isCustom).toBe(true) }) }) @@ -368,17 +368,17 @@ describe('answer state management patterns', () => { expect(newIndices.size).toBe(2) }) - it('toggling OTHER toggles isOther flag', () => { + it('toggling CUSTOM toggles isCustom flag', () => { const currentAnswer: AccordionAnswer = { selectedIndices: new Set([0]), - isOther: false, + isCustom: false, } - const optionIndex = OTHER_OPTION_INDEX - const toggledOtherOn = - optionIndex === OTHER_OPTION_INDEX && !currentAnswer.isOther + const optionIndex = CUSTOM_OPTION_INDEX + const toggledCustomOn = + optionIndex === CUSTOM_OPTION_INDEX && !currentAnswer.isCustom - expect(toggledOtherOn).toBe(true) + expect(toggledCustomOn).toBe(true) }) }) }) diff --git a/cli/src/components/ask-user/components/accordion-question.tsx b/cli/src/components/ask-user/components/accordion-question.tsx index 6172f47cb9..1011c0f579 100644 --- a/cli/src/components/ask-user/components/accordion-question.tsx +++ b/cli/src/components/ask-user/components/accordion-question.tsx @@ -2,14 +2,12 @@ * Accordion-style question component that can expand/collapse */ -import { TextAttributes } from '@opentui/core' -import React from 'react' +import React, { useCallback } from 'react' -import { QuestionOption } from './question-option' -import { useTheme } from '../../../hooks/use-theme' -import { Button } from '../../button' -import { MultilineInput } from '../../multiline-input' -import { getOptionLabel, OTHER_OPTION_INDEX, SYMBOLS } from '../constants' +import { CustomAnswerInput } from './custom-answer-input' +import { OptionsList } from './options-list' +import { QuestionHeader } from './question-header' +import { getOptionLabel } from '../constants' import type { AskUserQuestion } from '../../../state/chat-store' @@ -17,8 +15,8 @@ import type { AskUserQuestion } from '../../../state/chat-store' export interface AccordionAnswer { selectedIndex?: number selectedIndices?: Set - isOther?: boolean - otherText?: string + isCustom?: boolean + customText?: string } export interface AccordionQuestionProps { @@ -27,13 +25,13 @@ export interface AccordionQuestionProps { totalQuestions: number answer: AccordionAnswer | undefined isExpanded: boolean - isTypingOther: boolean + isTypingCustom: boolean onToggleExpand: () => void onSelectOption: (optionIndex: number) => void onToggleOption: (optionIndex: number) => void - onSetOtherText: (text: string, cursorPosition: number) => void - onOtherSubmit: () => void - otherCursorPosition: number + onSetCustomText: (text: string, cursorPosition: number) => void + onCustomSubmit: () => void + customCursorPosition: number focusedOptionIndex: number | null onFocusOption: (index: number | null) => void } @@ -44,17 +42,16 @@ export const AccordionQuestion: React.FC = ({ totalQuestions, answer, isExpanded, - isTypingOther, + isTypingCustom, onToggleExpand, onSelectOption, onToggleOption, - onSetOtherText, - onOtherSubmit, - otherCursorPosition, + onSetCustomText, + onCustomSubmit, + customCursorPosition, focusedOptionIndex, onFocusOption, }) => { - const theme = useTheme() const isMultiSelect = question.multiSelect const showQuestionNumber = totalQuestions > 1 const questionNumber = questionIndex + 1 @@ -64,7 +61,7 @@ export const AccordionQuestion: React.FC = ({ // Check if question has a valid answer const isAnswered = !!answer && - ((answer.isOther && !!answer.otherText?.trim()) || + ((answer.isCustom && !!answer.customText?.trim()) || (isMultiSelect && (answer.selectedIndices?.size ?? 0) > 0) || answer.selectedIndex !== undefined) @@ -72,8 +69,8 @@ export const AccordionQuestion: React.FC = ({ const getAnswerDisplay = (): string => { if (!answer) return '(click to answer)' - if (answer.isOther && answer.otherText) { - return `Custom: ${answer.otherText}` + if (answer.isCustom && answer.customText) { + return `Custom: ${answer.customText}` } if (isMultiSelect && answer.selectedIndices) { @@ -93,149 +90,57 @@ export const AccordionQuestion: React.FC = ({ return '(click to answer)' } - const handleOptionSelect = (optionIndex: number) => { - if (isMultiSelect) { - onToggleOption(optionIndex) - } else { - onSelectOption(optionIndex) - } - } - - const isCustomSelected = answer?.isOther ?? false - const isCustomFocused = focusedOptionIndex === question.options.length || isTypingOther - const selectedFg = theme.name === 'dark' ? '#ffffff' : '#000000' - const customSymbol = isMultiSelect - ? isCustomSelected ? SYMBOLS.CHECKBOX_CHECKED : SYMBOLS.CHECKBOX_UNCHECKED - : isCustomSelected ? SYMBOLS.SELECTED : SYMBOLS.UNSELECTED - const customFg = isCustomFocused ? '#000000' : isCustomSelected ? selectedFg : theme.muted - const customAttributes = isCustomFocused || isCustomSelected ? TextAttributes.BOLD : undefined + const isCustomSelected = answer?.isCustom ?? false + + const handlePaste = useCallback( + (text: string) => { + const currentText = answer?.customText || '' + const newText = + currentText.slice(0, customCursorPosition) + + text + + currentText.slice(customCursorPosition) + onSetCustomText(newText, customCursorPosition + text.length) + }, + [answer?.customText, customCursorPosition, onSetCustomText], + ) return ( {/* Question header - always visible */} - + {/* Expanded content - options */} {isExpanded && ( - {/* Multi-select hint */} - {isMultiSelect && ( - - (Select multiple options) - - )} - - {/* Options */} - {question.options.map((option, optionIndex) => { - const isSelected = isMultiSelect - ? answer?.selectedIndices?.has(optionIndex) ?? false - : answer?.selectedIndex === optionIndex - - return ( - handleOptionSelect(optionIndex)} - onMouseOver={() => onFocusOption(optionIndex)} - /> - ) - })} - - {/* Custom option - uses checkbox style for multi-select questions */} - - - {/* Text input area when typing Custom */} - {isTypingOther && ( - - { - onSetOtherText(inputValue.text, inputValue.cursorPosition) - }} - onSubmit={onOtherSubmit} - onPaste={(text) => { - if (text) { - const currentText = answer?.otherText || '' - const newText = - currentText.slice(0, otherCursorPosition) + - text + - currentText.slice(otherCursorPosition) - onSetOtherText(newText, otherCursorPosition + text.length) - } - }} - focused={true} - maxHeight={3} - minHeight={1} - placeholder="Type your answer..." - /> - + + + {/* Text input area when Custom is selected */} + {isCustomSelected && ( + )} )} diff --git a/cli/src/components/ask-user/components/custom-answer-input.tsx b/cli/src/components/ask-user/components/custom-answer-input.tsx new file mode 100644 index 0000000000..5986c109ef --- /dev/null +++ b/cli/src/components/ask-user/components/custom-answer-input.tsx @@ -0,0 +1,53 @@ +/** + * Custom answer input component - MultilineInput wrapper for custom text answers + */ + +import React, { memo } from 'react' + +import { MultilineInput } from '../../multiline-input' + +export interface CustomAnswerInputProps { + value: string + cursorPosition: number + focused: boolean + optionIndent: number + onChange: (text: string, cursorPosition: number) => void + onSubmit: () => void + onPaste: (text: string) => void +} + +export const CustomAnswerInput: React.FC = memo( + ({ + value, + cursorPosition, + focused, + optionIndent, + onChange, + onSubmit, + onPaste, + }) => { + return ( + + { + onChange(inputValue.text, inputValue.cursorPosition) + }} + onSubmit={onSubmit} + onPaste={(text) => { + if (text) { + onPaste(text) + } + }} + focused={focused} + maxHeight={3} + minHeight={1} + placeholder="Type your answer..." + /> + + ) + }, +) + +CustomAnswerInput.displayName = 'CustomAnswerInput' diff --git a/cli/src/components/ask-user/components/options-list.tsx b/cli/src/components/ask-user/components/options-list.tsx new file mode 100644 index 0000000000..b96a56d111 --- /dev/null +++ b/cli/src/components/ask-user/components/options-list.tsx @@ -0,0 +1,133 @@ +/** + * Options list component that renders all question options + * including the Custom option button + */ + +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { QuestionOption } from './question-option' +import { useTheme } from '../../../hooks/use-theme' +import { Button } from '../../button' +import { CUSTOM_OPTION_INDEX, SYMBOLS } from '../constants' + +import type { AskUserQuestion } from '../../../state/chat-store' +import type { AccordionAnswer } from './accordion-question' + +export interface OptionsListProps { + question: AskUserQuestion + answer: AccordionAnswer | undefined + optionIndent: number + focusedOptionIndex: number | null + isTypingCustom: boolean + onSelectOption: (optionIndex: number) => void + onToggleOption: (optionIndex: number) => void + onFocusOption: (index: number | null) => void +} + +export const OptionsList: React.FC = memo( + ({ + question, + answer, + optionIndent, + focusedOptionIndex, + isTypingCustom, + onSelectOption, + onToggleOption, + onFocusOption, + }) => { + const theme = useTheme() + const isMultiSelect = question.multiSelect + + const isCustomSelected = answer?.isCustom ?? false + const isCustomFocused = focusedOptionIndex === question.options.length || isTypingCustom + const selectedFg = theme.name === 'dark' ? '#ffffff' : '#000000' + const customSymbol = isMultiSelect + ? isCustomSelected + ? SYMBOLS.CHECKBOX_CHECKED + : SYMBOLS.CHECKBOX_UNCHECKED + : isCustomSelected + ? SYMBOLS.SELECTED + : SYMBOLS.UNSELECTED + const customFg = isCustomFocused ? '#000000' : isCustomSelected ? selectedFg : theme.muted + const customAttributes = isCustomFocused || isCustomSelected ? TextAttributes.BOLD : undefined + + const handleOptionSelect = (optionIndex: number) => { + if (isMultiSelect) { + onToggleOption(optionIndex) + } else { + onSelectOption(optionIndex) + } + } + + const handleCustomClick = () => { + if (isMultiSelect) { + onToggleOption(CUSTOM_OPTION_INDEX) + } else { + onSelectOption(CUSTOM_OPTION_INDEX) + } + } + + return ( + <> + {/* Multi-select hint */} + {isMultiSelect && ( + + (Select multiple options) + + )} + + {/* Options */} + {question.options.map((option, optionIndex) => { + const isSelected = isMultiSelect + ? answer?.selectedIndices?.has(optionIndex) ?? false + : answer?.selectedIndex === optionIndex + + return ( + handleOptionSelect(optionIndex)} + onMouseOver={() => onFocusOption(optionIndex)} + /> + ) + })} + + {/* Custom option - uses checkbox style for multi-select questions */} + + + ) + }, +) + +OptionsList.displayName = 'OptionsList' diff --git a/cli/src/components/ask-user/components/question-header.tsx b/cli/src/components/ask-user/components/question-header.tsx new file mode 100644 index 0000000000..402802756a --- /dev/null +++ b/cli/src/components/ask-user/components/question-header.tsx @@ -0,0 +1,68 @@ +/** + * Question header component with expand/collapse functionality + * and answer preview when collapsed + */ + +import { TextAttributes } from '@opentui/core' +import React, { memo } from 'react' + +import { useTheme } from '../../../hooks/use-theme' +import { Button } from '../../button' + +export interface QuestionHeaderProps { + questionText: string + questionPrefix: string + isExpanded: boolean + isAnswered: boolean + answerDisplay: string + onToggleExpand: () => void +} + +export const QuestionHeader: React.FC = memo( + ({ + questionText, + questionPrefix, + isExpanded, + isAnswered, + answerDisplay, + onToggleExpand, + }) => { + const theme = useTheme() + + return ( + + ) + }, +) + +QuestionHeader.displayName = 'QuestionHeader' diff --git a/cli/src/components/ask-user/constants.ts b/cli/src/components/ask-user/constants.ts index 4765df056a..9bd7ac351f 100644 --- a/cli/src/components/ask-user/constants.ts +++ b/cli/src/components/ask-user/constants.ts @@ -29,8 +29,8 @@ export const getOptionLabel = (option: AskUserOption): string => { return typeof option === 'string' ? option : option?.label ?? '' } -/** Constant for the "Other" option index */ -export const OTHER_OPTION_INDEX: number = -1 +/** Constant for the "Custom" option index */ +export const CUSTOM_OPTION_INDEX: number = -1 export const KEYBOARD_HINTS = [ '↑↓ navigate •', diff --git a/cli/src/components/ask-user/index.tsx b/cli/src/components/ask-user/index.tsx index f9826910d9..4913ac3fb8 100644 --- a/cli/src/components/ask-user/index.tsx +++ b/cli/src/components/ask-user/index.tsx @@ -14,7 +14,7 @@ import { AccordionQuestion, type AccordionAnswer, } from './components/accordion-question' -import { getOptionLabel, KEYBOARD_HINTS, OTHER_OPTION_INDEX } from './constants' +import { getOptionLabel, KEYBOARD_HINTS, CUSTOM_OPTION_INDEX } from './constants' import { useTheme } from '../../hooks/use-theme' import { useChatStore } from '../../state/chat-store' import { BORDER_CHARS } from '../../utils/ui-constants' @@ -67,11 +67,11 @@ export const MultipleChoiceForm: React.FC = ({ optionIndex: number } | null>(null) - // Track if user is typing in "Other" text input - const [isTypingOther, setIsTypingOther] = useState(false) + // Track if user is typing in "Custom" text input + const [isTypingCustom, setIsTypingCustom] = useState(false) - // Track cursor position for "Other" text input (per question) - const [otherCursorPositions, setOtherCursorPositions] = useState>( + // Track cursor position for "Custom" text input (per question) + const [customCursorPositions, setCustomCursorPositions] = useState>( new Map(), ) @@ -95,7 +95,7 @@ export const MultipleChoiceForm: React.FC = ({ setFocusedQuestionIndex(questionIndex) setFocusedOptionIndex(optionIndex) setSubmitFocused(false) - setIsTypingOther(false) + setIsTypingCustom(false) }, []) const focusSubmit = useCallback( @@ -104,20 +104,20 @@ export const MultipleChoiceForm: React.FC = ({ const questionIndex = from?.questionIndex ?? focusedQuestionIndex setLastFocusBeforeSubmit({ questionIndex, optionIndex }) setSubmitFocused(true) - setIsTypingOther(false) + setIsTypingCustom(false) }, [focusedOptionIndex, focusedQuestionIndex], ) - // Handle setting "Other" text (with cursor position) - const handleSetOtherText = useCallback( + // Handle setting "Custom" text (with cursor position) + const handleSetCustomText = useCallback( (questionIndex: number, text: string, cursorPosition: number) => { setAnswerForQuestion(questionIndex, (currentAnswer) => ({ ...currentAnswer, - isOther: true, - otherText: text, + isCustom: true, + customText: text, })) - setOtherCursorPositions((prev) => { + setCustomCursorPositions((prev) => { const newPositions = new Map(prev) newPositions.set(questionIndex, cursorPosition) return newPositions @@ -126,10 +126,10 @@ export const MultipleChoiceForm: React.FC = ({ [setAnswerForQuestion], ) - // Handle "Other" text submit (Enter key) - const handleOtherSubmit = useCallback( + // Handle "Custom" text submit (Enter key) + const handleCustomSubmit = useCallback( (questionIndex: number) => { - setIsTypingOther(false) + setIsTypingCustom(false) setSubmitFocused(false) if (questions[questionIndex]?.multiSelect) { @@ -157,34 +157,34 @@ export const MultipleChoiceForm: React.FC = ({ source: 'keyboard' | 'mouse' = 'keyboard', ) => { setSubmitFocused(false) - const isOtherOption = optionIndex === OTHER_OPTION_INDEX + const isCustomOption = optionIndex === CUSTOM_OPTION_INDEX - if (source === 'mouse' && !isOtherOption) { + if (source === 'mouse' && !isCustomOption) { setShowFocusHighlight(false) suppressNextHoverFocusRef.current = true } setAnswerForQuestion(questionIndex, (currentAnswer) => - isOtherOption + isCustomOption ? { // Selecting "Custom" should clear any single-select choice selectedIndex: undefined, selectedIndices: undefined, - isOther: true, - otherText: currentAnswer?.otherText || '', + isCustom: true, + customText: currentAnswer?.customText || '', } : { selectedIndex: optionIndex, selectedIndices: undefined, - isOther: false, + isCustom: false, }, ) - // For "Other" option, enter typing mode - if (isOtherOption) { + // For "Custom" option, enter typing mode + if (isCustomOption) { setFocusedQuestionIndex(questionIndex) setFocusedOptionIndex(questions[questionIndex]?.options.length ?? 0) - setIsTypingOther(true) + setIsTypingCustom(true) return } @@ -204,19 +204,19 @@ export const MultipleChoiceForm: React.FC = ({ const handleToggleOption = useCallback( (questionIndex: number, optionIndex: number) => { setSubmitFocused(false) - let toggledOtherOn = false + let toggledCustomOn = false setAnswers((prev) => { const newAnswers = new Map(prev) const currentAnswer: AccordionAnswer = prev.get(questionIndex) ?? {} - if (optionIndex === OTHER_OPTION_INDEX) { - toggledOtherOn = !(currentAnswer?.isOther ?? false) + if (optionIndex === CUSTOM_OPTION_INDEX) { + toggledCustomOn = !(currentAnswer?.isCustom ?? false) newAnswers.set(questionIndex, { ...currentAnswer, selectedIndices: new Set(currentAnswer?.selectedIndices ?? []), - isOther: !currentAnswer?.isOther, - otherText: currentAnswer?.otherText || '', + isCustom: !currentAnswer?.isCustom, + customText: currentAnswer?.customText || '', }) return newAnswers } @@ -230,14 +230,14 @@ export const MultipleChoiceForm: React.FC = ({ newAnswers.set(questionIndex, { ...currentAnswer, selectedIndices: newIndices, - isOther: currentAnswer?.isOther ?? false, + isCustom: currentAnswer?.isCustom ?? false, }) return newAnswers }) - // For "Other" option in multi-select, also enter typing mode - if (optionIndex === OTHER_OPTION_INDEX) { - setIsTypingOther(toggledOtherOn) + // For "Custom" option in multi-select, also enter typing mode + if (optionIndex === CUSTOM_OPTION_INDEX) { + setIsTypingCustom(toggledCustomOn) } }, [], @@ -261,8 +261,8 @@ export const MultipleChoiceForm: React.FC = ({ : [] const customText = - answer.isOther && (answer.otherText?.trim().length ?? 0) > 0 - ? (answer.otherText ?? '').trim() + answer.isCustom && (answer.customText?.trim().length ?? 0) > 0 + ? (answer.customText ?? '').trim() : '' const parts = customText ? [...selectedOptions, customText] : selectedOptions @@ -313,7 +313,7 @@ export const MultipleChoiceForm: React.FC = ({ if (submitFocused) { if (key.name === 'up' || (key.name === 'tab' && key.shift)) { preventDefault() - setIsTypingOther(false) + setIsTypingCustom(false) setSubmitFocused(false) if (questions.length === 0) return if (lastFocusBeforeSubmit) { @@ -337,8 +337,8 @@ export const MultipleChoiceForm: React.FC = ({ return } - // When typing in "Other" input, let MultilineInput handle all keyboard input - if (isTypingOther) { + // When typing in "Custom" input, let MultilineInput handle all keyboard input + if (isTypingCustom) { return } @@ -437,7 +437,7 @@ export const MultipleChoiceForm: React.FC = ({ const optionIdx = currentOptionIndex === lastOptionIndex - ? OTHER_OPTION_INDEX + ? CUSTOM_OPTION_INDEX : currentOptionIndex if (currentQuestion.multiSelect) { handleToggleOption(currentQuestionIndex, optionIdx) @@ -454,7 +454,7 @@ export const MultipleChoiceForm: React.FC = ({ focusedOptionIndex, submitFocused, lastFocusBeforeSubmit, - isTypingOther, + isTypingCustom, showFocusHighlight, handleSelectOption, handleToggleOption, @@ -502,13 +502,13 @@ export const MultipleChoiceForm: React.FC = ({ totalQuestions={questions.length} answer={answers.get(index)} isExpanded={expandedIndex === index} - isTypingOther={isTypingOther && expandedIndex === index} + isTypingCustom={isTypingCustom && expandedIndex === index} onToggleExpand={() => { const nextExpandedIndex = expandedIndex === index ? null : index setExpandedIndex(nextExpandedIndex) setFocusedQuestionIndex(index) setSubmitFocused(false) - setIsTypingOther(false) + setIsTypingCustom(false) setFocusedOptionIndex(nextExpandedIndex === null ? null : 0) }} onSelectOption={(optionIndex) => @@ -517,16 +517,16 @@ export const MultipleChoiceForm: React.FC = ({ onToggleOption={(optionIndex) => handleToggleOption(index, optionIndex) } - onSetOtherText={(text, cursorPos) => handleSetOtherText(index, text, cursorPos)} - onOtherSubmit={() => handleOtherSubmit(index)} - otherCursorPosition={otherCursorPositions.get(index) ?? 0} + onSetCustomText={(text, cursorPos) => handleSetCustomText(index, text, cursorPos)} + onCustomSubmit={() => handleCustomSubmit(index)} + customCursorPosition={customCursorPositions.get(index) ?? 0} focusedOptionIndex={ expandedIndex === index && !submitFocused && showFocusHighlight ? focusedOptionIndex : null } onFocusOption={(optionIndex) => { - if (!terminalFocused || isTypingOther) return + if (!terminalFocused || isTypingCustom) return if (suppressNextHoverFocusRef.current) { suppressNextHoverFocusRef.current = false return From eb5e637ce5cc5147ca235c4aa581b19cbba28fad Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 02:13:43 -0800 Subject: [PATCH 0035/1143] test(cli): rewrite agent-grid tests to be meaningful Rewrite tests to properly test MessageBlockStore behavior, MessageWithAgents rendering across variants, and callback invocation. --- .../components/__tests__/agent-grid.test.tsx | 538 ----------------- .../__tests__/message-with-agents.test.tsx | 563 ++++++++++++++++++ cli/src/components/message-with-agents.tsx | 18 +- 3 files changed, 572 insertions(+), 547 deletions(-) delete mode 100644 cli/src/components/__tests__/agent-grid.test.tsx create mode 100644 cli/src/components/__tests__/message-with-agents.test.tsx diff --git a/cli/src/components/__tests__/agent-grid.test.tsx b/cli/src/components/__tests__/agent-grid.test.tsx deleted file mode 100644 index dcf3a4e9ac..0000000000 --- a/cli/src/components/__tests__/agent-grid.test.tsx +++ /dev/null @@ -1,538 +0,0 @@ -import { describe, test, expect } from 'bun:test' -import React from 'react' -import { renderToStaticMarkup } from 'react-dom/server' - -import { initializeThemeStore } from '../../hooks/use-theme' -import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' -import { MessageBlock } from '../message-block' -import { MessageWithAgents } from '../message-with-agents' - -import type { MarkdownPalette } from '../../utils/markdown-renderer' -import type { AgentContentBlock, ContentBlock, ChatMessage } from '../../types/chat' - -initializeThemeStore() - -const theme = chatThemes.dark -const basePalette = createMarkdownPalette(theme) - -const palette: MarkdownPalette = { - ...basePalette, - inlineCodeFg: theme.foreground, - codeTextFg: theme.foreground, -} - -const createAgentBlock = ( - agentId: string, - agentName: string, - agentType: string, - status: 'running' | 'complete' | 'failed' = 'complete', -): AgentContentBlock => ({ - type: 'agent', - agentId, - agentName, - agentType, - content: `Content for ${agentName}`, - status, - blocks: [], -}) - -const createImplementorAgent = ( - agentId: string, - index: number, -): AgentContentBlock => ({ - type: 'agent', - agentId, - agentName: `Implementor ${index}`, - agentType: 'editor-implementor', - content: '', - status: 'complete', - blocks: [ - { - type: 'tool', - toolCallId: `tool-${agentId}`, - toolName: 'propose_str_replace', - input: { path: 'file.ts', replacements: [{ old: 'a', new: 'b' }] }, - }, - ], -}) - -const baseMessageBlockProps = { - messageId: 'test-message', - content: '', - isUser: false, - isAi: true, - isLoading: false, - timestamp: '12:00', - isComplete: true, - completionTime: undefined, - credits: undefined, - timerStartTime: null, - textColor: theme.foreground, - timestampColor: theme.muted, - markdownOptions: { - codeBlockWidth: 72, - palette, - }, - availableWidth: 120, - markdownPalette: basePalette, - collapsedAgents: new Set(), - autoCollapsedAgents: new Set(), - streamingAgents: new Set(), - onToggleCollapsed: () => {}, - onBuildFast: () => {}, - onBuildMax: () => {}, - setCollapsedAgents: () => {}, - addAutoCollapsedAgent: () => {}, -} - -const createAgentMessage = ( - id: string, - agentName: string, - parentId?: string, -): ChatMessage => ({ - id, - variant: 'agent', - content: `Agent ${agentName} content`, - timestamp: '12:00', - isComplete: true, - agent: { - agentName, - agentType: 'file-picker', - responseCount: 0, - }, - parentId, -}) - -const baseMessageWithAgentsProps = { - depth: 0, - isLastMessage: false, - theme, - markdownPalette: basePalette, - streamingAgents: new Set(), - messages: [] as ChatMessage[], - availableWidth: 120, - setFocusedAgentId: () => {}, - isWaitingForResponse: false, - timerStartTime: null, - onToggleCollapsed: () => {}, - onBuildFast: () => {}, - onBuildMax: () => {}, - onFeedback: () => {}, - onCloseFeedback: () => {}, -} - -describe('AgentBlockGrid (via MessageBlock)', () => { - describe('single agent rendering', () => { - test('renders a single agent without header', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('File Picker') - // Single agent should not show "1 agent completed" header - expect(markup).not.toContain('1 agent') - }) - }) - - describe('multiple agents rendering', () => { - test('renders multiple agents without footer label', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker'), - createAgentBlock('agent-2', 'Code Searcher', 'code-searcher'), - createAgentBlock('agent-3', 'Commander', 'commander'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('File Picker') - expect(markup).toContain('Code Searcher') - expect(markup).toContain('Commander') - // Footer label was removed as redundant - expect(markup).not.toContain('agents completed') - }) - - test('renders running agents without footer label', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker', 'running'), - createAgentBlock('agent-2', 'Code Searcher', 'code-searcher', 'running'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('File Picker') - expect(markup).toContain('Code Searcher') - // Footer label was removed as redundant - expect(markup).not.toContain('agents running') - }) - }) - - describe('implementor agents (should use ImplementorGroup instead)', () => { - test('renders implementor agents separately from regular agents', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker', 'file-picker'), - createImplementorAgent('impl-1', 1), - createImplementorAgent('impl-2', 2), - ] - - const markup = renderToStaticMarkup( - , - ) - - // Regular agent should be rendered - expect(markup).toContain('File Picker') - // Implementor agents should be grouped separately and show model names - // ImplementorGroup renders "Sonnet #1", "Sonnet #2" etc. for editor-implementor agents - expect(markup).toContain('Sonnet') - }) - }) - - describe('mixed block types', () => { - test('renders agents interspersed with text blocks', () => { - const blocks: ContentBlock[] = [ - { type: 'text', content: 'Before agents' }, - createAgentBlock('agent-1', 'File Picker', 'file-picker'), - createAgentBlock('agent-2', 'Code Searcher', 'code-searcher'), - { type: 'text', content: 'After agents' }, - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Before agents') - expect(markup).toContain('File Picker') - expect(markup).toContain('Code Searcher') - expect(markup).toContain('After agents') - }) - - test('groups only consecutive non-implementor agents', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'File Picker 1', 'file-picker'), - createAgentBlock('agent-2', 'File Picker 2', 'file-picker'), - { type: 'text', content: 'Separator' }, - createAgentBlock('agent-3', 'Commander', 'commander'), - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('File Picker 1') - expect(markup).toContain('File Picker 2') - expect(markup).toContain('Separator') - expect(markup).toContain('Commander') - }) - }) - - describe('empty and edge cases', () => { - test('handles empty blocks array', () => { - const markup = renderToStaticMarkup( - , - ) - - // Should render without errors - expect(markup).toBeDefined() - }) - - test('handles blocks with no agents', () => { - const blocks: ContentBlock[] = [ - { type: 'text', content: 'Just text' }, - ] - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Just text') - expect(markup).not.toContain('agent') - }) - }) -}) - -describe('AgentChildrenGrid (via MessageWithAgents)', () => { - describe('single child agent', () => { - test('renders a single child agent', () => { - const parentMessage: ChatMessage = { - id: 'parent-1', - variant: 'ai', - content: 'Parent message', - timestamp: '12:00', - isComplete: true, - } - - const childAgent = createAgentMessage('child-1', 'Child Agent', 'parent-1') - - const messageTree = new Map([ - ['parent-1', [childAgent]], - ]) - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Child Agent') - }) - }) - - describe('multiple child agents', () => { - test('renders multiple child agents', () => { - const parentMessage: ChatMessage = { - id: 'parent-1', - variant: 'ai', - content: 'Parent message', - timestamp: '12:00', - isComplete: true, - } - - const children = [ - createAgentMessage('child-1', 'Agent One', 'parent-1'), - createAgentMessage('child-2', 'Agent Two', 'parent-1'), - createAgentMessage('child-3', 'Agent Three', 'parent-1'), - ] - - const messageTree = new Map([ - ['parent-1', children], - ]) - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Agent One') - expect(markup).toContain('Agent Two') - expect(markup).toContain('Agent Three') - }) - }) - - describe('nested agent hierarchy', () => { - test('renders nested child agents', () => { - const parentMessage: ChatMessage = { - id: 'parent-1', - variant: 'ai', - content: 'Parent message', - timestamp: '12:00', - isComplete: true, - } - - const child1 = createAgentMessage('child-1', 'Level 1 Agent', 'parent-1') - const grandchild = createAgentMessage('grandchild-1', 'Level 2 Agent', 'child-1') - - const messageTree = new Map([ - ['parent-1', [child1]], - ['child-1', [grandchild]], - ]) - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Level 1 Agent') - expect(markup).toContain('Level 2 Agent') - }) - }) - - describe('depth limiting', () => { - test('respects MAX_AGENT_DEPTH limit', () => { - // Create a deeply nested hierarchy (11 levels) - const messages: ChatMessage[] = [] - const messageTree = new Map() - - const rootMessage: ChatMessage = { - id: 'root', - variant: 'ai', - content: 'Root', - timestamp: '12:00', - isComplete: true, - } - messages.push(rootMessage) - - let parentId = 'root' - for (let i = 1; i <= 12; i++) { - const agent = createAgentMessage(`agent-${i}`, `Agent Level ${i}`, parentId) - messages.push(agent) - messageTree.set(parentId, [agent]) - parentId = agent.id - } - - const markup = renderToStaticMarkup( - , - ) - - // Should render agents up to MAX_AGENT_DEPTH (10) - expect(markup).toContain('Agent Level 1') - expect(markup).toContain('Agent Level 9') - // Agent Level 11 and 12 should be cut off by depth limit - expect(markup).not.toContain('Agent Level 11') - expect(markup).not.toContain('Agent Level 12') - }) - }) - - describe('empty children', () => { - test('handles message with no children', () => { - const message: ChatMessage = { - id: 'msg-1', - variant: 'ai', - content: 'No children', - timestamp: '12:00', - isComplete: true, - } - - const messageTree = new Map() - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('No children') - }) - - test('handles empty children array in messageTree', () => { - const message: ChatMessage = { - id: 'msg-1', - variant: 'ai', - content: 'Empty children', - timestamp: '12:00', - isComplete: true, - } - - const messageTree = new Map([ - ['msg-1', []], - ]) - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Empty children') - }) - }) - - describe('streaming agents', () => { - test('passes streaming state to child agents', () => { - const parentMessage: ChatMessage = { - id: 'parent-1', - variant: 'ai', - content: 'Parent', - timestamp: '12:00', - isComplete: true, - } - - const streamingChild: ChatMessage = { - id: 'streaming-agent', - variant: 'agent', - content: 'Processing...', - timestamp: '12:00', - isComplete: false, - agent: { - agentName: 'Streaming Agent', - agentType: 'file-picker', - responseCount: 0, - }, - parentId: 'parent-1', - } - - const messageTree = new Map([ - ['parent-1', [streamingChild]], - ]) - - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Streaming Agent') - }) - }) -}) - -describe('Grid layout width handling', () => { - test('renders with narrow width (single column)', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'Agent 1', 'file-picker'), - createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), - ] - - // Width below SM_THRESHOLD (60) should force single column - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Agent 1') - expect(markup).toContain('Agent 2') - }) - - test('renders with medium width (up to 2 columns)', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'Agent 1', 'file-picker'), - createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), - ] - - // Width between MD_THRESHOLD (100) should allow 2 columns - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Agent 1') - expect(markup).toContain('Agent 2') - }) - - test('renders with wide width (up to 3 columns)', () => { - const blocks: ContentBlock[] = [ - createAgentBlock('agent-1', 'Agent 1', 'file-picker'), - createAgentBlock('agent-2', 'Agent 2', 'code-searcher'), - createAgentBlock('agent-3', 'Agent 3', 'commander'), - ] - - // Width above LG_THRESHOLD (140) should allow 3 columns - const markup = renderToStaticMarkup( - , - ) - - expect(markup).toContain('Agent 1') - expect(markup).toContain('Agent 2') - expect(markup).toContain('Agent 3') - }) -}) diff --git a/cli/src/components/__tests__/message-with-agents.test.tsx b/cli/src/components/__tests__/message-with-agents.test.tsx new file mode 100644 index 0000000000..902951cdcd --- /dev/null +++ b/cli/src/components/__tests__/message-with-agents.test.tsx @@ -0,0 +1,563 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import React from 'react' +import { renderToStaticMarkup } from 'react-dom/server' + +import { initializeThemeStore } from '../../hooks/use-theme' +import { chatThemes, createMarkdownPalette } from '../../utils/theme-system' +import { useChatStore } from '../../state/chat-store' +import { useMessageBlockStore } from '../../state/message-block-store' +import { MessageWithAgents } from '../message-with-agents' + +import type { MarkdownPalette } from '../../utils/markdown-renderer' +import type { ChatMessage } from '../../types/chat' + +initializeThemeStore() + +const theme = chatThemes.light +const basePalette: MarkdownPalette = createMarkdownPalette(theme) + +// ----------------------------------------------------------------------------- +// Helper factory functions for creating test messages +// ----------------------------------------------------------------------------- + +const createUserMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'user', + content, + timestamp: new Date().toISOString(), +}) + +const createAiMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'ai', + content, + timestamp: new Date().toISOString(), +}) + +const createAgentMessage = ( + id: string, + content: string, + agentName: string, + options: Partial = {}, +): ChatMessage => ({ + id, + variant: 'agent', + content, + timestamp: new Date().toISOString(), + agent: { + agentName, + agentType: 'test-agent', + responseCount: 1, + }, + ...options, +}) + +const createErrorMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'error', + content, + timestamp: new Date().toISOString(), +}) + +// Creates an agent message without the required agent info (for error testing) +const createMalformedAgentMessage = (id: string, content: string): ChatMessage => ({ + id, + variant: 'agent', + content, + timestamp: new Date().toISOString(), + // Intentionally missing agent property +} as ChatMessage) + +const createModeDividerMessage = (id: string, mode: string): ChatMessage => ({ + id, + variant: 'ai', + content: 'this content should be ignored', + timestamp: new Date().toISOString(), + blocks: [ + { + type: 'mode-divider', + mode, + }, + ], +}) + +const defaultCallbacks = { + onToggleCollapsed: () => {}, + onBuildFast: () => {}, + onBuildMax: () => {}, + onFeedback: () => {}, + onCloseFeedback: () => {}, +} + +const initializeStore = (overrides: { + messageTree?: Map + isWaitingForResponse?: boolean + timerStartTime?: number | null + availableWidth?: number +} = {}) => { + useMessageBlockStore.setState({ + context: { + theme, + markdownPalette: basePalette, + messageTree: overrides.messageTree ?? new Map(), + isWaitingForResponse: overrides.isWaitingForResponse ?? false, + timerStartTime: overrides.timerStartTime ?? null, + availableWidth: overrides.availableWidth ?? 80, + }, + callbacks: defaultCallbacks, + }) +} + +beforeEach(() => { + initializeStore() + useChatStore.setState({ streamingAgents: new Set() }) +}) + +afterEach(() => { + useMessageBlockStore.getState().reset() + useChatStore.setState({ streamingAgents: new Set() }) +}) + +const baseMessageWithAgentsProps = { + depth: 0, + isLastMessage: false, + availableWidth: 80, +} + +// ============================================================================= +// MessageBlockStore Tests - store behavior, not JS built-ins +// ============================================================================= + +describe('MessageBlockStore', () => { + describe('setContext', () => { + test('performs partial merge, preserving unspecified values', () => { + // Set initial state with specific values + initializeStore({ + isWaitingForResponse: true, + timerStartTime: 12345, + availableWidth: 100, + }) + + // Update only one value + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: false, + }) + + const state = useMessageBlockStore.getState() + // Updated value should change + expect(state.context.isWaitingForResponse).toBe(false) + // Other values should be preserved + expect(state.context.timerStartTime).toBe(12345) + expect(state.context.availableWidth).toBe(100) + expect(state.context.theme).toBe(theme) + }) + + test('updates messageTree without affecting other context values', () => { + const child1 = createAgentMessage('child-1', 'Content 1', 'Agent One') + const child2 = createAgentMessage('child-2', 'Content 2', 'Agent Two') + const newTree = new Map([ + ['parent-1', [child1, child2]], + ]) + + useMessageBlockStore.getState().setContext({ + messageTree: newTree, + }) + + const state = useMessageBlockStore.getState() + expect(state.context.messageTree).toBe(newTree) + expect(state.context.messageTree?.get('parent-1')).toHaveLength(2) + // Theme should be unchanged + expect(state.context.theme).toBe(theme) + }) + + test('can update multiple context values at once', () => { + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: true, + timerStartTime: 99999, + availableWidth: 200, + }) + + const state = useMessageBlockStore.getState() + expect(state.context.isWaitingForResponse).toBe(true) + expect(state.context.timerStartTime).toBe(99999) + expect(state.context.availableWidth).toBe(200) + }) + }) + + describe('setCallbacks', () => { + test('replaces entire callbacks object', () => { + const mockToggle = () => {} + const mockBuildFast = () => {} + const mockBuildMax = () => {} + const mockFeedback = () => {} + const mockCloseFeedback = () => {} + + useMessageBlockStore.getState().setCallbacks({ + onToggleCollapsed: mockToggle, + onBuildFast: mockBuildFast, + onBuildMax: mockBuildMax, + onFeedback: mockFeedback, + onCloseFeedback: mockCloseFeedback, + }) + + const state = useMessageBlockStore.getState() + expect(state.callbacks.onToggleCollapsed).toBe(mockToggle) + expect(state.callbacks.onBuildFast).toBe(mockBuildFast) + expect(state.callbacks.onBuildMax).toBe(mockBuildMax) + expect(state.callbacks.onFeedback).toBe(mockFeedback) + expect(state.callbacks.onCloseFeedback).toBe(mockCloseFeedback) + }) + + test('callbacks are independent from context', () => { + const originalTheme = useMessageBlockStore.getState().context.theme + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onToggleCollapsed: () => console.log('new toggle'), + }) + + // Context should be unchanged + expect(useMessageBlockStore.getState().context.theme).toBe(originalTheme) + }) + }) + + describe('reset', () => { + test('restores context to initial state', () => { + // Modify state significantly + useMessageBlockStore.getState().setContext({ + isWaitingForResponse: true, + timerStartTime: 12345, + availableWidth: 200, + messageTree: new Map([['key', [createAgentMessage('a', 'b', 'c')]]]), + }) + + useMessageBlockStore.getState().reset() + + const state = useMessageBlockStore.getState() + expect(state.context.theme).toBeNull() + expect(state.context.isWaitingForResponse).toBe(false) + expect(state.context.timerStartTime).toBeNull() + expect(state.context.availableWidth).toBe(80) + }) + + test('restores callbacks to noop functions', () => { + const mockFn = () => console.log('test') + useMessageBlockStore.getState().setCallbacks({ + onToggleCollapsed: mockFn, + onBuildFast: mockFn, + onBuildMax: mockFn, + onFeedback: mockFn, + onCloseFeedback: mockFn, + }) + + useMessageBlockStore.getState().reset() + + const state = useMessageBlockStore.getState() + // Callbacks should be noop functions (not undefined) + expect(typeof state.callbacks.onToggleCollapsed).toBe('function') + expect(typeof state.callbacks.onBuildFast).toBe('function') + // They should not throw when called + expect(() => state.callbacks.onToggleCollapsed('test-id')).not.toThrow() + }) + }) +}) + +// ============================================================================= +// MessageWithAgents Component Tests - behavior across variants +// ============================================================================= + +describe('MessageWithAgents', () => { + describe('message variant rendering', () => { + test('renders user message content', () => { + const message = createUserMessage('user-1', 'Hello from user') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Hello from user') + }) + + test('renders AI message content', () => { + const message = createAiMessage('ai-1', 'Hello from AI') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Hello from AI') + }) + + test('renders error message content', () => { + const message = createErrorMessage('error-1', 'An error occurred') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('An error occurred') + }) + + test('renders agent message with agent name displayed', () => { + const message = createAgentMessage('agent-1', 'Agent response', 'Code Searcher') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Code Searcher') + expect(markup).toContain('Agent response') + }) + + test('handles message with markdown content', () => { + const message = createAiMessage('ai-md', '**Bold** and *italic*') + + const markup = renderToStaticMarkup( + , + ) + + // Content should be present (markdown rendering may transform it) + expect(markup).toContain('Bold') + expect(markup).toContain('italic') + }) + + test('handles empty content without crashing', () => { + const message = createAiMessage('ai-empty', '') + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toBeDefined() + }) + }) + + describe('mode divider block rendering', () => { + test('renders ModeDivider when message contains only a mode-divider block and ignores content', () => { + const message = createModeDividerMessage('mode-1', 'Edit Mode') + + const markup = renderToStaticMarkup( + , + ) + + // Mode text should appear + expect(markup).toContain('Edit Mode') + // Original message content should not be rendered + expect(markup).not.toContain('this content should be ignored') + }) + }) + + describe('error handling', () => { + test('shows error message when agent message is missing agent info', () => { + const malformedMessage = createMalformedAgentMessage( + 'bad-agent', + 'This should fail', + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Error') + expect(markup).toContain('Missing agent info') + }) + }) + + describe('collapsed vs expanded agent state', () => { + test('renders collapsed agent with preview and collapsed indicator', () => { + const collapsedMessage = createAgentMessage( + 'collapsed-agent', + 'This is the full content\nwith multiple lines\nand the last line is shown', + 'Collapsed Agent', + { + metadata: { isCollapsed: true }, + }, + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Collapsed Agent') + // When collapsed, should show the collapsed indicator + expect(markup).toContain('▸') + // Preview should be the last line + expect(markup).toContain('and the last line is shown') + // First line of full content should not be present as a full block + expect(markup).not.toContain('This is the full content') + }) + + test('renders expanded agent with full content and expanded indicator', () => { + const expandedMessage = createAgentMessage( + 'expanded-agent', + 'Full expanded content here', + 'Expanded Agent', + { + metadata: { isCollapsed: false }, + }, + ) + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Expanded Agent') + expect(markup).toContain('Full expanded content here') + // When expanded, should show the expanded indicator + expect(markup).toContain('▾') + }) + }) +}) + +// ============================================================================= +// Callback Integration Tests +// ============================================================================= + +describe('callback invocation', () => { + test('callbacks are retrievable from store and callable', () => { + let toggleCalledWith: string | undefined + const mockToggle = (id: string) => { + toggleCalledWith = id + } + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onToggleCollapsed: mockToggle, + }) + + // Verify callback is stored and retrievable + const storedCallback = useMessageBlockStore.getState().callbacks + .onToggleCollapsed + storedCallback('test-message-id') + + expect(toggleCalledWith).toBe('test-message-id') + }) + + test('onFeedback callback receives messageId and options', () => { + let feedbackMessageId: string | undefined + let feedbackOptions: object | undefined + const mockFeedback = (messageId: string, options?: object) => { + feedbackMessageId = messageId + feedbackOptions = options + } + + useMessageBlockStore.getState().setCallbacks({ + ...defaultCallbacks, + onFeedback: mockFeedback, + }) + + const storedCallback = useMessageBlockStore.getState().callbacks.onFeedback + storedCallback('msg-123', { category: 'bug' }) + + expect(feedbackMessageId).toBe('msg-123') + expect(feedbackOptions).toEqual({ category: 'bug' }) + }) +}) + +// ============================================================================= +// Layout and visual structure tests +// ============================================================================= + +describe('layout handling', () => { + test('renders correctly across different terminal widths', () => { + const widths = [20, 80, 120, 300] + + for (const width of widths) { + const message = createAiMessage(`width-${width}`, `Content at width ${width}`) + const markup = renderToStaticMarkup( + , + ) + expect(markup).toContain(`Content at width ${width}`) + } + }) + + test('renders correctly with isLastMessage true and false', () => { + const message = createAiMessage('last-msg-test', 'Test content') + + const lastMarkup = renderToStaticMarkup( + , + ) + + const notLastMarkup = renderToStaticMarkup( + , + ) + + expect(lastMarkup).toContain('Test content') + expect(notLastMarkup).toContain('Test content') + }) +}) + +describe('vertical line for user messages', () => { + test('renders vertical line box for user messages only', () => { + const userMessage = createUserMessage('user-line', 'User content') + const aiMessage = createAiMessage('ai-no-line', 'AI content') + + const userMarkup = renderToStaticMarkup( + , + ) + + const aiMarkup = renderToStaticMarkup( + , + ) + + // Vertical line uses style={{ width: 1, backgroundColor: lineColor }} + // which becomes width:1px in the style string. + expect(userMarkup).toContain('width:1px') + expect(aiMarkup).not.toContain('width:1px') + }) +}) diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index 8017e4df24..21c70fb570 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -179,16 +179,16 @@ export const MessageWithAgents = memo( const estimatedMessageWidth = availableWidth const codeBlockWidth = Math.max(10, estimatedMessageWidth - 8) - const paletteForMessage: MarkdownPalette = useMemo( - () => ({ + const paletteForMessage: MarkdownPalette | undefined = useMemo( + () => markdownPalette ? { ...markdownPalette, codeTextFg: textColor, - }), + } : undefined, [markdownPalette, textColor], ) const markdownOptions = useMemo( - () => ({ codeBlockWidth, palette: paletteForMessage }), + () => ({ codeBlockWidth, palette: paletteForMessage! }), [codeBlockWidth, paletteForMessage], ) @@ -251,7 +251,7 @@ export const MessageWithAgents = memo( timestampColor={timestampColor} markdownOptions={markdownOptions} availableWidth={availableWidth} - markdownPalette={markdownPalette} + markdownPalette={markdownPalette!} streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} @@ -286,7 +286,7 @@ export const MessageWithAgents = memo( timestampColor={timestampColor} markdownOptions={markdownOptions} availableWidth={availableWidth} - markdownPalette={markdownPalette} + markdownPalette={markdownPalette!} streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} @@ -372,13 +372,13 @@ const AgentMessage = memo( 10, availableWidth - AGENT_CONTENT_HORIZONTAL_PADDING, ) - const agentPalette: MarkdownPalette = { + const agentPalette: MarkdownPalette | undefined = markdownPalette ? { ...markdownPalette, codeTextFg: theme?.foreground ?? markdownPalette.codeTextFg, - } + } : undefined const agentMarkdownOptions = { codeBlockWidth: agentCodeBlockWidth, - palette: agentPalette, + palette: agentPalette!, } const displayContent = hasMarkdown(rawDisplayContent) ? renderMarkdown(rawDisplayContent, agentMarkdownOptions) From 4a1141217123251dffed06db75ffe17283720bb8 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 10:33:16 -0800 Subject: [PATCH 0036/1143] fix(cli): improve message queue atomicity and add --wait-idle to tmux harness Defensive improvements to message queue handling: - chat.tsx: Use useLayoutEffect for synchronous store updates - use-message-queue.ts: Make queue operations atomic with functional setState Test harness fix for rapid message testing: - tmux-send.sh: Add --wait-idle SECS option that polls until terminal output stabilizes before returning. This allows rapid message tests to wait for streaming to complete between sends. Note: The 'rapid message scrolling' issue was primarily a test harness limitation where tmux sends input faster than the CLI can process during heavy rendering. The --wait-idle flag is the proper fix for this test scenario. Co-authored-by: Codex CLI Co-authored-by: Claude Code CLI Co-authored-by: Gemini CLI --- cli/src/chat.tsx | 6 ++- cli/src/hooks/use-message-queue.ts | 72 ++++++++++++++++++++---------- scripts/tmux/README.md | 3 ++ scripts/tmux/tmux-send.sh | 69 +++++++++++++++++++++++++++- 4 files changed, 123 insertions(+), 27 deletions(-) diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 7cc914e054..7ddb7f464b 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -5,6 +5,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, + useLayoutEffect, useMemo, useRef, useState, @@ -1372,8 +1373,9 @@ export const Chat = ({ (state) => state.setCallbacks, ) - // Update context when values change - useEffect(() => { + // Update context when values change - useLayoutEffect ensures synchronous updates + // to prevent message loss during rapid streaming (race condition fix) + useLayoutEffect(() => { setMessageBlockContext({ theme, markdownPalette, diff --git a/cli/src/hooks/use-message-queue.ts b/cli/src/hooks/use-message-queue.ts index 6b0e02b835..3139a7c5f6 100644 --- a/cli/src/hooks/use-message-queue.ts +++ b/cli/src/hooks/use-message-queue.ts @@ -28,9 +28,8 @@ export const useMessageQueue = ( const isQueuePausedRef = useRef(false) const isProcessingQueueRef = useRef(false) - useEffect(() => { - queuedMessagesRef.current = queuedMessages - }, [queuedMessages]) + // Note: queuedMessagesRef is now updated atomically inside functional setState calls + // (in addToQueue and the queue processing effect), so no sync effect is needed here. useEffect(() => { isQueuePausedRef.current = queuePaused @@ -114,24 +113,45 @@ export const useMessageQueue = ( isProcessingQueueRef.current = true - const nextMessage = queuedList[0] - const remainingMessages = queuedList.slice(1) - queuedMessagesRef.current = remainingMessages - setQueuedMessages(remainingMessages) - // Add .catch() to prevent unhandled promise rejections. - // Safety net: release lock here in case sendMessage failed before its own error handling. - // Lock is also released in finalizeQueueState and sendMessage's finally block (idempotent). - sendMessage(nextMessage).catch((err: unknown) => { - logger.warn( - { error: err }, - '[message-queue] sendMessage promise rejected - releasing lock', - ) - isProcessingQueueRef.current = false + // IMPORTANT: We must read the message to process INSIDE the functional setState + // to ensure we send the same message we remove. Reading from the ref separately + // can cause a race condition where we send message X but remove message Y. + let messageToProcess: QueuedMessage | undefined + + setQueuedMessages((prev) => { + if (prev.length === 0) { + return prev + } + messageToProcess = prev[0] + const remainingMessages = prev.slice(1) + queuedMessagesRef.current = remainingMessages + return remainingMessages }) + + if (!messageToProcess) { + isProcessingQueueRef.current = false + return + } + + // Use .finally() to ensure lock is always released after sendMessage completes + sendMessage(messageToProcess) + .catch((err: unknown) => { + logger.warn( + { error: err }, + '[message-queue] sendMessage promise rejected', + ) + }) + .finally(() => { + // Release the processing lock so the next message can be processed + // The effect will re-run when streamStatus changes or other deps update + isProcessingQueueRef.current = false + logger.debug('[message-queue] Processing lock released') + }) }, [ canProcessQueue, queuePaused, streamStatus, + queuedMessages, // Re-run when queue changes to process next message sendMessage, isChainInProgressRef, activeAgentStreamsRef, @@ -140,13 +160,19 @@ export const useMessageQueue = ( const addToQueue = useCallback( (message: string, attachments: PendingAttachment[] = []) => { const queuedMessage = { content: message, attachments } - const newQueue = [...queuedMessagesRef.current, queuedMessage] - queuedMessagesRef.current = newQueue - setQueuedMessages(newQueue) - logger.info( - { newQueueLength: newQueue.length, messageLength: message.length }, - '[message-queue] Message added to queue', - ) + // Use functional setState to ensure atomic updates during rapid calls. + // We update queuedMessagesRef inside the callback to keep ref and state + // in sync atomically - this prevents race conditions when multiple + // messages are added before React can process state updates. + setQueuedMessages((prev) => { + const newQueue = [...prev, queuedMessage] + queuedMessagesRef.current = newQueue + logger.info( + { newQueueLength: newQueue.length, messageLength: message.length }, + '[message-queue] Message added to queue', + ) + return newQueue + }) }, [], ) diff --git a/scripts/tmux/README.md b/scripts/tmux/README.md index 105fe87d42..bfbe8ad513 100644 --- a/scripts/tmux/README.md +++ b/scripts/tmux/README.md @@ -144,6 +144,9 @@ Send input to a running session. ./scripts/tmux/tmux-send.sh SESSION --key C-c ./scripts/tmux/tmux-send.sh SESSION --key Enter +# Send and wait for CLI to finish streaming (for rapid message tests) +./scripts/tmux/tmux-send.sh SESSION "hello" --wait-idle 2 + # Paste clipboard content and submit immediately ./scripts/tmux/tmux-send.sh SESSION --paste diff --git a/scripts/tmux/tmux-send.sh b/scripts/tmux/tmux-send.sh index d6ceeae3b5..efc8e02a58 100755 --- a/scripts/tmux/tmux-send.sh +++ b/scripts/tmux/tmux-send.sh @@ -34,6 +34,11 @@ # testing attachment UI before sending). # --no-enter Don't automatically press Enter after text # --retry N Retry session detection N times (default: 3) +# --delay MS Wait time in ms after Enter (default: 500, use 200 for faster tests) +# --wait-idle SECS Wait until terminal output is stable for SECS seconds (for streaming) +# This polls every 250ms until output hasn't changed for SECS seconds. +# Useful for rapid message testing where you need to wait for streaming. +# Max wait time is 120 seconds to prevent infinite loops. # --force Bypass duplicate detection (send even if same text was just sent) # --help Show this help message # @@ -50,6 +55,9 @@ # # Send Ctrl+C to interrupt # ./scripts/tmux/tmux-send.sh tui-test-123 --key C-c # +# # Send a message and wait for CLI to finish streaming before returning +# ./scripts/tmux/tmux-send.sh tui-test-123 "hello" --wait-idle 2 +# # # Paste clipboard content and submit immediately # ./scripts/tmux/tmux-send.sh tui-test-123 --paste # @@ -80,7 +88,11 @@ SPECIAL_KEY="" PASTE_CLIPBOARD=false RETRY_COUNT=3 RETRY_DELAY=0.3 +POST_ENTER_DELAY=0.5 FORCE_SEND=false +WAIT_IDLE_SECONDS=0 +WAIT_IDLE_MAX=120 +WAIT_IDLE_POLL_INTERVAL=0.25 # Check minimum arguments if [[ $# -lt 1 ]]; then @@ -120,6 +132,15 @@ while [[ $# -gt 0 ]]; do RETRY_COUNT="$2" shift 2 ;; + --delay) + # Convert ms to seconds for sleep command + POST_ENTER_DELAY=$(echo "scale=3; $2 / 1000" | bc) + shift 2 + ;; + --wait-idle) + WAIT_IDLE_SECONDS="$2" + shift 2 + ;; --force) FORCE_SEND=true shift @@ -249,8 +270,52 @@ if [[ "$AUTO_ENTER" == true ]]; then tmux send-keys -t "$SESSION_NAME" Enter # Wait for CLI to process Enter and clear input buffer before returning # This prevents the next send from concatenating with the previous input - # 200ms is needed for slower CLIs like Codex to fully process the command - sleep 0.2 + # Default 500ms is needed for TUI CLIs to fully process the command and reset input state + # Use --delay to customize (e.g., --delay 200 for faster tests if not testing rapid input) + sleep $POST_ENTER_DELAY +fi + +# If --wait-idle is specified, poll until terminal output stabilizes +# This is essential for rapid message testing where we need to wait for streaming to complete +# Works with both --auto-enter and --no-enter modes +if [[ "$WAIT_IDLE_SECONDS" != "0" && -n "$WAIT_IDLE_SECONDS" ]]; then + LAST_OUTPUT="" + STABLE_START=0 + POLL_COUNT=0 + # Calculate max polls: WAIT_IDLE_MAX / WAIT_IDLE_POLL_INTERVAL (120 / 0.25 = 480) + MAX_POLLS=480 + + while true; do + # Capture current terminal output + CURRENT_OUTPUT=$(tmux capture-pane -t "$SESSION_NAME" -p 2>/dev/null || echo "") + CURRENT_TIME=$(date +%s) + + if [[ "$CURRENT_OUTPUT" == "$LAST_OUTPUT" ]]; then + # Output unchanged - check if stable long enough + if [[ "$STABLE_START" == "0" ]]; then + STABLE_START=$CURRENT_TIME + fi + + STABLE_DURATION=$((CURRENT_TIME - STABLE_START)) + if [[ "$STABLE_DURATION" -ge "$WAIT_IDLE_SECONDS" ]]; then + # Output has been stable for the required duration + break + fi + else + # Output changed - reset stability timer + LAST_OUTPUT="$CURRENT_OUTPUT" + STABLE_START=0 + fi + + # Check max wait timeout using simple integer counter + POLL_COUNT=$((POLL_COUNT + 1)) + if [[ "$POLL_COUNT" -ge "$MAX_POLLS" ]]; then + echo "⚠️ --wait-idle timed out after ${WAIT_IDLE_MAX}s" >&2 + break + fi + + sleep $WAIT_IDLE_POLL_INTERVAL + done fi # Log the text send as YAML and update last-sent tracker From 81e1aec8469270f3c0e8f902762f788b986d2b91 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 18:24:20 -0800 Subject: [PATCH 0037/1143] Prefer git bash in windows to wls bash --- sdk/src/tools/run-terminal-command.ts | 58 +++++++++++++++++++++------ 1 file changed, 46 insertions(+), 12 deletions(-) diff --git a/sdk/src/tools/run-terminal-command.ts b/sdk/src/tools/run-terminal-command.ts index 87b819f282..66022a4597 100644 --- a/sdk/src/tools/run-terminal-command.ts +++ b/sdk/src/tools/run-terminal-command.ts @@ -20,12 +20,25 @@ const GIT_BASH_COMMON_PATHS = [ 'C:\\Git\\bin\\bash.exe', ] +// WSL bash paths that are often unreliable (VM may not be running, quote escaping issues) +// These are checked last as a fallback only +const WSL_BASH_PATH_PATTERNS = [ + 'system32', + 'windowsapps', +] + /** * Find bash executable on Windows. * Priority: - * 1. CODEBUFF_GIT_BASH_PATH environment variable - * 2. bash.exe in PATH (e.g., inside WSL or Git Bash terminal) - * 3. Common Git Bash installation locations + * 1. CODEBUFF_GIT_BASH_PATH environment variable (user override) + * 2. Common Git Bash installation locations (most reliable) + * 3. Non-WSL bash in PATH (e.g., Git Bash added to PATH) + * 4. WSL bash in PATH (last resort - System32, WindowsApps) + * + * WSL bash is deprioritized because it can fail with cryptic errors when: + * - The WSL VM is not running + * - Quote/argument escaping issues between Windows and Linux + * - UTF-16 encoding mismatches */ function findWindowsBash(env: NodeJS.ProcessEnv): string | null { // Check for user-specified path via environment variable @@ -34,27 +47,48 @@ function findWindowsBash(env: NodeJS.ProcessEnv): string | null { return customPath } - // Check if bash.exe is in PATH (works inside WSL or Git Bash) + // Check common Git Bash installation locations first (most reliable) + for (const commonPath of GIT_BASH_COMMON_PATHS) { + if (fs.existsSync(commonPath)) { + return commonPath + } + } + + // Fall back to bash.exe in PATH, but skip WSL paths initially const pathEnv = env.PATH || env.Path || '' const pathDirs = pathEnv.split(path.delimiter) + const wslFallbackPaths: string[] = [] for (const dir of pathDirs) { + const dirLower = dir.toLowerCase() + const isWslPath = WSL_BASH_PATH_PATTERNS.some(pattern => dirLower.includes(pattern)) + const bashPath = path.join(dir, 'bash.exe') if (fs.existsSync(bashPath)) { - return bashPath + if (isWslPath) { + // Save WSL paths for last resort + wslFallbackPaths.push(bashPath) + } else { + // Non-WSL bash in PATH (e.g., Git Bash added to PATH) + return bashPath + } } - // Also check for just 'bash' (for WSL) + + // Also check for just 'bash' (without .exe) const bashPathNoExt = path.join(dir, 'bash') if (fs.existsSync(bashPathNoExt)) { - return bashPathNoExt + if (isWslPath) { + wslFallbackPaths.push(bashPathNoExt) + } else { + return bashPathNoExt + } } } - // Check common Git Bash installation locations - for (const commonPath of GIT_BASH_COMMON_PATHS) { - if (fs.existsSync(commonPath)) { - return commonPath - } + // Last resort: use WSL bash if nothing else is available + // WSL can be unreliable (VM not running, quote escaping issues, UTF-16 encoding) + if (wslFallbackPaths.length > 0) { + return wslFallbackPaths[0] } return null From 058904e5205cd1bb46d5bfb84aaf4eb6f0f0d704 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 20:12:06 -0800 Subject: [PATCH 0038/1143] Tweak getting started docs --- web/src/content/help/quick-start.mdx | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/web/src/content/help/quick-start.mdx b/web/src/content/help/quick-start.mdx index 1df151e384..ad8e96b4d0 100644 --- a/web/src/content/help/quick-start.mdx +++ b/web/src/content/help/quick-start.mdx @@ -27,7 +27,7 @@ cd /path/to/your-repo codebuff ``` -Codebuff has multiple [modes](/docs/tips/modes): `lite` for quick tasks, `max` for complex work, and `plan` for planning without file changes. You can invoke them in the slash menu with `/mode:`. +Codebuff has multiple [modes](/docs/tips/modes): `plan` for planning without file changes, `max` for better results at higher cost and time. You can invoke them in the slash menu with `/mode`. ## 4. Initialize Your Project (Optional) @@ -39,18 +39,13 @@ Run the `/init` command inside Codebuff to set up project-specific files: ### What `/init` Creates -| File/Directory | Purpose | -|---------------|----------| -| `knowledge.md` | A starter file for documenting your project's setup commands, architecture, and coding conventions. Codebuff reads this to understand your project better. | -| `.agents/types/` | TypeScript type definitions for creating custom agents. | +- `knowledge.md` — A starter file for documenting your project's setup commands, architecture, and coding conventions. Codebuff reads this to understand your project better. +- `.agents/types/` — TypeScript type definitions for creating custom agents. ### When to Use `/init` -- **New projects** — Run `/init` once to create a `knowledge.md` file and get Codebuff familiar with your project. -- **Building custom agents** — The `.agents/types/` directory provides TypeScript types for full IntelliSense when creating agents. -- **Team onboarding** — Commit `knowledge.md` to your repo so Codebuff works consistently for all team members. - -> **Note:** `/init` is safe to run multiple times. It skips files that already exist and only creates missing ones. +- **New projects** — if you don't already have an AGENTS.md or CLAUDE.md (Codebuff will also read these files). +- **Building custom agents** — running /init is the first step to [creating your own agents](/docs/walkthroughs/creating-your-first-agent)! ## Troubleshooting From b70e947023aee9828a1c40665ed5043ddcdf35c7 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 21:11:45 -0800 Subject: [PATCH 0039/1143] fix(db): add db:migrate:render script using npx to avoid Bun SIGSEGV crash drizzle-kit crashes with SIGSEGV when run via Bun on Render. This adds a separate script that uses npx (Node.js) for Render deployments. See: oven-sh/bun#20483, oven-sh/bun#23740 --- packages/internal/package.json | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/internal/package.json b/packages/internal/package.json index 86b7d64f83..0e96415f55 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -48,6 +48,7 @@ "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", + "db:migrate:render": "npx drizzle-kit push --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From ba5871fbe7792202db877494101b16baeb98db87 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 21:18:14 -0800 Subject: [PATCH 0040/1143] fix(refactor): address Wave 1 review findings - Update 6 deprecated imports from old-constants to new domain paths: - sdk/src/run.ts, env.ts, client.ts -> constants/paths - packages/billing/src/auto-topup.ts -> constants/limits - packages/agent-runtime/src/process-file-block.ts -> constants/model-config - common/src/project-file-tree.ts -> constants/paths - Remove unused FileTreeError and PermissionError classes from project-file-tree.ts - Simplify logFileTreeError to only log in debug mode (remove confusing ENOENT/EACCES special case) - Export helper functions from context-pruner.ts for testability: - truncateLongText, estimateTokens, getTextContent, summarizeToolCall --- REFACTORING_PLAN.md | 1078 +++++++++++++++++ agents/context-pruner.ts | 537 ++++---- common/src/constants/index.ts | 7 + common/src/constants/limits.ts | 19 + common/src/constants/model-config.ts | 223 ++++ common/src/constants/paths.ts | 70 ++ common/src/constants/ui.ts | 25 + common/src/old-constants.ts | 365 +----- common/src/project-file-tree.ts | 48 +- .../agent-runtime/src/process-file-block.ts | 2 +- packages/billing/src/auto-topup.ts | 2 +- sdk/src/client.ts | 2 +- sdk/src/env.ts | 2 +- sdk/src/run.ts | 2 +- 14 files changed, 1765 insertions(+), 617 deletions(-) create mode 100644 REFACTORING_PLAN.md create mode 100644 common/src/constants/index.ts create mode 100644 common/src/constants/limits.ts create mode 100644 common/src/constants/model-config.ts create mode 100644 common/src/constants/paths.ts create mode 100644 common/src/constants/ui.ts diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md new file mode 100644 index 0000000000..14e789f8f4 --- /dev/null +++ b/REFACTORING_PLAN.md @@ -0,0 +1,1078 @@ +# Codebuff Refactoring Plan + +This document outlines a prioritized refactoring plan for the 51 issues identified across the codebase. Issues are grouped into commits targeting ~1k LOC each, with time estimates and dependencies noted. + +> **Updated based on multi-agent review feedback.** Key changes: +> - Extended timeline from 5 weeks to 7-8 weeks +> - Added 40% buffer to estimates (100-130 hours total) +> - Added rollback procedures and feature flags +> - Fixed incorrect file paths and line counts +> - Deferred low-ROI agent consolidation work +> - Added PR review time (~36 hours) +> - Added runtime metrics to success criteria + +--- + +## Progress Tracker + +> **Last Updated:** Wave 1 Complete +> **Current Status:** Ready for Wave 2 (Track A critical path) + +### Phase 1 Progress +| Commit | Description | Status | Completed By | +|--------|-------------|--------|-------------| +| 1.1a | Extract chat state management | ⬜ Not Started | - | +| 1.1b | Extract chat UI and orchestration | ⬜ Not Started | - | +| 1.2 | Refactor context-pruner god function | ✅ Complete | Codex CLI | +| 1.3 | Split old-constants.ts god module | ✅ Complete | Codex CLI | +| 1.4 | Fix silent error swallowing | ✅ Complete | Codex CLI | + +### Phase 2 Progress +| Commit | Description | Status | Completed By | +|--------|-------------|--------|-------------| +| 2.1 | Refactor use-send-message.ts | ⬜ Not Started | - | +| 2.2 | Consolidate block utils + think tags | ⬜ Not Started | - | +| 2.3 | Refactor loopAgentSteps | ⬜ Not Started | - | +| 2.4 | Consolidate billing duplication | ⬜ Not Started | - | +| 2.5a | Extract multiline keyboard navigation | ⬜ Not Started | - | +| 2.5b | Extract multiline editing handlers | ⬜ Not Started | - | +| 2.6 | Simplify use-activity-query.ts | ⬜ Not Started | - | +| 2.7 | Consolidate XML parsing | ⬜ Not Started | - | +| 2.8 | Consolidate analytics | ⬜ Not Started | - | +| 2.9 | Refactor doStream | ⬜ Not Started | - | +| 2.10 | DRY up OpenRouter stream handling | ⬜ Not Started | - | +| 2.11 | Consolidate image handling | ⬜ Not Started | - | +| 2.12 | Refactor suggestion-engine | ⬜ Not Started | - | +| 2.13 | Fix browser actions + string utils | ⬜ Not Started | - | +| 2.14 | Refactor agent-builder.ts | ⬜ Not Started | - | +| 2.15 | Refactor promptAiSdkStream | ⬜ Not Started | - | +| 2.16 | Simplify run-state.ts | ⬜ Not Started | - | + +### Phase 3 Progress +| Commit | Description | Status | Completed By | +|--------|-------------|--------|-------------| +| 3.1 | DRY up auto-topup logic | ⬜ Not Started | - | +| 3.2 | Split db/schema.ts | ⬜ Not Started | - | +| 3.3 | Remove dead code batch 1 | ⬜ Not Started | - | +| 3.4 | Remove dead code batch 2 | ⬜ Not Started | - | + +--- + +## Executive Summary + +| Priority | Count | Original Estimate | Revised Estimate | +|----------|-------|-------------------|------------------| +| 🔴 Critical | 5 | 12-16 hours | 18-24 hours | +| 🟡 Warning | 29 | 40-52 hours | 56-70 hours | +| 🔵 Suggestion | 5 | 8-12 hours | 6-10 hours | +| ℹ️ Info | 4 | 4-6 hours | 4-6 hours | +| **PR Review Time** | 22 commits | - | 44 hours | +| **Total** | **43** | **64-86 hours** | **128-154 hours** | + +### Changes from Original Plan +- **Deferred:** Commits 2.15, 2.16 (agent consolidation) - working code, unclear ROI +- **Cut:** Commit 3.1 (pluralize replacement) - adds unnecessary dependency +- **Combined:** 2.2+2.3 (block utils + think tags), 2.13+2.14 (browser actions + string utils) +- **Split:** 1.1 (chat.tsx) into 1.1a and 1.1b, 2.5 (multiline-input) into 2.5a and 2.5b +- **Moved:** 3.4 (run-state.ts) to Phase 2 as 2.17 +- **Upgraded:** 2.4 (billing) risk from Medium to High + +--- + +## Phase 1: Critical Issues (Week 1-2) + +### Commit 1.1a: Extract Chat State Management +**Files:** `cli/src/chat.tsx` → `cli/src/hooks/use-chat-state.ts`, `cli/src/hooks/use-chat-messages.ts` +**Est. Time:** 5-6 hours +**Est. LOC Changed:** ~800-900 + +> ⚠️ **Corrected:** Original file is 1,676 lines, not 800-1000. Split into two commits. + +| Task | Description | +|------|-------------| +| Extract `useChatState` hook | All Zustand state slices and selectors | +| Extract `useChatMessages` hook | Message handling, tree building | +| Create state types file | `types/chat-state.ts` | +| Wire up to main component | Update imports in chat.tsx | + +**Dependencies:** None +**Risk:** High - Core component +**Feature Flag:** `REFACTOR_CHAT_STATE=true` for gradual rollout +**Rollback:** Revert to previous chat.tsx, flag off + +--- + +### Commit 1.1b: Extract Chat UI and Orchestration +**Files:** `cli/src/chat.tsx` → `cli/src/hooks/use-chat-ui.ts`, `cli/src/chat-orchestrator.tsx` +**Est. Time:** 5-6 hours +**Est. LOC Changed:** ~700-800 + +| Task | Description | +|------|-------------| +| Extract `useChatUI` hook | Scroll behavior, focus, layout | +| Extract `useChatStreaming` hook | Streaming state management | +| Create `chat-orchestrator.tsx` | Thin wrapper composing hooks | +| Update remaining chat.tsx | Reduce to UI rendering only | + +**Dependencies:** Commit 1.1a +**Risk:** High +**Feature Flag:** Same as 1.1a +**Rollback:** Revert commits 1.1a and 1.1b together + +--- + +### Commit 1.2: Refactor `context-pruner.ts` God Function +**Files:** `agents/context-pruner.ts` +**Est. Time:** 4-5 hours +**Est. LOC Changed:** ~600-800 + +| Task | Description | +|------|-------------| +| Extract `summarizeMessages()` | Message summarization logic | +| Extract `calculateTokenBudget()` | Token budget calculations | +| Extract `pruneByPriority()` | Priority-based pruning strategy | +| Extract `formatPrunedContext()` | Output formatting | +| Simplify `handleSteps()` | Reduce to orchestration only | + +**Dependencies:** None +**Risk:** Medium - Core agent functionality +**Rollback:** Revert single commit + +--- + +### Commit 1.3: Split `old-constants.ts` God Module +**Files:** `common/src/old-constants.ts` → multiple domain files +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~400-500 + +| Task | Description | +|------|-------------| +| Create `constants/model-config.ts` | Model-related constants | +| Create `constants/limits.ts` | Size/count limits | +| Create `constants/ui.ts` | UI-related constants | +| Create `constants/paths.ts` | Path constants | +| Create `constants/index.ts` | Re-export for backwards compatibility | +| Update all imports | Find and replace across codebase | + +**Dependencies:** None +**Risk:** Low - Pure constants, easy to verify +**Rollback:** Revert single commit + +--- + +### Commit 1.4: Fix Silent Error Swallowing in `project-file-tree.ts` +**Files:** `common/src/project-file-tree.ts` +**Est. Time:** 1-2 hours +**Est. LOC Changed:** ~150-200 + +| Task | Description | +|------|-------------| +| Add error logging | Log errors before swallowing | +| Add error context | Include file paths in error messages | +| Create custom error types | `FileTreeError`, `PermissionError` | +| Update callers | Handle new error information | + +**Dependencies:** None +**Risk:** Low - Additive changes +**Rollback:** Revert single commit + +--- + +## Phase 2: High-Priority Warnings (Week 3-5) + +> **Note:** Commit 1.5 (run-agent-step.ts) moved to Phase 2 to let chat.tsx patterns establish first. + +### Commit 2.1: Refactor `use-send-message.ts` +**Files:** `cli/src/hooks/use-send-message.ts` +**Est. Time:** 4-5 hours +**Est. LOC Changed:** ~400-500 + +| Task | Description | +|------|-------------| +| Extract `useBashHandler` hook | Bash command handling | +| Extract `useAttachmentHandler` hook | File attachment processing | +| Extract `useMessageExecution` hook | Core execution logic | +| Extract `useMessageErrors` hook | Error handling | +| Compose in main hook | Wire up extracted hooks | + +**Dependencies:** Commits 1.1a, 1.1b (chat.tsx patterns) +**Risk:** Medium +**Rollback:** Revert single commit + +--- + +### Commit 2.2: Consolidate Block Utils and Think Tag Parsing +**Files:** Multiple CLI files + `utils/think-tag-parser.ts` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~550-650 + +> ⚠️ **Corrected:** `think-tag-parser.ts` already exists. Task is migration/consolidation, not creation. + +| Task | Description | +|------|-------------| +| Audit all `updateBlocksRecursively` usages | Map duplicates | +| Create `utils/block-tree-utils.ts` | Unified block tree operations | +| Audit all think tag parsing | Map implementations | +| Migrate to existing `think-tag-parser.ts` | Use as single source | +| Add type-safe variants | `updateBlockById`, `parseThinkTags` | +| Replace all usages | Update imports across CLI | +| Add unit tests | Cover edge cases | + +**Dependencies:** None +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.3: Refactor `loopAgentSteps` in `run-agent-step.ts` +**Files:** `packages/agent-runtime/src/run-agent-step.ts` +**Est. Time:** 4-5 hours +**Est. LOC Changed:** ~500-600 + +> **Moved from Phase 1:** Let chat.tsx patterns establish before tackling runtime. + +| Task | Description | +|------|-------------| +| Extract `processToolCalls()` | Tool call handling | +| Extract `handleStreamEvents()` | Stream event processing | +| Extract `validateStepResult()` | Step validation logic | +| Create `AgentStepProcessor` class | Optional: OOP refactor | +| Simplify main loop | Reduce to coordination only | + +**Dependencies:** Commits 1.1a, 1.1b (patterns) +**Risk:** High - Core runtime, extensive testing required +**Feature Flag:** `REFACTOR_AGENT_LOOP=true` +**Rollback:** Revert and flag off + +--- + +### Commit 2.4: Consolidate Billing Duplication +**Files:** `packages/billing/src/org-billing.ts`, `packages/billing/src/balance-calculator.ts` +**Est. Time:** 6-8 hours +**Est. LOC Changed:** ~500-600 + +> ⚠️ **Risk Upgraded to High:** Financial logic requires extensive testing and staged rollout. + +| Task | Description | +|------|-------------| +| Create `billing-core.ts` | Shared billing logic | +| Extract `calculateBalance()` | Core calculation | +| Extract `applyCredits()` | Credit application | +| Refactor `consumeCreditsAndAddAgentStep` | Split into separate operations | +| Update org-billing to use shared code | DRY up implementation | +| Add comprehensive unit tests | Cover all financial paths | +| Add integration tests | Verify end-to-end billing | + +**Dependencies:** None +**Risk:** High - Financial accuracy critical +**Feature Flag:** `REFACTOR_BILLING=true` (staged rollout to 1% → 10% → 100%) +**Rollback:** Immediate revert + flag off +**Extra Review:** Finance/billing team sign-off required + +--- + +### Commit 2.5a: Extract Multiline Input Keyboard Navigation +**Files:** `cli/src/components/multiline-input.tsx` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~500-550 + +> ⚠️ **Corrected:** File is 1,102 lines, not 350-450. Split into two commits. + +| Task | Description | +|------|-------------| +| Create `useKeyboardNavigation` hook | Arrow keys, home/end | +| Create `useKeyboardShortcuts` hook | Ctrl+C, Ctrl+D, etc. | +| Update multiline-input | Delegate navigation to hooks | + +**Dependencies:** Commit 2.1 (use-send-message patterns) +**Risk:** Medium - User input handling +**Rollback:** Revert single commit + +--- + +### Commit 2.5b: Extract Multiline Input Editing Handlers +**Files:** `cli/src/components/multiline-input.tsx` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~500-550 + +| Task | Description | +|------|-------------| +| Create `useKeyboardEditing` hook | Backspace, delete, paste | +| Create keyboard handler registry | Composable handler system | +| Simplify main component | Delegate all keyboard to hooks | +| Add comprehensive tests | Cover all key combinations | + +**Dependencies:** Commit 2.5a +**Risk:** Medium +**Rollback:** Revert both 2.5a and 2.5b together + +--- + +### Commit 2.6: Simplify `use-activity-query.ts` +**Files:** `cli/src/hooks/use-activity-query.ts` +**Est. Time:** 4-5 hours +**Est. LOC Changed:** ~500-600 + +| Task | Description | +|------|-------------| +| Evaluate external caching library | Consider `react-query` or similar | +| If keeping custom: Extract `QueryCache` class | Cache management | +| Extract `QueryExecutor` | Query execution logic | +| Extract `QueryInvalidation` | Invalidation strategies | +| Simplify main hook | Compose extracted pieces | + +**Dependencies:** None +**Risk:** Medium +**Rollback:** Revert single commit + +--- + +### Commit 2.7: Consolidate XML Parsing +**Files:** `common/src/util/saxy.ts` + 3 related files +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~400-500 + +| Task | Description | +|------|-------------| +| Audit all XML parsing usages | Map current implementations | +| Create unified `xml-parser.ts` | Single parsing module | +| Create typed interfaces | `XmlNode`, `XmlParser` | +| Migrate all usages | Update imports | +| Remove duplicate implementations | Clean up | + +**Dependencies:** None (can run in parallel with 2.6) +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.8: Consolidate Analytics +**Files:** `common/src/analytics*.ts` (10+ files across packages) +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~500-600 + +> ⚠️ **Corrected:** 10+ files across packages, not just 4 in common. + +| Task | Description | +|------|-------------| +| Audit all analytics files | Map across all packages | +| Create `analytics/index.ts` | Main entry point | +| Create `analytics/events.ts` | Event definitions | +| Create `analytics/providers.ts` | Provider implementations | +| Create `analytics/types.ts` | Shared types | +| Consolidate all files | Merge into new structure | + +**Dependencies:** None (can run in parallel with 2.7) +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.9: Refactor `doStream` in OpenAI Compatible Model +**Files:** `packages/internal/src/ai-sdk/openai-compatible-chat-language-model.ts` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~350-400 + +| Task | Description | +|------|-------------| +| Extract `StreamParser` class | Parsing logic | +| Extract `ChunkProcessor` | Chunk handling | +| Extract `StreamErrorHandler` | Error handling | +| Simplify `doStream` | Orchestration only | + +**Dependencies:** None +**Risk:** Medium - Core streaming +**Feature Flag:** `REFACTOR_STREAM=true` +**Rollback:** Revert and flag off + +--- + +### Commit 2.10: DRY Up OpenRouter Stream Handling +**Files:** `packages/internal/src/ai-sdk/openrouter-ai-sdk/chat/index.ts` +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~300-400 + +| Task | Description | +|------|-------------| +| Create shared `stream-utils.ts` | Common streaming utilities | +| Extract shared chunk processing | Reuse across providers | +| Update OpenRouter implementation | Use shared code | +| Update OpenAI compatible | Use shared code | + +**Dependencies:** Commit 2.9 +**Risk:** Medium +**Rollback:** Revert single commit + +--- + +### Commit 2.11: Consolidate Image Handling +**Files:** Clipboard/image related files in CLI +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~300-400 + +| Task | Description | +|------|-------------| +| Create `utils/image-handler.ts` | Unified image handling | +| Extract `processImageFromClipboard()` | Clipboard images | +| Extract `processImageFromFile()` | File images | +| Extract `validateImage()` | Image validation | +| Update all usages | Replace duplicates | + +**Dependencies:** None (can run in parallel with 2.10) +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.12: Refactor `use-suggestion-engine.ts` +**Files:** `cli/src/hooks/use-suggestion-engine.ts` +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~350-450 + +| Task | Description | +|------|-------------| +| Extract `useSuggestionCache` hook | Caching logic | +| Extract `useSuggestionRanking` hook | Ranking algorithms | +| Extract `useSuggestionFiltering` hook | Filter logic | +| Compose in main hook | Wire up | + +**Dependencies:** None (can run in parallel with 2.11) +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.13: Fix Browser Actions and String Utils +**Files:** `common/src/browser-actions.ts`, `common/src/util/string.ts` +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~200-300 + +> **Combined:** Original 2.13 + 2.14 merged (small changes) + +| Task | Description | +|------|-------------| +| Create `parseActionValue()` utility | Single parsing function | +| Add type guards | `isValidActionValue()` | +| Replace duplicated parsing | Use new utility | +| Consolidate regex patterns | Single source of truth for lazy edit | +| Create named constants | `LAZY_EDIT_PATTERNS` | +| Add unit tests | Cover edge cases | + +**Dependencies:** None (can run in parallel with 2.12) +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.14: Refactor `agent-builder.ts` +**Files:** `agents/agent-builder.ts` +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~300-400 + +| Task | Description | +|------|-------------| +| Extract file I/O helpers | `readAgentFile()`, `writeAgentFile()` | +| Create prompt templates | Separate from logic | +| Add proper error handling | Replace brittle I/O | +| Add input validation | Validate agent configs | + +**Dependencies:** None +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 2.15: Refactor `promptAiSdkStream` in SDK +**Files:** `sdk/src/impl/llm.ts` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~350-450 + +| Task | Description | +|------|-------------| +| Extract `StreamConfig` builder | Configuration handling | +| Extract `StreamEventEmitter` | Event emission | +| Extract `StreamErrorHandler` | Error handling | +| Simplify main function | Orchestration only | + +**Dependencies:** Commits 2.9, 2.10 (streaming patterns) +**Risk:** Medium +**Rollback:** Revert single commit + +--- + +### Commit 2.16: Simplify `run-state.ts` in SDK +**Files:** `sdk/src/run-state.ts` +**Est. Time:** 3-4 hours +**Est. LOC Changed:** ~400-500 + +> **Moved from Phase 3:** File is 737 lines, not a minor cleanup task. + +| Task | Description | +|------|-------------| +| Audit state complexity | Identify unnecessary parts | +| Extract state machine helpers | `createStateTransition()` | +| Remove unused state fields | Clean up | +| Simplify state transitions | Reduce complexity | +| Update tests | Ensure coverage | + +**Dependencies:** Commit 2.15 +**Risk:** Medium +**Rollback:** Revert single commit + +--- + +## Phase 3: Cleanup (Week 6-7) + +### Commit 3.1: DRY Up Auto-Topup Logic +**Files:** `packages/billing/src/auto-topup.ts` +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~200-250 + +| Task | Description | +|------|-------------| +| Create `TopupProcessor` | Shared processing logic | +| Extract user/org differences | Configuration-based | +| Reduce duplication | Single implementation | + +**Dependencies:** Commit 2.4 (billing) +**Risk:** Medium - Financial logic +**Rollback:** Revert single commit + +--- + +### Commit 3.2: Split `db/schema.ts` +**Files:** `packages/internal/src/db/schema.ts` → multiple files +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~600-700 + +> ⚠️ **Corrected:** Schema file is in `packages/internal/`, not `packages/billing/`. + +| Task | Description | +|------|-------------| +| Create `schema/users.ts` | User-related tables | +| Create `schema/billing.ts` | Billing tables | +| Create `schema/organizations.ts` | Org tables | +| Create `schema/agents.ts` | Agent tables | +| Create `schema/index.ts` | Re-exports | + +**Dependencies:** None +**Risk:** Low - Pure schema organization +**Rollback:** Revert single commit + +--- + +### Commit 3.3: Remove Dead Code (Batch 1) +**Files:** Various +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~400-600 + +| Task | Description | +|------|-------------| +| Remove commented code | Clean up | +| Remove unused exports | Clean up | +| Remove unused imports | Clean up | +| Update affected tests | Ensure coverage | + +**Dependencies:** All Phase 2 commits +**Risk:** Low +**Rollback:** Revert single commit + +--- + +### Commit 3.4: Remove Dead Code (Batch 2) +**Files:** Various +**Est. Time:** 2-3 hours +**Est. LOC Changed:** ~400-600 + +| Task | Description | +|------|-------------| +| Remove unused utilities | Clean up | +| Remove deprecated functions | Clean up | +| Update documentation | Reflect changes | + +**Dependencies:** Commit 3.3 +**Risk:** Low +**Rollback:** Revert single commit + +--- + +## Deferred Work (Backlog) + +The following items have been deferred due to unclear ROI or scope concerns: + +### ❌ Agent Consolidation (Originally 2.15, 2.16) +**Reason:** Working code being refactored for aesthetics. Unclear ROI. +**Revisit When:** Bugs traced to agent fragmentation, or new agent development blocked by duplication. + +| Original Commit | Description | Est. Hours | +|-----------------|-------------|------------| +| Reviewer agents (5-14 agents) | Consolidate into 2-3 | 4-6 | +| File explorer micro-agents (9 agents) | Consolidate into unified agent | 4-6 | + +### ❌ Pluralize Replacement (Originally 3.1) +**Reason:** Adds npm dependency for working code. 191 lines is acceptable for custom pluralization. +**Revisit When:** Pluralization bugs reported, or major i18n work planned. + +--- + +## Commit Dependency Graph + +``` +Phase 1 (Critical) - Week 1-2: +1.1a chat-state ────────────┐ + ▼ +1.1b chat-ui ───────────────┤ + │ +1.2 context-pruner │ +1.3 old-constants │ +1.4 project-file-tree │ + │ +Phase 2 (Warnings) - Week 3-5: + ▼ +2.1 use-send-message ◄──────┘ + +2.2 block-utils + think-tags (parallel track) + +2.3 run-agent-step ◄──── 1.1b (patterns) + +2.4 billing (can start Week 3) + │ + ▼ +3.1 auto-topup (Phase 3) + +2.5a multiline-nav ◄──── 2.1 + │ + ▼ +2.5b multiline-edit + +2.6 use-activity-query ─┐ +2.7 XML parsing ├─► (parallel - no dependencies) +2.8 analytics │ +2.11 image handling │ +2.12 suggestion-engine │ +2.13 browser + string ┘ + +2.9 doStream ─────────────┐ + ▼ +2.10 OpenRouter stream ───┤ + ▼ +2.15 promptAiSdkStream ───┤ + ▼ +2.16 run-state.ts ────────┘ + +2.14 agent-builder (parallel) + +Phase 3 (Cleanup) - Week 6-7: +3.1 auto-topup ◄──── 2.4 +3.2 db/schema +3.3 dead code batch 1 ◄── all Phase 2 +3.4 dead code batch 2 ◄── 3.3 +``` + +--- + +## Parallelization Analysis + +### Independent Parallel Tracks + +Based on the dependency graph, there are **4 distinct parallel tracks** that different developers can work on simultaneously: + +--- + +#### **Track A: Chat/UI Refactoring** (1 Developer - "Chat Lead") + +Sequential chain - must be done in order: + +``` +Week 1-2: 1.1a (chat-state) → 1.1b (chat-ui) +Week 3: 2.1 (use-send-message) +Week 4: 2.5a (multiline-nav) → 2.5b (multiline-edit) +``` + +| Commit | Description | Hours | Depends On | +|--------|-------------|-------|------------| +| 1.1a | Extract chat state management | 5-6 | None | +| 1.1b | Extract chat UI and orchestration | 5-6 | 1.1a | +| 2.1 | Refactor use-send-message.ts | 4-5 | 1.1b | +| 2.5a | Extract multiline keyboard navigation | 3-4 | 2.1 | +| 2.5b | Extract multiline editing handlers | 3-4 | 2.5a | + +**Total: 20-25 hours** + +--- + +#### **Track B: Common Utilities** (1 Developer - "Utils Lead") + +Mostly independent work - can be done in any order after Phase 1 foundations: + +``` +Week 1-2: 1.3 (old-constants), 1.4 (project-file-tree) +Week 3-5: 2.2 (block-utils + think-tags) + 2.7 (XML parsing) ← parallel + 2.8 (analytics) ← parallel + 2.11 (image handling) ← parallel + 2.12 (suggestion-engine) ← parallel + 2.13 (browser + string) ← parallel +``` + +| Commit | Description | Hours | Depends On | +|--------|-------------|-------|------------| +| 1.3 | Split old-constants.ts god module | 2-3 | None | +| 1.4 | Fix silent error swallowing | 1-2 | None | +| 2.2 | Consolidate block utils + think tags | 3-4 | None | +| 2.7 | Consolidate XML parsing | 2-3 | None | +| 2.8 | Consolidate analytics | 3-4 | None | +| 2.11 | Consolidate image handling | 2-3 | None | +| 2.12 | Refactor suggestion-engine | 2-3 | None | +| 2.13 | Fix browser actions + string utils | 2-3 | None | + +**Total: 18-24 hours** + +--- + +#### **Track C: Runtime/Streaming** (1 Developer - "Runtime Lead") + +Sequential chain with streaming dependency: + +``` +Week 1-2: 1.2 (context-pruner) +Week 3: 2.3 (run-agent-step) - waits for 1.1b patterns +Week 4-5: 2.9 (doStream) → 2.10 (OpenRouter) → 2.15 (promptAiSdkStream) → 2.16 (run-state) +Week 6: 2.14 (agent-builder) - independent, can slot anywhere +``` + +| Commit | Description | Hours | Depends On | +|--------|-------------|-------|------------| +| 1.2 | Refactor context-pruner god function | 4-5 | None | +| 2.3 | Refactor loopAgentSteps | 4-5 | 1.1b (patterns) | +| 2.9 | Refactor doStream | 3-4 | None | +| 2.10 | DRY up OpenRouter stream handling | 2-3 | 2.9 | +| 2.15 | Refactor promptAiSdkStream | 3-4 | 2.10 | +| 2.16 | Simplify run-state.ts | 3-4 | 2.15 | +| 2.14 | Refactor agent-builder.ts | 2-3 | None | + +**Total: 22-28 hours** + +--- + +#### **Track D: Billing** (1 Developer - "Billing Lead" or shared) + +Short but high-risk: + +``` +Week 3-4: 2.4 (billing consolidation) - 6-8 hours +Week 6: 3.1 (auto-topup) - depends on 2.4 +``` + +| Commit | Description | Hours | Depends On | +|--------|-------------|-------|------------| +| 2.4 | Consolidate billing duplication | 6-8 | None | +| 3.1 | DRY up auto-topup logic | 2-3 | 2.4 | + +**Total: 8-11 hours** + +> **Note:** Developer on Track D can assist Track B after completing billing work. + +--- + +### Week-by-Week Parallel Schedule + +| Week | Track A (Chat) | Track B (Utils) | Track C (Runtime) | Track D (Billing) | +|------|----------------|-----------------|-------------------|-------------------| +| **1** | 1.1a chat-state | 1.3 old-constants | 1.2 context-pruner | - | +| **2** | 1.1b chat-ui | 1.4 file-tree | - | - | +| *Stability* | *48h monitor* | *48h monitor* | *48h monitor* | - | +| **3** | 2.1 send-message | 2.2 block-utils | 2.3 run-agent-step | 2.4 billing | +| **4** | 2.5a multiline-nav | 2.7, 2.8 (parallel) | 2.9 doStream | (billing cont.) | +| **5** | 2.5b multiline-edit | 2.11, 2.12, 2.13 | 2.10, 2.15 | - | +| **6** | - | 2.14 agent-builder | 2.16 run-state | 3.1 auto-topup | +| *Stability* | *48h monitor* | *48h monitor* | *48h monitor* | - | +| **7** | 3.3 dead code | 3.2 db/schema | 3.4 dead code | - | + +--- + +### Sync Points (Mandatory Coordination) + +These commits create dependencies that require coordination between tracks: + +| After Commit | Blocks | Reason | +|--------------|--------|--------| +| **1.1b** | 2.1, 2.3 | Chat patterns must be established first | +| **2.1** | 2.5a | Send-message patterns inform input hooks | +| **2.9** | 2.10, 2.15 | Streaming refactor is sequential | +| **2.4** | 3.1 | Billing core before auto-topup | +| **All Phase 2** | 3.3, 3.4 | Dead code removal needs stable codebase | + +**Recommended sync meetings:** +- End of Week 2 (before Phase 2) +- End of Week 4 (mid-Phase 2 check-in) +- End of Week 6 (before Phase 3) + +--- + +### Commits With Zero Dependencies (Start Anytime) + +These can be picked up by anyone with spare capacity: + +| Commit | Description | Hours | Risk | +|--------|-------------|-------|------| +| 1.2 | context-pruner.ts | 4-5 | Medium | +| 1.3 | old-constants.ts | 2-3 | Low | +| 1.4 | project-file-tree.ts | 1-2 | Low | +| 2.2 | block-utils + think tags | 3-4 | Low | +| 2.6 | use-activity-query.ts | 4-5 | Medium | +| 2.7 | XML parsing | 2-3 | Low | +| 2.8 | analytics | 3-4 | Low | +| 2.9 | doStream | 3-4 | Medium | +| 2.11 | image handling | 2-3 | Low | +| 2.12 | suggestion-engine | 2-3 | Low | +| 2.13 | browser + string utils | 2-3 | Low | +| 2.14 | agent-builder.ts | 2-3 | Low | +| 3.2 | db/schema.ts | 2-3 | Low | + +--- + +### Visual Timeline by Team Size + +#### Solo Developer (1 person) + +``` +Week 1: ████ 1.1a ████ 1.3 ██ 1.4 ██ +Week 2: ████ 1.1b ████ 1.2 ████ + [48h stability window] +Week 3: ████ 2.1 ████ 2.2 ████ +Week 4: ████ 2.3 ████ 2.4 ████████ +Week 5: ██ 2.5a ██ 2.5b ██ 2.6 ██ 2.7 ██ +Week 6: ██ 2.8 ██ 2.9 ██ 2.10 ██ 2.11 ██ +Week 7: ██ 2.12 ██ 2.13 ██ 2.14 ██ 2.15 ██ +Week 8: ██ 2.16 ██ 3.1 ██ 3.2 ██ + [48h stability window] +Week 9: ██ 3.3 ██ 3.4 ██ +``` + +**Total: ~9 weeks** + +--- + +#### Dual Developer (2 people) + +``` +Week 1: + Dev 1 (Chat/Runtime): ████ 1.1a ████ 1.2 ████ + Dev 2 (Utils): ██ 1.3 ██ 1.4 ██ 2.2 ██ + +Week 2: + Dev 1 (Chat/Runtime): ████ 1.1b ████ + Dev 2 (Utils): ██ 2.7 ██ 2.8 ██ 2.11 ██ + [48h stability window] + +Week 3: + Dev 1 (Chat/Runtime): ████ 2.1 ████ 2.3 ████ + Dev 2 (Utils/Billing): ████████ 2.4 ████████ + +Week 4: + Dev 1 (Chat/Runtime): ██ 2.5a ██ 2.5b ██ 2.6 ██ + Dev 2 (Streaming): ██ 2.9 ██ 2.10 ██ 2.12 ██ 2.13 ██ + +Week 5: + Dev 1 (SDK): ██ 2.14 ██ 2.15 ██ 2.16 ██ + Dev 2 (Cleanup): ██ 3.1 ██ 3.2 ██ + [48h stability window] + +Week 6: + Both: ██ 3.3 ██ 3.4 ██ [buffer] +``` + +**Total: ~6 weeks** + +--- + +#### Full Parallelization (4 Developers) + +``` +Week 1: + Dev 1 (Chat): ████ 1.1a ████ + Dev 2 (Utils): ██ 1.3 ██ 1.4 ██ 2.2 ██ + Dev 3 (Runtime): ████ 1.2 ████ + Dev 4 (Billing): [idle - billing starts week 3] + +Week 2: + Dev 1 (Chat): ████ 1.1b ████ + Dev 2 (Utils): ██ 2.7 ██ 2.8 ██ + Dev 3 (Runtime): [buffer / help Utils] + Dev 4 (Billing): [buffer / help Utils] + [48h stability window] + +Week 3: + Dev 1 (Chat): ████ 2.1 ████ + Dev 2 (Utils): ██ 2.11 ██ 2.12 ██ 2.13 ██ + Dev 3 (Runtime): ████ 2.3 ████ 2.9 ████ + Dev 4 (Billing): ██████ 2.4 ██████ + +Week 4: + Dev 1 (Chat): ██ 2.5a ██ 2.5b ██ 2.6 ██ + Dev 2 (Utils): ██ 2.14 ██ [help others] + Dev 3 (Runtime): ██ 2.10 ██ 2.15 ██ 2.16 ██ + Dev 4 (Billing): ██ 3.1 ██ [help Cleanup] + [48h stability window] + +Week 5: + All devs: ██ 3.2 ██ 3.3 ██ 3.4 ██ [buffer] +``` + +**Total: ~5 weeks** + +--- + +### Team Size Impact Summary + +| Team Size | Duration | Efficiency | Coordination Overhead | +|-----------|----------|------------|----------------------| +| 1 developer | 9 weeks | 100% utilization | None | +| 2 developers | 6 weeks | ~85% utilization | Low (weekly sync) | +| 3 developers | 5.5 weeks | ~75% utilization | Medium (2x/week sync) | +| 4 developers | 5 weeks | ~65% utilization | High (daily standup) | + +> **Recommendation:** 2-3 developers is the sweet spot for this refactoring effort. +> 4 developers provides diminishing returns due to coordination overhead and dependency bottlenecks. + +--- + +## Testing Strategy Per Commit + +| Commit | Testing Required | Estimated Test Time | +|--------|-----------------|---------------------| +| 1.1a, 1.1b | Full E2E + manual CLI + visual regression | +2h each | +| 1.2, 2.3 | Agent integration tests + unit tests | +1h each | +| 1.3, 1.4 | Unit tests + type checking | +30min each | +| 2.1, 2.5a, 2.5b | CLI integration tests + keyboard tests | +1h each | +| 2.4, 3.1 | Financial accuracy tests + staging validation | +2h each | +| 2.9, 2.10, 2.15 | Streaming E2E tests | +1h each | +| 2.6-2.8, 2.11-2.14 | Unit tests + type checking | +30min each | +| 3.2-3.4 | Full regression suite | +1h total | + +--- + +## Feature Flags Required + +| Commit | Flag Name | Default | Staged Rollout | +|--------|-----------|---------|----------------| +| 1.1a, 1.1b | `REFACTOR_CHAT_STATE` | `false` | 10% → 50% → 100% | +| 2.3 | `REFACTOR_AGENT_LOOP` | `false` | 5% → 25% → 100% | +| 2.4 | `REFACTOR_BILLING` | `false` | 1% → 10% → 50% → 100% | +| 2.9, 2.10 | `REFACTOR_STREAM` | `false` | 10% → 50% → 100% | + +--- + +## Risk Mitigation + +### High-Risk Commits (require extra review) +- **1.1a, 1.1b** - `chat.tsx`: Core UI, use feature flag +- **2.3** - `run-agent-step.ts`: Core runtime, use feature flag +- **2.4** - Billing: Financial accuracy, staged rollout, finance team sign-off +- **2.9, 2.10** - Streaming: Core functionality, use feature flag + +### Rollback Procedures + +| Phase | Rollback Procedure | Time to Rollback | +|-------|-------------------|------------------| +| Phase 1 | Feature flag off + git revert | < 5 minutes | +| Phase 2 (billing) | Immediate revert + flag off + on-call page | < 2 minutes | +| Phase 2 (other) | Git revert + redeploy | < 15 minutes | +| Phase 3 | Git revert + redeploy | < 15 minutes | + +### Stability Windows +- **48 hours** between Phase 1 and Phase 2 +- **48 hours** between Phase 2 and Phase 3 +- **No deploys** on Fridays for refactoring changes + +--- + +## Revised Schedule (7-8 Weeks) + +| Week | Commits | Hours | Focus | +|------|---------|-------|-------| +| Week 1 | 1.1a, 1.1b | 10-12 | Chat.tsx extraction | +| Week 2 | 1.2, 1.3, 1.4 | 6-9 | Remaining critical issues | +| **Stability Window** | - | 48h | Monitor, fix issues | +| Week 3 | 2.1, 2.2, 2.3 | 11-14 | Core hook refactoring | +| Week 4 | 2.4, 2.5a, 2.5b, 2.6 | 16-22 | Billing + input | +| Week 5 | 2.7-2.13 | 18-24 | Parallel utility work | +| Week 6 | 2.14-2.16, 3.1 | 10-14 | SDK + auto-topup | +| **Stability Window** | - | 48h | Monitor, fix issues | +| Week 7 | 3.2, 3.3, 3.4 | 6-9 | Cleanup | +| Week 8 | Buffer | 0-10 | Overflow, polish | + +### Time Breakdown +| Activity | Hours | +|----------|-------| +| Implementation | 84-108 | +| PR Review (2h × 22 commits) | 44 | +| Testing overhead | ~20 | +| Buffer (unexpected issues) | ~15 | +| **Total** | **163-187** | + +--- + +## Success Metrics + +### Code Quality Metrics +- [ ] No file > 400 lines (except schema files) +- [ ] No function > 100 lines +- [ ] No hook managing > 3 concerns +- [ ] Cyclomatic complexity < 15 for all functions +- [ ] 0 duplicate implementations of core utilities +- [ ] All tests passing +- [ ] No increase in bundle size > 5% +- [ ] Improved code coverage (target: +5%) + +### Runtime Metrics (New) +- [ ] P95 latency unchanged (within 5%) +- [ ] Error rate unchanged (within 0.1%) +- [ ] Memory usage unchanged (within 10%) +- [ ] No new Sentry errors post-deploy + +### Observability Checkpoint (After Phase 1) +- [ ] Verify Datadog/Sentry dashboards show no regressions +- [ ] Confirm feature flag metrics are tracked +- [ ] Review on-call incidents for any refactoring-related issues + +--- + +## Hook Refactoring Template + +> **Recommended pattern** established after Commit 1.1. Apply consistently. + +```typescript +// Before: God hook with multiple concerns +function useGodHook() { + // State management (100+ lines) + // Business logic (100+ lines) + // UI effects (50+ lines) +} + +// After: Composed hooks with single responsibility +function useComposedHook() { + const state = useStateSlice() + const logic = useBusinessLogic(state) + const effects = useUIEffects(logic) + return { ...state, ...logic, ...effects } +} +``` + +Apply this pattern to: +- `use-send-message.ts` (Commit 2.1) +- `multiline-input.tsx` (Commits 2.5a, 2.5b) +- `use-activity-query.ts` (Commit 2.6) +- `use-suggestion-engine.ts` (Commit 2.12) + +--- + +## Notes + +- Time estimates assume familiarity with the codebase +- Estimates include writing/updating tests and PR review +- 40% buffer applied to all estimates (vs. original 20%) +- Some commits may be combined if changes are smaller than expected +- Some commits may need to be split if changes are larger than expected +- **Scope creep risk:** Resist adding "while we're here" changes to commits diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index b414f46dc0..f0f15c5b13 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -3,6 +3,289 @@ import { publisher } from './constants' import type { AgentDefinition, ToolCall } from './types/agent-definition' import type { Message, ToolMessage } from './types/util-types' +// ============================================================================= +// Constants +// ============================================================================= + +/** Target: summarized messages should be at most 10% of max context */ +const TARGET_SUMMARY_FACTOR = 0.1 + +/** Agent IDs whose output should be excluded from spawn_agents results */ +const SPAWN_AGENTS_OUTPUT_BLACKLIST = [ + 'file-picker', + 'code-searcher', + 'directory-lister', + 'glob-matcher', + 'researcher-web', + 'researcher-docs', + 'code-reviewer', + 'code-reviewer-multi-prompt', +] + +/** Limits for truncating long messages (chars) */ +const USER_MESSAGE_LIMIT = 15000 +const ASSISTANT_MESSAGE_LIMIT = 4000 + +/** Prompt cache expiry time (Anthropic caches for 5 minutes) */ +const CACHE_EXPIRY_MS = 5 * 60 * 1000 + +/** Header used in conversation summaries */ +const SUMMARY_HEADER = + 'This is a summary of the conversation so far. The original messages have been condensed to save context space.' + +// ============================================================================= +// Helper Functions +// ============================================================================= + +/** + * Truncates long text with 80% from the beginning and 20% from the end. + * Preserves context from both ends of the text while indicating what was removed. + * + * @param text - The text to truncate + * @param limit - Maximum character length + * @returns Truncated text with notice of how many chars were removed + */ +export function truncateLongText(text: string, limit: number): string { + if (text.length <= limit) { + return text + } + const availableChars = limit - 50 // 50 chars for the truncation notice + const prefixLength = Math.floor(availableChars * 0.8) + const suffixLength = availableChars - prefixLength + const prefix = text.slice(0, prefixLength) + const suffix = text.slice(-suffixLength) + const truncatedChars = text.length - prefixLength - suffixLength + return `${prefix}\n\n[...truncated ${truncatedChars} chars...]\n\n${suffix}` +} + +/** + * Estimates token count from a JSON-serializable object. + * Uses a simple heuristic of ~3 characters per token. + * + * @param obj - The object to estimate tokens for + * @returns Estimated token count + */ +export function estimateTokens(obj: unknown): number { + return Math.ceil(JSON.stringify(obj).length / 3) +} + +/** + * Extracts text content from a message, handling both string and array formats. + * + * @param message - The message to extract text from + * @returns Combined text content from the message + */ +export function getTextContent(message: Message): string { + if (typeof message.content === 'string') { + return message.content + } + if (Array.isArray(message.content)) { + return message.content + .filter( + (part: Record) => + part.type === 'text' && typeof part.text === 'string', + ) + .map((part: Record) => part.text as string) + .join('\n') + } + return '' +} + +/** + * Summarizes a tool call into a human-readable description. + * Handles various tool types with appropriate formatting. + * + * @param toolName - The name of the tool + * @param input - The tool's input parameters + * @returns A concise summary of the tool call + */ +export function summarizeToolCall( + toolName: string, + input: Record, +): string { + switch (toolName) { + case 'read_files': { + const paths = input.paths as string[] | undefined + if (paths && paths.length > 0) { + return `Read files: ${paths.join(', ')}` + } + return 'Read files' + } + case 'write_file': { + const path = input.path as string | undefined + return path ? `Wrote file: ${path}` : 'Wrote file' + } + case 'str_replace': { + const path = input.path as string | undefined + return path ? `Edited file: ${path}` : 'Edited file' + } + case 'propose_write_file': { + const path = input.path as string | undefined + return path ? `Proposed write to: ${path}` : 'Proposed file write' + } + case 'propose_str_replace': { + const path = input.path as string | undefined + return path ? `Proposed edit to: ${path}` : 'Proposed file edit' + } + case 'read_subtree': { + const paths = input.paths as string[] | undefined + if (paths && paths.length > 0) { + return `Read subtree: ${paths.join(', ')}` + } + return 'Read subtree' + } + case 'code_search': { + const pattern = input.pattern as string | undefined + const flags = input.flags as string | undefined + if (pattern && flags) { + return `Code search: "${pattern}" (${flags})` + } + return pattern ? `Code search: "${pattern}"` : 'Code search' + } + case 'glob': { + const patterns = input.patterns as + | Array<{ pattern: string }> + | undefined + if (patterns && patterns.length > 0) { + return `Glob: ${patterns.map((p) => p.pattern).join(', ')}` + } + return 'Glob search' + } + case 'list_directory': { + const directories = input.directories as + | Array<{ path: string }> + | undefined + if (directories && directories.length > 0) { + return `Listed dirs: ${directories.map((d) => d.path).join(', ')}` + } + return 'Listed directory' + } + case 'find_files': { + const pattern = input.pattern as string | undefined + return pattern ? `Find files: "${pattern}"` : 'Find files' + } + case 'run_terminal_command': { + const command = input.command as string | undefined + if (command) { + const shortCmd = + command.length > 50 ? command.slice(0, 50) + '...' : command + return `Ran command: ${shortCmd}` + } + return 'Ran terminal command' + } + case 'spawn_agents': + case 'spawn_agent_inline': { + const agents = input.agents as + | Array<{ + agent_type: string + prompt?: string + params?: Record + }> + | undefined + const agentType = input.agent_type as string | undefined + const prompt = input.prompt as string | undefined + const agentParams = input.params as + | Record + | undefined + + if (agents && agents.length > 0) { + const agentDetails = agents.map((a) => { + let detail = a.agent_type + const extras: string[] = [] + if (a.prompt) { + const truncatedPrompt = + a.prompt.length > 1000 + ? a.prompt.slice(0, 1000) + '...' + : a.prompt + extras.push(`prompt: "${truncatedPrompt}"`) + } + if (a.params && Object.keys(a.params).length > 0) { + const paramsStr = JSON.stringify(a.params) + const truncatedParams = + paramsStr.length > 1000 + ? paramsStr.slice(0, 1000) + '...' + : paramsStr + extras.push(`params: ${truncatedParams}`) + } + if (extras.length > 0) { + detail += ` (${extras.join(', ')})` + } + return detail + }) + return `Spawned agents:\n${agentDetails.map((d) => `- ${d}`).join('\n')}` + } + if (agentType) { + const extras: string[] = [] + if (prompt) { + const truncatedPrompt = + prompt.length > 1000 ? prompt.slice(0, 1000) + '...' : prompt + extras.push(`prompt: "${truncatedPrompt}"`) + } + if (agentParams && Object.keys(agentParams).length > 0) { + const paramsStr = JSON.stringify(agentParams) + const truncatedParams = + paramsStr.length > 1000 + ? paramsStr.slice(0, 1000) + '...' + : paramsStr + extras.push(`params: ${truncatedParams}`) + } + if (extras.length > 0) { + return `Spawned agent: ${agentType} (${extras.join(', ')})` + } + return `Spawned agent: ${agentType}` + } + return 'Spawned agent(s)' + } + case 'write_todos': { + const todos = input.todos as + | Array<{ task: string; completed: boolean }> + | undefined + if (todos) { + const completed = todos.filter((t) => t.completed).length + const incomplete = todos.filter((t) => !t.completed) + if (incomplete.length === 0) { + return `Todos: ${completed}/${todos.length} complete (all done!)` + } + const remainingTasks = incomplete + .map((t) => `- ${t.task}`) + .join('\n') + return `Todos: ${completed}/${todos.length} complete. Remaining:\n${remainingTasks}` + } + return 'Updated todos' + } + case 'ask_user': { + const questions = input.questions as + | Array<{ question: string }> + | undefined + if (questions && questions.length > 0) { + const questionTexts = questions.map((q) => q.question).join('; ') + const truncated = + questionTexts.length > 200 + ? questionTexts.slice(0, 200) + '...' + : questionTexts + return `Asked user: ${truncated}` + } + return 'Asked user question' + } + case 'suggest_followups': + return 'Suggested followups' + case 'web_search': { + const query = input.query as string | undefined + return query ? `Web search: "${query}"` : 'Web search' + } + case 'read_docs': { + const query = input.query as string | undefined + return query ? `Read docs: "${query}"` : 'Read docs' + } + case 'set_output': + return 'Set output' + case 'set_messages': + return 'Set messages' + default: + return `Used tool: ${toolName}` + } +} + const definition: AgentDefinition = { id: 'context-pruner', publisher, @@ -28,47 +311,6 @@ const definition: AgentDefinition = { handleSteps: function* ({ agentState, params }) { const messages = agentState.messageHistory - - // Target: summarized messages should be at most 10% of max context - const TARGET_SUMMARY_FACTOR = 0.1 - - // Blacklist of agent IDs whose output should be excluded from spawn_agents results - const SPAWN_AGENTS_OUTPUT_BLACKLIST = [ - 'file-picker', - 'code-searcher', - 'directory-lister', - 'glob-matcher', - 'researcher-web', - 'researcher-docs', - 'code-reviewer', - 'code-reviewer-multi-prompt', - ] - - // Limits for truncating long messages (chars) - const USER_MESSAGE_LIMIT = 15000 - const ASSISTANT_MESSAGE_LIMIT = 4000 - - // Prompt cache expiry time (Anthropic caches for 5 minutes) - const CACHE_EXPIRY_MS = 5 * 60 * 1000 - - // Helper to truncate long text with 80% beginning + 20% end - const truncateLongText = (text: string, limit: number): string => { - if (text.length <= limit) { - return text - } - const availableChars = limit - 50 // 50 chars for the truncation notice - const prefixLength = Math.floor(availableChars * 0.8) - const suffixLength = availableChars - prefixLength - const prefix = text.slice(0, prefixLength) - const suffix = text.slice(-suffixLength) - const truncatedChars = text.length - prefixLength - suffixLength - return `${prefix}\n\n[...truncated ${truncatedChars} chars...]\n\n${suffix}` - } - - const countTokensJson = (obj: unknown): number => { - return Math.ceil(JSON.stringify(obj).length / 3) - } - const maxContextLength: number = params?.maxContextLength ?? 200_000 // STEP 0: Always remove the last INSTRUCTIONS_PROMPT and SUBAGENT_SPAWN @@ -142,8 +384,6 @@ const definition: AgentDefinition = { // Check for existing conversation summary and extract its content let previousSummary = '' - const SUMMARY_HEADER = - 'This is a summary of the conversation so far. The original messages have been condensed to save context space.' for (const message of currentMessages) { if (message.role === 'user' && Array.isArray(message.content)) { for (const part of message.content) { @@ -194,211 +434,6 @@ const definition: AgentDefinition = { return true }) - // Helper to get text content from a message - const getTextContent = (message: Message): string => { - if (typeof message.content === 'string') { - return message.content - } - if (Array.isArray(message.content)) { - return message.content - .filter( - (part: Record) => - part.type === 'text' && typeof part.text === 'string', - ) - .map((part: Record) => part.text as string) - .join('\n') - } - return '' - } - - // Helper to summarize a tool call - const summarizeToolCall = ( - toolName: string, - input: Record, - ): string => { - switch (toolName) { - case 'read_files': { - const paths = input.paths as string[] | undefined - if (paths && paths.length > 0) { - return `Read files: ${paths.join(', ')}` - } - return 'Read files' - } - case 'write_file': { - const path = input.path as string | undefined - return path ? `Wrote file: ${path}` : 'Wrote file' - } - case 'str_replace': { - const path = input.path as string | undefined - return path ? `Edited file: ${path}` : 'Edited file' - } - case 'propose_write_file': { - const path = input.path as string | undefined - return path ? `Proposed write to: ${path}` : 'Proposed file write' - } - case 'propose_str_replace': { - const path = input.path as string | undefined - return path ? `Proposed edit to: ${path}` : 'Proposed file edit' - } - case 'read_subtree': { - const paths = input.paths as string[] | undefined - if (paths && paths.length > 0) { - return `Read subtree: ${paths.join(', ')}` - } - return 'Read subtree' - } - case 'code_search': { - const pattern = input.pattern as string | undefined - const flags = input.flags as string | undefined - if (pattern && flags) { - return `Code search: "${pattern}" (${flags})` - } - return pattern ? `Code search: "${pattern}"` : 'Code search' - } - case 'glob': { - const patterns = input.patterns as - | Array<{ pattern: string }> - | undefined - if (patterns && patterns.length > 0) { - return `Glob: ${patterns.map((p) => p.pattern).join(', ')}` - } - return 'Glob search' - } - case 'list_directory': { - const directories = input.directories as - | Array<{ path: string }> - | undefined - if (directories && directories.length > 0) { - return `Listed dirs: ${directories.map((d) => d.path).join(', ')}` - } - return 'Listed directory' - } - case 'find_files': { - const pattern = input.pattern as string | undefined - return pattern ? `Find files: "${pattern}"` : 'Find files' - } - case 'run_terminal_command': { - const command = input.command as string | undefined - if (command) { - const shortCmd = - command.length > 50 ? command.slice(0, 50) + '...' : command - return `Ran command: ${shortCmd}` - } - return 'Ran terminal command' - } - case 'spawn_agents': - case 'spawn_agent_inline': { - const agents = input.agents as - | Array<{ - agent_type: string - prompt?: string - params?: Record - }> - | undefined - const agentType = input.agent_type as string | undefined - const prompt = input.prompt as string | undefined - const agentParams = input.params as - | Record - | undefined - - if (agents && agents.length > 0) { - const agentDetails = agents.map((a) => { - let detail = a.agent_type - const extras: string[] = [] - if (a.prompt) { - const truncatedPrompt = - a.prompt.length > 1000 - ? a.prompt.slice(0, 1000) + '...' - : a.prompt - extras.push(`prompt: "${truncatedPrompt}"`) - } - if (a.params && Object.keys(a.params).length > 0) { - const paramsStr = JSON.stringify(a.params) - const truncatedParams = - paramsStr.length > 1000 - ? paramsStr.slice(0, 1000) + '...' - : paramsStr - extras.push(`params: ${truncatedParams}`) - } - if (extras.length > 0) { - detail += ` (${extras.join(', ')})` - } - return detail - }) - return `Spawned agents:\n${agentDetails.map((d) => `- ${d}`).join('\n')}` - } - if (agentType) { - const extras: string[] = [] - if (prompt) { - const truncatedPrompt = - prompt.length > 1000 ? prompt.slice(0, 1000) + '...' : prompt - extras.push(`prompt: "${truncatedPrompt}"`) - } - if (agentParams && Object.keys(agentParams).length > 0) { - const paramsStr = JSON.stringify(agentParams) - const truncatedParams = - paramsStr.length > 1000 - ? paramsStr.slice(0, 1000) + '...' - : paramsStr - extras.push(`params: ${truncatedParams}`) - } - if (extras.length > 0) { - return `Spawned agent: ${agentType} (${extras.join(', ')})` - } - return `Spawned agent: ${agentType}` - } - return 'Spawned agent(s)' - } - case 'write_todos': { - const todos = input.todos as - | Array<{ task: string; completed: boolean }> - | undefined - if (todos) { - const completed = todos.filter((t) => t.completed).length - const incomplete = todos.filter((t) => !t.completed) - if (incomplete.length === 0) { - return `Todos: ${completed}/${todos.length} complete (all done!)` - } - const remainingTasks = incomplete - .map((t) => `- ${t.task}`) - .join('\n') - return `Todos: ${completed}/${todos.length} complete. Remaining:\n${remainingTasks}` - } - return 'Updated todos' - } - case 'ask_user': { - const questions = input.questions as - | Array<{ question: string }> - | undefined - if (questions && questions.length > 0) { - const questionTexts = questions.map((q) => q.question).join('; ') - const truncated = - questionTexts.length > 200 - ? questionTexts.slice(0, 200) + '...' - : questionTexts - return `Asked user: ${truncated}` - } - return 'Asked user question' - } - case 'suggest_followups': - return 'Suggested followups' - case 'web_search': { - const query = input.query as string | undefined - return query ? `Web search: "${query}"` : 'Web search' - } - case 'read_docs': { - const query = input.query as string | undefined - return query ? `Read docs: "${query}"` : 'Read docs' - } - case 'set_output': - return 'Set output' - case 'set_messages': - return 'Set messages' - default: - return `Used tool: ${toolName}` - } - } - // Build the summary const summaryParts: string[] = [] @@ -582,15 +617,15 @@ const definition: AgentDefinition = { let summaryText = summaryParts.join('\n\n---\n\n') - // Calculate target size (15% of max context, for messages only) + // Calculate target size (10% of max context, for messages only) const targetTokens = maxContextLength * TARGET_SUMMARY_FACTOR - let summaryTokens = countTokensJson(summaryText) + let summaryTokens = estimateTokens(summaryText) // If summary is too big, truncate from the beginning if (summaryTokens > targetTokens) { const truncationMessage = '[CONVERSATION TRUNCATED - Earlier messages omitted due to length]\n\n' - const truncationTokens = countTokensJson(truncationMessage) + const truncationTokens = estimateTokens(truncationMessage) const availableTokens = targetTokens - truncationTokens // Estimate characters to keep (rough: 3 chars per token) diff --git a/common/src/constants/index.ts b/common/src/constants/index.ts new file mode 100644 index 0000000000..190abd4347 --- /dev/null +++ b/common/src/constants/index.ts @@ -0,0 +1,7 @@ +// Re-export all constants from domain-specific files for backwards compatibility +// This allows existing imports from '@codebuff/common/old-constants' to continue working + +export * from './model-config' +export * from './limits' +export * from './ui' +export * from './paths' diff --git a/common/src/constants/limits.ts b/common/src/constants/limits.ts new file mode 100644 index 0000000000..afdcfe74b0 --- /dev/null +++ b/common/src/constants/limits.ts @@ -0,0 +1,19 @@ +export const PROFIT_MARGIN = 0.055 + +export const REQUEST_CREDIT_SHOW_THRESHOLD = 1 +export const MAX_DATE = new Date(86399999999999) +export const BILLING_PERIOD_DAYS = 30 +export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days +export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation +export const CREDITS_REFERRAL_BONUS = 250 +export const AFFILIATE_USER_REFFERAL_LIMIT = 500 + +// Default number of free credits granted per cycle +export const DEFAULT_FREE_CREDITS_GRANT = 500 + +// Credit pricing configuration +export const CREDIT_PRICING = { + CENTS_PER_CREDIT: 1, // 1 credit = 1 cent = $0.01 + MIN_PURCHASE_CREDITS: 100, // $1.00 minimum + DISPLAY_RATE: '$0.01 per credit', +} as const diff --git a/common/src/constants/model-config.ts b/common/src/constants/model-config.ts new file mode 100644 index 0000000000..3c8e605db7 --- /dev/null +++ b/common/src/constants/model-config.ts @@ -0,0 +1,223 @@ +import { isExplicitlyDefinedModel } from '../util/model-utils' + +// Allowed model prefixes for validation +export const ALLOWED_MODEL_PREFIXES = [ + 'anthropic', + 'openai', + 'google', + 'x-ai', +] as const + +export const costModes = [ + 'lite', + 'normal', + 'max', + 'experimental', + 'ask', +] as const +export type CostMode = (typeof costModes)[number] + +export const openaiModels = { + gpt4_1: 'gpt-4.1-2025-04-14', + gpt4o: 'gpt-4o-2024-11-20', + gpt4omini: 'gpt-4o-mini-2024-07-18', + o3mini: 'o3-mini-2025-01-31', + o3: 'o3-2025-04-16', + o3pro: 'o3-pro-2025-06-10', + o4mini: 'o4-mini-2025-04-16', + generatePatch: + 'ft:gpt-4o-2024-08-06:manifold-markets:generate-patch-batch2:AKYtDIhk', +} as const +export type OpenAIModel = (typeof openaiModels)[keyof typeof openaiModels] + +export const openrouterModels = { + openrouter_claude_sonnet_4_5: 'anthropic/claude-sonnet-4.5', + openrouter_claude_sonnet_4: 'anthropic/claude-4-sonnet-20250522', + openrouter_claude_opus_4: 'anthropic/claude-opus-4.1', + openrouter_claude_3_5_haiku: 'anthropic/claude-3.5-haiku-20241022', + openrouter_claude_3_5_sonnet: 'anthropic/claude-3.5-sonnet-20240620', + openrouter_gpt4o: 'openai/gpt-4o-2024-11-20', + openrouter_gpt5: 'openai/gpt-5.1', + openrouter_gpt5_chat: 'openai/gpt-5.1-chat', + openrouter_gpt4o_mini: 'openai/gpt-4o-mini-2024-07-18', + openrouter_gpt4_1_nano: 'openai/gpt-4.1-nano', + openrouter_o3_mini: 'openai/o3-mini-2025-01-31', + openrouter_gemini2_5_pro_preview: 'google/gemini-2.5-pro', + openrouter_gemini2_5_flash: 'google/gemini-2.5-flash', + openrouter_gemini2_5_flash_thinking: + 'google/gemini-2.5-flash-preview:thinking', + openrouter_grok_4: 'x-ai/grok-4-07-09', +} as const +export type openrouterModel = + (typeof openrouterModels)[keyof typeof openrouterModels] + +export const deepseekModels = { + deepseekChat: 'deepseek-chat', + deepseekReasoner: 'deepseek-reasoner', +} as const +export type DeepseekModel = (typeof deepseekModels)[keyof typeof deepseekModels] + +// Vertex uses "endpoint IDs" for finetuned models, which are just integers +export const finetunedVertexModels = { + ft_filepicker_003: '196166068534771712', + ft_filepicker_005: '8493203957034778624', + ft_filepicker_007: '2589952415784501248', + ft_filepicker_topk_001: '3676445825887633408', + ft_filepicker_008: '2672143108984012800', + ft_filepicker_topk_002: '1694861989844615168', + ft_filepicker_010: '3808739064941641728', + ft_filepicker_010_epoch_2: '6231675664466968576', + ft_filepicker_topk_003: '1502192368286171136', +} as const +export const finetunedVertexModelNames: Record = { + [finetunedVertexModels.ft_filepicker_003]: 'ft_filepicker_003', + [finetunedVertexModels.ft_filepicker_005]: 'ft_filepicker_005', + [finetunedVertexModels.ft_filepicker_007]: 'ft_filepicker_007', + [finetunedVertexModels.ft_filepicker_topk_001]: 'ft_filepicker_topk_001', + [finetunedVertexModels.ft_filepicker_008]: 'ft_filepicker_008', + [finetunedVertexModels.ft_filepicker_topk_002]: 'ft_filepicker_topk_002', + [finetunedVertexModels.ft_filepicker_010]: 'ft_filepicker_010', + [finetunedVertexModels.ft_filepicker_010_epoch_2]: + 'ft_filepicker_010_epoch_2', + [finetunedVertexModels.ft_filepicker_topk_003]: 'ft_filepicker_topk_003', +} +export type FinetunedVertexModel = + (typeof finetunedVertexModels)[keyof typeof finetunedVertexModels] + +export const models = { + ...openaiModels, + ...deepseekModels, + ...openrouterModels, + ...finetunedVertexModels, +} as const + +export const shortModelNames = { + 'gemini-2.5-pro': models.openrouter_gemini2_5_pro_preview, + 'flash-2.5': models.openrouter_gemini2_5_flash, + 'opus-4': models.openrouter_claude_opus_4, + 'sonnet-4.5': models.openrouter_claude_sonnet_4_5, + 'sonnet-4': models.openrouter_claude_sonnet_4, + 'sonnet-3.7': models.openrouter_claude_sonnet_4, + 'sonnet-3.6': models.openrouter_claude_3_5_sonnet, + 'sonnet-3.5': models.openrouter_claude_3_5_sonnet, + 'gpt-4.1': models.gpt4_1, + 'o3-mini': models.o3mini, + o3: models.o3, + 'o4-mini': models.o4mini, + 'o3-pro': models.o3pro, +} + +export const providerModelNames = { + ...Object.fromEntries( + Object.entries(openaiModels).map(([name, model]) => [ + model, + 'openai' as const, + ]), + ), + ...Object.fromEntries( + Object.entries(openrouterModels).map(([name, model]) => [ + model, + 'openrouter' as const, + ]), + ), +} + +export type Model = (typeof models)[keyof typeof models] | (string & {}) + +export const shouldCacheModels = [ + 'anthropic/claude-opus-4.1', + 'anthropic/claude-sonnet-4', + 'anthropic/claude-opus-4', + 'anthropic/claude-3.7-sonnet', + 'anthropic/claude-3.5-haiku', + 'z-ai/glm-4.5', + 'qwen/qwen3-coder', +] +const nonCacheableModels = [ + models.openrouter_grok_4, +] satisfies string[] as string[] +export function supportsCacheControl(model: Model): boolean { + if (model.startsWith('openai/')) { + return true + } + if (model.startsWith('anthropic/')) { + return true + } + if (!isExplicitlyDefinedModel(model)) { + // Default to no cache control for unknown models + return false + } + return !nonCacheableModels.includes(model) +} + +export function getModelFromShortName( + modelName: string | undefined, +): Model | undefined { + if (!modelName) return undefined + if (modelName && !(modelName in shortModelNames)) { + throw new Error( + `Unknown model: ${modelName}. Please use a valid model. Valid models are: ${Object.keys( + shortModelNames, + ).join(', ')}`, + ) + } + + return shortModelNames[modelName as keyof typeof shortModelNames] +} + +export const providerDomains = { + google: 'google.com', + anthropic: 'anthropic.com', + openai: 'chatgpt.com', + deepseek: 'deepseek.com', + xai: 'x.ai', +} as const + +export function getLogoForModel(modelName: string): string | undefined { + let domain: string | undefined + + if (Object.values(openaiModels).includes(modelName as OpenAIModel)) + domain = providerDomains.openai + else if (Object.values(deepseekModels).includes(modelName as DeepseekModel)) + domain = providerDomains.deepseek + else if (modelName.includes('claude')) domain = providerDomains.anthropic + else if (modelName.includes('grok')) domain = providerDomains.xai + + return domain + ? `https://www.google.com/s2/favicons?domain=${domain}&sz=256` + : undefined +} + +export const getModelForMode = ( + costMode: CostMode, + operation: 'agent' | 'file-requests' | 'check-new-files', +) => { + if (operation === 'agent') { + return { + lite: models.openrouter_gemini2_5_flash, + normal: models.openrouter_claude_sonnet_4, + max: models.openrouter_claude_sonnet_4, + experimental: models.openrouter_gemini2_5_pro_preview, + ask: models.openrouter_gemini2_5_pro_preview, + }[costMode] + } + if (operation === 'file-requests') { + return { + lite: models.openrouter_claude_3_5_haiku, + normal: models.openrouter_claude_3_5_haiku, + max: models.openrouter_claude_sonnet_4, + experimental: models.openrouter_claude_sonnet_4, + ask: models.openrouter_claude_3_5_haiku, + }[costMode] + } + if (operation === 'check-new-files') { + return { + lite: models.openrouter_claude_3_5_haiku, + normal: models.openrouter_claude_sonnet_4, + max: models.openrouter_claude_sonnet_4, + experimental: models.openrouter_claude_sonnet_4, + ask: models.openrouter_claude_sonnet_4, + }[costMode] + } + throw new Error(`Unknown operation: ${operation}`) +} diff --git a/common/src/constants/paths.ts b/common/src/constants/paths.ts new file mode 100644 index 0000000000..1135d5e080 --- /dev/null +++ b/common/src/constants/paths.ts @@ -0,0 +1,70 @@ +export const STOP_MARKER = '[' + 'END]' +export const FIND_FILES_MARKER = '[' + 'FIND_FILES_PLEASE]' +export const EXISTING_CODE_MARKER = '[[**REPLACE_WITH_EXISTING_CODE**]]' + +// Directory where agent template override files are stored +export const AGENT_TEMPLATES_DIR = '.agents/' +export const AGENT_DEFINITION_FILE = 'agent-definition.d.ts' + +export const API_KEY_ENV_VAR = 'CODEBUFF_API_KEY' + +export const INVALID_AUTH_TOKEN_MESSAGE = + 'Invalid auth token. You may have been logged out from the web portal. Please log in again.' + +export const DEFAULT_IGNORED_PATHS = [ + '.git', + '.env', + '.env.*', + '*.min.*', + 'node_modules', + 'venv', + 'virtualenv', + '.venv', + '.virtualenv', + '__pycache__', + '*.egg-info/', + '*.pyc', + '.DS_Store', + '.pytest_cache', + '.mypy_cache', + '.ruff_cache', + '.next', + 'package-lock.json', + 'bun.lockb', +] + +// Special message content tags indicating specific server states +export const ASKED_CONFIG = 'asked_config' +export const SHOULD_ASK_CONFIG = 'should_ask_config' +export const ONE_TIME_TAGS = [] as const +export const ONE_TIME_LABELS = [ + ...ONE_TIME_TAGS, + ASKED_CONFIG, + SHOULD_ASK_CONFIG, +] as const + +export const FILE_READ_STATUS = { + DOES_NOT_EXIST: '[FILE_DOES_NOT_EXIST]', + IGNORED: '[BLOCKED]', + TEMPLATE: '[TEMPLATE]', + OUTSIDE_PROJECT: '[FILE_OUTSIDE_PROJECT]', + TOO_LARGE: '[FILE_TOO_LARGE]', + ERROR: '[FILE_READ_ERROR]', +} as const + +export const HIDDEN_FILE_READ_STATUS = [ + FILE_READ_STATUS.DOES_NOT_EXIST, + FILE_READ_STATUS.IGNORED, + FILE_READ_STATUS.OUTSIDE_PROJECT, + FILE_READ_STATUS.TOO_LARGE, + FILE_READ_STATUS.ERROR, +] + +export function toOptionalFile(file: string | null) { + if (file === null) return null + return HIDDEN_FILE_READ_STATUS.some((status) => file.startsWith(status)) + ? null + : file +} + +export const TEST_USER_ID = 'test-user-id' diff --git a/common/src/constants/ui.ts b/common/src/constants/ui.ts new file mode 100644 index 0000000000..238b56e051 --- /dev/null +++ b/common/src/constants/ui.ts @@ -0,0 +1,25 @@ +export const AuthState = { + LOGGED_OUT: 'LOGGED_OUT', + LOGGED_IN: 'LOGGED_IN', +} as const + +export type AuthState = (typeof AuthState)[keyof typeof AuthState] + +export const UserState = { + LOGGED_OUT: 'LOGGED_OUT', + GOOD_STANDING: 'GOOD_STANDING', // >= 100 credits + ATTENTION_NEEDED: 'ATTENTION_NEEDED', // 20-99 credits + CRITICAL: 'CRITICAL', // 1-19 credits + DEPLETED: 'DEPLETED', // <= 0 credits +} as const + +export type UserState = (typeof UserState)[keyof typeof UserState] + +export function getUserState(isLoggedIn: boolean, credits: number): UserState { + if (!isLoggedIn) return UserState.LOGGED_OUT + + if (credits >= 100) return UserState.GOOD_STANDING + if (credits >= 20) return UserState.ATTENTION_NEEDED + if (credits >= 1) return UserState.CRITICAL + return UserState.DEPLETED +} diff --git a/common/src/old-constants.ts b/common/src/old-constants.ts index 252f9f6122..66d954fcda 100644 --- a/common/src/old-constants.ts +++ b/common/src/old-constants.ts @@ -1,355 +1,10 @@ -import { isExplicitlyDefinedModel } from './util/model-utils' - -export const PROFIT_MARGIN = 0.055 - -export const STOP_MARKER = '[' + 'END]' -export const FIND_FILES_MARKER = '[' + 'FIND_FILES_PLEASE]' -export const EXISTING_CODE_MARKER = '[[**REPLACE_WITH_EXISTING_CODE**]]' - -// Directory where agent template override files are stored -export const AGENT_TEMPLATES_DIR = '.agents/' -export const AGENT_DEFINITION_FILE = 'agent-definition.d.ts' - -export const API_KEY_ENV_VAR = 'CODEBUFF_API_KEY' - -export const INVALID_AUTH_TOKEN_MESSAGE = - 'Invalid auth token. You may have been logged out from the web portal. Please log in again.' - -// Allowed model prefixes for validation -export const ALLOWED_MODEL_PREFIXES = [ - 'anthropic', - 'openai', - 'google', - 'x-ai', -] as const - -export const DEFAULT_IGNORED_PATHS = [ - '.git', - '.env', - '.env.*', - '*.min.*', - 'node_modules', - 'venv', - 'virtualenv', - '.venv', - '.virtualenv', - '__pycache__', - '*.egg-info/', - '*.pyc', - '.DS_Store', - '.pytest_cache', - '.mypy_cache', - '.ruff_cache', - '.next', - 'package-lock.json', - 'bun.lockb', -] - -// Special message content tags indicating specific server states -export const ASKED_CONFIG = 'asked_config' -export const SHOULD_ASK_CONFIG = 'should_ask_config' -export const ONE_TIME_TAGS = [] as const -export const ONE_TIME_LABELS = [ - ...ONE_TIME_TAGS, - ASKED_CONFIG, - SHOULD_ASK_CONFIG, -] as const - -export const FILE_READ_STATUS = { - DOES_NOT_EXIST: '[FILE_DOES_NOT_EXIST]', - IGNORED: '[BLOCKED]', - TEMPLATE: '[TEMPLATE]', - OUTSIDE_PROJECT: '[FILE_OUTSIDE_PROJECT]', - TOO_LARGE: '[FILE_TOO_LARGE]', - ERROR: '[FILE_READ_ERROR]', -} as const - -export const HIDDEN_FILE_READ_STATUS = [ - FILE_READ_STATUS.DOES_NOT_EXIST, - FILE_READ_STATUS.IGNORED, - FILE_READ_STATUS.OUTSIDE_PROJECT, - FILE_READ_STATUS.TOO_LARGE, - FILE_READ_STATUS.ERROR, -] - -export function toOptionalFile(file: string | null) { - if (file === null) return null - return HIDDEN_FILE_READ_STATUS.some((status) => file.startsWith(status)) - ? null - : file -} - -export const REQUEST_CREDIT_SHOW_THRESHOLD = 1 -export const MAX_DATE = new Date(86399999999999) -export const BILLING_PERIOD_DAYS = 30 -export const SESSION_MAX_AGE_SECONDS = 30 * 24 * 60 * 60 // 30 days -export const SESSION_TIME_WINDOW_MS = 30 * 60 * 1000 // 30 minutes - used for matching sessions created around fingerprint creation -export const CREDITS_REFERRAL_BONUS = 250 -export const AFFILIATE_USER_REFFERAL_LIMIT = 500 - -// Default number of free credits granted per cycle -export const DEFAULT_FREE_CREDITS_GRANT = 500 - -// Credit pricing configuration -export const CREDIT_PRICING = { - CENTS_PER_CREDIT: 1, // 1 credit = 1 cent = $0.01 - MIN_PURCHASE_CREDITS: 100, // $1.00 minimum - DISPLAY_RATE: '$0.01 per credit', -} as const - -export const AuthState = { - LOGGED_OUT: 'LOGGED_OUT', - LOGGED_IN: 'LOGGED_IN', -} as const - -export type AuthState = (typeof AuthState)[keyof typeof AuthState] - -export const UserState = { - LOGGED_OUT: 'LOGGED_OUT', - GOOD_STANDING: 'GOOD_STANDING', // >= 100 credits - ATTENTION_NEEDED: 'ATTENTION_NEEDED', // 20-99 credits - CRITICAL: 'CRITICAL', // 1-19 credits - DEPLETED: 'DEPLETED', // <= 0 credits -} as const - -export type UserState = (typeof UserState)[keyof typeof UserState] - -export function getUserState(isLoggedIn: boolean, credits: number): UserState { - if (!isLoggedIn) return UserState.LOGGED_OUT - - if (credits >= 100) return UserState.GOOD_STANDING - if (credits >= 20) return UserState.ATTENTION_NEEDED - if (credits >= 1) return UserState.CRITICAL - return UserState.DEPLETED -} - -export const costModes = [ - 'lite', - 'normal', - 'max', - 'experimental', - 'ask', -] as const -export type CostMode = (typeof costModes)[number] - -export const getModelForMode = ( - costMode: CostMode, - operation: 'agent' | 'file-requests' | 'check-new-files', -) => { - if (operation === 'agent') { - return { - lite: models.openrouter_gemini2_5_flash, - normal: models.openrouter_claude_sonnet_4, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_gemini2_5_pro_preview, - ask: models.openrouter_gemini2_5_pro_preview, - }[costMode] - } - if (operation === 'file-requests') { - return { - lite: models.openrouter_claude_3_5_haiku, - normal: models.openrouter_claude_3_5_haiku, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_claude_sonnet_4, - ask: models.openrouter_claude_3_5_haiku, - }[costMode] - } - if (operation === 'check-new-files') { - return { - lite: models.openrouter_claude_3_5_haiku, - normal: models.openrouter_claude_sonnet_4, - max: models.openrouter_claude_sonnet_4, - experimental: models.openrouter_claude_sonnet_4, - ask: models.openrouter_claude_sonnet_4, - }[costMode] - } - throw new Error(`Unknown operation: ${operation}`) -} - -// export const claudeModels = { -// sonnet: 'claude-sonnet-4-20250514', -// sonnet3_7: 'claude-3-7-sonnet-20250219', -// sonnet3_5: 'claude-3-5-sonnet-20241022', -// opus4: 'claude-opus-4-20250514', -// haiku: 'claude-3-5-haiku-20241022', -// } as const - -export const openaiModels = { - gpt4_1: 'gpt-4.1-2025-04-14', - gpt4o: 'gpt-4o-2024-11-20', - gpt4omini: 'gpt-4o-mini-2024-07-18', - o3mini: 'o3-mini-2025-01-31', - o3: 'o3-2025-04-16', - o3pro: 'o3-pro-2025-06-10', - o4mini: 'o4-mini-2025-04-16', - generatePatch: - 'ft:gpt-4o-2024-08-06:manifold-markets:generate-patch-batch2:AKYtDIhk', -} as const -export type OpenAIModel = (typeof openaiModels)[keyof typeof openaiModels] - -export const openrouterModels = { - openrouter_claude_sonnet_4_5: 'anthropic/claude-sonnet-4.5', - openrouter_claude_sonnet_4: 'anthropic/claude-4-sonnet-20250522', - openrouter_claude_opus_4: 'anthropic/claude-opus-4.1', - openrouter_claude_3_5_haiku: 'anthropic/claude-3.5-haiku-20241022', - openrouter_claude_3_5_sonnet: 'anthropic/claude-3.5-sonnet-20240620', - openrouter_gpt4o: 'openai/gpt-4o-2024-11-20', - openrouter_gpt5: 'openai/gpt-5.1', - openrouter_gpt5_chat: 'openai/gpt-5.1-chat', - openrouter_gpt4o_mini: 'openai/gpt-4o-mini-2024-07-18', - openrouter_gpt4_1_nano: 'openai/gpt-4.1-nano', - openrouter_o3_mini: 'openai/o3-mini-2025-01-31', - openrouter_gemini2_5_pro_preview: 'google/gemini-2.5-pro', - openrouter_gemini2_5_flash: 'google/gemini-2.5-flash', - openrouter_gemini2_5_flash_thinking: - 'google/gemini-2.5-flash-preview:thinking', - openrouter_grok_4: 'x-ai/grok-4-07-09', -} as const -export type openrouterModel = - (typeof openrouterModels)[keyof typeof openrouterModels] - -export const deepseekModels = { - deepseekChat: 'deepseek-chat', - deepseekReasoner: 'deepseek-reasoner', -} as const -export type DeepseekModel = (typeof deepseekModels)[keyof typeof deepseekModels] - -// Vertex uses "endpoint IDs" for finetuned models, which are just integers -export const finetunedVertexModels = { - ft_filepicker_003: '196166068534771712', - ft_filepicker_005: '8493203957034778624', - ft_filepicker_007: '2589952415784501248', - ft_filepicker_topk_001: '3676445825887633408', - ft_filepicker_008: '2672143108984012800', - ft_filepicker_topk_002: '1694861989844615168', - ft_filepicker_010: '3808739064941641728', - ft_filepicker_010_epoch_2: '6231675664466968576', - ft_filepicker_topk_003: '1502192368286171136', -} as const -export const finetunedVertexModelNames: Record = { - [finetunedVertexModels.ft_filepicker_003]: 'ft_filepicker_003', - [finetunedVertexModels.ft_filepicker_005]: 'ft_filepicker_005', - [finetunedVertexModels.ft_filepicker_007]: 'ft_filepicker_007', - [finetunedVertexModels.ft_filepicker_topk_001]: 'ft_filepicker_topk_001', - [finetunedVertexModels.ft_filepicker_008]: 'ft_filepicker_008', - [finetunedVertexModels.ft_filepicker_topk_002]: 'ft_filepicker_topk_002', - [finetunedVertexModels.ft_filepicker_010]: 'ft_filepicker_010', - [finetunedVertexModels.ft_filepicker_010_epoch_2]: - 'ft_filepicker_010_epoch_2', - [finetunedVertexModels.ft_filepicker_topk_003]: 'ft_filepicker_topk_003', -} -export type FinetunedVertexModel = - (typeof finetunedVertexModels)[keyof typeof finetunedVertexModels] - -export const models = { - // ...claudeModels, - ...openaiModels, - ...deepseekModels, - ...openrouterModels, - ...finetunedVertexModels, -} as const - -export const shortModelNames = { - 'gemini-2.5-pro': models.openrouter_gemini2_5_pro_preview, - 'flash-2.5': models.openrouter_gemini2_5_flash, - 'opus-4': models.openrouter_claude_opus_4, - 'sonnet-4.5': models.openrouter_claude_sonnet_4_5, - 'sonnet-4': models.openrouter_claude_sonnet_4, - 'sonnet-3.7': models.openrouter_claude_sonnet_4, - 'sonnet-3.6': models.openrouter_claude_3_5_sonnet, - 'sonnet-3.5': models.openrouter_claude_3_5_sonnet, - 'gpt-4.1': models.gpt4_1, - 'o3-mini': models.o3mini, - o3: models.o3, - 'o4-mini': models.o4mini, - 'o3-pro': models.o3pro, -} - -export const providerModelNames = { - // ...Object.fromEntries( - // Object.entries(openrouterModels).map(([name, model]) => [ - // model, - // 'claude' as const, - // ]) - // ), - ...Object.fromEntries( - Object.entries(openaiModels).map(([name, model]) => [ - model, - 'openai' as const, - ]), - ), - ...Object.fromEntries( - Object.entries(openrouterModels).map(([name, model]) => [ - model, - 'openrouter' as const, - ]), - ), -} - -export type Model = (typeof models)[keyof typeof models] | (string & {}) - -export const shouldCacheModels = [ - 'anthropic/claude-opus-4.1', - 'anthropic/claude-sonnet-4', - 'anthropic/claude-opus-4', - 'anthropic/claude-3.7-sonnet', - 'anthropic/claude-3.5-haiku', - 'z-ai/glm-4.5', - 'qwen/qwen3-coder', -] -const nonCacheableModels = [ - models.openrouter_grok_4, -] satisfies string[] as string[] -export function supportsCacheControl(model: Model): boolean { - if (model.startsWith('openai/')) { - return true - } - if (model.startsWith('anthropic/')) { - return true - } - if (!isExplicitlyDefinedModel(model)) { - // Default to no cache control for unknown models - return false - } - return !nonCacheableModels.includes(model) -} - -export const TEST_USER_ID = 'test-user-id' - -export function getModelFromShortName( - modelName: string | undefined, -): Model | undefined { - if (!modelName) return undefined - if (modelName && !(modelName in shortModelNames)) { - throw new Error( - `Unknown model: ${modelName}. Please use a valid model. Valid models are: ${Object.keys( - shortModelNames, - ).join(', ')}`, - ) - } - - return shortModelNames[modelName as keyof typeof shortModelNames] -} - -export const providerDomains = { - google: 'google.com', - anthropic: 'anthropic.com', - openai: 'chatgpt.com', - deepseek: 'deepseek.com', - xai: 'x.ai', -} as const - -export function getLogoForModel(modelName: string): string | undefined { - let domain: string | undefined - - if (Object.values(openaiModels).includes(modelName as OpenAIModel)) - domain = providerDomains.openai - else if (Object.values(deepseekModels).includes(modelName as DeepseekModel)) - domain = providerDomains.deepseek - else if (modelName.includes('claude')) domain = providerDomains.anthropic - else if (modelName.includes('grok')) domain = providerDomains.xai - - return domain - ? `https://www.google.com/s2/favicons?domain=${domain}&sz=256` - : undefined -} +/** + * @deprecated Import from '@codebuff/common/constants' or specific files instead: + * - '@codebuff/common/constants/model-config' for model-related constants + * - '@codebuff/common/constants/limits' for billing and numeric limits + * - '@codebuff/common/constants/ui' for auth/user state + * - '@codebuff/common/constants/paths' for file paths and markers + * + * This file re-exports all constants for backwards compatibility. + */ +export * from './constants' diff --git a/common/src/project-file-tree.ts b/common/src/project-file-tree.ts index 9bc45383f0..647408c717 100644 --- a/common/src/project-file-tree.ts +++ b/common/src/project-file-tree.ts @@ -3,12 +3,41 @@ import path from 'path' import * as ignore from 'ignore' import { sortBy } from 'lodash' -import { DEFAULT_IGNORED_PATHS } from './old-constants' +import { DEFAULT_IGNORED_PATHS } from './constants/paths' import { fileExists, isValidProjectRoot } from './util/file' import type { CodebuffFileSystem } from './types/filesystem' import type { DirectoryNode, FileTreeNode } from './util/file' +/** + * Logs file tree errors in debug mode only. + * Errors are logged but not thrown to preserve tree-building behavior. + * + * File tree operations commonly encounter expected errors (permissions, + * deleted files) that are not fatal. We only log in debug mode to avoid + * noisy output during normal operation. + */ +function logFileTreeError( + operation: string, + filePath: string, + error: unknown, +): void { + // Only log in debug mode to avoid noisy output + if (!process.env.DEBUG && !process.env.CODEBUFF_DEBUG) { + return + } + + const err = error as { code?: string } | undefined + const code = err?.code + const errorMessage = error instanceof Error ? error.message : String(error) + + console.debug( + `[FileTree] ${operation} failed for "${filePath}"${ + code ? ` (${code})` : '' + }: ${errorMessage}`, + ) +} + export const DEFAULT_MAX_FILES = 10_000 export async function getProjectFileTree(params: { @@ -97,12 +126,16 @@ export async function getProjectFileTree(params: { }) totalFiles++ } - } catch (error: any) { - // Don't print errors, you probably just don't have access to the file. + } catch (error: unknown) { + // File may be inaccessible due to permissions or may have been deleted. + // Log with context for debugging, but continue building the tree. + logFileTreeError('fs.stat', filePath, error) } } - } catch (error: any) { - // Don't print errors, you probably just don't have access to the directory. + } catch (error: unknown) { + // Directory may be inaccessible due to permissions. + // Log with context for debugging, but continue building the tree. + logFileTreeError('fs.readdir', fullPath, error) } } return root.children @@ -178,7 +211,10 @@ export async function parseGitignore(params: { let ignoreContent: string try { ignoreContent = await fs.readFile(ignoreFilePath, 'utf8') - } catch { + } catch (error: unknown) { + // Ignore file may be inaccessible or deleted after existence check. + // Log with context for debugging, but continue without these ignore rules. + logFileTreeError('fs.readFile (ignore file)', ignoreFilePath, error) continue } const lines = ignoreContent.split('\n') diff --git a/packages/agent-runtime/src/process-file-block.ts b/packages/agent-runtime/src/process-file-block.ts index 5c3113423b..74197528a0 100644 --- a/packages/agent-runtime/src/process-file-block.ts +++ b/packages/agent-runtime/src/process-file-block.ts @@ -1,4 +1,4 @@ -import { models } from '@codebuff/common/old-constants' +import { models } from '@codebuff/common/constants/model-config' import { cleanMarkdownCodeBlock } from '@codebuff/common/util/file' import { userMessage } from '@codebuff/common/util/messages' import { hasLazyEdit } from '@codebuff/common/util/string' diff --git a/packages/billing/src/auto-topup.ts b/packages/billing/src/auto-topup.ts index dc48b8217b..a6ab855410 100644 --- a/packages/billing/src/auto-topup.ts +++ b/packages/billing/src/auto-topup.ts @@ -1,6 +1,6 @@ import { env } from 'process' -import { CREDIT_PRICING } from '@codebuff/common/old-constants' +import { CREDIT_PRICING } from '@codebuff/common/constants/limits' import { convertCreditsToUsdCents } from '@codebuff/common/util/currency' import { getNextQuotaReset } from '@codebuff/common/util/dates' import db from '@codebuff/internal/db' diff --git a/sdk/src/client.ts b/sdk/src/client.ts index ae203a194d..c974e89938 100644 --- a/sdk/src/client.ts +++ b/sdk/src/client.ts @@ -1,7 +1,7 @@ import { WEBSITE_URL } from './constants' import { getCodebuffApiKeyFromEnv } from './env' import { run } from './run' -import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' +import { API_KEY_ENV_VAR } from '@codebuff/common/constants/paths' import type { RunOptions, CodebuffClientOptions } from './run' import type { RunState } from './run-state' diff --git a/sdk/src/env.ts b/sdk/src/env.ts index 56d01040d7..ab9fbce499 100644 --- a/sdk/src/env.ts +++ b/sdk/src/env.ts @@ -8,7 +8,7 @@ import { getBaseEnv } from '@codebuff/common/env-process' import { BYOK_OPENROUTER_ENV_VAR } from '@codebuff/common/constants/byok' import { CLAUDE_OAUTH_TOKEN_ENV_VAR } from '@codebuff/common/constants/claude-oauth' -import { API_KEY_ENV_VAR } from '@codebuff/common/old-constants' +import { API_KEY_ENV_VAR } from '@codebuff/common/constants/paths' import type { SdkEnv } from './types/env' diff --git a/sdk/src/run.ts b/sdk/src/run.ts index d4aed6bd31..bb26ccd72d 100644 --- a/sdk/src/run.ts +++ b/sdk/src/run.ts @@ -7,7 +7,7 @@ import { } from '@codebuff/agent-runtime/util/messages' import { MAX_AGENT_STEPS_DEFAULT } from '@codebuff/common/constants/agents' import { getMCPClient, listMCPTools, callMCPTool } from '@codebuff/common/mcp/client' -import { toOptionalFile } from '@codebuff/common/old-constants' +import { toOptionalFile } from '@codebuff/common/constants/paths' import { toolNames } from '@codebuff/common/tools/constants' import { clientToolCallSchema } from '@codebuff/common/tools/list' import { AgentOutputSchema } from '@codebuff/common/types/session-state' From e3744f74f3d39304e005b3d26ac3fe2c73ee41aa Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 21:34:01 -0800 Subject: [PATCH 0041/1143] refactor(cli): extract chat state management from chat.tsx (Commit 1.1a) - Create cli/src/hooks/use-chat-state.ts: encapsulates Zustand store selectors, streamingAgents stabilization, refs (activeAgentStreamsRef, isChainInProgressRef, activeSubagentsRef, abortControllerRef, sendMessageRef), and sync effects - Create cli/src/hooks/use-chat-messages.ts: extracts message tree building, pagination (MESSAGE_BATCH_SIZE, visibleMessageCount), collapse toggle handling, and isUserCollapsing ref management - Create cli/src/types/chat-state.ts: re-exports types from extracted hooks - Update cli/src/chat.tsx to use new hooks, reducing component complexity Part of Wave 2 refactoring plan - Phase 1 critical path --- REFACTORING_PLAN.md | 2 +- cli/src/chat.tsx | 227 +++------------------- cli/src/hooks/use-chat-messages.ts | 225 +++++++++++++++++++++ cli/src/hooks/use-chat-state.ts | 218 +++++++++++++++++++++ cli/src/types/chat-state.ts | 18 ++ packages/internal/src/db/advisory-lock.ts | 66 +++++++ packages/internal/src/db/index.ts | 8 + web/scripts/discord/index.ts | 73 ++++++- 8 files changed, 631 insertions(+), 206 deletions(-) create mode 100644 cli/src/hooks/use-chat-messages.ts create mode 100644 cli/src/hooks/use-chat-state.ts create mode 100644 cli/src/types/chat-state.ts create mode 100644 packages/internal/src/db/advisory-lock.ts diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index 14e789f8f4..bbc4625d58 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -21,7 +21,7 @@ This document outlines a prioritized refactoring plan for the 51 issues identifi ### Phase 1 Progress | Commit | Description | Status | Completed By | |--------|-------------|--------|-------------| -| 1.1a | Extract chat state management | ⬜ Not Started | - | +| 1.1a | Extract chat state management | ✅ Complete | Codex CLI | | 1.1b | Extract chat UI and orchestration | ⬜ Not Started | - | | 1.2 | Refactor context-pruner god function | ✅ Complete | Codex CLI | | 1.3 | Split old-constants.ts god module | ✅ Complete | Codex CLI | diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index 7ddb7f464b..d420fb1db1 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -34,6 +34,8 @@ import { useChatKeyboard, type ChatKeyboardHandlers, } from './hooks/use-chat-keyboard' +import { useChatMessages } from './hooks/use-chat-messages' +import { useChatState } from './hooks/use-chat-state' import { useClipboard } from './hooks/use-clipboard' import { useConnectionStatus } from './hooks/use-connection-status' import { useElapsedTime } from './hooks/use-elapsed-time' @@ -75,7 +77,7 @@ import { createDefaultChatKeyboardState, } from './utils/keyboard-actions' import { loadLocalAgents } from './utils/local-agent-registry' -import { buildMessageTree } from './utils/message-tree-utils' +// buildMessageTree is now used internally by useChatMessages hook import { getStatusIndicatorState, type AuthStatus, @@ -90,8 +92,8 @@ import { logger } from './utils/logger' import type { CommandResult } from './commands/command-registry' import type { MultilineInputHandle } from './components/multiline-input' -import type { ContentBlock } from './types/chat' -import type { SendMessageFn } from './types/contracts/send-message' + +// SendMessageFn type is now used internally by useChatState hook import type { User } from './utils/auth' import type { AgentMode } from './utils/constants' import type { FileTreeNode } from '@codebuff/common/util/file' @@ -134,10 +136,7 @@ export const Chat = ({ const [hasOverflow, setHasOverflow] = useState(false) const hasOverflowRef = useRef(false) - // Message pagination - show last N messages with "Load previous" button - const MESSAGE_BATCH_SIZE = 15 - const [visibleMessageCount, setVisibleMessageCount] = - useState(MESSAGE_BATCH_SIZE) + // Message handling extracted to useChatMessages hook (initialized below after streamStatus is available) const queryClient = useQueryClient() const [, startUiTransition] = useTransition() @@ -164,6 +163,7 @@ export const Chat = ({ // Monitor usage data and auto-show banner when thresholds are crossed useUsageMonitor() + // Get chat state from extracted hook const { inputValue, cursorPosition, @@ -175,7 +175,7 @@ export const Chat = ({ setSlashSelectedIndex, agentSelectedIndex, setAgentSelectedIndex, - streamingAgents: rawStreamingAgents, + streamingAgents, focusedAgentId, setFocusedAgentId, messages, @@ -186,49 +186,15 @@ export const Chat = ({ setAgentMode, toggleAgentMode, isRetrying, - } = useChatStore( - useShallow((store) => ({ - inputValue: store.inputValue, - cursorPosition: store.cursorPosition, - lastEditDueToNav: store.lastEditDueToNav, - setInputValue: store.setInputValue, - inputFocused: store.inputFocused, - setInputFocused: store.setInputFocused, - slashSelectedIndex: store.slashSelectedIndex, - setSlashSelectedIndex: store.setSlashSelectedIndex, - agentSelectedIndex: store.agentSelectedIndex, - setAgentSelectedIndex: store.setAgentSelectedIndex, - streamingAgents: store.streamingAgents, - focusedAgentId: store.focusedAgentId, - setFocusedAgentId: store.setFocusedAgentId, - messages: store.messages, - setMessages: store.setMessages, - activeSubagents: store.activeSubagents, - isChainInProgress: store.isChainInProgress, - agentMode: store.agentMode, - setAgentMode: store.setAgentMode, - toggleAgentMode: store.toggleAgentMode, - isRetrying: store.isRetrying, - })), - ) - - // Stabilize streamingAgents reference - only create new Set when content changes - const streamingAgentsKey = useMemo( - () => Array.from(rawStreamingAgents).sort().join(','), - [rawStreamingAgents], - ) - const streamingAgents = useMemo( - () => rawStreamingAgents, - [streamingAgentsKey], - ) - const pendingBashMessages = useChatStore((state) => state.pendingBashMessages) - - // Refs for tracking state across renders - const activeAgentStreamsRef = useRef(0) - const isChainInProgressRef = useRef(isChainInProgress) - const activeSubagentsRef = useRef>(activeSubagents) - const abortControllerRef = useRef(null) - const sendMessageRef = useRef() + pendingBashMessages, + refs: { + activeAgentStreamsRef, + isChainInProgressRef, + activeSubagentsRef, + abortControllerRef, + sendMessageRef, + }, + } = useChatState() const { statusMessage } = useClipboard() @@ -268,135 +234,16 @@ export const Chat = ({ } }, [initialMode, setAgentMode]) - // Sync refs with state - useEffect(() => { - isChainInProgressRef.current = isChainInProgress - }, [isChainInProgress]) - - useEffect(() => { - activeSubagentsRef.current = activeSubagents - }, [activeSubagents]) - - // Reset visible message count when messages are cleared or conversation changes - useEffect(() => { - if (messages.length <= MESSAGE_BATCH_SIZE) { - setVisibleMessageCount(MESSAGE_BATCH_SIZE) - } - }, [messages.length]) - - const isUserCollapsingRef = useRef(false) - - const handleCollapseToggle = useCallback( - (id: string) => { - // Set flag to prevent auto-scroll during user-initiated collapse - isUserCollapsingRef.current = true - - // Find and toggle the block's isCollapsed property - setMessages((prevMessages) => { - return prevMessages.map((message) => { - // Handle agent variant messages - if (message.variant === 'agent' && message.id === id) { - const wasCollapsed = message.metadata?.isCollapsed ?? false - return { - ...message, - metadata: { - ...message.metadata, - isCollapsed: !wasCollapsed, - userOpened: wasCollapsed, // Mark as user-opened if expanding - }, - } - } - - // Handle blocks within messages - if (!message.blocks) return message - - const updateBlocksRecursively = ( - blocks: ContentBlock[], - ): ContentBlock[] => { - let foundTarget = false - const result = blocks.map((block) => { - // Handle thinking blocks - just match by thinkingId - if (block.type === 'text' && block.thinkingId === id) { - foundTarget = true - const wasCollapsed = block.isCollapsed ?? false - return { - ...block, - isCollapsed: !wasCollapsed, - userOpened: wasCollapsed, // Mark as user-opened if expanding - } - } - - // Handle agent blocks - if (block.type === 'agent' && block.agentId === id) { - foundTarget = true - const wasCollapsed = block.isCollapsed ?? false - return { - ...block, - isCollapsed: !wasCollapsed, - userOpened: wasCollapsed, // Mark as user-opened if expanding - } - } - - // Handle tool blocks - if (block.type === 'tool' && block.toolCallId === id) { - foundTarget = true - const wasCollapsed = block.isCollapsed ?? false - return { - ...block, - isCollapsed: !wasCollapsed, - userOpened: wasCollapsed, // Mark as user-opened if expanding - } - } - - // Handle agent-list blocks - if (block.type === 'agent-list' && block.id === id) { - foundTarget = true - const wasCollapsed = block.isCollapsed ?? false - return { - ...block, - isCollapsed: !wasCollapsed, - userOpened: wasCollapsed, // Mark as user-opened if expanding - } - } - - // Recursively update nested blocks inside agent blocks - if (block.type === 'agent' && block.blocks) { - const updatedBlocks = updateBlocksRecursively(block.blocks) - // Only create new block if nested blocks actually changed - if (updatedBlocks !== block.blocks) { - foundTarget = true - return { - ...block, - blocks: updatedBlocks, - } - } - } - - return block - }) - - // Return original array reference if nothing changed - return foundTarget ? result : blocks - } - - return { - ...message, - blocks: updateBlocksRecursively(message.blocks), - } - }) - }) - - // Reset flag after state update completes - setTimeout(() => { - isUserCollapsingRef.current = false - }, 0) - }, - [setMessages], - ) - - const isUserCollapsing = useCallback(() => { - return isUserCollapsingRef.current - }, []) + // Use extracted chat messages hook for message tree and pagination + const { + messageTree, + topLevelMessages, + visibleTopLevelMessages, + hiddenMessageCount, + handleCollapseToggle, + isUserCollapsing, + handleLoadPreviousMessages, + } = useChatMessages({ messages, setMessages }) const { scrollToLatest, scrollUp, scrollDown, scrollboxProps, isAtBottom } = useChatScrollbox( scrollRef, @@ -1360,10 +1207,7 @@ export const Chat = ({ disabled: askUserState !== null, }) - const { tree: messageTree, topLevelMessages } = useMemo( - () => buildMessageTree(messages), - [messages], - ) + // messageTree and topLevelMessages now come from useChatMessages hook // Sync message block context to zustand store for child components const setMessageBlockContext = useMessageBlockStore( @@ -1412,20 +1256,7 @@ export const Chat = ({ setMessageBlockCallbacks, ]) - // Compute visible messages slice (from the end) - const visibleTopLevelMessages = useMemo(() => { - if (topLevelMessages.length <= visibleMessageCount) { - return topLevelMessages - } - return topLevelMessages.slice(-visibleMessageCount) - }, [topLevelMessages, visibleMessageCount]) - - const hiddenMessageCount = - topLevelMessages.length - visibleTopLevelMessages.length - - const handleLoadPreviousMessages = useCallback(() => { - setVisibleMessageCount((prev) => prev + MESSAGE_BATCH_SIZE) - }, []) + // visibleTopLevelMessages, hiddenMessageCount, handleLoadPreviousMessages come from useChatMessages hook const modeConfig = getInputModeConfig(inputMode) const hasSlashSuggestions = diff --git a/cli/src/hooks/use-chat-messages.ts b/cli/src/hooks/use-chat-messages.ts new file mode 100644 index 0000000000..94d5ec6502 --- /dev/null +++ b/cli/src/hooks/use-chat-messages.ts @@ -0,0 +1,225 @@ +/** + * Extracted chat messages hook. + * Handles message tree building, pagination, and collapse state management. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import { buildMessageTree } from '../utils/message-tree-utils' + +import type { ChatMessage, ContentBlock } from '../types/chat' + +/** Batch size for message pagination */ +const MESSAGE_BATCH_SIZE = 15 + +/** + * Options for useChatMessages hook. + */ +export interface UseChatMessagesOptions { + /** Current messages array from store */ + messages: ChatMessage[] + /** Setter for messages */ + setMessages: ( + value: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]), + ) => void +} + +/** + * Return type for useChatMessages hook. + */ +export interface UseChatMessagesReturn { + /** Map of parent ID to child messages */ + messageTree: Map + /** Messages without a parent (root level) */ + topLevelMessages: ChatMessage[] + /** Paginated visible messages from top level */ + visibleTopLevelMessages: ChatMessage[] + /** Count of hidden messages due to pagination */ + hiddenMessageCount: number + /** Handler to toggle collapsed state of a block */ + handleCollapseToggle: (id: string) => void + /** Returns true if user is currently collapsing (to prevent auto-scroll) */ + isUserCollapsing: () => boolean + /** Handler to load more previous messages */ + handleLoadPreviousMessages: () => void +} + +/** + * Custom hook that encapsulates message handling logic. + * Extracts message tree building, pagination, and collapse management. + * + * @param options - Messages array and setter from store + * @returns Message tree, pagination state, and handlers + */ +export function useChatMessages({ + messages, + setMessages, +}: UseChatMessagesOptions): UseChatMessagesReturn { + // Message pagination state + const [visibleMessageCount, setVisibleMessageCount] = + useState(MESSAGE_BATCH_SIZE) + + // Reset visible message count when messages are cleared or conversation changes + useEffect(() => { + if (messages.length <= MESSAGE_BATCH_SIZE) { + setVisibleMessageCount(MESSAGE_BATCH_SIZE) + } + }, [messages.length]) + + // Ref to track user-initiated collapse (prevents auto-scroll during collapse) + const isUserCollapsingRef = useRef(false) + + /** + * Returns true if user is currently collapsing. + * Used by scroll management to prevent auto-scroll during collapse. + */ + const isUserCollapsing = useCallback(() => { + return isUserCollapsingRef.current + }, []) + + /** + * Toggles the collapsed state of a block or agent message. + * Handles both top-level agent messages and nested content blocks. + */ + const handleCollapseToggle = useCallback( + (id: string) => { + // Set flag to prevent auto-scroll during user-initiated collapse + isUserCollapsingRef.current = true + + // Find and toggle the block's isCollapsed property + setMessages((prevMessages) => { + return prevMessages.map((message) => { + // Handle agent variant messages + if (message.variant === 'agent' && message.id === id) { + const wasCollapsed = message.metadata?.isCollapsed ?? false + return { + ...message, + metadata: { + ...message.metadata, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + }, + } + } + + // Handle blocks within messages + if (!message.blocks) return message + + const updateBlocksRecursively = ( + blocks: ContentBlock[], + ): ContentBlock[] => { + let foundTarget = false + const result = blocks.map((block) => { + // Handle thinking blocks - just match by thinkingId + if (block.type === 'text' && block.thinkingId === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Handle agent blocks + if (block.type === 'agent' && block.agentId === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Handle tool blocks + if (block.type === 'tool' && block.toolCallId === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Handle agent-list blocks + if (block.type === 'agent-list' && block.id === id) { + foundTarget = true + const wasCollapsed = block.isCollapsed ?? false + return { + ...block, + isCollapsed: !wasCollapsed, + userOpened: wasCollapsed, // Mark as user-opened if expanding + } + } + + // Recursively update nested blocks inside agent blocks + if (block.type === 'agent' && block.blocks) { + const updatedBlocks = updateBlocksRecursively(block.blocks) + // Only create new block if nested blocks actually changed + if (updatedBlocks !== block.blocks) { + foundTarget = true + return { + ...block, + blocks: updatedBlocks, + } + } + } + + return block + }) + + // Return original array reference if nothing changed + return foundTarget ? result : blocks + } + + return { + ...message, + blocks: updateBlocksRecursively(message.blocks), + } + }) + }) + + // Reset flag after state update completes + setTimeout(() => { + isUserCollapsingRef.current = false + }, 0) + }, + [setMessages], + ) + + /** + * Loads more previous messages by increasing the visible count. + */ + const handleLoadPreviousMessages = useCallback(() => { + setVisibleMessageCount((prev) => prev + MESSAGE_BATCH_SIZE) + }, []) + + // Build message tree from flat messages array + const { tree: messageTree, topLevelMessages } = useMemo( + () => buildMessageTree(messages), + [messages], + ) + + // Compute visible messages slice (from the end) + const visibleTopLevelMessages = useMemo(() => { + if (topLevelMessages.length <= visibleMessageCount) { + return topLevelMessages + } + return topLevelMessages.slice(-visibleMessageCount) + }, [topLevelMessages, visibleMessageCount]) + + const hiddenMessageCount = + topLevelMessages.length - visibleTopLevelMessages.length + + return { + messageTree, + topLevelMessages, + visibleTopLevelMessages, + hiddenMessageCount, + handleCollapseToggle, + isUserCollapsing, + handleLoadPreviousMessages, + } +} diff --git a/cli/src/hooks/use-chat-state.ts b/cli/src/hooks/use-chat-state.ts new file mode 100644 index 0000000000..657dc1f829 --- /dev/null +++ b/cli/src/hooks/use-chat-state.ts @@ -0,0 +1,218 @@ +/** + * Extracted chat state management hook. + * Encapsulates Zustand store subscriptions, refs, and derived state. + */ + +import { useEffect, useMemo, useRef } from 'react' +import { useShallow } from 'zustand/react/shallow' + +import { useChatStore } from '../state/chat-store' + +import type { MutableRefObject } from 'react' +import type { InputValue, PendingBashMessage } from '../state/chat-store' +import type { ChatMessage } from '../types/chat' +import type { SendMessageFn } from '../types/contracts/send-message' +import type { AgentMode } from '../utils/constants' + +/** + * Ref objects used to track state across renders. + * These maintain values that need to be accessed in callbacks without + * causing re-renders. + */ +export interface ChatStateRefs { + /** Tracks number of active agent streams */ + activeAgentStreamsRef: MutableRefObject + /** Tracks whether a chain of operations is in progress */ + isChainInProgressRef: MutableRefObject + /** Tracks set of active subagent IDs */ + activeSubagentsRef: MutableRefObject> + /** AbortController for canceling requests */ + abortControllerRef: MutableRefObject + /** Reference to sendMessage function for use in callbacks */ + sendMessageRef: MutableRefObject +} + +/** + * Return type for useChatState hook. + */ +export interface UseChatStateReturn { + // Input state + inputValue: string + cursorPosition: number + lastEditDueToNav: boolean + setInputValue: (value: InputValue | ((prev: InputValue) => InputValue)) => void + inputFocused: boolean + setInputFocused: (focused: boolean) => void + + // Suggestion menu state + slashSelectedIndex: number + setSlashSelectedIndex: (value: number | ((prev: number) => number)) => void + agentSelectedIndex: number + setAgentSelectedIndex: (value: number | ((prev: number) => number)) => void + + // Streaming/agent state (stabilized) + streamingAgents: Set + focusedAgentId: string | null + setFocusedAgentId: ( + value: string | null | ((prev: string | null) => string | null), + ) => void + activeSubagents: Set + isChainInProgress: boolean + + // Messages + messages: ChatMessage[] + setMessages: ( + value: ChatMessage[] | ((prev: ChatMessage[]) => ChatMessage[]), + ) => void + + // Mode + agentMode: AgentMode + setAgentMode: (mode: AgentMode) => void + toggleAgentMode: () => void + + // Retry state + isRetrying: boolean + + // Pending bash messages + pendingBashMessages: PendingBashMessage[] + + // Refs + refs: ChatStateRefs +} + +/** + * Custom hook that encapsulates chat state management. + * Extracts state selectors, refs, and derived values from the main Chat component. + * + * @returns Chat state values, setters, refs + */ +export function useChatState(): UseChatStateReturn { + // Main store selector - uses useShallow to prevent unnecessary re-renders + const { + inputValue, + cursorPosition, + lastEditDueToNav, + setInputValue, + inputFocused, + setInputFocused, + slashSelectedIndex, + setSlashSelectedIndex, + agentSelectedIndex, + setAgentSelectedIndex, + streamingAgents: rawStreamingAgents, + focusedAgentId, + setFocusedAgentId, + messages, + setMessages, + activeSubagents, + isChainInProgress, + agentMode, + setAgentMode, + toggleAgentMode, + isRetrying, + } = useChatStore( + useShallow((store) => ({ + inputValue: store.inputValue, + cursorPosition: store.cursorPosition, + lastEditDueToNav: store.lastEditDueToNav, + setInputValue: store.setInputValue, + inputFocused: store.inputFocused, + setInputFocused: store.setInputFocused, + slashSelectedIndex: store.slashSelectedIndex, + setSlashSelectedIndex: store.setSlashSelectedIndex, + agentSelectedIndex: store.agentSelectedIndex, + setAgentSelectedIndex: store.setAgentSelectedIndex, + streamingAgents: store.streamingAgents, + focusedAgentId: store.focusedAgentId, + setFocusedAgentId: store.setFocusedAgentId, + messages: store.messages, + setMessages: store.setMessages, + activeSubagents: store.activeSubagents, + isChainInProgress: store.isChainInProgress, + agentMode: store.agentMode, + setAgentMode: store.setAgentMode, + toggleAgentMode: store.toggleAgentMode, + isRetrying: store.isRetrying, + })), + ) + + // Additional selector for pending bash messages (separate for performance) + const pendingBashMessages = useChatStore((state) => state.pendingBashMessages) + + // Stabilize streamingAgents reference - only create new Set when content changes + const streamingAgentsKey = useMemo( + () => Array.from(rawStreamingAgents).sort().join(','), + [rawStreamingAgents], + ) + const streamingAgents = useMemo( + () => rawStreamingAgents, + // eslint-disable-next-line react-hooks/exhaustive-deps + [streamingAgentsKey], + ) + + // Refs for tracking state across renders + const activeAgentStreamsRef = useRef(0) + const isChainInProgressRef = useRef(isChainInProgress) + const activeSubagentsRef = useRef>(activeSubagents) + const abortControllerRef = useRef(null) + const sendMessageRef = useRef(undefined) + + // Sync refs with state + useEffect(() => { + isChainInProgressRef.current = isChainInProgress + }, [isChainInProgress]) + + useEffect(() => { + activeSubagentsRef.current = activeSubagents + }, [activeSubagents]) + + // Assemble refs object + const refs: ChatStateRefs = { + activeAgentStreamsRef, + isChainInProgressRef, + activeSubagentsRef, + abortControllerRef, + sendMessageRef, + } + + return { + // Input state + inputValue, + cursorPosition, + lastEditDueToNav, + setInputValue, + inputFocused, + setInputFocused, + + // Suggestion menu state + slashSelectedIndex, + setSlashSelectedIndex, + agentSelectedIndex, + setAgentSelectedIndex, + + // Streaming/agent state (stabilized) + streamingAgents, + focusedAgentId, + setFocusedAgentId, + activeSubagents, + isChainInProgress, + + // Messages + messages, + setMessages, + + // Mode + agentMode, + setAgentMode, + toggleAgentMode, + + // Retry state + isRetrying, + + // Pending bash messages + pendingBashMessages, + + // Refs + refs, + } +} diff --git a/cli/src/types/chat-state.ts b/cli/src/types/chat-state.ts new file mode 100644 index 0000000000..dbc3034457 --- /dev/null +++ b/cli/src/types/chat-state.ts @@ -0,0 +1,18 @@ +/** + * Type definitions for chat state management. + * Re-exports types from the extracted hooks for convenience. + */ + +// Re-export types from the extracted hooks +export type { + ChatStateRefs, + UseChatStateReturn, +} from '../hooks/use-chat-state' + +export type { + UseChatMessagesOptions, + UseChatMessagesReturn, +} from '../hooks/use-chat-messages' + +// Re-export StreamStatus from use-message-queue for convenience +export type { StreamStatus } from '../hooks/use-message-queue' diff --git a/packages/internal/src/db/advisory-lock.ts b/packages/internal/src/db/advisory-lock.ts new file mode 100644 index 0000000000..b4448d70ff --- /dev/null +++ b/packages/internal/src/db/advisory-lock.ts @@ -0,0 +1,66 @@ +import postgres from 'postgres' + +import { env } from '@codebuff/internal/env' + +/** + * Lock IDs for different singleton processes. + * These are arbitrary integers that must be unique per process type. + */ +export const ADVISORY_LOCK_IDS = { + DISCORD_BOT: 741852963, +} as const + +export type AdvisoryLockId = (typeof ADVISORY_LOCK_IDS)[keyof typeof ADVISORY_LOCK_IDS] + +/** + * Tries to acquire a PostgreSQL session-level advisory lock. + * + * Advisory locks are held until explicitly released or the connection closes. + * This is useful for leader election - only one instance can hold the lock. + * + * @param lockId - The unique lock identifier + * @returns An object with `acquired` boolean and the `connection` if acquired. + * The connection must be kept alive to maintain the lock. + * Close the connection to release the lock. + */ +export async function tryAcquireAdvisoryLock(lockId: AdvisoryLockId): Promise<{ + acquired: boolean + connection: postgres.Sql | null +}> { + // Create a dedicated connection for this lock + // This connection must stay open to maintain the lock + const connection = postgres(env.DATABASE_URL, { + max: 1, // Single connection for the lock + idle_timeout: 0, // Never timeout - keep connection alive + connect_timeout: 10, // 10 second connection timeout + }) + + try { + const result = await connection`SELECT pg_try_advisory_lock(${lockId}) as acquired` + const acquired = result[0]?.acquired === true + + if (acquired) { + return { acquired: true, connection } + } else { + // Lock not acquired, close the connection + await connection.end() + return { acquired: false, connection: null } + } + } catch (error) { + // On error, ensure connection is closed + await connection.end().catch(() => {}) + throw error + } +} + +/** + * Releases an advisory lock by closing the connection. + * The lock is automatically released when the connection closes. + */ +export async function releaseAdvisoryLock( + connection: postgres.Sql | null, +): Promise { + if (connection) { + await connection.end() + } +} diff --git a/packages/internal/src/db/index.ts b/packages/internal/src/db/index.ts index 53f0a1b6f3..0f72180c09 100644 --- a/packages/internal/src/db/index.ts +++ b/packages/internal/src/db/index.ts @@ -11,3 +11,11 @@ const client = postgres(env.DATABASE_URL) export const db: CodebuffPgDatabase = drizzle(client, { schema }) export default db + +// Re-export advisory lock utilities +export { + ADVISORY_LOCK_IDS, + tryAcquireAdvisoryLock, + releaseAdvisoryLock, +} from './advisory-lock' +export type { AdvisoryLockId } from './advisory-lock' diff --git a/web/scripts/discord/index.ts b/web/scripts/discord/index.ts index 8d775bc99a..0566f4e401 100644 --- a/web/scripts/discord/index.ts +++ b/web/scripts/discord/index.ts @@ -1,13 +1,72 @@ +import { + ADVISORY_LOCK_IDS, + tryAcquireAdvisoryLock, + releaseAdvisoryLock, +} from '@codebuff/internal/db' + import { startDiscordBot } from '../../src/discord/client' +import type postgres from 'postgres' +import type { Client } from 'discord.js' + +const LOCK_RETRY_INTERVAL_MS = 30_000 // 30 seconds + +let lockConnection: postgres.Sql | null = null +let discordClient: Client | null = null +let isShuttingDown = false + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)) +} + async function main() { - try { - console.log('Starting Discord bot...') - startDiscordBot() - } catch (error) { - console.error('Error starting Discord bot:', error) - process.exit(1) + // Set up shutdown handlers early + const shutdown = async () => { + if (isShuttingDown) return + isShuttingDown = true + + console.log('Shutting down Discord bot...') + if (discordClient) { + discordClient.destroy() + } + await releaseAdvisoryLock(lockConnection) + process.exit(0) + } + + process.on('SIGTERM', shutdown) + process.on('SIGINT', shutdown) + + // Poll for the lock until acquired + let attemptCount = 0 + while (!isShuttingDown) { + attemptCount++ + console.log(`Attempting to acquire Discord bot lock (attempt ${attemptCount})...`) + + const { acquired, connection } = await tryAcquireAdvisoryLock( + ADVISORY_LOCK_IDS.DISCORD_BOT, + ) + + if (acquired) { + lockConnection = connection + console.log('Lock acquired. Starting Discord bot...') + + discordClient = startDiscordBot() + return // Bot is running, exit the polling loop + } + + console.log( + `Another instance is already running the Discord bot. Retrying in ${LOCK_RETRY_INTERVAL_MS / 1000} seconds...`, + ) + await sleep(LOCK_RETRY_INTERVAL_MS) } } -main() +main().catch(async (error) => { + console.error('Error in Discord bot script:', error) + // Clean up on error + if (discordClient) { + discordClient.destroy() + } + await releaseAdvisoryLock(lockConnection) + process.exit(1) +}) From 05f269fa442ebbe267208cd9f9bd9f75b5c6f71f Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 21:40:14 -0800 Subject: [PATCH 0042/1143] fix(db): use drizzle-kit migrate instead of push to avoid SIGSEGV drizzle-kit push crashes with SIGSEGV even with npx/Node.js. Switch to drizzle-kit migrate which applies pre-generated migration files instead of doing a live schema diff. --- packages/internal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/package.json b/packages/internal/package.json index 0e96415f55..9502fe1932 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -48,7 +48,7 @@ "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", - "db:migrate:render": "npx drizzle-kit push --config=./src/db/drizzle.config.ts", + "db:migrate:render": "npx drizzle-kit migrate --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From 2b699893258c77c043d74a12e9de388ce021d7ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 06:23:21 +0000 Subject: [PATCH 0043/1143] Bump version to 1.0.588 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 89314ed2bd..90d2acbc34 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.587", + "version": "1.0.588", "description": "AI coding agent", "license": "MIT", "bin": { From 714239738de58e501658f8c018b807b32974a62f Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 22:49:07 -0800 Subject: [PATCH 0044/1143] Update file lister to grok 4.1 fast --- agents/file-explorer/file-lister.ts | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/agents/file-explorer/file-lister.ts b/agents/file-explorer/file-lister.ts index 3ee7334ecd..d7fdccab4d 100644 --- a/agents/file-explorer/file-lister.ts +++ b/agents/file-explorer/file-lister.ts @@ -1,11 +1,10 @@ import { publisher } from '../constants' import { type SecretAgentDefinition } from '../types/secret-agent-definition' -const definition: SecretAgentDefinition = { - id: 'file-lister', +export const createFileLister = (): Omit => ({ displayName: 'Liszt the File Lister', publisher, - model: 'x-ai/grok-4-fast', + model: 'x-ai/grok-4.1-fast', spawnerPrompt: 'Lists up to 12 files that are relevant to the prompt within the given directories. Unless you know which directories are relevant, omit the directories parameter. This agent is great for finding files that could be relevant to the prompt.', inputSchema: { @@ -33,11 +32,12 @@ const definition: SecretAgentDefinition = { systemPrompt: `You are an expert at finding relevant files in a codebase and listing them out.`, instructionsPrompt: `Instructions: +- List out the full paths of 12 files that are relevant to the prompt, separated by newlines. Each file path is relative to the project root. Don't forget to include all the subdirectories in the path -- sometimes you have forgotten to include 'src' in the path. Make sure that the file paths are exactly correct. - Do not write any introductory commentary. - Do not write any analysis or any English text at all. - Do not use any more tools. Do not call read_subtree again. -- List out the full paths of up to 12 files that are relevant to the prompt, separated by newlines. Each file path is relative to the project root. Don't forget to include all the subdirectories in the path -- sometimes you have forgotten to include 'src' in the path. +Here's an example response with made up file paths (these are not real file paths, just an example): packages/core/src/index.ts packages/core/src/api/server.ts @@ -53,7 +53,7 @@ package.json README.md -Again: Do not write anything else other than the file paths on new lines. +Again: Do not call any tools or write anything else other than the chosen file paths on new lines. Go. `.trim(), handleSteps: function* ({ params }) { @@ -66,8 +66,13 @@ Again: Do not write anything else other than the file paths on new lines. }, } - yield 'STEP_ALL' + yield 'STEP' }, +}) + +const definition: SecretAgentDefinition = { + id: 'file-lister', + ...createFileLister(), } export default definition From 8a309f7bde4a251aeeb13e392650409571e3fa55 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 18 Jan 2026 22:52:50 -0800 Subject: [PATCH 0045/1143] Add file-picker-max --- agents/base2/base2.ts | 3 +- agents/file-explorer/file-picker-max.ts | 9 + agents/file-explorer/file-picker.ts | 360 +++++++++++++++--------- 3 files changed, 244 insertions(+), 128 deletions(-) create mode 100644 agents/file-explorer/file-picker-max.ts diff --git a/agents/base2/base2.ts b/agents/base2/base2.ts index 18106c41cf..51827bd0a0 100644 --- a/agents/base2/base2.ts +++ b/agents/base2/base2.ts @@ -65,7 +65,8 @@ export function createBase2( 'set_output', ), spawnableAgents: buildArray( - 'file-picker', + !isMax && 'file-picker', + isMax && 'file-picker-max', 'code-searcher', 'directory-lister', 'glob-matcher', diff --git a/agents/file-explorer/file-picker-max.ts b/agents/file-explorer/file-picker-max.ts new file mode 100644 index 0000000000..d876e09566 --- /dev/null +++ b/agents/file-explorer/file-picker-max.ts @@ -0,0 +1,9 @@ +import { createFilePicker } from './file-picker' +import { type SecretAgentDefinition } from '../types/secret-agent-definition' + +const definition: SecretAgentDefinition = { + id: 'file-picker-max', + ...createFilePicker('max'), +} + +export default definition diff --git a/agents/file-explorer/file-picker.ts b/agents/file-explorer/file-picker.ts index 048d904d30..4d29023fbf 100644 --- a/agents/file-explorer/file-picker.ts +++ b/agents/file-explorer/file-picker.ts @@ -6,157 +6,263 @@ import { type SecretAgentDefinition, } from '../types/secret-agent-definition' -const definition: SecretAgentDefinition = { - id: 'file-picker', - displayName: 'Fletcher the File Fetcher', - publisher, - model: 'google/gemini-2.0-flash-001', - reasoningOptions: { - enabled: false, - effort: 'low', - exclude: false, - }, - spawnerPrompt: - 'Spawn to find relevant files in a codebase related to the prompt. Outputs up to 12 file paths with short summaries for each file. Cannot do string searches on the codebase, but does a fuzzy search. Unless you know which directories are relevant, omit the directories parameter. This agent is extremely effective at finding files in the codebase that could be relevant to the prompt.', - inputSchema: { - prompt: { - type: 'string', - description: - 'A description of the files you need to find. Be more broad for better results: instead of "Find x file" say "Find x file and related files". This agent is designed to help you find several files that could be relevant to the prompt.', +type FilePickerMode = 'default' | 'max' + +export const createFilePicker = ( + mode: FilePickerMode, +): Omit => { + const isMax = mode === 'max' + const model = isMax ? 'x-ai/grok-4.1-fast' : 'google/gemini-2.5-flash-lite' + + return { + displayName: 'Fletcher the File Fetcher', + publisher, + model, + reasoningOptions: { + enabled: false, + effort: 'low', + exclude: false, }, - params: { - type: 'object' as const, - properties: { - directories: { - type: 'array' as const, - items: { type: 'string' as const }, - description: - 'Optional list of paths to directories to look within. If omitted, the entire project tree is used.', + spawnerPrompt: + 'Spawn to find relevant files in a codebase related to the prompt. Outputs up to 12 file paths with short summaries for each file. Cannot do string searches on the codebase, but does a fuzzy search. Unless you know which directories are relevant, omit the directories parameter. This agent is extremely effective at finding files in the codebase that could be relevant to the prompt.', + inputSchema: { + prompt: { + type: 'string', + description: + 'A description of the files you need to find. Be more broad for better results: instead of "Find x file" say "Find x file and related files". This agent is designed to help you find several files that could be relevant to the prompt.', + }, + params: { + type: 'object' as const, + properties: { + directories: { + type: 'array' as const, + items: { type: 'string' as const }, + description: + 'Optional list of paths to directories to look within. If omitted, the entire project tree is used.', + }, }, + required: [], }, - required: [], }, - }, - outputMode: 'last_message', - includeMessageHistory: false, - toolNames: ['spawn_agents'], - spawnableAgents: ['file-lister'], - - systemPrompt: `You are an expert at finding relevant files in a codebase. ${PLACEHOLDER.FILE_TREE_PROMPT}`, - instructionsPrompt: `Instructions: + outputMode: 'last_message', + includeMessageHistory: false, + toolNames: ['spawn_agents'], + spawnableAgents: ['file-lister'], + + systemPrompt: `You are an expert at finding relevant files in a codebase. ${PLACEHOLDER.FILE_TREE_PROMPT}`, + instructionsPrompt: `Instructions: Provide an extremely short report of the locations in the codebase that could be helpful. Focus on the files that are most relevant to the user prompt. In your report, please give a very concise analysis that includes the full paths of files that are relevant and (extremely briefly) how they could be useful. Do not use any further tools or spawn any further agents. `.trim(), - handleSteps: function* ({ prompt, params, logger }) { - const { toolResult: fileListerResults } = yield { - toolName: 'spawn_agents', - input: { - agents: [ - { - agent_type: 'file-lister', - prompt: prompt ?? '', - params: params ?? {}, - }, - ], - }, - } satisfies ToolCall - - const spawnResults = extractSpawnResults(fileListerResults) - const firstResult = spawnResults[0] - const fileListText = extractLastMessageText(firstResult) - - if (!fileListText) { - const errorMessage = extractErrorMessage(firstResult) - yield { - type: 'STEP_TEXT', - text: errorMessage - ? `Error from file-lister: ${errorMessage}` - : 'Error: Could not extract file list from spawned agent', - } satisfies StepText - return - } + handleSteps: isMax ? handleStepsMax : handleStepsDefault, + } +} - const paths = fileListText.split('\n').filter(Boolean) +// handleSteps for default mode - spawns 1 file-lister +const handleStepsDefault: SecretAgentDefinition['handleSteps'] = function* ({ + prompt, + params, +}) { + const { toolResult: fileListerResults } = yield { + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: 'file-lister', + prompt: prompt ?? '', + params: params ?? {}, + }, + ], + }, + } satisfies ToolCall + const spawnResults = extractSpawnResults(fileListerResults) + + // Collect paths from all agents and deduplicate + const allPaths = new Set() + let hasAnyResults = false + + for (const result of spawnResults) { + const fileListText = extractLastMessageText(result) + if (fileListText) { + hasAnyResults = true + const paths = fileListText.split('\n').filter(Boolean) + for (const path of paths) { + allPaths.add(path) + } + } + } + + if (!hasAnyResults) { + const errorMessages = spawnResults + .map(extractErrorMessage) + .filter(Boolean) + .join('; ') yield { - toolName: 'read_files', - input: { - paths, - }, + type: 'STEP_TEXT', + text: errorMessages + ? `Error from file-lister(s): ${errorMessages}` + : 'Error: Could not extract file list from spawned agent(s)', + } satisfies StepText + return + } + + const paths = Array.from(allPaths) + + yield { + toolName: 'read_files', + input: { paths }, + } + + yield 'STEP' + + function extractSpawnResults(results: any[] | undefined): any[] { + if (!results || results.length === 0) return [] + const jsonResult = results.find((r) => r.type === 'json') + if (!jsonResult?.value) return [] + const spawnedResults = Array.isArray(jsonResult.value) + ? jsonResult.value + : [jsonResult.value] + return spawnedResults.map((result: any) => result?.value).filter(Boolean) + } + + function extractLastMessageText(agentOutput: any): string | null { + if (!agentOutput) return null + if ( + agentOutput.type === 'lastMessage' && + Array.isArray(agentOutput.value) + ) { + for (let i = agentOutput.value.length - 1; i >= 0; i--) { + const message = agentOutput.value[i] + if (message.role === 'assistant' && Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'text' && typeof part.text === 'string') { + return part.text + } + } + } + } } + return null + } + + function extractErrorMessage(agentOutput: any): string | null { + if (!agentOutput) return null + if (agentOutput.type === 'error') { + return agentOutput.message ?? agentOutput.value ?? null + } + return null + } +} + +// handleSteps for max mode - spawns 2 file-listers in parallel +const handleStepsMax: SecretAgentDefinition['handleSteps'] = function* ({ + prompt, + params, +}) { + const { toolResult: fileListerResults } = yield { + toolName: 'spawn_agents', + input: { + agents: [ + { + agent_type: 'file-lister', + prompt: prompt ?? '', + params: params ?? {}, + }, + { + agent_type: 'file-lister', + prompt: prompt ?? '', + params: params ?? {}, + }, + ], + }, + } satisfies ToolCall + + const spawnResults = extractSpawnResults(fileListerResults) + + // Collect paths from all agents and deduplicate + const allPaths = new Set() + let hasAnyResults = false - yield 'STEP' - - /** - * Extracts the array of subagent results from spawn_agents tool output. - * - * The spawn_agents tool result structure is: - * [{ type: 'json', value: [{ agentName, agentType, value: AgentOutput }] }] - * - * Returns an array of agent outputs, one per spawned agent. - */ - function extractSpawnResults(results: any[] | undefined): any[] { - if (!results || results.length === 0) return [] - - // Find the json result containing spawn results - const jsonResult = results.find((r) => r.type === 'json') - if (!jsonResult?.value) return [] - - // Get the spawned agent results array - const spawnedResults = Array.isArray(jsonResult.value) ? jsonResult.value : [jsonResult.value] - - // Extract the value (AgentOutput) from each result - return spawnedResults.map((result: any) => result?.value).filter(Boolean) + for (const result of spawnResults) { + const fileListText = extractLastMessageText(result) + if (fileListText) { + hasAnyResults = true + const paths = fileListText.split('\n').filter(Boolean) + for (const path of paths) { + allPaths.add(path) + } } + } + + if (!hasAnyResults) { + const errorMessages = spawnResults + .map(extractErrorMessage) + .filter(Boolean) + .join('; ') + yield { + type: 'STEP_TEXT', + text: errorMessages + ? `Error from file-lister(s): ${errorMessages}` + : 'Error: Could not extract file list from spawned agent(s)', + } satisfies StepText + return + } + + const paths = Array.from(allPaths) + + yield { + toolName: 'read_files', + input: { paths }, + } + + yield 'STEP' - /** - * Extracts the text content from a 'lastMessage' AgentOutput. - * - * For agents with outputMode: 'last_message', the output structure is: - * { type: 'lastMessage', value: [{ role: 'assistant', content: [{ type: 'text', text: '...' }] }] } - * - * Returns the text from the last assistant message, or null if not found. - */ - function extractLastMessageText(agentOutput: any): string | null { - if (!agentOutput) return null - - // Handle 'lastMessage' output mode - the value contains an array of messages - if (agentOutput.type === 'lastMessage' && Array.isArray(agentOutput.value)) { - // Find the last assistant message with text content - for (let i = agentOutput.value.length - 1; i >= 0; i--) { - const message = agentOutput.value[i] - if (message.role === 'assistant' && Array.isArray(message.content)) { - // Find text content in the message - for (const part of message.content) { - if (part.type === 'text' && typeof part.text === 'string') { - return part.text - } + function extractSpawnResults(results: any[] | undefined): any[] { + if (!results || results.length === 0) return [] + const jsonResult = results.find((r) => r.type === 'json') + if (!jsonResult?.value) return [] + const spawnedResults = Array.isArray(jsonResult.value) + ? jsonResult.value + : [jsonResult.value] + return spawnedResults.map((result: any) => result?.value).filter(Boolean) + } + + function extractLastMessageText(agentOutput: any): string | null { + if (!agentOutput) return null + if ( + agentOutput.type === 'lastMessage' && + Array.isArray(agentOutput.value) + ) { + for (let i = agentOutput.value.length - 1; i >= 0; i--) { + const message = agentOutput.value[i] + if (message.role === 'assistant' && Array.isArray(message.content)) { + for (const part of message.content) { + if (part.type === 'text' && typeof part.text === 'string') { + return part.text } } } } - - return null } + return null + } - /** - * Extracts the error message from an AgentOutput if it's an error type. - * - * Returns the error message string, or null if not an error output. - */ - function extractErrorMessage(agentOutput: any): string | null { - if (!agentOutput) return null - - if (agentOutput.type === 'error') { - return agentOutput.message ?? agentOutput.value ?? null - } - - return null + function extractErrorMessage(agentOutput: any): string | null { + if (!agentOutput) return null + if (agentOutput.type === 'error') { + return agentOutput.message ?? agentOutput.value ?? null } - }, + return null + } +} + +const definition: SecretAgentDefinition = { + id: 'file-picker', + ...createFilePicker('default'), } export default definition From dc74cce8ea3b3fec2d7d5cfb0b60cf593df99f36 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 21:57:41 -0800 Subject: [PATCH 0046/1143] fix(discord): improve advisory lock reliability for leader election - Add LockHandle interface with onLost() callback and release() method - Add 30s health check (SELECT 1) to detect connection loss - Make startDiscordBot() return Promise that resolves on ready event - Wait for bot ready before holding lock (fixes lock-on-failed-login) - Release lock and retry if bot fails to start - Add consecutive error tracking with max retry limit - Fix connection cleanup in triggerLost() --- .../src/db/__tests__/advisory-lock.test.ts | 442 ++++++++++++++++++ packages/internal/src/db/advisory-lock.ts | 99 ++-- packages/internal/src/db/index.ts | 3 +- web/scripts/discord/index.ts | 128 +++-- web/src/discord/client.ts | 237 +++++----- 5 files changed, 733 insertions(+), 176 deletions(-) create mode 100644 packages/internal/src/db/__tests__/advisory-lock.test.ts diff --git a/packages/internal/src/db/__tests__/advisory-lock.test.ts b/packages/internal/src/db/__tests__/advisory-lock.test.ts new file mode 100644 index 0000000000..27efdc570d --- /dev/null +++ b/packages/internal/src/db/__tests__/advisory-lock.test.ts @@ -0,0 +1,442 @@ +import { + afterEach, + beforeEach, + describe, + expect, + it, + mock, + spyOn, +} from 'bun:test' + +import { ADVISORY_LOCK_IDS } from '../advisory-lock' + +describe('advisory-lock', () => { + let mockConnection: { + end: ReturnType + tagged: ReturnType + } + let postgresMock: ReturnType + let setIntervalSpy: ReturnType + let clearIntervalSpy: ReturnType + let consoleErrorSpy: ReturnType + + // Import the module fresh for each test + let tryAcquireAdvisoryLock: typeof import('../advisory-lock').tryAcquireAdvisoryLock + + beforeEach(async () => { + // Create mock connection with tagged template support + mockConnection = { + end: mock(() => Promise.resolve()), + tagged: mock(() => Promise.resolve([{ acquired: true }])), + } + + // Make the connection callable as a tagged template function + const callableConnection = Object.assign( + (strings: TemplateStringsArray, ...values: unknown[]) => { + return mockConnection.tagged(strings, ...values) + }, + mockConnection, + ) + + // Mock the postgres module + postgresMock = mock(() => callableConnection) + + mock.module('postgres', () => ({ + default: postgresMock, + })) + + // Spy on timers + setIntervalSpy = spyOn(globalThis, 'setInterval') + clearIntervalSpy = spyOn(globalThis, 'clearInterval') + consoleErrorSpy = spyOn(console, 'error').mockImplementation(() => {}) + + // Re-import to get fresh module with mocks + const module = await import('../advisory-lock') + tryAcquireAdvisoryLock = module.tryAcquireAdvisoryLock + }) + + afterEach(() => { + mock.restore() + }) + + describe('ADVISORY_LOCK_IDS', () => { + it('should have a DISCORD_BOT lock ID', () => { + expect(ADVISORY_LOCK_IDS.DISCORD_BOT).toBe(741852963) + }) + }) + + describe('tryAcquireAdvisoryLock', () => { + describe('successful lock acquisition', () => { + it('should return acquired: true with a valid handle', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(result.acquired).toBe(true) + expect(result.handle).not.toBeNull() + expect(typeof result.handle?.onLost).toBe('function') + expect(typeof result.handle?.release).toBe('function') + + // Clean up + await result.handle?.release() + }) + + it('should create postgres connection with correct options', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(postgresMock).toHaveBeenCalledTimes(1) + const callArgs = postgresMock.mock.calls[0] + expect(callArgs[1]).toEqual({ + max: 1, + idle_timeout: 0, + connect_timeout: 10, + }) + + await result.handle?.release() + }) + + it('should call pg_try_advisory_lock with the correct lock ID', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(mockConnection.tagged).toHaveBeenCalled() + const [strings, lockId] = mockConnection.tagged.mock.calls[0] + expect(strings[0]).toContain('SELECT pg_try_advisory_lock(') + expect(lockId).toBe(ADVISORY_LOCK_IDS.DISCORD_BOT) + + await result.handle?.release() + }) + + it('should set up health check interval', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(setIntervalSpy).toHaveBeenCalledTimes(1) + expect(setIntervalSpy.mock.calls[0][1]).toBe(30_000) // 30 seconds + + await result.handle?.release() + }) + }) + + describe('failed lock acquisition', () => { + it('should return acquired: false when lock is held by another', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: false }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(result.acquired).toBe(false) + expect(result.handle).toBeNull() + }) + + it('should close connection when lock not acquired', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: false }]) + + await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(mockConnection.end).toHaveBeenCalledTimes(1) + }) + + it('should not set up health check when lock not acquired', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: false }]) + + await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(setIntervalSpy).not.toHaveBeenCalled() + }) + }) + + describe('connection errors', () => { + it('should throw error when connection fails', async () => { + mockConnection.tagged.mockRejectedValue(new Error('Connection refused')) + + await expect( + tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT), + ).rejects.toThrow('Connection refused') + }) + + it('should close connection on error', async () => { + mockConnection.tagged.mockRejectedValue(new Error('Connection refused')) + + try { + await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + } catch { + // Expected + } + + expect(mockConnection.end).toHaveBeenCalledTimes(1) + }) + + it('should handle connection.end() failure on error cleanup', async () => { + mockConnection.tagged.mockRejectedValue(new Error('Query failed')) + mockConnection.end.mockRejectedValue(new Error('End failed')) + + // Should not throw from the end() failure + await expect( + tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT), + ).rejects.toThrow('Query failed') + }) + }) + + describe('handle.release()', () => { + it('should close connection when released', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + await result.handle?.release() + + expect(mockConnection.end).toHaveBeenCalledTimes(1) + }) + + it('should clear health check interval when released', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + await result.handle?.release() + + expect(clearIntervalSpy).toHaveBeenCalledTimes(1) + }) + + it('should be idempotent - calling twice should not error', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + await result.handle?.release() + await result.handle?.release() + + // Should only close once + expect(mockConnection.end).toHaveBeenCalledTimes(1) + }) + + it('should handle connection.end() error gracefully', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + mockConnection.end.mockRejectedValue(new Error('End failed')) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + // Should not throw + await result.handle?.release() + + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Error releasing advisory lock:', + expect.any(Error), + ) + }) + }) + + describe('handle.onLost()', () => { + it('should register callback', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + const lostCallback = mock(() => {}) + result.handle?.onLost(lostCallback) + + // Callback should not be called immediately + expect(lostCallback).not.toHaveBeenCalled() + + await result.handle?.release() + }) + }) + + describe('health check mechanism', () => { + it('should trigger onLost when health check fails', async () => { + // First call succeeds (acquire lock), second call fails (health check) + let callCount = 0 + mockConnection.tagged.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve([{ acquired: true }]) + } + return Promise.reject(new Error('Connection lost')) + }) + + // Mock setInterval to capture the callback + let healthCheckCallback: (() => Promise) | null = null + setIntervalSpy.mockImplementation((callback: () => Promise) => { + healthCheckCallback = callback + return 123 as unknown as NodeJS.Timeout + }) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + const lostCallback = mock(() => {}) + result.handle?.onLost(lostCallback) + + // Trigger the health check + expect(healthCheckCallback).not.toBeNull() + await healthCheckCallback!() + + expect(lostCallback).toHaveBeenCalledTimes(1) + expect(consoleErrorSpy).toHaveBeenCalledWith( + 'Advisory lock health check failed - connection lost', + ) + }) + + it('should close connection when health check fails', async () => { + let callCount = 0 + mockConnection.tagged.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve([{ acquired: true }]) + } + return Promise.reject(new Error('Connection lost')) + }) + + let healthCheckCallback: (() => Promise) | null = null + setIntervalSpy.mockImplementation((callback: () => Promise) => { + healthCheckCallback = callback + return 123 as unknown as NodeJS.Timeout + }) + + await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + // Trigger the health check + await healthCheckCallback!() + + expect(mockConnection.end).toHaveBeenCalled() + }) + + it('should clear interval when health check fails', async () => { + let callCount = 0 + mockConnection.tagged.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve([{ acquired: true }]) + } + return Promise.reject(new Error('Connection lost')) + }) + + const timerId = 456 + setIntervalSpy.mockImplementation((callback: () => Promise) => { + // Execute callback asynchronously to simulate real behavior + setTimeout(() => callback(), 0) + return timerId as unknown as NodeJS.Timeout + }) + + await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + // Wait for the async callback to execute + await new Promise((resolve) => setTimeout(resolve, 10)) + + expect(clearIntervalSpy).toHaveBeenCalledWith(timerId) + }) + + it('should not trigger onLost after release', async () => { + let callCount = 0 + mockConnection.tagged.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve([{ acquired: true }]) + } + return Promise.reject(new Error('Connection lost')) + }) + + let healthCheckCallback: (() => Promise) | null = null + setIntervalSpy.mockImplementation((callback: () => Promise) => { + healthCheckCallback = callback + return 123 as unknown as NodeJS.Timeout + }) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + const lostCallback = mock(() => {}) + result.handle?.onLost(lostCallback) + + // Release first + await result.handle?.release() + + // Then trigger health check (should be no-op since already released) + await healthCheckCallback!() + + expect(lostCallback).not.toHaveBeenCalled() + }) + + it('should not call onLost twice if health check fails multiple times', async () => { + let callCount = 0 + mockConnection.tagged.mockImplementation(() => { + callCount++ + if (callCount === 1) { + return Promise.resolve([{ acquired: true }]) + } + return Promise.reject(new Error('Connection lost')) + }) + + let healthCheckCallback: (() => Promise) | null = null + setIntervalSpy.mockImplementation((callback: () => Promise) => { + healthCheckCallback = callback + return 123 as unknown as NodeJS.Timeout + }) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + const lostCallback = mock(() => {}) + result.handle?.onLost(lostCallback) + + // Trigger health check twice + await healthCheckCallback!() + await healthCheckCallback!() + + // Should only be called once + expect(lostCallback).toHaveBeenCalledTimes(1) + }) + + it('should do nothing when health check succeeds', async () => { + // All calls succeed + mockConnection.tagged.mockResolvedValue([{ acquired: true }]) + + let healthCheckCallback: (() => Promise) | null = null + setIntervalSpy.mockImplementation((callback: () => Promise) => { + healthCheckCallback = callback + return 123 as unknown as NodeJS.Timeout + }) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + const lostCallback = mock(() => {}) + result.handle?.onLost(lostCallback) + + // Trigger health check + await healthCheckCallback!() + + expect(lostCallback).not.toHaveBeenCalled() + expect(mockConnection.end).not.toHaveBeenCalled() + + // Clean up + await result.handle?.release() + }) + }) + + describe('edge cases', () => { + it('should handle empty result from pg_try_advisory_lock', async () => { + mockConnection.tagged.mockResolvedValue([]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(result.acquired).toBe(false) + expect(result.handle).toBeNull() + }) + + it('should handle undefined acquired value', async () => { + mockConnection.tagged.mockResolvedValue([{ acquired: undefined }]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(result.acquired).toBe(false) + expect(result.handle).toBeNull() + }) + + it('should handle null result', async () => { + mockConnection.tagged.mockResolvedValue([null]) + + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + + expect(result.acquired).toBe(false) + expect(result.handle).toBeNull() + }) + }) + }) +}) diff --git a/packages/internal/src/db/advisory-lock.ts b/packages/internal/src/db/advisory-lock.ts index b4448d70ff..97a2387f94 100644 --- a/packages/internal/src/db/advisory-lock.ts +++ b/packages/internal/src/db/advisory-lock.ts @@ -12,55 +12,94 @@ export const ADVISORY_LOCK_IDS = { export type AdvisoryLockId = (typeof ADVISORY_LOCK_IDS)[keyof typeof ADVISORY_LOCK_IDS] +const HEALTH_CHECK_INTERVAL_MS = 30_000 // 30 seconds + +export interface LockHandle { + /** Register a callback to be called if the lock is lost (connection dies) */ + onLost(callback: () => void): void + /** Release the lock and clean up resources */ + release(): Promise +} + /** * Tries to acquire a PostgreSQL session-level advisory lock. * - * Advisory locks are held until explicitly released or the connection closes. - * This is useful for leader election - only one instance can hold the lock. - * * @param lockId - The unique lock identifier - * @returns An object with `acquired` boolean and the `connection` if acquired. - * The connection must be kept alive to maintain the lock. - * Close the connection to release the lock. + * @returns An object with `acquired` boolean and a `handle` if acquired. + * Use handle.onLost() to detect connection failures. + * Use handle.release() to release the lock. */ export async function tryAcquireAdvisoryLock(lockId: AdvisoryLockId): Promise<{ acquired: boolean - connection: postgres.Sql | null + handle: LockHandle | null }> { - // Create a dedicated connection for this lock - // This connection must stay open to maintain the lock const connection = postgres(env.DATABASE_URL, { - max: 1, // Single connection for the lock - idle_timeout: 0, // Never timeout - keep connection alive - connect_timeout: 10, // 10 second connection timeout + max: 1, + idle_timeout: 0, + connect_timeout: 10, }) try { const result = await connection`SELECT pg_try_advisory_lock(${lockId}) as acquired` const acquired = result[0]?.acquired === true - if (acquired) { - return { acquired: true, connection } - } else { - // Lock not acquired, close the connection + if (!acquired) { await connection.end() - return { acquired: false, connection: null } + return { acquired: false, handle: null } + } + + // Create the lock handle + let lostCallback: (() => void) | null = null + let isReleased = false + let healthCheckTimer: ReturnType | null = null + + const triggerLost = () => { + if (isReleased) return + if (healthCheckTimer) { + clearInterval(healthCheckTimer) + healthCheckTimer = null + } + // Close the connection before marking as released + connection.end().catch(() => {}) + isReleased = true + if (lostCallback) { + lostCallback() + } + } + + // Start health check interval + healthCheckTimer = setInterval(async () => { + if (isReleased) return + try { + await connection`SELECT 1` + } catch { + console.error('Advisory lock health check failed - connection lost') + triggerLost() + } + }, HEALTH_CHECK_INTERVAL_MS) + + const handle: LockHandle = { + onLost(callback: () => void) { + lostCallback = callback + }, + async release() { + if (isReleased) return + isReleased = true + if (healthCheckTimer) { + clearInterval(healthCheckTimer) + healthCheckTimer = null + } + try { + await connection.end() + } catch (error) { + console.error('Error releasing advisory lock:', error) + } + }, } + + return { acquired: true, handle } } catch (error) { - // On error, ensure connection is closed await connection.end().catch(() => {}) throw error } } - -/** - * Releases an advisory lock by closing the connection. - * The lock is automatically released when the connection closes. - */ -export async function releaseAdvisoryLock( - connection: postgres.Sql | null, -): Promise { - if (connection) { - await connection.end() - } -} diff --git a/packages/internal/src/db/index.ts b/packages/internal/src/db/index.ts index 0f72180c09..3c158d3b91 100644 --- a/packages/internal/src/db/index.ts +++ b/packages/internal/src/db/index.ts @@ -16,6 +16,5 @@ export default db export { ADVISORY_LOCK_IDS, tryAcquireAdvisoryLock, - releaseAdvisoryLock, } from './advisory-lock' -export type { AdvisoryLockId } from './advisory-lock' +export type { LockHandle, AdvisoryLockId } from './advisory-lock' diff --git a/web/scripts/discord/index.ts b/web/scripts/discord/index.ts index 0566f4e401..b0864315e3 100644 --- a/web/scripts/discord/index.ts +++ b/web/scripts/discord/index.ts @@ -1,17 +1,17 @@ import { ADVISORY_LOCK_IDS, tryAcquireAdvisoryLock, - releaseAdvisoryLock, } from '@codebuff/internal/db' import { startDiscordBot } from '../../src/discord/client' -import type postgres from 'postgres' +import type { LockHandle } from '@codebuff/internal/db' import type { Client } from 'discord.js' const LOCK_RETRY_INTERVAL_MS = 30_000 // 30 seconds +const MAX_CONSECUTIVE_ERRORS = 5 -let lockConnection: postgres.Sql | null = null +let lockHandle: LockHandle | null = null let discordClient: Client | null = null let isShuttingDown = false @@ -19,54 +19,114 @@ function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)) } -async function main() { - // Set up shutdown handlers early - const shutdown = async () => { - if (isShuttingDown) return - isShuttingDown = true +async function shutdown(exitCode: number = 0) { + if (isShuttingDown) return + isShuttingDown = true - console.log('Shutting down Discord bot...') - if (discordClient) { + console.log('Shutting down Discord bot...') + + if (discordClient) { + try { discordClient.destroy() + } catch (error) { + console.error('Error destroying Discord client:', error) } - await releaseAdvisoryLock(lockConnection) - process.exit(0) + discordClient = null } + + if (lockHandle) { + await lockHandle.release() + lockHandle = null + } + + process.exit(exitCode) +} - process.on('SIGTERM', shutdown) - process.on('SIGINT', shutdown) +async function main() { + process.on('SIGTERM', () => shutdown(0)) + process.on('SIGINT', () => shutdown(0)) - // Poll for the lock until acquired + let consecutiveErrors = 0 let attemptCount = 0 + while (!isShuttingDown) { attemptCount++ console.log(`Attempting to acquire Discord bot lock (attempt ${attemptCount})...`) - const { acquired, connection } = await tryAcquireAdvisoryLock( - ADVISORY_LOCK_IDS.DISCORD_BOT, - ) + let acquired = false + let handle: LockHandle | null = null - if (acquired) { - lockConnection = connection - console.log('Lock acquired. Starting Discord bot...') + try { + const result = await tryAcquireAdvisoryLock(ADVISORY_LOCK_IDS.DISCORD_BOT) + acquired = result.acquired + handle = result.handle + consecutiveErrors = 0 // Reset on successful DB connection + } catch (error) { + consecutiveErrors++ + console.error(`Error acquiring lock (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error) + + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + console.error('Too many consecutive errors, exiting...') + await shutdown(1) + return + } + + await sleep(LOCK_RETRY_INTERVAL_MS) + continue + } - discordClient = startDiscordBot() - return // Bot is running, exit the polling loop + if (!acquired || !handle) { + console.log( + `Another instance is already running the Discord bot. Retrying in ${LOCK_RETRY_INTERVAL_MS / 1000} seconds...`, + ) + await sleep(LOCK_RETRY_INTERVAL_MS) + continue } - console.log( - `Another instance is already running the Discord bot. Retrying in ${LOCK_RETRY_INTERVAL_MS / 1000} seconds...`, - ) - await sleep(LOCK_RETRY_INTERVAL_MS) + lockHandle = handle + console.log('Lock acquired. Starting Discord bot...') + + // Set up lock loss handler BEFORE starting the bot + handle.onLost(() => { + console.error('Advisory lock lost! Another instance may have taken over.') + shutdown(1) + }) + + try { + // Wait for bot to be ready - this is critical! + // If login fails, we release the lock so another instance can try + discordClient = await startDiscordBot() + console.log('Discord bot is ready and running.') + + // Set up error handler for runtime errors + discordClient.on('error', (error) => { + console.error('Discord client error:', error) + }) + + // Handle disconnection + discordClient.on('disconnect', () => { + console.error('Discord client disconnected') + }) + + // Bot is running, keep the process alive + return + } catch (error) { + console.error('Failed to start Discord bot:', error) + + // Release the lock so another instance can try + await handle.release() + lockHandle = null + discordClient = null + + // Continue polling - maybe another instance will have better luck, + // or maybe the issue is transient (Discord outage) + console.log(`Will retry in ${LOCK_RETRY_INTERVAL_MS / 1000} seconds...`) + await sleep(LOCK_RETRY_INTERVAL_MS) + } } } main().catch(async (error) => { - console.error('Error in Discord bot script:', error) - // Clean up on error - if (discordClient) { - discordClient.destroy() - } - await releaseAdvisoryLock(lockConnection) - process.exit(1) + console.error('Fatal error in Discord bot script:', error) + await shutdown(1) }) diff --git a/web/src/discord/client.ts b/web/src/discord/client.ts index 45506bef80..b6f309689b 100644 --- a/web/src/discord/client.ts +++ b/web/src/discord/client.ts @@ -13,138 +13,155 @@ import { logger } from '@/util/logger' const VERIFIED_ROLE_ID = '1354877460583415929' const WELCOME_CHANNEL_ID = '1272621334580429053' -export function startDiscordBot() { - const client = new Client({ - intents: [ - GatewayIntentBits.Guilds, - GatewayIntentBits.GuildMembers, - GatewayIntentBits.GuildMessages, - GatewayIntentBits.MessageContent, - ], - }) - - client.once(Events.ClientReady, (c) => { - logger.info(`Discord bot ready! Logged in as ${c.user.tag}`) - }) - - // Listen for messages in the welcome channel - client.on(Events.MessageCreate, async (message) => { - if (message.channelId !== WELCOME_CHANNEL_ID) return - - // Check if this is a system message about a new member (7 is GuildMemberJoin) - if (message.system && message.type === 7) { - try { - await message.reply({ - content: `Hey there! Enter \`/link\` to connect your Discord account with Codebuff (don't worry, only you can see it).`, - }) - } catch (error) { - logger.error({ error }, 'Failed to send welcome message') +/** + * Starts the Discord bot and waits for it to be ready. + * @returns A promise that resolves with the client when ready, or rejects on error. + */ +export function startDiscordBot(): Promise { + return new Promise((resolve, reject) => { + const client = new Client({ + intents: [ + GatewayIntentBits.Guilds, + GatewayIntentBits.GuildMembers, + GatewayIntentBits.GuildMessages, + GatewayIntentBits.MessageContent, + ], + }) + + let isResolved = false + + client.once(Events.ClientReady, (c) => { + logger.info(`Discord bot ready! Logged in as ${c.user.tag}`) + isResolved = true + resolve(client) + }) + + client.once('error', (error) => { + if (!isResolved) { + reject(error) } - } - }) + }) - // Handle slash commands - client.on(Events.InteractionCreate, async (interaction: Interaction) => { - if (!interaction.isChatInputCommand()) return + // Listen for messages in the welcome channel + client.on(Events.MessageCreate, async (message) => { + if (message.channelId !== WELCOME_CHANNEL_ID) return - const command = interaction as ChatInputCommandInteraction + // Check if this is a system message about a new member (7 is GuildMemberJoin) + if (message.system && message.type === 7) { + try { + await message.reply({ + content: `Hey there! Enter \`/link\` to connect your Discord account with Codebuff (don't worry, only you can see it).`, + }) + } catch (error) { + logger.error({ error }, 'Failed to send welcome message') + } + } + }) - // Check rate limit before processing command - if (isRateLimited(command.user.id)) { - await command.reply({ - content: - 'You are sending commands too quickly. Please wait a minute and try again.', - ephemeral: true, - }) - return - } + // Handle slash commands + client.on(Events.InteractionCreate, async (interaction: Interaction) => { + if (!interaction.isChatInputCommand()) return - if (command.commandName === 'link') { - const email = command.options.getString('email') + const command = interaction as ChatInputCommandInteraction - if (!email) { + // Check rate limit before processing command + if (isRateLimited(command.user.id)) { await command.reply({ - content: 'Please provide your email address with the command.', + content: + 'You are sending commands too quickly. Please wait a minute and try again.', ephemeral: true, }) return } - try { - // Get any users with this discord_id or email in one query - const users = await db - .select({ - id: user.id, - email: user.email, - discordId: user.discord_id, - }) - .from(user) - .where( - or(eq(user.discord_id, command.user.id), eq(user.email, email)), - ) - - // Find the user with this email - const userRecord = users.find((u) => u.email === email) - - if ( - // Discord ID is already linked to any account - users.some((u) => u.discordId === command.user.id) || - // Email doesn't exist - !userRecord || - // Email exists but has a different discord_id - userRecord.discordId !== null - ) { + if (command.commandName === 'link') { + const email = command.options.getString('email') + + if (!email) { await command.reply({ - content: `I couldn't link that email to your Discord account. Make sure you're using the correct email and that it isn't already linked to another Discord account. Contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} if you need help.`, + content: 'Please provide your email address with the command.', ephemeral: true, }) return } - // Update the discord_id since we know it's null - await db - .update(user) - .set({ discord_id: command.user.id }) - .where(eq(user.id, userRecord.id)) - - // Add the role - if (command.guild) { - try { - const member = await command.guild.members.fetch(command.user.id) - await member.roles.add(VERIFIED_ROLE_ID) - logger.info( - { - userId: userRecord.id, - discordId: command.user.id, - discordUsername: command.user.username, - }, - 'Added verified role to user', + try { + // Get any users with this discord_id or email in one query + const users = await db + .select({ + id: user.id, + email: user.email, + discordId: user.discord_id, + }) + .from(user) + .where( + or(eq(user.discord_id, command.user.id), eq(user.email, email)), ) - } catch (error) { - logger.error({ error }, 'Failed to add verified role to user') + + // Find the user with this email + const userRecord = users.find((u) => u.email === email) + + if ( + // Discord ID is already linked to any account + users.some((u) => u.discordId === command.user.id) || + // Email doesn't exist + !userRecord || + // Email exists but has a different discord_id + userRecord.discordId !== null + ) { + await command.reply({ + content: `I couldn't link that email to your Discord account. Make sure you're using the correct email and that it isn't already linked to another Discord account. Contact ${env.NEXT_PUBLIC_SUPPORT_EMAIL} if you need help.`, + ephemeral: true, + }) + return } - } - await command.reply({ - content: - "Thanks! I've linked your Discord account to your Codebuff account. You're all set! 🎉", - ephemeral: true, - }) - } catch (error) { - logger.error({ error }, 'Error updating user Discord ID') - await command.reply({ - content: - 'Sorry, I ran into an error while trying to link your account. Please try again later or contact support if the problem persists.', - ephemeral: true, - }) + // Update the discord_id since we know it's null + await db + .update(user) + .set({ discord_id: command.user.id }) + .where(eq(user.id, userRecord.id)) + + // Add the role + if (command.guild) { + try { + const member = await command.guild.members.fetch(command.user.id) + await member.roles.add(VERIFIED_ROLE_ID) + logger.info( + { + userId: userRecord.id, + discordId: command.user.id, + discordUsername: command.user.username, + }, + 'Added verified role to user', + ) + } catch (error) { + logger.error({ error }, 'Failed to add verified role to user') + } + } + + await command.reply({ + content: + "Thanks! I've linked your Discord account to your Codebuff account. You're all set! 🎉", + ephemeral: true, + }) + } catch (error) { + logger.error({ error }, 'Error updating user Discord ID') + await command.reply({ + content: + 'Sorry, I ran into an error while trying to link your account. Please try again later or contact support if the problem persists.', + ephemeral: true, + }) + } } - } - }) + }) - // Login to Discord - client.login(env.DISCORD_BOT_TOKEN).catch((error) => { - logger.error({ error }, 'Failed to start Discord bot') + // Login to Discord + client.login(env.DISCORD_BOT_TOKEN).catch((error) => { + logger.error({ error }, 'Failed to start Discord bot') + if (!isResolved) { + reject(error) + } + }) }) - - return client } From fd33bc7a138e7f67bde1c49be2e47a1a96c44681 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 22:48:03 -0800 Subject: [PATCH 0047/1143] refactor(cli): extract chat UI and streaming hooks (Commit 1.1b) - Create use-chat-ui.ts: scroll behavior, terminal dimensions, theme - Create use-chat-streaming.ts: connection status, timer, queue management - Update chat.tsx to use the new extracted hooks - Uses existing useExitHandler hook (not reimplementing) - Omit chat-orchestrator.tsx (reviewers agreed it was dead code) - Mark Commit 1.1b complete in REFACTORING_PLAN.md --- REFACTORING_PLAN.md | 2 +- cli/src/chat.tsx | 203 +++++------------------- cli/src/hooks/use-chat-streaming.ts | 235 ++++++++++++++++++++++++++++ cli/src/hooks/use-chat-ui.ts | 131 ++++++++++++++++ 4 files changed, 408 insertions(+), 163 deletions(-) create mode 100644 cli/src/hooks/use-chat-streaming.ts create mode 100644 cli/src/hooks/use-chat-ui.ts diff --git a/REFACTORING_PLAN.md b/REFACTORING_PLAN.md index bbc4625d58..173421e0d9 100644 --- a/REFACTORING_PLAN.md +++ b/REFACTORING_PLAN.md @@ -22,7 +22,7 @@ This document outlines a prioritized refactoring plan for the 51 issues identifi | Commit | Description | Status | Completed By | |--------|-------------|--------|-------------| | 1.1a | Extract chat state management | ✅ Complete | Codex CLI | -| 1.1b | Extract chat UI and orchestration | ⬜ Not Started | - | +| 1.1b | Extract chat UI and orchestration | ✅ Complete | Codebuff | | 1.2 | Refactor context-pruner god function | ✅ Complete | Codex CLI | | 1.3 | Split old-constants.ts god module | ✅ Complete | Codex CLI | | 1.4 | Fix silent error swallowing | ✅ Complete | Codex CLI | diff --git a/cli/src/chat.tsx b/cli/src/chat.tsx index d420fb1db1..e93979c53f 100644 --- a/cli/src/chat.tsx +++ b/cli/src/chat.tsx @@ -1,7 +1,5 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk' import open from 'open' -import { useQueryClient } from '@tanstack/react-query' import { useCallback, useEffect, @@ -9,7 +7,6 @@ import { useMemo, useRef, useState, - useTransition, } from 'react' import { useShallow } from 'zustand/react/shallow' @@ -27,7 +24,6 @@ import { TopBanner } from './components/top-banner' import { SLASH_COMMANDS } from './data/slash-commands' import { useAgentValidation } from './hooks/use-agent-validation' import { useAskUserBridge } from './hooks/use-ask-user-bridge' -import { authQueryKeys } from './hooks/use-auth-query' import { useChatInput } from './hooks/use-chat-input' import { useClaudeQuotaQuery } from './hooks/use-claude-quota-query' import { @@ -36,24 +32,16 @@ import { } from './hooks/use-chat-keyboard' import { useChatMessages } from './hooks/use-chat-messages' import { useChatState } from './hooks/use-chat-state' +import { useChatStreaming } from './hooks/use-chat-streaming' +import { useChatUI } from './hooks/use-chat-ui' import { useClipboard } from './hooks/use-clipboard' -import { useConnectionStatus } from './hooks/use-connection-status' -import { useElapsedTime } from './hooks/use-elapsed-time' import { useGravityAd } from './hooks/use-gravity-ad' import { useEvent } from './hooks/use-event' -import { useExitHandler } from './hooks/use-exit-handler' import { useInputHistory } from './hooks/use-input-history' -import { useMessageQueue, type QueuedMessage } from './hooks/use-message-queue' +import { type QueuedMessage } from './hooks/use-message-queue' import { usePublishMutation } from './hooks/use-publish-mutation' -import { useQueueControls } from './hooks/use-queue-controls' -import { useQueueUi } from './hooks/use-queue-ui' -import { useChatScrollbox } from './hooks/use-scroll-management' import { useSendMessage } from './hooks/use-send-message' import { useSuggestionEngine } from './hooks/use-suggestion-engine' -import { useTerminalDimensions } from './hooks/use-terminal-dimensions' -import { useTerminalLayout } from './hooks/use-terminal-layout' -import { useTheme } from './hooks/use-theme' -import { useTimeout } from './hooks/use-timeout' import { useUsageMonitor } from './hooks/use-usage-monitor' import { WEBSITE_URL } from './login/constants' import { getProjectRoot } from './project-files' @@ -65,10 +53,8 @@ import { usePublishStore } from './state/publish-store' import { addClipboardPlaceholder, addPendingImageFromFile, - capturePendingAttachments, validateAndAddImage, } from './utils/pending-attachments' -import { createChatScrollAcceleration } from './utils/chat-scroll-accel' import { showClipboardMessage } from './utils/clipboard' import { readClipboardImage } from './utils/clipboard-image' import { getInputModeConfig } from './utils/input-modes' @@ -77,7 +63,6 @@ import { createDefaultChatKeyboardState, } from './utils/keyboard-actions' import { loadLocalAgents } from './utils/local-agent-registry' -// buildMessageTree is now used internally by useChatMessages hook import { getStatusIndicatorState, type AuthStatus, @@ -85,15 +70,12 @@ import { import { getClaudeOAuthStatus } from './utils/claude-oauth' import { createPasteHandler } from './utils/strings' import { computeInputLayoutMetrics } from './utils/text-layout' -import { createMarkdownPalette } from './utils/theme-system' import { reportActivity } from './utils/activity-tracker' import { trackEvent } from './utils/analytics' import { logger } from './utils/logger' import type { CommandResult } from './commands/command-registry' import type { MultilineInputHandle } from './components/multiline-input' - -// SendMessageFn type is now used internally by useChatState hook import type { User } from './utils/auth' import type { AgentMode } from './utils/constants' import type { FileTreeNode } from '@codebuff/common/util/file' @@ -132,29 +114,8 @@ export const Chat = ({ gitRoot?: string | null onSwitchToGitRoot?: () => void }) => { - const scrollRef = useRef(null) - const [hasOverflow, setHasOverflow] = useState(false) - const hasOverflowRef = useRef(false) - - // Message handling extracted to useChatMessages hook (initialized below after streamStatus is available) - - const queryClient = useQueryClient() - const [, startUiTransition] = useTransition() - - const [showReconnectionMessage, setShowReconnectionMessage] = useState(false) - const reconnectionTimeout = useTimeout() const [forceFileOnlyMentions, setForceFileOnlyMentions] = useState(false) - const { separatorWidth, terminalWidth, terminalHeight } = - useTerminalDimensions() - const { height: heightLayout, width: widthLayout } = useTerminalLayout() - const isCompactHeight = heightLayout.is('xs') - const isNarrowWidth = widthLayout.is('xs') - const messageAvailableWidth = separatorWidth - - const theme = useTheme() - const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) - const { validate: validateAgents } = useAgentValidation() // Subscribe to ask_user bridge to trigger form display @@ -197,35 +158,7 @@ export const Chat = ({ } = useChatState() const { statusMessage } = useClipboard() - - const handleReconnection = useCallback( - (isInitialConnection: boolean) => { - // Invalidate auth queries so we refetch with current credentials - queryClient.invalidateQueries({ queryKey: authQueryKeys.all }) - - startUiTransition(() => { - if (!isInitialConnection) { - setShowReconnectionMessage(true) - reconnectionTimeout.setTimeout( - 'reconnection-message', - () => { - startUiTransition(() => { - setShowReconnectionMessage(false) - }) - }, - RECONNECTION_MESSAGE_DURATION_MS, - ) - } - }) - }, - [queryClient, reconnectionTimeout, startUiTransition], - ) - - const isConnected = useConnectionStatus(handleReconnection) - const mainAgentTimer = useElapsedTime() const { ad } = useGravityAd() - // Use startTime for active timer display; when paused, timer hook maintains frozen value - const timerStartTime = mainAgentTimer.startTime // Set initial mode from CLI flag on mount useEffect(() => { @@ -245,61 +178,30 @@ export const Chat = ({ handleLoadPreviousMessages, } = useChatMessages({ messages, setMessages }) - const { scrollToLatest, scrollUp, scrollDown, scrollboxProps, isAtBottom } = useChatScrollbox( + // Use extracted UI hook for scroll, terminal dimensions, and theme + const { scrollRef, - messages, - isUserCollapsing, - ) - - // Check if content has overflowed and needs scrolling - useEffect(() => { - const scrollbox = scrollRef.current - if (!scrollbox) return - - const checkOverflow = () => { - const contentHeight = scrollbox.scrollHeight - const viewportHeight = scrollbox.viewport.height - const isOverflowing = contentHeight > viewportHeight - - // Only update state if overflow status actually changed - if (hasOverflowRef.current !== isOverflowing) { - hasOverflowRef.current = isOverflowing - setHasOverflow(isOverflowing) - } - } - - // Check initially and whenever scroll state changes - checkOverflow() - scrollbox.verticalScrollBar.on('change', checkOverflow) - - return () => { - scrollbox.verticalScrollBar.off('change', checkOverflow) - } - }, []) - - const inertialScrollAcceleration = useMemo( - () => createChatScrollAcceleration(), - [], - ) - - const appliedScrollboxProps = inertialScrollAcceleration - ? { ...scrollboxProps, scrollAcceleration: inertialScrollAcceleration } - : scrollboxProps + scrollToLatest, + scrollUp, + scrollDown, + appliedScrollboxProps, + isAtBottom, + hasOverflow, + terminalWidth, + terminalHeight, + separatorWidth, + messageAvailableWidth, + isCompactHeight, + isNarrowWidth, + theme, + markdownPalette, + } = useChatUI({ messages, isUserCollapsing }) const localAgents = useMemo(() => loadLocalAgents(agentMode), [agentMode]) const inputMode = useChatStore((state) => state.inputMode) const setInputMode = useChatStore((state) => state.setInputMode) const askUserState = useChatStore((state) => state.askUserState) - // Pause/resume timer when ask_user tool becomes active/inactive - useEffect(() => { - if (askUserState !== null) { - mainAgentTimer.pause() - } else if (mainAgentTimer.isPaused) { - mainAgentTimer.resume() - } - }, [askUserState, mainAgentTimer]) - // Filter slash commands based on current ads state - only show the option that changes state const filteredSlashCommands = useMemo(() => { const adsEnabled = getAdsEnabled() @@ -421,62 +323,46 @@ export const Chat = ({ { inputMode, setInputMode }, ) + // Use extracted streaming hook for connection, timer, queue, and exit handling const { - queuedMessages, + isConnected, + showReconnectionMessage, + mainAgentTimer, + timerStartTime, streamStatus, + isWaitingForResponse, + isStreaming, + setStreamStatus, + queuedMessages, queuePaused, streamMessageIdRef, addToQueue, stopStreaming, - setStreamStatus, setCanProcessQueue, pauseQueue, resumeQueue, clearQueue, isQueuePausedRef, isProcessingQueueRef, - } = useMessageQueue( - (message: QueuedMessage) => - sendMessageRef.current?.({ - content: message.content, - agentMode, - attachments: message.attachments, - }) ?? Promise.resolve(), - isChainInProgressRef, - activeAgentStreamsRef, - ) - - const { queuedCount, shouldShowQueuePreview, queuePreviewTitle, pausedQueueText, inputPlaceholder, - } = useQueueUi({ - queuePaused, - queuedMessages, - separatorWidth, - terminalWidth, - }) - - const { handleCtrlC: baseHandleCtrlC, nextCtrlCWillExit } = useExitHandler({ + handleCtrlC, + ensureQueueActiveBeforeSubmit, + nextCtrlCWillExit, + } = useChatStreaming({ + agentMode, inputValue, setInputValue, + terminalWidth, + separatorWidth, + isChainInProgressRef, + activeAgentStreamsRef, + sendMessageRef, }) - const { handleCtrlC, ensureQueueActiveBeforeSubmit } = useQueueControls({ - queuePaused, - queuedCount, - clearQueue, - resumeQueue, - inputHasText: Boolean(inputValue), - baseHandleCtrlC, - }) - - // Derive boolean flags from streamStatus for convenience - const isWaitingForResponse = streamStatus === 'waiting' - const isStreaming = streamStatus !== 'idle' - // When streaming completes, flush any pending bash commands into history (ghost mode only) // Non-ghost mode commands are already in history and will be cleared when user sends next message useEffect(() => { @@ -516,9 +402,6 @@ export const Chat = ({ } }, [isStreaming, pendingBashMessages, setMessages]) - // Timer events are currently tracked but not used for UI updates - // Future: Could be used for analytics or debugging - const { sendMessage, clearMessages } = useSendMessage({ inputRef, activeSubagentsRef, @@ -530,7 +413,7 @@ export const Chat = ({ onBeforeMessageSend: validateAgents, mainAgentTimer, scrollToLatest, - onTimerEvent: () => {}, // No-op for now + onTimerEvent: () => {}, isQueuePausedRef, isProcessingQueueRef, resumeQueue, @@ -1207,8 +1090,6 @@ export const Chat = ({ disabled: askUserState !== null, }) - // messageTree and topLevelMessages now come from useChatMessages hook - // Sync message block context to zustand store for child components const setMessageBlockContext = useMessageBlockStore( (state) => state.setContext, @@ -1256,8 +1137,6 @@ export const Chat = ({ setMessageBlockCallbacks, ]) - // visibleTopLevelMessages, hiddenMessageCount, handleLoadPreviousMessages come from useChatMessages hook - const modeConfig = getInputModeConfig(inputMode) const hasSlashSuggestions = slashContext.active && @@ -1355,7 +1234,7 @@ export const Chat = ({ }} > } stickyScroll stickyStart="bottom" scrollX={false} diff --git a/cli/src/hooks/use-chat-streaming.ts b/cli/src/hooks/use-chat-streaming.ts new file mode 100644 index 0000000000..bed7d12f06 --- /dev/null +++ b/cli/src/hooks/use-chat-streaming.ts @@ -0,0 +1,235 @@ +/** + * Chat streaming hook - connection status, timer, queue management, and exit handling. + */ + +import { useCallback, useEffect, useState, useTransition } from 'react' +import { useQueryClient } from '@tanstack/react-query' + +import { RECONNECTION_MESSAGE_DURATION_MS } from '@codebuff/sdk' + +import { authQueryKeys } from './use-auth-query' +import { useConnectionStatus } from './use-connection-status' +import { useElapsedTime } from './use-elapsed-time' +import { useExitHandler } from './use-exit-handler' +import { useMessageQueue, type QueuedMessage, type StreamStatus } from './use-message-queue' +import { useQueueControls } from './use-queue-controls' +import { useQueueUi } from './use-queue-ui' +import { useTimeout } from './use-timeout' +import { useChatStore } from '../state/chat-store' + +import type { ElapsedTimeTracker } from './use-elapsed-time' +import type { SendMessageFn } from '../types/contracts/send-message' +import type { AgentMode } from '../utils/constants' +import type { MutableRefObject } from 'react' +import type { PendingAttachment } from '../state/chat-store' + +export interface UseChatStreamingOptions { + agentMode: AgentMode + inputValue: string + setInputValue: (value: { text: string; cursorPosition: number; lastEditDueToNav: boolean }) => void + terminalWidth: number + separatorWidth: number + isChainInProgressRef: MutableRefObject + activeAgentStreamsRef: MutableRefObject + sendMessageRef: MutableRefObject +} + +export interface UseChatStreamingReturn { + // Connection state + isConnected: boolean + showReconnectionMessage: boolean + + // Timer + mainAgentTimer: ElapsedTimeTracker + timerStartTime: number | null + + // Stream status + streamStatus: StreamStatus + isWaitingForResponse: boolean + isStreaming: boolean + setStreamStatus: (status: StreamStatus) => void + + // Queue management + queuedMessages: QueuedMessage[] + queuePaused: boolean + streamMessageIdRef: MutableRefObject + addToQueue: (message: string, attachments?: PendingAttachment[]) => void + stopStreaming: () => void + setCanProcessQueue: (value: boolean | ((prev: boolean) => boolean)) => void + pauseQueue: () => void + resumeQueue: () => void + clearQueue: () => QueuedMessage[] + isQueuePausedRef: MutableRefObject + isProcessingQueueRef: MutableRefObject + + // Queue UI + queuedCount: number + shouldShowQueuePreview: boolean + queuePreviewTitle: string | undefined + pausedQueueText: string | undefined + inputPlaceholder: string + + // Exit handling + handleCtrlC: () => true + ensureQueueActiveBeforeSubmit: () => boolean + nextCtrlCWillExit: boolean +} + +export function useChatStreaming({ + agentMode, + inputValue, + setInputValue, + terminalWidth, + separatorWidth, + isChainInProgressRef, + activeAgentStreamsRef, + sendMessageRef, +}: UseChatStreamingOptions): UseChatStreamingReturn { + const queryClient = useQueryClient() + const [, startUiTransition] = useTransition() + + // Reconnection state + const [showReconnectionMessage, setShowReconnectionMessage] = useState(false) + const reconnectionTimeout = useTimeout() + + // Reconnection handler + const handleReconnection = useCallback( + (isInitialConnection: boolean) => { + queryClient.invalidateQueries({ queryKey: authQueryKeys.all }) + + startUiTransition(() => { + if (!isInitialConnection) { + setShowReconnectionMessage(true) + reconnectionTimeout.setTimeout( + 'reconnection-message', + () => { + startUiTransition(() => { + setShowReconnectionMessage(false) + }) + }, + RECONNECTION_MESSAGE_DURATION_MS, + ) + } + }) + }, + [queryClient, reconnectionTimeout, startUiTransition], + ) + + // Connection status + const isConnected = useConnectionStatus(handleReconnection) + + // Timer + const mainAgentTimer = useElapsedTime() + const timerStartTime = mainAgentTimer.startTime + + // Pause/resume timer when ask_user tool becomes active/inactive + const askUserState = useChatStore((state) => state.askUserState) + useEffect(() => { + if (askUserState !== null) { + mainAgentTimer.pause() + } else if (mainAgentTimer.isPaused) { + mainAgentTimer.resume() + } + }, [askUserState, mainAgentTimer]) + + // Message queue + const { + queuedMessages, + streamStatus, + queuePaused, + streamMessageIdRef, + addToQueue, + stopStreaming, + setStreamStatus, + setCanProcessQueue, + pauseQueue, + resumeQueue, + clearQueue, + isQueuePausedRef, + isProcessingQueueRef, + } = useMessageQueue( + (message: QueuedMessage) => + sendMessageRef.current?.({ + content: message.content, + agentMode, + attachments: message.attachments, + }) ?? Promise.resolve(), + isChainInProgressRef, + activeAgentStreamsRef, + ) + + // Queue UI + const { + queuedCount, + shouldShowQueuePreview, + queuePreviewTitle, + pausedQueueText, + inputPlaceholder, + } = useQueueUi({ + queuePaused, + queuedMessages, + separatorWidth, + terminalWidth, + }) + + // Exit handling + const { handleCtrlC: baseHandleCtrlC, nextCtrlCWillExit } = useExitHandler({ + inputValue, + setInputValue, + }) + + // Queue controls + const { handleCtrlC, ensureQueueActiveBeforeSubmit } = useQueueControls({ + queuePaused, + queuedCount, + clearQueue, + resumeQueue, + inputHasText: Boolean(inputValue), + baseHandleCtrlC, + }) + + // Derived flags + const isWaitingForResponse = streamStatus === 'waiting' + const isStreaming = streamStatus !== 'idle' + + return { + // Connection state + isConnected, + showReconnectionMessage, + + // Timer + mainAgentTimer, + timerStartTime, + + // Stream status + streamStatus, + isWaitingForResponse, + isStreaming, + setStreamStatus, + + // Queue management + queuedMessages, + queuePaused, + streamMessageIdRef, + addToQueue, + stopStreaming, + setCanProcessQueue, + pauseQueue, + resumeQueue, + clearQueue, + isQueuePausedRef, + isProcessingQueueRef, + + // Queue UI + queuedCount, + shouldShowQueuePreview, + queuePreviewTitle, + pausedQueueText, + inputPlaceholder, + + // Exit handling + handleCtrlC, + ensureQueueActiveBeforeSubmit, + nextCtrlCWillExit, + } +} diff --git a/cli/src/hooks/use-chat-ui.ts b/cli/src/hooks/use-chat-ui.ts new file mode 100644 index 0000000000..f5181650f0 --- /dev/null +++ b/cli/src/hooks/use-chat-ui.ts @@ -0,0 +1,131 @@ +/** + * Chat UI hook - scroll behavior, terminal dimensions, and theme. + */ + +import { useEffect, useMemo, useRef, useState } from 'react' + +import { useChatScrollbox } from './use-scroll-management' +import { useTerminalDimensions } from './use-terminal-dimensions' +import { useTerminalLayout } from './use-terminal-layout' +import { useTheme } from './use-theme' +import { createChatScrollAcceleration } from '../utils/chat-scroll-accel' +import { createMarkdownPalette } from '../utils/theme-system' + +import type { ChatMessage } from '../types/chat' +import type { ChatTheme } from '../types/theme-system' +import type { ScrollBoxRenderable } from '@opentui/core' +import type { MarkdownPalette } from '../utils/markdown-renderer' + +export interface UseChatUIOptions { + messages: ChatMessage[] + isUserCollapsing: () => boolean +} + +export interface UseChatUIReturn { + // Scroll management + scrollRef: React.RefObject + scrollToLatest: () => void + scrollUp: () => void + scrollDown: () => void + appliedScrollboxProps: Record + isAtBottom: boolean + hasOverflow: boolean + + // Terminal dimensions + terminalWidth: number + terminalHeight: number + separatorWidth: number + messageAvailableWidth: number + isCompactHeight: boolean + isNarrowWidth: boolean + + // Theme + theme: ChatTheme + markdownPalette: MarkdownPalette +} + +export function useChatUI({ + messages, + isUserCollapsing, +}: UseChatUIOptions): UseChatUIReturn { + const scrollRef = useRef(null) + const [hasOverflow, setHasOverflow] = useState(false) + const hasOverflowRef = useRef(false) + + // Terminal dimensions + const { separatorWidth, terminalWidth, terminalHeight } = + useTerminalDimensions() + const { height: heightLayout, width: widthLayout } = useTerminalLayout() + const isCompactHeight = heightLayout.is('xs') + const isNarrowWidth = widthLayout.is('xs') + const messageAvailableWidth = separatorWidth + + // Theme + const theme = useTheme() + const markdownPalette = useMemo(() => createMarkdownPalette(theme), [theme]) + + // Scroll management + const { scrollToLatest, scrollUp, scrollDown, scrollboxProps, isAtBottom } = + useChatScrollbox(scrollRef, messages, isUserCollapsing) + + // Check if content has overflowed and needs scrolling + useEffect(() => { + const scrollbox = scrollRef.current + if (!scrollbox) return + + const checkOverflow = () => { + const contentHeight = scrollbox.scrollHeight + const viewportHeight = scrollbox.viewport.height + const isOverflowing = contentHeight > viewportHeight + + if (hasOverflowRef.current !== isOverflowing) { + hasOverflowRef.current = isOverflowing + setHasOverflow(isOverflowing) + } + } + + checkOverflow() + scrollbox.verticalScrollBar.on('change', checkOverflow) + + return () => { + scrollbox.verticalScrollBar.off('change', checkOverflow) + } + }, []) + + // Inertial scroll acceleration + const inertialScrollAcceleration = useMemo( + () => createChatScrollAcceleration(), + [], + ) + + const appliedScrollboxProps = useMemo( + () => + inertialScrollAcceleration + ? { ...scrollboxProps, scrollAcceleration: inertialScrollAcceleration } + : scrollboxProps, + [scrollboxProps, inertialScrollAcceleration], + ) + + return { + // Scroll management + scrollRef, + scrollToLatest, + scrollUp, + scrollDown, + appliedScrollboxProps, + isAtBottom, + hasOverflow, + + // Terminal dimensions + terminalWidth, + terminalHeight, + separatorWidth, + messageAvailableWidth, + isCompactHeight, + isNarrowWidth, + + // Theme + theme, + markdownPalette, + } +} From acb2357f013c22fb1f91a0ee422450e699d6fced Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 22:58:29 -0800 Subject: [PATCH 0048/1143] Revert "fix(db): use drizzle-kit migrate instead of push to avoid SIGSEGV" This reverts commit 05f269fa442ebbe267208cd9f9bd9f75b5c6f71f. --- packages/internal/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/internal/package.json b/packages/internal/package.json index 9502fe1932..0e96415f55 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -48,7 +48,7 @@ "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", - "db:migrate:render": "npx drizzle-kit migrate --config=./src/db/drizzle.config.ts", + "db:migrate:render": "npx drizzle-kit push --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From 26e276dcfe33f447f1c3b7b0366dcaddd58025e0 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Sun, 18 Jan 2026 22:58:33 -0800 Subject: [PATCH 0049/1143] Revert "fix(db): add db:migrate:render script using npx to avoid Bun SIGSEGV crash" This reverts commit b70e947023aee9828a1c40665ed5043ddcdf35c7. --- packages/internal/package.json | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/internal/package.json b/packages/internal/package.json index 0e96415f55..86b7d64f83 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -48,7 +48,6 @@ "test": "bun test", "db:generate": "drizzle-kit generate --config=./src/db/drizzle.config.ts", "db:migrate": "drizzle-kit push --config=./src/db/drizzle.config.ts", - "db:migrate:render": "npx drizzle-kit push --config=./src/db/drizzle.config.ts", "db:start": "docker compose -f ./src/db/docker-compose.yml up --wait && bun run db:generate && (timeout 1 || sleep 1) && bun run db:migrate", "db:e2e:setup": "bun ./src/db/e2e-setup.ts", "db:e2e:down": "docker compose -f ./src/db/docker-compose.e2e.yml down --volumes", From 1647ca7af7bc1202a1cd1bca2ea4640b5322b3e4 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Mon, 19 Jan 2026 12:04:55 -0800 Subject: [PATCH 0050/1143] Fix for context pruner: move referenced variables inside handleSteps. Add unit test --- agents/__tests__/context-pruner.test.ts | 141 +++++++++++ agents/context-pruner.ts | 302 +++++++++++++++++++++--- 2 files changed, 412 insertions(+), 31 deletions(-) diff --git a/agents/__tests__/context-pruner.test.ts b/agents/__tests__/context-pruner.test.ts index df51a230ea..80a1d9cb57 100644 --- a/agents/__tests__/context-pruner.test.ts +++ b/agents/__tests__/context-pruner.test.ts @@ -4,6 +4,147 @@ import contextPruner from '../context-pruner' import type { Message, ToolMessage } from '../types/util-types' +/** + * Regression test: Verify handleSteps can be serialized and run in isolation. + * This catches bugs like CACHE_EXPIRY_MS not being defined when the function + * is stringified and executed in a QuickJS sandbox. + * + * The handleSteps function is serialized to a string and executed in a sandbox + * at runtime. Any variables referenced from outside the function scope will + * cause "X is not defined" errors. This test ensures all constants and helper + * functions are defined inside handleSteps. + */ +describe('context-pruner handleSteps serialization', () => { + test('handleSteps works when serialized and executed in isolation (regression test for external variable references)', () => { + // Get the handleSteps function and convert it to a string, just like the SDK does + const handleStepsString = contextPruner.handleSteps!.toString() + + // Verify it's a valid generator function string + expect(handleStepsString).toMatch(/^function\*\s*\(/) + + // Create a new function from the string to simulate sandbox isolation. + // This will fail if handleSteps references any external variables + // (like CACHE_EXPIRY_MS was before the fix). + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const isolatedFunction = new Function(`return (${handleStepsString})`)() + + // Create minimal mock data to run the function + const mockAgentState = { + messageHistory: [ + { + role: 'user', + content: [{ type: 'text', text: 'Hello' }], + }, + { + role: 'assistant', + content: [{ type: 'text', text: 'Hi there!' }], + }, + ], + contextTokenCount: 100, // Under the limit, so it won't prune + } + + const mockLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } + + // Run the isolated function - this will throw if any external variables are undefined + const generator = isolatedFunction({ + agentState: mockAgentState, + logger: mockLogger, + params: { maxContextLength: 200000 }, + }) + + // Consume the generator to ensure all code paths execute + const results: unknown[] = [] + let result = generator.next() + while (!result.done) { + results.push(result.value) + result = generator.next() + } + + // Should have produced a result (set_messages call) + expect(results.length).toBeGreaterThan(0) + }) + + test('handleSteps works in isolation when pruning is triggered', () => { + // Get the handleSteps function and convert it to a string + const handleStepsString = contextPruner.handleSteps!.toString() + + // Create a new function from the string to simulate sandbox isolation + // eslint-disable-next-line @typescript-eslint/no-implied-eval + const isolatedFunction = new Function(`return (${handleStepsString})`)() + + // Create mock data that will trigger pruning (context over limit) + const mockAgentState = { + messageHistory: [ + { + role: 'user', + content: [{ type: 'text', text: 'Please help me with a task' }], + }, + { + role: 'assistant', + content: [ + { type: 'text', text: 'Sure, I can help with that' }, + { + type: 'tool-call', + toolCallId: 'call-1', + toolName: 'read_files', + input: { paths: ['test.ts'] }, + }, + ], + }, + { + role: 'tool', + toolCallId: 'call-1', + toolName: 'read_files', + content: [{ type: 'json', value: { content: 'file content' } }], + }, + { + role: 'user', + content: [{ type: 'text', text: 'Thanks!' }], + }, + ], + contextTokenCount: 250000, // Over the limit, will trigger pruning + } + + const mockLogger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } + + // Run the isolated function - exercises all the helper functions like + // truncateLongText, estimateTokens, getTextContent, summarizeToolCall + const generator = isolatedFunction({ + agentState: mockAgentState, + logger: mockLogger, + params: { maxContextLength: 200000 }, + }) + + // Consume the generator + const results: any[] = [] + let result = generator.next() + while (!result.done) { + results.push(result.value) + result = generator.next() + } + + // Should have produced a result + expect(results.length).toBeGreaterThan(0) + + // The result should contain a summary + const setMessagesCall = results[0] + expect(setMessagesCall.toolName).toBe('set_messages') + expect(setMessagesCall.input.messages[0].content[0].text).toContain( + '', + ) + }) +}) + const createMessage = ( role: 'user' | 'assistant', content: string, diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index f0f15c5b13..29e02af1eb 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -4,37 +4,7 @@ import type { AgentDefinition, ToolCall } from './types/agent-definition' import type { Message, ToolMessage } from './types/util-types' // ============================================================================= -// Constants -// ============================================================================= - -/** Target: summarized messages should be at most 10% of max context */ -const TARGET_SUMMARY_FACTOR = 0.1 - -/** Agent IDs whose output should be excluded from spawn_agents results */ -const SPAWN_AGENTS_OUTPUT_BLACKLIST = [ - 'file-picker', - 'code-searcher', - 'directory-lister', - 'glob-matcher', - 'researcher-web', - 'researcher-docs', - 'code-reviewer', - 'code-reviewer-multi-prompt', -] - -/** Limits for truncating long messages (chars) */ -const USER_MESSAGE_LIMIT = 15000 -const ASSISTANT_MESSAGE_LIMIT = 4000 - -/** Prompt cache expiry time (Anthropic caches for 5 minutes) */ -const CACHE_EXPIRY_MS = 5 * 60 * 1000 - -/** Header used in conversation summaries */ -const SUMMARY_HEADER = - 'This is a summary of the conversation so far. The original messages have been condensed to save context space.' - -// ============================================================================= -// Helper Functions +// Helper Functions (exported for testing) // ============================================================================= /** @@ -310,6 +280,276 @@ const definition: AgentDefinition = { includeMessageHistory: true, handleSteps: function* ({ agentState, params }) { + // ============================================================================= + // Constants (must be inside handleSteps since it's serialized to a string) + // ============================================================================= + + /** Target: summarized messages should be at most 10% of max context */ + const TARGET_SUMMARY_FACTOR = 0.1 + + /** Agent IDs whose output should be excluded from spawn_agents results */ + const SPAWN_AGENTS_OUTPUT_BLACKLIST = [ + 'file-picker', + 'code-searcher', + 'directory-lister', + 'glob-matcher', + 'researcher-web', + 'researcher-docs', + 'code-reviewer', + 'code-reviewer-multi-prompt', + ] + + /** Limits for truncating long messages (chars) */ + const USER_MESSAGE_LIMIT = 15000 + const ASSISTANT_MESSAGE_LIMIT = 4000 + + /** Prompt cache expiry time (Anthropic caches for 5 minutes) */ + const CACHE_EXPIRY_MS = 5 * 60 * 1000 + + /** Header used in conversation summaries */ + const SUMMARY_HEADER = + 'This is a summary of the conversation so far. The original messages have been condensed to save context space.' + + // ============================================================================= + // Helper Functions (must be inside handleSteps since it's serialized to a string) + // ============================================================================= + + /** + * Truncates long text with 80% from the beginning and 20% from the end. + */ + function truncateLongText(text: string, limit: number): string { + if (text.length <= limit) { + return text + } + const availableChars = limit - 50 // 50 chars for the truncation notice + const prefixLength = Math.floor(availableChars * 0.8) + const suffixLength = availableChars - prefixLength + const prefix = text.slice(0, prefixLength) + const suffix = text.slice(-suffixLength) + const truncatedChars = text.length - prefixLength - suffixLength + return `${prefix}\n\n[...truncated ${truncatedChars} chars...]\n\n${suffix}` + } + + /** + * Estimates token count from a JSON-serializable object. + */ + function estimateTokens(obj: unknown): number { + return Math.ceil(JSON.stringify(obj).length / 3) + } + + /** + * Extracts text content from a message. + */ + function getTextContent(message: Message): string { + if (typeof message.content === 'string') { + return message.content + } + if (Array.isArray(message.content)) { + return message.content + .filter( + (part: Record) => + part.type === 'text' && typeof part.text === 'string', + ) + .map((part: Record) => part.text as string) + .join('\n') + } + return '' + } + + /** + * Summarizes a tool call into a human-readable description. + */ + function summarizeToolCall( + toolName: string, + input: Record, + ): string { + switch (toolName) { + case 'read_files': { + const paths = input.paths as string[] | undefined + if (paths && paths.length > 0) { + return `Read files: ${paths.join(', ')}` + } + return 'Read files' + } + case 'write_file': { + const path = input.path as string | undefined + return path ? `Wrote file: ${path}` : 'Wrote file' + } + case 'str_replace': { + const path = input.path as string | undefined + return path ? `Edited file: ${path}` : 'Edited file' + } + case 'propose_write_file': { + const path = input.path as string | undefined + return path ? `Proposed write to: ${path}` : 'Proposed file write' + } + case 'propose_str_replace': { + const path = input.path as string | undefined + return path ? `Proposed edit to: ${path}` : 'Proposed file edit' + } + case 'read_subtree': { + const paths = input.paths as string[] | undefined + if (paths && paths.length > 0) { + return `Read subtree: ${paths.join(', ')}` + } + return 'Read subtree' + } + case 'code_search': { + const pattern = input.pattern as string | undefined + const flags = input.flags as string | undefined + if (pattern && flags) { + return `Code search: "${pattern}" (${flags})` + } + return pattern ? `Code search: "${pattern}"` : 'Code search' + } + case 'glob': { + const patterns = input.patterns as + | Array<{ pattern: string }> + | undefined + if (patterns && patterns.length > 0) { + return `Glob: ${patterns.map((p) => p.pattern).join(', ')}` + } + return 'Glob search' + } + case 'list_directory': { + const directories = input.directories as + | Array<{ path: string }> + | undefined + if (directories && directories.length > 0) { + return `Listed dirs: ${directories.map((d) => d.path).join(', ')}` + } + return 'Listed directory' + } + case 'find_files': { + const pattern = input.pattern as string | undefined + return pattern ? `Find files: "${pattern}"` : 'Find files' + } + case 'run_terminal_command': { + const command = input.command as string | undefined + if (command) { + const shortCmd = + command.length > 50 ? command.slice(0, 50) + '...' : command + return `Ran command: ${shortCmd}` + } + return 'Ran terminal command' + } + case 'spawn_agents': + case 'spawn_agent_inline': { + const agents = input.agents as + | Array<{ + agent_type: string + prompt?: string + params?: Record + }> + | undefined + const agentType = input.agent_type as string | undefined + const prompt = input.prompt as string | undefined + const agentParams = input.params as + | Record + | undefined + + if (agents && agents.length > 0) { + const agentDetails = agents.map((a) => { + let detail = a.agent_type + const extras: string[] = [] + if (a.prompt) { + const truncatedPrompt = + a.prompt.length > 1000 + ? a.prompt.slice(0, 1000) + '...' + : a.prompt + extras.push(`prompt: "${truncatedPrompt}"`) + } + if (a.params && Object.keys(a.params).length > 0) { + const paramsStr = JSON.stringify(a.params) + const truncatedParams = + paramsStr.length > 1000 + ? paramsStr.slice(0, 1000) + '...' + : paramsStr + extras.push(`params: ${truncatedParams}`) + } + if (extras.length > 0) { + detail += ` (${extras.join(', ')})` + } + return detail + }) + return `Spawned agents:\n${agentDetails.map((d) => `- ${d}`).join('\n')}` + } + if (agentType) { + const extras: string[] = [] + if (prompt) { + const truncatedPrompt = + prompt.length > 1000 ? prompt.slice(0, 1000) + '...' : prompt + extras.push(`prompt: "${truncatedPrompt}"`) + } + if (agentParams && Object.keys(agentParams).length > 0) { + const paramsStr = JSON.stringify(agentParams) + const truncatedParams = + paramsStr.length > 1000 + ? paramsStr.slice(0, 1000) + '...' + : paramsStr + extras.push(`params: ${truncatedParams}`) + } + if (extras.length > 0) { + return `Spawned agent: ${agentType} (${extras.join(', ')})` + } + return `Spawned agent: ${agentType}` + } + return 'Spawned agent(s)' + } + case 'write_todos': { + const todos = input.todos as + | Array<{ task: string; completed: boolean }> + | undefined + if (todos) { + const completed = todos.filter((t) => t.completed).length + const incomplete = todos.filter((t) => !t.completed) + if (incomplete.length === 0) { + return `Todos: ${completed}/${todos.length} complete (all done!)` + } + const remainingTasks = incomplete + .map((t) => `- ${t.task}`) + .join('\n') + return `Todos: ${completed}/${todos.length} complete. Remaining:\n${remainingTasks}` + } + return 'Updated todos' + } + case 'ask_user': { + const questions = input.questions as + | Array<{ question: string }> + | undefined + if (questions && questions.length > 0) { + const questionTexts = questions.map((q) => q.question).join('; ') + const truncated = + questionTexts.length > 200 + ? questionTexts.slice(0, 200) + '...' + : questionTexts + return `Asked user: ${truncated}` + } + return 'Asked user question' + } + case 'suggest_followups': + return 'Suggested followups' + case 'web_search': { + const query = input.query as string | undefined + return query ? `Web search: "${query}"` : 'Web search' + } + case 'read_docs': { + const query = input.query as string | undefined + return query ? `Read docs: "${query}"` : 'Read docs' + } + case 'set_output': + return 'Set output' + case 'set_messages': + return 'Set messages' + default: + return `Used tool: ${toolName}` + } + } + + // ============================================================================= + // Main Logic + // ============================================================================= + const messages = agentState.messageHistory const maxContextLength: number = params?.maxContextLength ?? 200_000 From 198b0a4d1c63ecfd1e91dce95059eec5a99d36c6 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Mon, 19 Jan 2026 20:07:25 +0000 Subject: [PATCH 0051/1143] Bump version to 1.0.589 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 90d2acbc34..24d03078c9 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.588", + "version": "1.0.589", "description": "AI coding agent", "license": "MIT", "bin": { From 5b7b14905d54aa50ce9ac22e290d92480684ce52 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 20 Jan 2026 12:18:25 -0800 Subject: [PATCH 0052/1143] refactor(agents): simplify CLI agent modes to work/review, add customization options - Remove test mode (redundant with work mode for e2e scenarios) - Add defaultMode config option for agents to set their own default - Add workModeInstructions/testModeInstructions config overrides - Add CliAgentMode type and CLI_AGENT_MODES constant for type safety - Extract getTestModeInstructions into separate function (then removed) - Use CLI_AGENT_MODES constant instead of hardcoded arrays (DRY) - Update prompts and schemas to reflect two-mode system --- .agents/lib/cli-agent-prompts.ts | 119 ++++++++++++++++++++++--------- .agents/lib/cli-agent-schemas.ts | 18 ++--- .agents/lib/cli-agent-types.ts | 18 ++++- .agents/lib/create-cli-agent.ts | 17 ++++- 4 files changed, 123 insertions(+), 49 deletions(-) diff --git a/.agents/lib/cli-agent-prompts.ts b/.agents/lib/cli-agent-prompts.ts index c2cd73ef1f..b6c45e25df 100644 --- a/.agents/lib/cli-agent-prompts.ts +++ b/.agents/lib/cli-agent-prompts.ts @@ -1,4 +1,5 @@ import type { CliAgentConfig } from './cli-agent-types' +import { CLI_AGENT_MODES } from './cli-agent-types' const TMUX_SESSION_DOCS = `## Session Logs (Paper Trail) @@ -71,18 +72,27 @@ The review should focus on these key areas: - Missing or incomplete type definitions` export function getSpawnerPrompt(config: CliAgentConfig): string { - const base = `Expert at testing ${config.cliName} CLI functionality using tmux, or performing code reviews via ${config.cliName}. + const defaultMode = config.defaultMode ?? 'work' + const modeDescriptions = { + work: `Use ${config.cliName} to implement features, fix bugs, refactor code, or complete other coding tasks.`, + review: `Uses ${config.cliName} CLI to perform code reviews on specified files or directories.`, + } + const modeLines = CLI_AGENT_MODES.map(mode => { + const isDefault = mode === defaultMode + return `- \`${mode}\`${isDefault ? ' (default)' : ''}: ${modeDescriptions[mode]}` + }).join('\n') + + const base = `Expert at using ${config.cliName} CLI via tmux for implementation work or code reviews. **Modes:** -- \`test\` (default): Spawns tmux sessions, sends input to ${config.cliName} CLI, captures terminal output, and validates behavior. -- \`review\`: Uses ${config.cliName} CLI to perform code reviews on specified files or directories. +${modeLines} **Paper trail:** Session logs are saved to \`debug/tmux-sessions/{session}/\`. Use \`read_files\` to view captures. **Your responsibilities as the parent agent:** 1. If \`scriptIssues\` is not empty, fix the scripts in \`scripts/tmux/\` based on the suggested fixes 2. Use \`read_files\` on the capture paths to see what the CLI displayed -3. Re-run the test after fixing any script issues` +3. Re-run the agent after fixing any script issues` return config.spawnerPromptExtras ? `${base}\n\n${config.spawnerPromptExtras}` : base } @@ -90,14 +100,14 @@ export function getSpawnerPrompt(config: CliAgentConfig): string { export function getSystemPrompt(config: CliAgentConfig): string { const cliSpecificSection = config.cliSpecificDocs ? `\n${config.cliSpecificDocs}\n` : '\n' - return `You are an expert at testing ${config.cliName} CLI using tmux. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. + return `You are an expert at using ${config.cliName} CLI via tmux for implementation work and code reviews. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. ## ${config.cliName} Startup -For testing ${config.cliName}, use the \`--command\` flag with permission bypass: +To start ${config.cliName}, use the \`--command\` flag with permission bypass: \`\`\`bash -# Start ${config.cliName} CLI (with permission bypass for testing) +# Start ${config.cliName} CLI (with permission bypass) SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") # Or with specific options @@ -108,12 +118,12 @@ SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand} --h ${cliSpecificSection} ## Helper Scripts -Use these scripts in \`scripts/tmux/\` for reliable CLI testing: +Use these scripts in \`scripts/tmux/\` for reliable CLI interaction: ### Unified Script (Recommended) \`\`\`bash -# Start a ${config.cliName} test session (with permission bypass) +# Start a ${config.cliName} session (with permission bypass) SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") # Send input to the CLI @@ -162,7 +172,8 @@ ${TMUX_DEBUG_TIPS}` } export function getDefaultReviewModeInstructions(config: CliAgentConfig): string { - return `## Review Mode Instructions + const isDefault = config.defaultMode === 'review' + return `## Review Mode Instructions${isDefault ? ' (Default)' : ''} In review mode, you send a detailed review prompt to ${config.cliName}. The prompt MUST start with the word "review" and include specific areas of concern. @@ -216,60 +227,98 @@ ${REVIEW_CRITERIA} \`\`\`` } -export function getInstructionsPrompt(config: CliAgentConfig): string { - const reviewModeInstructions = config.reviewModeInstructions ?? getDefaultReviewModeInstructions(config) +export function getWorkModeInstructions(config: CliAgentConfig): string { + const isDefault = (config.defaultMode ?? 'work') === 'work' + return `## Work Mode Instructions${isDefault ? ' (Default)' : ''} - return `Instructions: +Use ${config.cliName} to complete implementation tasks like building features, fixing bugs, or refactoring code. -Check the \`mode\` parameter to determine your operation: -- If \`mode\` is "review": follow **Review Mode** instructions -- Otherwise: follow **Test Mode** instructions (default) +### Workflow ---- +1. **Start ${config.cliName}** with permission bypass: + \`\`\`bash + SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + \`\`\` -## Test Mode Instructions +2. **Wait for CLI to initialize**, then capture: + \`\`\`bash + sleep 3 + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" + \`\`\` + +3. **Send your task** (from the prompt you received) to the CLI: + \`\`\`bash + ./scripts/tmux/tmux-cli.sh send "$SESSION" "" + \`\`\` -1. **Use the helper scripts** in \`scripts/tmux/\` - they handle bracketed paste mode automatically + Use the exact task description from the prompt the parent agent gave you. -2. **Start a ${config.cliName} test session** with permission bypass: +4. **Wait for completion and capture output** (implementation tasks may take a while): \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-in-progress" --wait 30 \`\`\` -3. **Verify the CLI started** by capturing initial output: + If the work is still in progress, wait and capture again: \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-continued" --wait 30 \`\`\` -4. **Send commands** and capture responses: +5. **Send follow-up prompts** if needed to refine or continue the work: \`\`\`bash - ./scripts/tmux/tmux-cli.sh send "$SESSION" "your command here" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 + ./scripts/tmux/tmux-cli.sh send "$SESSION" "" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "follow-up" --wait 30 \`\`\` -5. **Always clean up** when done: +6. **Verify the changes** by checking files or running commands: \`\`\`bash - ./scripts/tmux/tmux-cli.sh stop "$SESSION" + ./scripts/tmux/tmux-cli.sh send "$SESSION" "run the tests to verify the changes" + ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "verification" --wait 60 \`\`\` -6. **Use labels when capturing** to create a clear paper trail: +7. **Clean up** when done: \`\`\`bash - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" - ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-help-command" --wait 2 + ./scripts/tmux/tmux-cli.sh stop "$SESSION" \`\`\` +### Tips + +- Break complex tasks into smaller prompts +- Capture frequently to track progress +- Use descriptive labels for captures +- Check intermediate results before moving on` +} + +export function getInstructionsPrompt(config: CliAgentConfig): string { + const defaultMode = config.defaultMode ?? 'work' + const workModeInstructions = config.workModeInstructions ?? getWorkModeInstructions(config) + const reviewModeInstructions = config.reviewModeInstructions ?? getDefaultReviewModeInstructions(config) + + const modeNames = { work: 'Work Mode', review: 'Review Mode' } + const nonDefaultModes = CLI_AGENT_MODES.filter(m => m !== defaultMode) + const modeChecks = nonDefaultModes.map(m => `- If \`mode\` is "${m}": follow **${modeNames[m]}** instructions`).join('\n') + + return `Instructions: + +Check the \`mode\` parameter to determine your operation: +${modeChecks} +- Otherwise: follow **${modeNames[defaultMode]}** instructions (default) + +--- + +${workModeInstructions} + --- ${reviewModeInstructions} --- -## Output (Both Modes) +## Output (All Modes) **Report results using set_output** - You MUST call set_output with structured results: - \`overallStatus\`: "success", "failure", or "partial" -- \`summary\`: Brief description of what was tested/reviewed -- \`testResults\`: Array of test outcomes (for test mode) +- \`summary\`: Brief description of what was done +- \`results\`: Array of task outcomes (for work mode) - \`scriptIssues\`: Array of any problems with the helper scripts - \`captures\`: Array of capture paths with labels - \`reviewFindings\`: Array of code review findings (for review mode) @@ -278,7 +327,7 @@ ${reviewModeInstructions} - \`script\`: Which script failed - \`issue\`: What went wrong - \`errorOutput\`: The actual error message -- \`suggestedFix\`: How the parent agent should fix the script +- \`suggestedFix\`: How to fix the script **Always include captures** in your output so the parent agent can see what you saw. diff --git a/.agents/lib/cli-agent-schemas.ts b/.agents/lib/cli-agent-schemas.ts index c5cde7e1cb..e67a522aa1 100644 --- a/.agents/lib/cli-agent-schemas.ts +++ b/.agents/lib/cli-agent-schemas.ts @@ -1,29 +1,29 @@ -// Shared output schema for CLI tester agents. testResults for test mode, reviewFindings for review mode. +// Shared output schema for CLI agents. results for work mode, reviewFindings for review mode. export const outputSchema = { type: 'object' as const, properties: { overallStatus: { type: 'string' as const, enum: ['success', 'failure', 'partial'], - description: 'Overall test outcome', + description: 'Overall outcome', }, summary: { type: 'string' as const, - description: 'Brief summary of what was tested and the outcome', + description: 'Brief summary of what was done and the outcome', }, - testResults: { + results: { type: 'array' as const, items: { type: 'object' as const, properties: { - testName: { type: 'string' as const, description: 'Name/description of the test' }, - passed: { type: 'boolean' as const, description: 'Whether the test passed' }, + name: { type: 'string' as const, description: 'Name/description of the task' }, + passed: { type: 'boolean' as const, description: 'Whether the task succeeded' }, details: { type: 'string' as const, description: 'Details about what happened' }, capturedOutput: { type: 'string' as const, description: 'Relevant output captured from the CLI' }, }, - required: ['testName', 'passed'], + required: ['name', 'passed'], }, - description: 'Array of individual test results', + description: 'Array of individual task results', }, scriptIssues: { type: 'array' as const, @@ -37,7 +37,7 @@ export const outputSchema = { }, required: ['script', 'issue', 'suggestedFix'], }, - description: 'Issues encountered with the helper scripts that the parent agent should fix', + description: 'Issues encountered with the helper scripts that should be fixed', }, captures: { type: 'array' as const, diff --git a/.agents/lib/cli-agent-types.ts b/.agents/lib/cli-agent-types.ts index 4912b36c0a..6b115fee60 100644 --- a/.agents/lib/cli-agent-types.ts +++ b/.agents/lib/cli-agent-types.ts @@ -1,11 +1,20 @@ +export type CliAgentMode = 'work' | 'review' + +export const CLI_AGENT_MODES: readonly CliAgentMode[] = ['work', 'review'] as const + export interface InputParamDefinition { type: 'string' | 'number' | 'boolean' | 'array' | 'object' description?: string enum?: string[] } -// Prevent extraInputParams from overriding 'mode' at compile time -export type ExtraInputParams = Omit, 'mode'> +/** + * Extra input params that can be added to CLI agent configs. + * Uses key remapping to exclude 'mode' at compile time (Omit on Record is a no-op). + */ +export type ExtraInputParams = { + [K in string as K extends 'mode' ? never : K]?: InputParamDefinition +} export interface CliAgentConfig { id: string @@ -16,8 +25,13 @@ export interface CliAgentConfig { startCommand: string permissionNote: string model: string + /** Default mode when mode param is not specified. Defaults to 'work' */ + defaultMode?: CliAgentMode spawnerPromptExtras?: string extraInputParams?: ExtraInputParams + /** Custom instructions for work mode. If not provided, uses getWorkModeInstructions() */ + workModeInstructions?: string + /** Custom instructions for review mode. If not provided, uses getDefaultReviewModeInstructions() */ reviewModeInstructions?: string cliSpecificDocs?: string } diff --git a/.agents/lib/create-cli-agent.ts b/.agents/lib/create-cli-agent.ts index d982a24b71..fd26651d14 100644 --- a/.agents/lib/create-cli-agent.ts +++ b/.agents/lib/create-cli-agent.ts @@ -1,5 +1,6 @@ import type { AgentDefinition } from '../types/agent-definition' import type { CliAgentConfig } from './cli-agent-types' +import { CLI_AGENT_MODES } from './cli-agent-types' import { outputSchema } from './cli-agent-schemas' import { getSpawnerPrompt, @@ -15,11 +16,21 @@ export function createCliAgent(config: CliAgentConfig): AgentDefinition { ) } + const defaultMode = config.defaultMode ?? 'work' + const modeDescriptions = { + work: 'implementation tasks', + review: `code review via ${config.cliName}`, + } + const modeDescParts = CLI_AGENT_MODES.map(mode => { + const isDefault = mode === defaultMode + return `"${mode}" for ${modeDescriptions[mode]}${isDefault ? ' (default)' : ''}` + }) + const baseInputParams = { mode: { type: 'string' as const, - enum: ['test', 'review'], - description: `Operation mode - "test" for CLI testing (default), "review" for code review via ${config.cliName}`, + enum: [...CLI_AGENT_MODES], + description: `Operation mode - ${modeDescParts.join(', ')}`, }, } @@ -38,7 +49,7 @@ export function createCliAgent(config: CliAgentConfig): AgentDefinition { prompt: { type: 'string' as const, description: - 'Description of what to do. For test mode: what CLI functionality to test. For review mode: what code to review and any specific concerns.', + 'Description of what to do. For work mode: implementation task to complete. For review mode: code to review.', }, params: { type: 'object' as const, From 5a693c956257276f088e4e9bd13bb9806747021b Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 20 Jan 2026 12:27:23 -0800 Subject: [PATCH 0053/1143] fix(cli): collapse set_output tool toggle by default --- cli/src/components/blocks/tool-branch.tsx | 3 ++- cli/src/utils/constants.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/cli/src/components/blocks/tool-branch.tsx b/cli/src/components/blocks/tool-branch.tsx index f63274f066..f6f85b9d9a 100644 --- a/cli/src/components/blocks/tool-branch.tsx +++ b/cli/src/components/blocks/tool-branch.tsx @@ -3,6 +3,7 @@ import { memo, useCallback } from 'react' import { ContentWithMarkdown } from './content-with-markdown' import { useTheme } from '../../hooks/use-theme' import { getToolDisplayInfo } from '../../utils/codebuff-client' +import { shouldCollapseToolByDefault } from '../../utils/constants' import { renderToolComponent } from '../tools/registry' import { ToolCallItem } from '../tools/tool-call-item' @@ -43,7 +44,7 @@ export const ToolBranch = memo( } const displayInfo = getToolDisplayInfo(toolBlock.toolName) - const isCollapsed = toolBlock.isCollapsed ?? false + const isCollapsed = toolBlock.isCollapsed ?? shouldCollapseToolByDefault(toolBlock.toolName) const isStreaming = streamingAgents.has(toolBlock.toolCallId) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` diff --git a/cli/src/utils/constants.ts b/cli/src/utils/constants.ts index 2b19d8853e..cbfea66610 100644 --- a/cli/src/utils/constants.ts +++ b/cli/src/utils/constants.ts @@ -1,6 +1,21 @@ +import type { ToolName } from '@codebuff/sdk' + // Agent IDs that should not be rendered in the CLI UI export const HIDDEN_AGENT_IDS = ['codebuff/context-pruner'] as const +// Tool names that should be collapsed by default when rendered +// Uses ToolName type to ensure only valid tool names are added +export const COLLAPSED_BY_DEFAULT_TOOL_NAMES: readonly ToolName[] = [ + 'set_output', +] as const + +/** + * Check if a tool should be collapsed by default + */ +export const shouldCollapseToolByDefault = (toolName: string): boolean => { + return COLLAPSED_BY_DEFAULT_TOOL_NAMES.includes(toolName as ToolName) +} + /** * Check if an agent ID should be hidden from rendering */ From 024f1444323b480464c0e6d6c5e01cc0a1947a1a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 20 Jan 2026 12:44:25 -0800 Subject: [PATCH 0054/1143] feat(cli): add cancelled status for interrupted subagents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add cancelled status to AgentContentBlock type - Show ⊘ cancelled indicator in red when user interrupts response - Mark running subagents as cancelled on abort - Clear streamingAgents set so cancelled status displays correctly --- .../helpers/__tests__/send-message.test.ts | 6 ++++ cli/src/hooks/helpers/send-message.ts | 11 ++++++- cli/src/hooks/use-send-message.ts | 1 + cli/src/types/chat.ts | 2 +- cli/src/utils/agent-helpers.ts | 2 ++ cli/src/utils/block-operations.ts | 32 +++++++++++++++++++ 6 files changed, 52 insertions(+), 2 deletions(-) diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index e57acdb257..0eb87d1a5f 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -101,6 +101,7 @@ describe('setupStreamingContext', () => { setIsRetrying: (value: boolean) => { isRetrying = value }, + setStreamingAgents: () => {}, }) // Trigger abort @@ -163,6 +164,7 @@ describe('setupStreamingContext', () => { isQueuePausedRef, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Trigger abort @@ -192,6 +194,7 @@ describe('setupStreamingContext', () => { isProcessingQueueRef, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Verify ref starts as true @@ -238,6 +241,7 @@ describe('setupStreamingContext', () => { setIsRetrying: (value) => { isRetrying = value }, + setStreamingAgents: () => {}, }) // Sanity check initial state @@ -278,6 +282,7 @@ describe('setupStreamingContext', () => { setCanProcessQueue: () => {}, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Verify abortController is stored in ref @@ -306,6 +311,7 @@ describe('setupStreamingContext', () => { setCanProcessQueue: () => {}, updateChainInProgress: () => {}, setIsRetrying: () => {}, + setStreamingAgents: () => {}, }) // Verify streamRefs was reset diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index 4e3e0f6580..c4db1753ef 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -7,6 +7,7 @@ import { } from '../../utils/error-handling' import { invalidateActivityQuery } from '../use-activity-query' import { usageQueryKeys } from '../use-usage-query' +import { markRunningAgentsAsCancelled } from '../../utils/block-operations' import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' import { logger } from '../../utils/logger' @@ -192,6 +193,7 @@ export const setupStreamingContext = (params: { isProcessingQueueRef?: MutableRefObject updateChainInProgress: (value: boolean) => void setIsRetrying: (value: boolean) => void + setStreamingAgents: (updater: (prev: Set) => Set) => void }) => { const { aiMessageId, @@ -205,6 +207,7 @@ export const setupStreamingContext = (params: { isProcessingQueueRef, updateChainInProgress, setIsRetrying, + setStreamingAgents, } = params streamRefs.reset() @@ -229,7 +232,13 @@ export const setupStreamingContext = (params: { setIsRetrying(false) timerController.stop('aborted') - updater.updateAiMessageBlocks((blocks) => appendInterruptionNotice(blocks)) + // Clear streaming agents so cancelled status displays correctly in UI + setStreamingAgents(() => new Set()) + + updater.updateAiMessageBlocks((blocks) => { + const cancelledBlocks = markRunningAgentsAsCancelled(blocks) + return appendInterruptionNotice(cancelledBlocks) + }) updater.markComplete() }) diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index a68688b84d..ca62791593 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -343,6 +343,7 @@ export const useSendMessage = ({ isProcessingQueueRef, updateChainInProgress, setIsRetrying, + setStreamingAgents, }) setStreamStatus('waiting') setMessages((prev) => [...prev, aiMessage]) diff --git a/cli/src/types/chat.ts b/cli/src/types/chat.ts index ffba3a2d35..abc37bf115 100644 --- a/cli/src/types/chat.ts +++ b/cli/src/types/chat.ts @@ -49,7 +49,7 @@ export type AgentContentBlock = { agentName: string agentType: string content: string - status: 'running' | 'complete' | 'failed' + status: 'running' | 'complete' | 'failed' | 'cancelled' blocks?: ContentBlock[] initialPrompt?: string params?: Record diff --git a/cli/src/utils/agent-helpers.ts b/cli/src/utils/agent-helpers.ts index 943dae9411..b79e984927 100644 --- a/cli/src/utils/agent-helpers.ts +++ b/cli/src/utils/agent-helpers.ts @@ -19,6 +19,8 @@ export function getAgentStatusInfo( return { indicator: '✗', label: 'failed', color: 'red', text: '✗ failed' } case 'complete': return { indicator: '✓', label: 'completed', color: theme.foreground, text: 'completed ✓' } + case 'cancelled': + return { indicator: '⊘', label: 'cancelled', color: 'red', text: '⊘ cancelled' } default: return { indicator: '○', label: 'waiting', color: theme.muted, text: '○ waiting' } } diff --git a/cli/src/utils/block-operations.ts b/cli/src/utils/block-operations.ts index 07dca8a653..cce775a344 100644 --- a/cli/src/utils/block-operations.ts +++ b/cli/src/utils/block-operations.ts @@ -355,3 +355,35 @@ export const markAgentComplete = (blocks: ContentBlock[], agentId: string) => } return { ...block, status: 'complete' as const } }) + +/** + * Recursively marks all agent blocks with status 'running' as 'cancelled'. + * Used when the user interrupts a response to indicate subagents were stopped. + */ +export const markRunningAgentsAsCancelled = ( + blocks: ContentBlock[], +): ContentBlock[] => { + return blocks.map((block) => { + if (block.type !== 'agent') { + return block + } + + const updatedBlocks = block.blocks + ? markRunningAgentsAsCancelled(block.blocks) + : undefined + + if (block.status === 'running') { + return { + ...block, + status: 'cancelled' as const, + ...(updatedBlocks && { blocks: updatedBlocks }), + } + } + + if (updatedBlocks && updatedBlocks !== block.blocks) { + return { ...block, blocks: updatedBlocks } + } + + return block + }) +} From e1e02dfd32576c0f95d78e0b535316529f564e6a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 20 Jan 2026 15:26:45 -0800 Subject: [PATCH 0055/1143] Remove smoke test on downloaded binary. Triggered EPERM error on Windows. Also adds latency, and it doesn't really add value anyway --- cli/release-staging/index.js | 58 +----------------------------------- cli/release/index.js | 58 +----------------------------------- 2 files changed, 2 insertions(+), 114 deletions(-) diff --git a/cli/release-staging/index.js b/cli/release-staging/index.js index 6a9551c7e4..1c95d83367 100644 --- a/cli/release-staging/index.js +++ b/cli/release-staging/index.js @@ -193,51 +193,6 @@ function getCurrentVersion() { } } -function runSmokeTest(binaryPath) { - return new Promise((resolve) => { - if (!fs.existsSync(binaryPath)) { - resolve(false) - return - } - - const child = spawn(binaryPath, ['--version'], { - cwd: os.homedir(), - stdio: 'pipe', - }) - - let output = '' - - child.stdout.on('data', (data) => { - output += data.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL') - } - }, 1000) - resolve(false) - }, 5000) - - child.on('exit', (code) => { - clearTimeout(timeout) - // Check that it exits successfully and outputs something that looks like a version - if (code === 0 && output.trim().match(/^\d+(\.\d+)*(-beta\.\d+)?$/)) { - resolve(true) - } else { - resolve(false) - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve(false) - }) - }) -} - function compareVersions(v1, v2) { if (!v1 || !v2) return 0 @@ -399,18 +354,7 @@ async function downloadBinary(version) { fs.chmodSync(tempBinaryPath, 0o755) } - // Run smoke test on the downloaded binary - term.write('Verifying download...') - const smokeTestPassed = await runSmokeTest(tempBinaryPath) - - if (!smokeTestPassed) { - fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) - const error = new Error('Downloaded binary failed smoke test (--version check)') - trackUpdateFailed(error.message, version, { stage: 'smoke_test' }) - throw error - } - - // Smoke test passed - move binary to final location + // Move binary to final location try { if (fs.existsSync(CONFIG.binaryPath)) { try { diff --git a/cli/release/index.js b/cli/release/index.js index 025e3836fc..25965c8b7a 100644 --- a/cli/release/index.js +++ b/cli/release/index.js @@ -192,51 +192,6 @@ function getCurrentVersion() { } } -function runSmokeTest(binaryPath) { - return new Promise((resolve) => { - if (!fs.existsSync(binaryPath)) { - resolve(false) - return - } - - const child = spawn(binaryPath, ['--version'], { - cwd: os.homedir(), - stdio: 'pipe', - }) - - let output = '' - - child.stdout.on('data', (data) => { - output += data.toString() - }) - - const timeout = setTimeout(() => { - child.kill('SIGTERM') - setTimeout(() => { - if (!child.killed) { - child.kill('SIGKILL') - } - }, 1000) - resolve(false) - }, 5000) - - child.on('exit', (code) => { - clearTimeout(timeout) - // Check that it exits successfully and outputs something that looks like a version - if (code === 0 && output.trim().match(/^\d+(\.\d+)*$/)) { - resolve(true) - } else { - resolve(false) - } - }) - - child.on('error', () => { - clearTimeout(timeout) - resolve(false) - }) - }) -} - function compareVersions(v1, v2) { if (!v1 || !v2) return 0 @@ -398,18 +353,7 @@ async function downloadBinary(version) { fs.chmodSync(tempBinaryPath, 0o755) } - // Run smoke test on the downloaded binary - term.write('Verifying download...') - const smokeTestPassed = await runSmokeTest(tempBinaryPath) - - if (!smokeTestPassed) { - fs.rmSync(CONFIG.tempDownloadDir, { recursive: true }) - const error = new Error('Downloaded binary failed smoke test (--version check)') - trackUpdateFailed(error.message, version, { stage: 'smoke_test' }) - throw error - } - - // Smoke test passed - move binary to final location + // Move binary to final location try { if (fs.existsSync(CONFIG.binaryPath)) { try { From 472e7c6252acadaf3189c405eb2e24a697990c6e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 20 Jan 2026 21:17:10 -0800 Subject: [PATCH 0056/1143] Add back memo() and don't prop drill streamingAgents --- .../components/blocks/agent-block-grid.tsx | 1 - .../blocks/agent-branch-wrapper.tsx | 20 ++---- cli/src/components/blocks/blocks-renderer.tsx | 8 --- cli/src/components/blocks/single-block.tsx | 3 - .../components/blocks/tool-block-group.tsx | 3 - cli/src/components/blocks/tool-branch.tsx | 6 +- cli/src/components/message-block.tsx | 16 ++--- cli/src/components/message-with-agents.tsx | 66 ++++++++++--------- 8 files changed, 49 insertions(+), 74 deletions(-) diff --git a/cli/src/components/blocks/agent-block-grid.tsx b/cli/src/components/blocks/agent-block-grid.tsx index bebe3f14a3..31534d7b37 100644 --- a/cli/src/components/blocks/agent-block-grid.tsx +++ b/cli/src/components/blocks/agent-block-grid.tsx @@ -7,7 +7,6 @@ export interface AgentBlockGridProps { agentBlocks: AgentContentBlock[] keyPrefix: string availableWidth: number - streamingAgents: Set renderAgentBranch: ( agentBlock: AgentContentBlock, keyPrefix: string, diff --git a/cli/src/components/blocks/agent-branch-wrapper.tsx b/cli/src/components/blocks/agent-branch-wrapper.tsx index f49ce665f5..e33cdae936 100644 --- a/cli/src/components/blocks/agent-branch-wrapper.tsx +++ b/cli/src/components/blocks/agent-branch-wrapper.tsx @@ -9,6 +9,7 @@ import { ContentWithMarkdown } from './content-with-markdown' import { ThinkingBlock } from './thinking-block' import { trimTrailingNewlines, sanitizePreview } from './block-helpers' import { useTheme } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' import { AGENT_CONTENT_HORIZONTAL_PADDING } from '../../utils/layout-helpers' import { shouldRenderAsSimpleText } from '../../utils/constants' import { isImplementorAgent, getImplementorIndex } from '../../utils/implementor-helpers' @@ -30,7 +31,6 @@ interface AgentBodyProps { parentIsStreaming: boolean availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -44,7 +44,6 @@ interface AgentBodyPropsRef { parentIsStreaming: boolean availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -60,7 +59,6 @@ const AgentBody = memo( parentIsStreaming, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -94,7 +92,6 @@ const AgentBody = memo( parentIsStreaming, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -130,7 +127,6 @@ const AgentBody = memo( nextIndex={nextIndex} siblingBlocks={p.nestedBlocks} availableWidth={p.availableWidth} - streamingAgents={p.streamingAgents} onToggleCollapsed={p.onToggleCollapsed} markdownPalette={p.markdownPalette} /> @@ -157,14 +153,12 @@ const AgentBody = memo( agentBlocks={agentBlocks} keyPrefix={`${p.keyPrefix}-agent-grid-${startIndex}`} availableWidth={p.availableWidth} - streamingAgents={p.streamingAgents} renderAgentBranch={(innerAgentBlock, prefix, width) => ( onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -263,7 +256,6 @@ export const AgentBranchWrapper = memo( keyPrefix, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -271,11 +263,11 @@ export const AgentBranchWrapper = memo( isLastMessage, }: AgentBranchWrapperProps) => { const theme = useTheme() + // Derive streaming boolean for this specific agent to avoid re-renders when other agents change + const agentIsStreaming = useChatStore((state) => state.streamingAgents.has(agentBlock.agentId)) if (shouldRenderAsSimpleText(agentBlock.agentType)) { - const isStreaming = - agentBlock.status === 'running' || - streamingAgents.has(agentBlock.agentId) + const isStreaming = agentBlock.status === 'running' || agentIsStreaming const effectiveStatus = isStreaming ? 'running' : agentBlock.status const { indicator: statusIndicator, color: statusColor } = @@ -343,8 +335,7 @@ export const AgentBranchWrapper = memo( } const isCollapsed = agentBlock.isCollapsed ?? false - const isStreaming = - agentBlock.status === 'running' || streamingAgents.has(agentBlock.agentId) + const isStreaming = agentBlock.status === 'running' || agentIsStreaming const allTextContent = agentBlock.blocks @@ -395,7 +386,6 @@ export const AgentBranchWrapper = memo( parentIsStreaming={isStreaming} availableWidth={availableWidth} markdownPalette={markdownPalette} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} diff --git a/cli/src/components/blocks/blocks-renderer.tsx b/cli/src/components/blocks/blocks-renderer.tsx index bc7ac00d03..09b908d236 100644 --- a/cli/src/components/blocks/blocks-renderer.tsx +++ b/cli/src/components/blocks/blocks-renderer.tsx @@ -20,7 +20,6 @@ interface BlocksRendererProps { textColor: string availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -38,7 +37,6 @@ interface BlocksRendererPropsRef { textColor: string availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -57,7 +55,6 @@ export const BlocksRenderer = memo( textColor, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -83,7 +80,6 @@ export const BlocksRenderer = memo( textColor, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -130,7 +126,6 @@ export const BlocksRenderer = memo( nextIndex={nextIndex} siblingBlocks={p.sourceBlocks} availableWidth={p.availableWidth} - streamingAgents={p.streamingAgents} onToggleCollapsed={p.onToggleCollapsed} markdownPalette={p.markdownPalette} /> @@ -157,14 +152,12 @@ export const BlocksRenderer = memo( agentBlocks={agentBlocks} keyPrefix={`${p.messageId}-agent-grid-${startIndex}`} availableWidth={p.availableWidth} - streamingAgents={p.streamingAgents} renderAgentBranch={(agentBlock, prefix, width) => ( onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -49,7 +48,6 @@ export const SingleBlock = memo( textColor, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -176,7 +174,6 @@ export const SingleBlock = memo( keyPrefix={`${messageId}-agent-${block.agentId}`} availableWidth={availableWidth} markdownPalette={markdownPalette} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} diff --git a/cli/src/components/blocks/tool-block-group.tsx b/cli/src/components/blocks/tool-block-group.tsx index 2c0508c9d7..09c36ccccc 100644 --- a/cli/src/components/blocks/tool-block-group.tsx +++ b/cli/src/components/blocks/tool-block-group.tsx @@ -11,7 +11,6 @@ interface ToolBlockGroupProps { nextIndex: number siblingBlocks: ContentBlock[] availableWidth: number - streamingAgents: Set onToggleCollapsed: (id: string) => void markdownPalette: MarkdownPalette } @@ -50,7 +49,6 @@ export const ToolBlockGroup = memo( nextIndex, siblingBlocks, availableWidth, - streamingAgents, onToggleCollapsed, markdownPalette, }: ToolBlockGroupProps): ReactNode => { @@ -61,7 +59,6 @@ export const ToolBlockGroup = memo( toolBlock={toolBlock} keyPrefix={`${keyPrefix}-tool-${toolBlock.toolCallId}`} availableWidth={availableWidth} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} markdownPalette={markdownPalette} /> diff --git a/cli/src/components/blocks/tool-branch.tsx b/cli/src/components/blocks/tool-branch.tsx index f6f85b9d9a..e953b0bb9a 100644 --- a/cli/src/components/blocks/tool-branch.tsx +++ b/cli/src/components/blocks/tool-branch.tsx @@ -2,6 +2,7 @@ import { memo, useCallback } from 'react' import { ContentWithMarkdown } from './content-with-markdown' import { useTheme } from '../../hooks/use-theme' +import { useChatStore } from '../../state/chat-store' import { getToolDisplayInfo } from '../../utils/codebuff-client' import { shouldCollapseToolByDefault } from '../../utils/constants' import { renderToolComponent } from '../tools/registry' @@ -14,7 +15,6 @@ interface ToolBranchProps { toolBlock: Extract keyPrefix: string availableWidth: number - streamingAgents: Set onToggleCollapsed: (id: string) => void markdownPalette: MarkdownPalette } @@ -24,11 +24,12 @@ export const ToolBranch = memo( toolBlock, keyPrefix, availableWidth, - streamingAgents, onToggleCollapsed, markdownPalette, }: ToolBranchProps) => { const theme = useTheme() + // Derive streaming boolean for this specific tool to avoid re-renders when other tools/agents change + const isStreaming = useChatStore((state) => state.streamingAgents.has(toolBlock.toolCallId)) const sanitizePreview = (value: string): string => value.replace(/[#*_`~\[\]()]/g, '').trim() @@ -45,7 +46,6 @@ export const ToolBranch = memo( const displayInfo = getToolDisplayInfo(toolBlock.toolName) const isCollapsed = toolBlock.isCollapsed ?? shouldCollapseToolByDefault(toolBlock.toolName) - const isStreaming = streamingAgents.has(toolBlock.toolCallId) const inputContent = `\`\`\`json\n${JSON.stringify(toolBlock.input, null, 2)}\n\`\`\`` const codeBlockLang = diff --git a/cli/src/components/message-block.tsx b/cli/src/components/message-block.tsx index b3df59ea7b..6354e1f43f 100644 --- a/cli/src/components/message-block.tsx +++ b/cli/src/components/message-block.tsx @@ -1,5 +1,5 @@ import { TextAttributes } from '@opentui/core' -import React, { useState } from 'react' +import { memo, useState } from 'react' import { Button } from './button' import { ImageCard } from './image-card' @@ -40,7 +40,6 @@ interface MessageBlockProps { markdownOptions: { codeBlockWidth: number; palette: MarkdownPalette } availableWidth: number markdownPalette: MarkdownPalette - streamingAgents: Set onToggleCollapsed: (id: string) => void onBuildFast: () => void onBuildMax: () => void @@ -60,7 +59,7 @@ interface MessageBlockProps { isLastMessage?: boolean } -const MessageAttachments = ({ +const MessageAttachments = memo(({ imageAttachments, textAttachments, }: { @@ -96,9 +95,9 @@ const MessageAttachments = ({ ))} ) -} +}) -export const MessageBlock: React.FC = ({ +export const MessageBlock = memo(({ messageId, blocks, content, @@ -115,7 +114,6 @@ export const MessageBlock: React.FC = ({ markdownOptions, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -128,7 +126,7 @@ export const MessageBlock: React.FC = ({ textAttachments, metadata, isLastMessage, -}) => { +}: MessageBlockProps) => { const [showValidationPopover, setShowValidationPopover] = useState(false) const bashCwd = metadata?.bashCwd ? formatCwd(metadata.bashCwd) : undefined @@ -153,7 +151,6 @@ export const MessageBlock: React.FC = ({ markdownOptions, availableWidth, markdownPalette, - streamingAgents, onToggleCollapsed, onBuildFast, onBuildMax, @@ -276,7 +273,6 @@ export const MessageBlock: React.FC = ({ textColor={resolvedTextColor} availableWidth={availableWidth} markdownPalette={markdownPalette} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} @@ -326,4 +322,4 @@ export const MessageBlock: React.FC = ({ )} ) -} +}) diff --git a/cli/src/components/message-with-agents.tsx b/cli/src/components/message-with-agents.tsx index 21c70fb570..0395f5aa4e 100644 --- a/cli/src/components/message-with-agents.tsx +++ b/cli/src/components/message-with-agents.tsx @@ -1,6 +1,7 @@ import { TextAttributes } from '@opentui/core' import { memo, useCallback, useMemo, type ReactNode } from 'react' import React from 'react' +import { useShallow } from 'zustand/react/shallow' import { Button } from './button' import { ErrorBoundary } from './error-boundary' @@ -92,26 +93,28 @@ export const MessageWithAgents = memo( const SIDE_GUTTER = 1 const isAgent = message.variant === 'agent' - const context = useMessageBlockStore((state) => state.context) - const callbacks = useMessageBlockStore((state) => state.callbacks) - - const { - theme, - markdownPalette, - messageTree, - isWaitingForResponse, - timerStartTime, - } = context - - const { - onToggleCollapsed, - onBuildFast, - onBuildMax, - onFeedback, - onCloseFeedback, - } = callbacks - - const streamingAgents = useChatStore((state) => state.streamingAgents) + // Use useShallow for grouped selectors to prevent unnecessary re-renders + const { theme, markdownPalette, messageTree, isWaitingForResponse, timerStartTime } = + useMessageBlockStore( + useShallow((state) => ({ + theme: state.context.theme, + markdownPalette: state.context.markdownPalette, + messageTree: state.context.messageTree, + isWaitingForResponse: state.context.isWaitingForResponse, + timerStartTime: state.context.timerStartTime, + })), + ) + + const { onToggleCollapsed, onBuildFast, onBuildMax, onFeedback, onCloseFeedback } = + useMessageBlockStore( + useShallow((state) => ({ + onToggleCollapsed: state.callbacks.onToggleCollapsed, + onBuildFast: state.callbacks.onBuildFast, + onBuildMax: state.callbacks.onBuildMax, + onFeedback: state.callbacks.onFeedback, + onCloseFeedback: state.callbacks.onCloseFeedback, + })), + ) // Memoize onOpenFeedback to prevent unnecessary re-renders const onOpenFeedback = useCallback( @@ -252,7 +255,6 @@ export const MessageWithAgents = memo( markdownOptions={markdownOptions} availableWidth={availableWidth} markdownPalette={markdownPalette!} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} @@ -287,7 +289,6 @@ export const MessageWithAgents = memo( markdownOptions={markdownOptions} availableWidth={availableWidth} markdownPalette={markdownPalette!} - streamingAgents={streamingAgents} onToggleCollapsed={onToggleCollapsed} onBuildFast={onBuildFast} onBuildMax={onBuildMax} @@ -325,14 +326,18 @@ interface AgentMessageProps { const AgentMessage = memo( ({ message, depth, availableWidth }: AgentMessageProps): ReactNode => { - // Get values from zustand stores - const context = useMessageBlockStore((state) => state.context) - const callbacks = useMessageBlockStore((state) => state.callbacks) - - const { theme, markdownPalette, messageTree } = context - const { onToggleCollapsed } = callbacks - - const streamingAgents = useChatStore((state) => state.streamingAgents) + // Use useShallow for grouped selectors to prevent unnecessary re-renders + const { theme, markdownPalette, messageTree, onToggleCollapsed } = useMessageBlockStore( + useShallow((state) => ({ + theme: state.context.theme, + markdownPalette: state.context.markdownPalette, + messageTree: state.context.messageTree, + onToggleCollapsed: state.callbacks.onToggleCollapsed, + })), + ) + + // Derive streaming boolean for this specific message to avoid re-renders when other agents change + const isStreaming = useChatStore((state) => state.streamingAgents.has(message.id)) const setFocusedAgentId = useChatStore((state) => state.setFocusedAgentId) // Guard against missing agent info (should not happen for agent variant messages) @@ -347,7 +352,6 @@ const AgentMessage = memo( // Get or initialize collapse state from message metadata const isCollapsed = message.metadata?.isCollapsed ?? false - const isStreaming = streamingAgents.has(message.id) const agentChildren = messageTree?.get(message.id) ?? [] From 91f17c8b564f10be4a55ac3b8a64b2a9e643fe0f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Wed, 21 Jan 2026 07:33:05 +0000 Subject: [PATCH 0057/1143] Bump version to 1.0.590 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 24d03078c9..5587b7fbdc 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.589", + "version": "1.0.590", "description": "AI coding agent", "license": "MIT", "bin": { From f61822d62668c2e56d4644ff9cedd19c75c41265 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 20 Jan 2026 23:42:31 -0800 Subject: [PATCH 0058/1143] Exclude commander output from pruned context --- agents/context-pruner.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index 29e02af1eb..8c200027cd 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -295,6 +295,8 @@ const definition: AgentDefinition = { 'glob-matcher', 'researcher-web', 'researcher-docs', + 'commander', + 'commander-lite', 'code-reviewer', 'code-reviewer-multi-prompt', ] From 285e9f9829419684b279e110b91ff2443b8ec553 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 20 Jan 2026 23:42:51 -0800 Subject: [PATCH 0059/1143] Upgrade tar to 7.0.0 to fix npm warning --- cli/release-staging/package.json | 2 +- cli/release/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cli/release-staging/package.json b/cli/release-staging/package.json index 82a9531092..23ae8cac37 100644 --- a/cli/release-staging/package.json +++ b/cli/release-staging/package.json @@ -28,7 +28,7 @@ "node": ">=16" }, "dependencies": { - "tar": "^6.2.0" + "tar": "^7.0.0" }, "repository": { "type": "git", diff --git a/cli/release/package.json b/cli/release/package.json index 5587b7fbdc..03996d92dc 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -29,7 +29,7 @@ "node": ">=16" }, "dependencies": { - "tar": "^6.2.0" + "tar": "^7.0.0" }, "repository": { "type": "git", From e0f5625aa67280fbde33e8faaa19f127e64d61b6 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 00:07:12 -0800 Subject: [PATCH 0060/1143] Quick mcp vibe-coded docs page --- web/src/content/agents/mcp-servers.mdx | 253 +++++++++++++++++++++++++ 1 file changed, 253 insertions(+) create mode 100644 web/src/content/agents/mcp-servers.mdx diff --git a/web/src/content/agents/mcp-servers.mdx b/web/src/content/agents/mcp-servers.mdx new file mode 100644 index 0000000000..e73bec0a03 --- /dev/null +++ b/web/src/content/agents/mcp-servers.mdx @@ -0,0 +1,253 @@ +--- +title: 'MCP Servers' +section: 'agents' +tags: ['agents', 'mcp', 'integrations', 'model-context-protocol'] +order: 3 +--- + +# MCP Servers + +The Model Context Protocol (MCP) is an open standard that lets you connect AI agents to external tools and data sources. Codebuff agents can use MCP servers to access APIs, databases, and other services. + +## How It Works + +To use an MCP server, create an agent in your `.agents/` directory and configure the `mcpServers` field. The MCP server will be started automatically when the agent runs, and its tools will be available to the agent. + +## Example: Notion Integration + +Here's a complete example that connects to Notion using the official Notion MCP server: + +**.agents/notion-agent.ts** + +```typescript +import type { AgentDefinition } from './types/agent-definition' + +const definition: AgentDefinition = { + id: 'notion-query-agent', + displayName: 'Notion Query Agent', + model: 'anthropic/claude-sonnet-4.5', + + spawnerPrompt: + 'Expert at querying Notion databases and pages to find information and answer questions about content stored in Notion workspaces.', + + inputSchema: { + prompt: { + type: 'string', + description: + 'A question or request about information stored in your Notion workspace', + }, + }, + + outputMode: 'last_message', + includeMessageHistory: false, + + mcpServers: { + notionApi: { + command: 'npx', + args: ['-y', '@notionhq/notion-mcp-server'], + env: { + NOTION_TOKEN: '$NOTION_TOKEN', + }, + }, + }, + + systemPrompt: `You are a Notion expert who helps users find and retrieve information from their Notion workspace. You can search across pages and databases, read specific pages, and query databases with filters.`, + + instructionsPrompt: `Instructions: +1. Use the Notion tools to search for relevant information based on the user's question. Start with a broad search. +2. If you find relevant pages or databases, read them in detail or query them with appropriate filters +3. Provide a comprehensive answer based on the information found in Notion. +`, +} + +export default definition +``` + +Steps: + +1. Run `/init` within Codebuff to set up your `.agents` directory. +2. Save this file to `.agents/notion-agent.ts` in your project. +3. Get your [Notion key](https://developers.notion.com/docs/get-started-with-mcp) and set it as an environment variable. +4. Start Codebuff and ask it to use your new Notion agent! + +Use similar steps to create new agents with other mcp tools! + +## Configuration Reference + +### `mcpServers` (object) + +A map of MCP server configurations. Each key is a name for the server (used for identification), and the value is the server configuration. + +There are two types of MCP server configurations: + +### Stdio (Local Process) + +Runs an MCP server as a local process that communicates via stdin/stdout: + +```typescript +mcpServers: { + serverName: { + type: 'stdio', // Optional, defaults to 'stdio' + command: string, // Command to run the MCP server + args: string[], // Arguments to pass to the command + env: { // Environment variables for the server + VAR_NAME: string, // Use '$VAR_NAME' to reference environment variables + }, + }, +} +``` + +#### Stdio Fields + +- **`type`** (`'stdio'`): Optional. Indicates a local process server (default) +- **`command`** (`string`): The command to execute (e.g., `'npx'`, `'node'`, `'python'`) +- **`args`** (`string[]`): Arguments passed to the command +- **`env`** (`object`): Environment variables for the MCP server process + +### Remote (HTTP/SSE) + +Connects to a remote MCP server via HTTP or Server-Sent Events (SSE): + +```typescript +mcpServers: { + serverName: { + type: 'http', // 'http' or 'sse' + url: string, // URL of the remote MCP server + params: { // Query parameters to include in requests + paramName: string, + }, + headers: { // HTTP headers to include in requests + headerName: string, // Use '$VAR_NAME' to reference environment variables + }, + }, +} +``` + +#### Remote Fields + +- **`type`** (`'http'` | `'sse'`): Required. `'http'` for standard HTTP, `'sse'` for Server-Sent Events +- **`url`** (`string`): The URL of the remote MCP server +- **`params`** (`object`): Query parameters to include in requests +- **`headers`** (`object`): HTTP headers to include in requests (e.g., for authentication) + +### Environment Variables + +Use the `$VAR_NAME` syntax to reference environment variables from your shell. For example: + +```typescript +env: { + NOTION_TOKEN: '$NOTION_TOKEN', + API_KEY: '$MY_API_KEY', +} +``` + +This reads `NOTION_TOKEN` and `MY_API_KEY` from your environment and passes them to the MCP server. + +**Setup:** Add your token to your shell configuration (e.g., `.bashrc`, `.zshrc`): + +```bash +export NOTION_TOKEN="your-notion-integration-token" +``` + +Or use a `.env` file in your project root. + +## Using Your MCP Agent + +### Spawning with `@` + +Reference your agent in the CLI using `@` followed by the agent's display name: + +``` +@Notion Query Agent what meetings do I have this week? +``` + +Codebuff will spawn your agent to handle the request. + +### Spawning from Other Agents + +Other agents can spawn your MCP-enabled agent if it's listed in their `spawnableAgents`: + +```typescript +spawnableAgents: ['notion-query-agent'] +``` + +## Customizing When Your Agent Is Spawned + +The `spawnerPrompt` field tells other agents when they should spawn your agent. Write a clear description of your agent's capabilities: + +```typescript +spawnerPrompt: + 'Expert at querying Notion databases and pages to find information and answer questions about content stored in Notion workspaces.', +``` + +The base agent reads this description and decides whether to spawn your agent based on the user's request. Make it specific and descriptive so the base agent knows when your agent is the right choice. + +## More MCP Server Examples + +### GitHub Integration (Stdio) + +```typescript +mcpServers: { + github: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-github'], + env: { + GITHUB_PERSONAL_ACCESS_TOKEN: '$GITHUB_TOKEN', + }, + }, +} +``` + +### Remote API Integration (HTTP) + +```typescript +mcpServers: { + myApi: { + type: 'http', + url: 'https://api.example.com/mcp', + headers: { + Authorization: '$API_TOKEN', + }, + }, +} +``` + +### Streaming Server (SSE) + +```typescript +mcpServers: { + streamingApi: { + type: 'sse', + url: 'https://stream.example.com/mcp/events', + headers: { + 'X-API-Key': '$STREAM_API_KEY', + }, + params: { + workspace: 'default', + }, + }, +} +``` + +## Finding MCP Servers + +Browse available MCP servers at: + +- [MCP Server Registry](https://github.com/modelcontextprotocol/servers) - Official and community servers +- [NPM](https://www.npmjs.com/search?q=mcp-server) - Search for `mcp-server` packages + +## Troubleshooting + +**Agent not connecting to MCP server:** +- Verify the command and args are correct +- Check that environment variables are set in your shell +- Run the MCP server command manually to test it works + +**Environment variable not found:** +- Ensure the variable is exported in your shell +- Restart your terminal after adding to `.bashrc`/`.zshrc` +- Check for typos in the `$VAR_NAME` reference + +**MCP server tools not appearing:** +- The server may take a moment to start +- Check the server's documentation for required setup steps From 18e72ca1decdd545ce5ff1edc9c50e06de68a3cf Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Tue, 20 Jan 2026 16:13:05 -0800 Subject: [PATCH 0061/1143] Fix retry detection and narrow grant reads --- .../balance-calculator.integration.test.ts | 789 ++++++++++++++++++ .../src/__tests__/balance-calculator.test.ts | 400 +++++++++ packages/billing/src/balance-calculator.ts | 90 +- .../src/db/__tests__/transaction.test.ts | 112 +++ packages/internal/src/db/transaction.ts | 56 +- 5 files changed, 1427 insertions(+), 20 deletions(-) create mode 100644 packages/billing/src/__tests__/balance-calculator.integration.test.ts create mode 100644 packages/billing/src/__tests__/balance-calculator.test.ts diff --git a/packages/billing/src/__tests__/balance-calculator.integration.test.ts b/packages/billing/src/__tests__/balance-calculator.integration.test.ts new file mode 100644 index 0000000000..3647152f23 --- /dev/null +++ b/packages/billing/src/__tests__/balance-calculator.integration.test.ts @@ -0,0 +1,789 @@ +/** + * Integration tests for balance-calculator.ts UNION query behavior. + * + * These tests run against a real PostgreSQL database to verify that the + * Drizzle ORM generates correct SQL for the UNION query in + * getOrderedActiveGrantsForConsumption. + * + * To run these tests: + * 1. Ensure the E2E database is running (see packages/internal/src/db/e2e-constants.ts) + * 2. Run: DATABASE_URL= bun test balance-calculator.integration + * + * Tests will be skipped if DATABASE_URL is not available. + */ +import { + afterAll, + afterEach, + beforeAll, + describe, + expect, + it, +} from 'bun:test' +import { drizzle } from 'drizzle-orm/postgres-js' +import postgres from 'postgres' +import { eq, and, asc, desc, ne, or, gt, isNull, sql } from 'drizzle-orm' +import { union } from 'drizzle-orm/pg-core' +import * as schema from '@codebuff/internal/db/schema' +import { consumeFromOrderedGrants } from '../balance-calculator' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +// Test logger that silently discards all logs +const testLogger: Logger = { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, +} + +// Test configuration +const TEST_USER_ID = 'integration-test-user-balance-calc' +const TEST_DATABASE_URL = process.env.DATABASE_URL + +// Skip all tests if no DATABASE_URL is available +const shouldSkip = !TEST_DATABASE_URL + +// Create test database connection +let testClient: ReturnType | null = null +let testDb: ReturnType> | null = null + +function getTestDb() { + if (!testDb) { + throw new Error('Test database not initialized') + } + return testDb +} + +// Helper to create grants with specific properties +function createGrantData(overrides: { + operation_id: string + balance: number + priority: number + expires_at: Date | null + created_at: Date + principal?: number +}) { + return { + operation_id: overrides.operation_id, + user_id: TEST_USER_ID, + principal: overrides.principal ?? Math.max(overrides.balance, 100), + balance: overrides.balance, + type: 'free' as const, + description: 'Integration test grant', + priority: overrides.priority, + expires_at: overrides.expires_at, + created_at: overrides.created_at, + } +} + +// Helper to build active grants filter (mirrors production code) +function buildActiveGrantsFilter(userId: string, now: Date) { + return and( + eq(schema.creditLedger.user_id, userId), + or( + isNull(schema.creditLedger.expires_at), + gt(schema.creditLedger.expires_at, now), + ), + ) +} + +// Helper that mirrors the production getOrderedActiveGrantsForConsumption +async function getOrderedActiveGrantsForConsumption(params: { + userId: string + now: Date + conn: ReturnType> +}) { + const { userId, now, conn } = params + const activeGrantsFilter = buildActiveGrantsFilter(userId, now) + + const grants = await union( + conn + .select() + .from(schema.creditLedger) + .where(and(activeGrantsFilter, ne(schema.creditLedger.balance, 0))), + conn + .select() + .from(schema.creditLedger) + .where(activeGrantsFilter) + .orderBy( + desc(schema.creditLedger.priority), + sql`${schema.creditLedger.expires_at} DESC NULLS FIRST`, + desc(schema.creditLedger.created_at), + ) + .limit(1), + ).orderBy( + asc(schema.creditLedger.priority), + sql`${schema.creditLedger.expires_at} ASC NULLS LAST`, + asc(schema.creditLedger.created_at), + ) + + return grants +} + +describe.skipIf(shouldSkip)( + 'Balance Calculator - Integration Tests (Real DB)', + () => { + beforeAll(async () => { + if (shouldSkip) return + + // Create test database connection + testClient = postgres(TEST_DATABASE_URL!) + testDb = drizzle(testClient, { schema }) + + // Create test user if not exists + try { + await testDb.insert(schema.user).values({ + id: TEST_USER_ID, + email: 'integration-test@codebuff.test', + name: 'Integration Test User', + }) + } catch { + // User might already exist, that's fine + } + }) + + afterAll(async () => { + if (shouldSkip || !testDb || !testClient) return + + // Clean up test user and all their grants + await testDb + .delete(schema.creditLedger) + .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) + await testDb.delete(schema.user).where(eq(schema.user.id, TEST_USER_ID)) + + // Close connection + await testClient.end() + }) + + afterEach(async () => { + if (shouldSkip || !testDb) return + + // Clean up grants between tests for isolation + await testDb + .delete(schema.creditLedger) + .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) + }) + + describe('getOrderedActiveGrantsForConsumption UNION query', () => { + it('should return grants ordered by priority ASC, expires_at ASC NULLS LAST, created_at ASC', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants in random order + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-grant-3', + balance: 100, + priority: 30, + expires_at: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-1', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-2', + balance: 100, + priority: 10, // Same priority as grant-1 + expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), // Expires sooner + created_at: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-4', + balance: 100, + priority: 60, // Lowest priority + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + expect(grants.map((g) => g.operation_id)).toEqual([ + 'int-test-grant-2', // priority 10, expires soonest + 'int-test-grant-1', // priority 10, expires later + 'int-test-grant-3', // priority 30 + 'int-test-grant-4', // priority 60, never expires (NULLS LAST) + ]) + }) + + it('should include zero-balance last grant for debt recording', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-positive', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-zero-last', + balance: 0, // Zero balance + priority: 60, // Lowest priority = last grant + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Should include both: non-zero + zero-balance last grant + expect(grants.length).toBe(2) + expect(grants.map((g) => g.operation_id)).toEqual([ + 'int-test-positive', + 'int-test-zero-last', + ]) + }) + + it('should deduplicate when last grant has non-zero balance', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-first', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-last-nonzero', + balance: 50, // Non-zero balance + priority: 60, // Lowest priority = last grant + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // UNION should deduplicate - last grant appears only once + expect(grants.length).toBe(2) + expect( + grants.filter((g) => g.operation_id === 'int-test-last-nonzero') + .length, + ).toBe(1) + }) + + it('should handle all-zero-balance grants correctly', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-zero-1', + balance: 0, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-zero-2', + balance: 0, + priority: 60, // This is the "last grant" + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Only the last grant should be returned (for debt recording) + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-zero-2') + }) + + it('should correctly order NULL expires_at as NULLS LAST in consumption order', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-expires-soon', + balance: 100, + priority: 60, // Same priority + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-never-expires', + balance: 100, + priority: 60, // Same priority + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // In consumption order: expires-soon first, never-expires last + expect(grants[0].operation_id).toBe('int-test-expires-soon') + expect(grants[1].operation_id).toBe('int-test-never-expires') + }) + + it('should filter out expired grants', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-active', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-expired', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() - 1000), // Already expired + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Only active grant should be returned + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-active') + }) + + it('should handle empty grants case', async () => { + const db = getTestDb() + const now = new Date() + + // Don't insert any grants + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + expect(grants).toEqual([]) + }) + + it('should handle single grant case', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-single', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Single grant should be returned (deduplicated by UNION) + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-single') + }) + + it('should handle grants with identical priority, expires_at, and created_at deterministically', async () => { + const db = getTestDb() + const now = new Date() + + // Create grants with IDENTICAL sorting fields (priority, expires_at, created_at) + // This tests the known non-determinism issue - without a tiebreaker like operation_id, + // PostgreSQL may return these in any order + const sharedExpiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + const sharedCreatedAt = new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000) + const sharedPriority = 10 + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-identical-a', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + createGrantData({ + operation_id: 'int-test-identical-b', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + createGrantData({ + operation_id: 'int-test-identical-c', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + ]) + + // Query multiple times to verify ordering stability + const grants1 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + const grants2 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + const grants3 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // All grants should be returned + expect(grants1.length).toBe(3) + expect(grants2.length).toBe(3) + expect(grants3.length).toBe(3) + + // Extract operation_ids for comparison + const order1 = grants1.map((g) => g.operation_id) + const order2 = grants2.map((g) => g.operation_id) + const order3 = grants3.map((g) => g.operation_id) + + // All should contain the same grants + expect(order1.sort()).toEqual(['int-test-identical-a', 'int-test-identical-b', 'int-test-identical-c']) + + // NOTE: This test documents the non-determinism issue. + // Without an operation_id tiebreaker in the ORDER BY clause, + // these assertions may randomly fail as PostgreSQL doesn't guarantee + // a stable order for rows with identical sorting keys. + // If this test fails intermittently, add operation_id as a tiebreaker. + expect(order1).toEqual(order2) + expect(order2).toEqual(order3) + }) + }) + + describe('consumeCredits end-to-end tests', () => { + // Helper to get grant balance from DB + async function getGrantBalance(operationId: string): Promise { + const db = getTestDb() + const result = await db + .select({ balance: schema.creditLedger.balance }) + .from(schema.creditLedger) + .where(eq(schema.creditLedger.operation_id, operationId)) + return result[0]?.balance ?? 0 + } + + it('should consume credits from grants in priority order', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants with different priorities + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-high-priority', + balance: 50, + principal: 50, + priority: 10, // Consumed first + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-low-priority', + balance: 100, + principal: 100, + priority: 60, // Consumed second + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 70 credits (should take 50 from high-priority, 20 from low-priority) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 70, + grants, + tx: db as any, + logger: testLogger, + }) + + expect(result.consumed).toBe(70) + + // Verify balances in database + const highPriorityBalance = await getGrantBalance('e2e-high-priority') + const lowPriorityBalance = await getGrantBalance('e2e-low-priority') + + expect(highPriorityBalance).toBe(0) // 50 - 50 = 0 + expect(lowPriorityBalance).toBe(80) // 100 - 20 = 80 + }) + + it('should record debt on last grant when all credits exhausted', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants with limited balance + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-depleted', + balance: 30, + principal: 30, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-last-grant', + balance: 0, // Already exhausted - this is the "last grant" for debt + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 100 credits (only 30 available, should create 70 debt) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 100, + grants, + tx: db as any, + logger: testLogger, + }) + + expect(result.consumed).toBe(100) + + // Verify balances in database + const depletedBalance = await getGrantBalance('e2e-depleted') + const lastGrantBalance = await getGrantBalance('e2e-last-grant') + + expect(depletedBalance).toBe(0) // 30 - 30 = 0 + expect(lastGrantBalance).toBe(-70) // 0 - 70 = -70 (debt) + }) + + it('should consume partial credits from multiple grants correctly', async () => { + const db = getTestDb() + const now = new Date() + + // Insert three grants + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-grant-1', + balance: 25, + principal: 25, + priority: 10, + expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-grant-2', + balance: 50, + principal: 50, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-grant-3', + balance: 100, + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 60 credits (should take 25 from grant-1, 35 from grant-2) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 60, + grants, + tx: db as any, + logger: testLogger, + }) + + expect(result.consumed).toBe(60) + + // Verify balances in database + const grant1Balance = await getGrantBalance('e2e-grant-1') + const grant2Balance = await getGrantBalance('e2e-grant-2') + const grant3Balance = await getGrantBalance('e2e-grant-3') + + expect(grant1Balance).toBe(0) // 25 - 25 = 0 + expect(grant2Balance).toBe(15) // 50 - 35 = 15 + expect(grant3Balance).toBe(100) // Untouched + }) + + it('should repay debt when consuming from grants with negative balance', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants: one with debt, one with positive balance + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-debt-grant', + balance: -50, // Has debt + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-positive-grant', + balance: 100, + principal: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 80 credits + // The consumption algorithm works as follows: + // 1. First pass (debt repayment): Uses creditsToConsume to repay debt + // - debt-grant has -50, repay 50 from the 80 requested, debt becomes 0 + // - remainingToConsume = 30, consumed = 50 + // 2. Second pass (consumption): Consumes from positive balances + // - positive-grant has 100, consume 30, becomes 70 + // - remainingToConsume = 0, consumed = 80 + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 80, + grants, + tx: db as any, + logger: testLogger, + }) + + expect(result.consumed).toBe(80) + + // Verify balances in database + const debtGrantBalance = await getGrantBalance('e2e-debt-grant') + const positiveGrantBalance = await getGrantBalance('e2e-positive-grant') + + // Debt should be repaid: -50 + 50 = 0 + expect(debtGrantBalance).toBe(0) + // Positive grant: 100 - 30 (consume after debt repayment) = 70 + expect(positiveGrantBalance).toBe(70) + }) + + it('should track purchased credits consumption correctly', async () => { + const db = getTestDb() + const now = new Date() + + // Insert a mix of free and purchased grants + await db.insert(schema.creditLedger).values([ + { + operation_id: 'e2e-free-grant', + user_id: TEST_USER_ID, + balance: 30, + principal: 30, + type: 'free' as const, + description: 'Free credits', + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }, + { + operation_id: 'e2e-purchased-grant', + user_id: TEST_USER_ID, + balance: 100, + principal: 100, + type: 'purchase' as const, + description: 'Purchased credits', + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }, + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 50 credits (30 from free, 20 from purchased) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 50, + grants, + tx: db as any, + logger: testLogger, + }) + + expect(result.consumed).toBe(50) + expect(result.fromPurchased).toBe(20) // Only 20 came from purchase grant + + // Verify balances in database + const freeBalance = await getGrantBalance('e2e-free-grant') + const purchasedBalance = await getGrantBalance('e2e-purchased-grant') + + expect(freeBalance).toBe(0) // 30 - 30 = 0 + expect(purchasedBalance).toBe(80) // 100 - 20 = 80 + }) + }) + }, +) diff --git a/packages/billing/src/__tests__/balance-calculator.test.ts b/packages/billing/src/__tests__/balance-calculator.test.ts new file mode 100644 index 0000000000..0f0160b817 --- /dev/null +++ b/packages/billing/src/__tests__/balance-calculator.test.ts @@ -0,0 +1,400 @@ +import { + clearMockedModules, + mockModule, +} from '@codebuff/common/testing/mock-modules' +import { afterEach, beforeEach, describe, expect, it } from 'bun:test' + +import type { Logger } from '@codebuff/common/types/contracts/logger' + +const logger: Logger = { + debug: () => {}, + error: () => {}, + info: () => {}, + warn: () => {}, +} + +// Helper to create mock grants with specific properties +function createMockGrant(overrides: { + operation_id: string + balance: number + priority: number + expires_at: Date | null + created_at: Date + principal?: number + type?: 'subscription' | 'purchase' | 'promotion' | 'organization' | 'referral' +}) { + return { + operation_id: overrides.operation_id, + user_id: 'user-123', + organization_id: null, + principal: overrides.principal ?? Math.max(overrides.balance, 100), + balance: overrides.balance, + type: overrides.type ?? ('subscription' as const), + description: 'Test grant', + priority: overrides.priority, + expires_at: overrides.expires_at, + created_at: overrides.created_at, + } +} + +// Track grants returned by mock queries for verification +let capturedNonZeroQuery: any[] = [] +let capturedLastGrantQuery: any[] = [] +let unionResults: any[] = [] + +/** + * Creates a mock that simulates the UNION query behavior. + * The mock tracks what grants would be returned and verifies UNION deduplication. + */ +function createDbMockForUnion(options: { + grants: ReturnType[] + updateCallback?: (grantId: string, newBalance: number) => void +}) { + const { grants, updateCallback } = options + + // Simulate what the UNION query returns: + // 1. Non-zero balance grants + // 2. UNION with last grant (by priority DESC, expires_at DESC NULLS FIRST, created_at DESC) + // 3. Deduplicated by UNION + // 4. Ordered by priority ASC, expires_at ASC NULLS LAST, created_at ASC + + const now = new Date() + const activeGrants = grants.filter( + (g) => !g.expires_at || g.expires_at > now, + ) + + // Non-zero grants + const nonZeroGrants = activeGrants.filter((g) => g.balance !== 0) + capturedNonZeroQuery = [...nonZeroGrants] + + // Last grant (would be consumed last) + const sortedForLast = [...activeGrants].sort((a, b) => { + // Priority DESC + if (b.priority !== a.priority) return b.priority - a.priority + // expires_at DESC NULLS FIRST + if (a.expires_at === null && b.expires_at !== null) return -1 + if (b.expires_at === null && a.expires_at !== null) return 1 + if (a.expires_at && b.expires_at) { + if (b.expires_at.getTime() !== a.expires_at.getTime()) { + return b.expires_at.getTime() - a.expires_at.getTime() + } + } + // created_at DESC + return b.created_at.getTime() - a.created_at.getTime() + }) + const lastGrant = sortedForLast[0] + capturedLastGrantQuery = lastGrant ? [lastGrant] : [] + + // UNION (deduplicate) and sort for consumption + const combined = [...nonZeroGrants] + if ( + lastGrant && + !nonZeroGrants.some((g) => g.operation_id === lastGrant.operation_id) + ) { + combined.push(lastGrant) + } + + // Sort for consumption order + combined.sort((a, b) => { + // Priority ASC + if (a.priority !== b.priority) return a.priority - b.priority + // expires_at ASC NULLS LAST + if (a.expires_at === null && b.expires_at !== null) return 1 + if (b.expires_at === null && a.expires_at !== null) return -1 + if (a.expires_at && b.expires_at) { + if (a.expires_at.getTime() !== b.expires_at.getTime()) { + return a.expires_at.getTime() - b.expires_at.getTime() + } + } + // created_at ASC + return a.created_at.getTime() - b.created_at.getTime() + }) + + unionResults = combined + + return { + select: () => ({ + from: () => ({ + where: () => ({ + orderBy: () => combined, + }), + }), + }), + update: () => ({ + set: (values: { balance: number }) => ({ + where: () => { + if (updateCallback) { + // Find which grant is being updated based on the balance change + const targetGrant = grants.find( + (g) => g.balance !== values.balance, + ) + if (targetGrant) { + updateCallback(targetGrant.operation_id, values.balance) + } + } + return Promise.resolve() + }, + }), + }), + } +} + +describe('Balance Calculator - Grant Ordering for Consumption', () => { + // NOTE: This test suite uses a complex mock (createDbMockForUnion) to simulate the + // behavior of the UNION query in `getOrderedActiveGrantsForConsumption`. + // While it's useful for verifying the business logic and sorting/deduplication rules, + // it does not test the actual SQL generated by Drizzle. + // A better long-term solution would be to replace this with an integration test + // that runs against a real test database to ensure the query itself is correct. + afterEach(() => { + clearMockedModules() + capturedNonZeroQuery = [] + capturedLastGrantQuery = [] + unionResults = [] + }) + + describe('getOrderedActiveGrantsForConsumption UNION query behavior', () => { + it('should return grants ordered by priority ASC, expires_at ASC NULLS LAST, created_at ASC', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'grant-3', + balance: 100, + priority: 30, // Medium priority + expires_at: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000), // 60 days + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'grant-1', + balance: 100, + priority: 10, // Highest priority (consumed first) + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), // 30 days + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'grant-2', + balance: 100, + priority: 10, // Same priority as grant-1 + expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), // 15 days (expires sooner) + created_at: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'grant-4', + balance: 100, + priority: 60, // Lowest priority (consumed last) + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + await mockModule('@codebuff/internal/db/transaction', () => ({ + withSerializableTransaction: async ({ + callback, + }: { + callback: (tx: any) => Promise + }) => callback(dbMock), + })) + + // Verify the UNION result order + expect(unionResults.map((g) => g.operation_id)).toEqual([ + 'grant-2', // priority 10, expires soonest + 'grant-1', // priority 10, expires later + 'grant-3', // priority 30 + 'grant-4', // priority 60, never expires (NULLS LAST) + ]) + }) + + it('should include zero-balance last grant when all other grants have positive balance', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'grant-1', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'grant-2-zero', + balance: 0, // Zero balance - should still be included as last grant + priority: 60, // Lowest priority = last grant + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + // Non-zero query should only have grant-1 + expect(capturedNonZeroQuery.map((g) => g.operation_id)).toEqual([ + 'grant-1', + ]) + + // Last grant query should return grant-2-zero (lowest priority, never expires) + expect(capturedLastGrantQuery.map((g) => g.operation_id)).toEqual([ + 'grant-2-zero', + ]) + + // UNION result should include both (zero-balance grant added for debt recording) + expect(unionResults.map((g) => g.operation_id)).toEqual([ + 'grant-1', + 'grant-2-zero', + ]) + }) + + it('should deduplicate when last grant already has non-zero balance', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'grant-1', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'grant-2', + balance: 50, // Non-zero balance + priority: 60, // Lowest priority = last grant + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + // Both grants are in non-zero query + expect(capturedNonZeroQuery.length).toBe(2) + + // Last grant is grant-2 (already in non-zero set) + expect(capturedLastGrantQuery[0].operation_id).toBe('grant-2') + + // UNION should NOT duplicate grant-2 + expect(unionResults.length).toBe(2) + expect( + unionResults.filter((g) => g.operation_id === 'grant-2').length, + ).toBe(1) + }) + + it('should handle empty grants case', async () => { + const dbMock = createDbMockForUnion({ grants: [] }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + expect(unionResults).toEqual([]) + expect(capturedNonZeroQuery).toEqual([]) + expect(capturedLastGrantQuery).toEqual([]) + }) + + it('should handle single grant case', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'only-grant', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + // Single grant should be in both queries + expect(capturedNonZeroQuery.length).toBe(1) + expect(capturedLastGrantQuery.length).toBe(1) + + // UNION should return exactly one grant (deduplicated) + expect(unionResults.length).toBe(1) + expect(unionResults[0].operation_id).toBe('only-grant') + }) + + it('should handle all-zero-balance grants correctly', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'zero-1', + balance: 0, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'zero-2', + balance: 0, + priority: 60, // This is the "last grant" + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + // No non-zero grants + expect(capturedNonZeroQuery).toEqual([]) + + // Last grant should still be identified + expect(capturedLastGrantQuery[0].operation_id).toBe('zero-2') + + // UNION should return just the last grant (for debt recording) + expect(unionResults.length).toBe(1) + expect(unionResults[0].operation_id).toBe('zero-2') + }) + + it('should correctly identify last grant with NULL expires_at as NULLS FIRST in DESC order', async () => { + const now = new Date() + const grants = [ + createMockGrant({ + operation_id: 'expires-soon', + balance: 100, + priority: 60, // Same priority + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createMockGrant({ + operation_id: 'never-expires', + balance: 100, + priority: 60, // Same priority + expires_at: null, // Never expires - should be "last" due to NULLS FIRST in DESC + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ] + + const dbMock = createDbMockForUnion({ grants }) + + await mockModule('@codebuff/internal/db', () => ({ + default: dbMock, + })) + + // Last grant should be the one that never expires (NULL = NULLS FIRST in DESC) + expect(capturedLastGrantQuery[0].operation_id).toBe('never-expires') + + // In consumption order (ASC NULLS LAST), expires-soon comes first + expect(unionResults[0].operation_id).toBe('expires-soon') + expect(unionResults[1].operation_id).toBe('never-expires') + }) + }) +}) diff --git a/packages/billing/src/balance-calculator.ts b/packages/billing/src/balance-calculator.ts index 59d9072841..6be314102a 100644 --- a/packages/billing/src/balance-calculator.ts +++ b/packages/billing/src/balance-calculator.ts @@ -6,7 +6,8 @@ import { failure, getErrorObject, success } from '@codebuff/common/util/error' import db from '@codebuff/internal/db' import * as schema from '@codebuff/internal/db/schema' import { withSerializableTransaction } from '@codebuff/internal/db/transaction' -import { and, asc, gt, isNull, or, eq, sql } from 'drizzle-orm' +import { and, asc, desc, gt, isNull, ne, or, eq, sql } from 'drizzle-orm' +import { union } from 'drizzle-orm/pg-core' import { reportPurchasedCreditsToStripe } from './stripe-metering' @@ -43,6 +44,16 @@ type DbConn = Pick< 'select' | 'update' > /* + whatever else you call */ +function buildActiveGrantsFilter(userId: string, now: Date) { + return and( + eq(schema.creditLedger.user_id, userId), + or( + isNull(schema.creditLedger.expires_at), + gt(schema.creditLedger.expires_at, now), + ), + ) +} + /** * Gets active grants for a user, ordered by expiration (soonest first), then priority, and creation date. * Added optional `conn` param so callers inside a transaction can supply their TX object. @@ -50,21 +61,14 @@ type DbConn = Pick< export async function getOrderedActiveGrants(params: { userId: string now: Date - conn?: DbConn // use DbConn instead of typeof db + conn?: DbConn }) { const { userId, now, conn = db } = params + const activeGrantsFilter = buildActiveGrantsFilter(userId, now) return conn .select() .from(schema.creditLedger) - .where( - and( - eq(schema.creditLedger.user_id, userId), - or( - isNull(schema.creditLedger.expires_at), - gt(schema.creditLedger.expires_at, now), - ), - ), - ) + .where(activeGrantsFilter) .orderBy( // Use grants based on priority, then expiration date, then creation date asc(schema.creditLedger.priority), @@ -73,6 +77,66 @@ export async function getOrderedActiveGrants(params: { ) } +/** + * Gets active grants ordered for credit consumption, ensuring the "last grant" is always + * included even if its balance is zero. + * + * The "last grant" (lowest priority, latest expiration, latest creation) is preserved because: + * - When a user exhausts all credits, debt must be recorded against a grant + * - Debt should accumulate on the grant that would be consumed last under normal circumstances + * - This is typically a subscription grant (lowest priority) that renews monthly + * - Recording debt on the correct grant ensures proper attribution and repayment when + * credits are added (debt is repaid from the same grant it was charged to) + * + * Uses a single UNION query to fetch both non-zero grants and the "last grant" in one + * database round-trip. UNION automatically deduplicates if the last grant already + * appears in the non-zero set. + */ +async function getOrderedActiveGrantsForConsumption(params: { + userId: string + now: Date + conn?: DbConn +}) { + const { userId, now, conn = db } = params + const activeGrantsFilter = buildActiveGrantsFilter(userId, now) + + // Single UNION query combining: + // 1. Non-zero grants (consumed in priority order) + // 2. The "last grant" (for debt recording, even if balance is zero) + // + // UNION (not UNION ALL) automatically deduplicates if the last grant has non-zero balance. + // Final ORDER BY sorts all results in consumption order. + const grants = await union( + // First query: all non-zero balance grants + conn + .select() + .from(schema.creditLedger) + .where(and(activeGrantsFilter, ne(schema.creditLedger.balance, 0))), + // Second query: the single "last grant" that would be consumed last + // (highest priority number, latest/never expiration, latest creation) + conn + .select() + .from(schema.creditLedger) + .where(activeGrantsFilter) + .orderBy( + desc(schema.creditLedger.priority), + sql`${schema.creditLedger.expires_at} DESC NULLS FIRST`, + desc(schema.creditLedger.created_at), + ) + .limit(1), + ).orderBy( + // Sort in consumption order: + // - Lower priority number = consumed first + // - Earlier expiration = consumed first (NULL = never expires, consumed last) + // - Earlier creation = consumed first + asc(schema.creditLedger.priority), + sql`${schema.creditLedger.expires_at} ASC NULLS LAST`, + asc(schema.creditLedger.created_at), + ) + + return grants +} + /** * Updates a single grant's balance and logs the change. */ @@ -343,7 +407,7 @@ export async function consumeCredits(params: { const result = await withSerializableTransaction({ callback: async (tx) => { const now = new Date() - const activeGrants = await getOrderedActiveGrants({ + const activeGrants = await getOrderedActiveGrantsForConsumption({ ...params, now, conn: tx, @@ -506,7 +570,7 @@ export async function consumeCreditsAndAddAgentStep(params: { break consumeCredits } - const activeGrants = await getOrderedActiveGrants({ + const activeGrants = await getOrderedActiveGrantsForConsumption({ ...params, now, conn: tx, diff --git a/packages/internal/src/db/__tests__/transaction.test.ts b/packages/internal/src/db/__tests__/transaction.test.ts index ad842371a5..0d65e10b57 100644 --- a/packages/internal/src/db/__tests__/transaction.test.ts +++ b/packages/internal/src/db/__tests__/transaction.test.ts @@ -208,6 +208,59 @@ describe('transaction error handling', () => { const error = createPostgresError('Connection failed', '08006') expect(getRetryableErrorDescription(error)).toBe('connection_failure') }) + + it('should read retryable code from nested cause', () => { + const error = { cause: { code: '40001' } } + expect(getRetryableErrorDescription(error)).toBe( + 'serialization_failure', + ) + }) + + it('should fall back to nested cause when top-level code is invalid', () => { + const error = { code: 40001, cause: { code: '40P01' } } + expect(getRetryableErrorDescription(error)).toBe('deadlock_detected') + }) + + it('should skip non-PG string codes and find real PG code in cause', () => { + const error = { code: 'FETCH_ERROR', cause: { code: '40001' } } + expect(getRetryableErrorDescription(error)).toBe('serialization_failure') + }) + + it('should skip ECONNRESET and find PG code deeper in chain', () => { + const error = { + code: 'ECONNRESET', + cause: { + code: 'TIMEOUT', + cause: { + code: '08006', + }, + }, + } + expect(getRetryableErrorDescription(error)).toBe('connection_failure') + }) + + it('should return null when only non-PG codes exist in chain', () => { + const error = { + code: 'FETCH_ERROR', + cause: { + code: 'ECONNRESET', + cause: { + code: 'TIMEOUT', + }, + }, + } + expect(getRetryableErrorDescription(error)).toBeNull() + }) + + it('should skip 3-character codes and find valid PG code', () => { + const error = { code: 'ERR', cause: { code: '53300' } } + expect(getRetryableErrorDescription(error)).toBe('too_many_connections') + }) + + it('should skip codes with special characters and find valid PG code', () => { + const error = { code: 'ERR_CONN', cause: { code: '40P01' } } + expect(getRetryableErrorDescription(error)).toBe('deadlock_detected') + }) }) }) @@ -275,6 +328,65 @@ describe('transaction error handling', () => { it('should return false for numeric code', () => { expect(isRetryablePostgresError({ code: 40001 })).toBe(false) }) + + it('should return true for nested cause code', () => { + expect(isRetryablePostgresError({ cause: { code: '40001' } })).toBe( + true, + ) + }) + + it('should handle self-referential error cause (cycle of 1)', () => { + const error: { code?: number; cause?: unknown } = { code: 40001 } + error.cause = error // self-referential + expect(isRetryablePostgresError(error)).toBe(false) + }) + + it('should handle two-object circular reference', () => { + const errorA: { cause?: unknown } = {} + const errorB: { cause?: unknown; code: string } = { code: '40001' } + errorA.cause = errorB + errorB.cause = errorA + // Should find code in errorB before hitting cycle + expect(isRetryablePostgresError(errorA)).toBe(true) + }) + + it('should find code at max depth (depth 5)', () => { + // Build a chain of 5 levels deep (0-indexed: depths 0, 1, 2, 3, 4, 5) + const error = { + cause: { + cause: { + cause: { + cause: { + cause: { + code: '40001', + }, + }, + }, + }, + }, + } + expect(isRetryablePostgresError(error)).toBe(true) + }) + + it('should return false when code is beyond max depth (depth 6+)', () => { + // Build a chain of 7 levels deep - code at depth 6 should not be found + const error = { + cause: { + cause: { + cause: { + cause: { + cause: { + cause: { + code: '40001', + }, + }, + }, + }, + }, + }, + } + expect(isRetryablePostgresError(error)).toBe(false) + }) }) }) }) diff --git a/packages/internal/src/db/transaction.ts b/packages/internal/src/db/transaction.ts index 9198c79331..b589e8d804 100644 --- a/packages/internal/src/db/transaction.ts +++ b/packages/internal/src/db/transaction.ts @@ -39,6 +39,51 @@ const RETRYABLE_PG_ERROR_CODES: Record = { '53300': 'too_many_connections', } +/** + * Maximum depth to traverse when searching for PostgreSQL error codes in nested cause chains. + * This limit prevents excessive iteration in pathological cases where the seen set check + * might not catch very long non-circular chains. In practice, Drizzle/pg errors typically + * nest 2-3 levels deep, so 6 provides ample headroom while ensuring bounded execution. + */ +const MAX_ERROR_CAUSE_DEPTH = 6 + +/** + * Regular expression to validate PostgreSQL error codes. + * PostgreSQL error codes are exactly 5 characters consisting of digits (0-9) and + * uppercase letters (A-Z). Examples: 40001, 40P01, 08006, 23505 + * + * This validation ensures we don't mistakenly return non-PG error codes like + * 'ECONNRESET', 'TIMEOUT', or 'FETCH_ERROR' that may appear in wrapper errors. + */ +const PG_ERROR_CODE_REGEX = /^[0-9A-Z]{5}$/i + +function getPostgresErrorCode(error: unknown): string | null { + if (!error || typeof error !== 'object') { + return null + } + + let current: unknown = error + const seen = new Set() + let depth = 0 + + while (current && typeof current === 'object' && depth < MAX_ERROR_CAUSE_DEPTH) { + if (seen.has(current)) { + return null // Circular reference detected + } + seen.add(current) + + const record = current as Record + if (typeof record.code === 'string' && PG_ERROR_CODE_REGEX.test(record.code)) { + return record.code + } + + current = record.cause + depth += 1 + } + + return null +} + /** * Checks if an error is a retryable PostgreSQL error. * Returns the error description if retryable, null otherwise. @@ -46,11 +91,7 @@ const RETRYABLE_PG_ERROR_CODES: Record = { export function getRetryableErrorDescription( error: unknown, ): string | null { - if (!error || typeof error !== 'object') { - return null - } - - const errorCode = (error as Record).code + const errorCode = getPostgresErrorCode(error) if (typeof errorCode !== 'string') { return null } @@ -118,8 +159,9 @@ export async function withSerializableTransaction({ return getRetryableErrorDescription(error) !== null }, onRetry: (error, attempt) => { - const errorCode = (error as Record)?.code ?? 'unknown' - const errorDescription = getRetryableErrorDescription(error) ?? 'unknown' + const errorCode = getPostgresErrorCode(error) ?? 'unknown' + const errorDescription = + getRetryableErrorDescription(error) ?? 'unknown' // Base delay before jitter is applied (actual delay will be ±20%) const baseDelayMs = INITIAL_RETRY_DELAY * Math.pow(2, attempt - 1) logger.warn( From b254e986e6b19799372bc691fed49a28ac3b1529 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 01:40:40 -0800 Subject: [PATCH 0062/1143] feat(.agents): add programmatic handleSteps to CLI agents for enforced tmux invocation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add two-phase workflow (prep → CLI execution) for external CLI agents - codebuff-local-cli skips prep phase (tests Codebuff itself) - Add 30s timeout and standardized session name parsing - Make sessionName required in output schema - Update prompts to explain two-phase workflow --- .agents/claude-code-cli.ts | 112 +++++++++++++++++++++++++- .agents/codebuff-local-cli.ts | 101 ++++++++++++++++++++++- .agents/codex-cli.ts | 133 ++++++++++++++++++++++++++++--- .agents/gemini-cli.ts | 112 +++++++++++++++++++++++++- .agents/lib/cli-agent-prompts.ts | 51 +++++++----- .agents/lib/cli-agent-schemas.ts | 6 +- .agents/lib/create-cli-agent.ts | 6 +- 7 files changed, 483 insertions(+), 38 deletions(-) diff --git a/.agents/claude-code-cli.ts b/.agents/claude-code-cli.ts index f81f0e6f3d..4366e48740 100644 --- a/.agents/claude-code-cli.ts +++ b/.agents/claude-code-cli.ts @@ -1,6 +1,8 @@ import { createCliAgent } from './lib/create-cli-agent' -export default createCliAgent({ +import type { AgentDefinition } from './types/agent-definition' + +const baseDefinition = createCliAgent({ id: 'claude-code-cli', displayName: 'Claude Code CLI', cliName: 'Claude Code', @@ -10,3 +12,111 @@ export default createCliAgent({ 'Always use `--dangerously-skip-permissions` when testing to avoid permission prompts that would block automated tests.', model: 'anthropic/claude-opus-4.5', }) + +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'claude --dangerously-skip-permissions' + const CLI_NAME = 'Claude Code' + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I\'ll first gather context and prepare before starting the ' + CLI_NAME + ' CLI session.\n\n' + + 'Let me read relevant files and understand the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } + + yield 'STEP' + + logger.info('Starting ' + CLI_NAME + ' tmux session...') + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, + }, + } + + let sessionName = '' + let parseError = '' + + if (!toolResult || toolResult.length === 0) { + parseError = 'No result returned from run_terminal_command' + } else { + const result = toolResult[0] + if (!result || result.type !== 'json') { + logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') + parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + } else { + const value = result.value + if (typeof value === 'string') { + sessionName = value.trim() + } else if (value && typeof value === 'object') { + const obj = value as Record + const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined + const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' + const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + + if (exitCode !== undefined && exitCode !== 0) { + logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') + parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') + } else { + const output = typeof obj.output === 'string' ? obj.output : '' + sessionName = (stdout || output).trim() + } + } else { + logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') + parseError = 'Unexpected value format: ' + typeof value + } + } + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', + }, + ], + captures: [], + }, + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I have started a ' + CLI_NAME + ' tmux session: `' + sessionName + '`\n\n' + + 'I will use this session for all CLI interactions. The session name must be included in my final output.\n\n' + + 'Now I\'ll proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', + }, + includeToolCall: false, + } + + yield 'STEP_ALL' + }, +} + +export default definition diff --git a/.agents/codebuff-local-cli.ts b/.agents/codebuff-local-cli.ts index 79a6df5e37..771d511da7 100644 --- a/.agents/codebuff-local-cli.ts +++ b/.agents/codebuff-local-cli.ts @@ -1,6 +1,8 @@ import { createCliAgent } from './lib/create-cli-agent' -export default createCliAgent({ +import type { AgentDefinition } from './types/agent-definition' + +const baseDefinition = createCliAgent({ id: 'codebuff-local-cli', displayName: 'Codebuff Local CLI', cliName: 'Codebuff', @@ -16,3 +18,100 @@ export default createCliAgent({ **When to use:** After implementing CLI UI changes, use this to verify the visual output actually renders correctly. Unit tests and typechecks cannot catch layout bugs, rendering issues, or visual regressions. This agent captures real terminal output including colors and layout.`, }) + +// Constants must be inside handleSteps since it gets serialized via .toString() +// No prep phase needed since this tests Codebuff itself, not an external tool +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'bun --cwd=cli run dev' + const CLI_NAME = 'Codebuff' + + logger.info('Starting ' + CLI_NAME + ' tmux session...') + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, + }, + } + + let sessionName = '' + let parseError = '' + + if (!toolResult || toolResult.length === 0) { + parseError = 'No result returned from run_terminal_command' + } else { + const result = toolResult[0] + if (!result || result.type !== 'json') { + logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') + parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + } else { + const value = result.value + if (typeof value === 'string') { + sessionName = value.trim() + } else if (value && typeof value === 'object') { + const obj = value as Record + const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined + const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' + const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + + if (exitCode !== undefined && exitCode !== 0) { + logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') + parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') + } else { + const output = typeof obj.output === 'string' ? obj.output : '' + sessionName = (stdout || output).trim() + } + } else { + logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') + parseError = 'Unexpected value format: ' + typeof value + } + } + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', + }, + ], + captures: [], + }, + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I have started a ' + CLI_NAME + ' tmux session: `' + sessionName + '`\n\n' + + 'I will use this session for all CLI interactions. The session name must be included in my final output.\n\n' + + 'Now I\'ll proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', + }, + includeToolCall: false, + } + + yield 'STEP_ALL' + }, +} + +export default definition diff --git a/.agents/codex-cli.ts b/.agents/codex-cli.ts index 43afef22a9..0a31eb7f62 100644 --- a/.agents/codex-cli.ts +++ b/.agents/codex-cli.ts @@ -1,5 +1,7 @@ import { createCliAgent } from './lib/create-cli-agent' +import type { AgentDefinition } from './types/agent-definition' + /** * Codex-specific review mode instructions. * Codex CLI has a built-in /review command with an interactive questionnaire. @@ -8,6 +10,8 @@ const CODEX_REVIEW_MODE_INSTRUCTIONS = `## Review Mode Instructions Codex CLI has a built-in \`/review\` command that presents an interactive questionnaire. You must navigate it using arrow keys and Enter. +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. + ### Review Type Mapping The \`reviewType\` param maps to menu options (1-indexed from top): @@ -18,25 +22,20 @@ The \`reviewType\` param maps to menu options (1-indexed from top): ### Workflow -1. **Start Codex** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "codex -a never -s danger-full-access") - \`\`\` - -2. **Wait for CLI to initialize**, then capture: +1. **Wait for CLI to initialize**, then capture: \`\`\`bash sleep 3 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" \`\`\` -3. **Send the /review command**: +2. **Send the /review command**: \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "/review" sleep 2 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-menu" \`\`\` -4. **Navigate to the correct option** using arrow keys: +3. **Navigate to the correct option** using arrow keys: - The menu starts with Option 1 selected (PR Style) - Use Down arrow to move to the desired option: - \`reviewType="pr"\`: No navigation needed, just press Enter @@ -51,30 +50,30 @@ The \`reviewType\` param maps to menu options (1-indexed from top): ./scripts/tmux/tmux-send.sh "$SESSION" --key Enter \`\`\` -5. **For "custom" reviewType**, after selecting option 4, you'll need to send the custom instructions from the prompt: +4. **For "custom" reviewType**, after selecting option 4, you'll need to send the custom instructions from the prompt: \`\`\`bash sleep 1 ./scripts/tmux/tmux-cli.sh send "$SESSION" "[custom instructions from the prompt]" \`\`\` -6. **Wait for and capture the review output** (reviews take longer): +5. **Wait for and capture the review output** (reviews take longer): \`\`\`bash ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 \`\`\` -7. **Parse the review output** and populate \`reviewFindings\` with: +6. **Parse the review output** and populate \`reviewFindings\` with: - \`file\`: Path to the file with the issue - \`severity\`: "critical", "warning", "suggestion", or "info" - \`line\`: Line number if mentioned - \`finding\`: Description of the issue - \`suggestion\`: How to fix it -8. **Clean up**: +7. **Clean up**: \`\`\`bash ./scripts/tmux/tmux-cli.sh stop "$SESSION" \`\`\`` -export default createCliAgent({ +const baseDefinition = createCliAgent({ id: 'codex-cli', displayName: 'Codex CLI', cliName: 'Codex', @@ -93,3 +92,111 @@ export default createCliAgent({ }, reviewModeInstructions: CODEX_REVIEW_MODE_INSTRUCTIONS, }) + +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'codex -a never -s danger-full-access' + const CLI_NAME = 'Codex' + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I\'ll first gather context and prepare before starting the ' + CLI_NAME + ' CLI session.\n\n' + + 'Let me read relevant files and understand the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } + + yield 'STEP' + + logger.info('Starting ' + CLI_NAME + ' tmux session...') + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, + }, + } + + let sessionName = '' + let parseError = '' + + if (!toolResult || toolResult.length === 0) { + parseError = 'No result returned from run_terminal_command' + } else { + const result = toolResult[0] + if (!result || result.type !== 'json') { + logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') + parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + } else { + const value = result.value + if (typeof value === 'string') { + sessionName = value.trim() + } else if (value && typeof value === 'object') { + const obj = value as Record + const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined + const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' + const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + + if (exitCode !== undefined && exitCode !== 0) { + logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') + parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') + } else { + const output = typeof obj.output === 'string' ? obj.output : '' + sessionName = (stdout || output).trim() + } + } else { + logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') + parseError = 'Unexpected value format: ' + typeof value + } + } + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', + }, + ], + captures: [], + }, + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I have started a ' + CLI_NAME + ' tmux session: `' + sessionName + '`\n\n' + + 'I will use this session for all CLI interactions. The session name must be included in my final output.\n\n' + + 'Now I\'ll proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', + }, + includeToolCall: false, + } + + yield 'STEP_ALL' + }, +} + +export default definition diff --git a/.agents/gemini-cli.ts b/.agents/gemini-cli.ts index 03e8283d82..df7d4649fe 100644 --- a/.agents/gemini-cli.ts +++ b/.agents/gemini-cli.ts @@ -1,6 +1,8 @@ import { createCliAgent } from './lib/create-cli-agent' -export default createCliAgent({ +import type { AgentDefinition } from './types/agent-definition' + +const baseDefinition = createCliAgent({ id: 'gemini-cli', displayName: 'Gemini CLI', cliName: 'Gemini', @@ -16,3 +18,111 @@ Gemini CLI uses slash commands for navigation: - \`/tools\` - List available tools - \`/quit\` - Exit the CLI (or Ctrl-C twice)`, }) + +// Constants must be inside handleSteps since it gets serialized via .toString() +const definition: AgentDefinition = { + ...baseDefinition, + handleSteps: function* ({ prompt, params, logger }) { + const START_COMMAND = 'gemini --yolo' + const CLI_NAME = 'Gemini' + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I\'ll first gather context and prepare before starting the ' + CLI_NAME + ' CLI session.\n\n' + + 'Let me read relevant files and understand the task to provide better guidance to the CLI.', + }, + includeToolCall: false, + } + + yield 'STEP' + + logger.info('Starting ' + CLI_NAME + ' tmux session...') + + const { toolResult } = yield { + toolName: 'run_terminal_command', + input: { + command: './scripts/tmux/tmux-cli.sh start --command "' + START_COMMAND + '"', + timeout_seconds: 30, + }, + } + + let sessionName = '' + let parseError = '' + + if (!toolResult || toolResult.length === 0) { + parseError = 'No result returned from run_terminal_command' + } else { + const result = toolResult[0] + if (!result || result.type !== 'json') { + logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') + parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + } else { + const value = result.value + if (typeof value === 'string') { + sessionName = value.trim() + } else if (value && typeof value === 'object') { + const obj = value as Record + const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined + const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' + const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + + if (exitCode !== undefined && exitCode !== 0) { + logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') + parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') + } else { + const output = typeof obj.output === 'string' ? obj.output : '' + sessionName = (stdout || output).trim() + } + } else { + logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') + parseError = 'Unexpected value format: ' + typeof value + } + } + } + + if (!sessionName) { + const errorMsg = parseError || 'Session name was empty' + logger.error({ parseError: errorMsg }, 'Failed to start tmux session') + yield { + toolName: 'set_output', + input: { + overallStatus: 'failure', + summary: 'Failed to start ' + CLI_NAME + ' tmux session. ' + errorMsg, + sessionName: '', + scriptIssues: [ + { + script: 'tmux-cli.sh', + issue: errorMsg, + errorOutput: JSON.stringify(toolResult), + suggestedFix: 'Ensure tmux-cli.sh outputs the session name to stdout and exits with code 0. Check that tmux is installed.', + }, + ], + captures: [], + }, + } + return + } + + logger.info('Successfully started tmux session: ' + sessionName) + + yield { + toolName: 'add_message', + input: { + role: 'assistant', + content: 'I have started a ' + CLI_NAME + ' tmux session: `' + sessionName + '`\n\n' + + 'I will use this session for all CLI interactions. The session name must be included in my final output.\n\n' + + 'Now I\'ll proceed with the task using the helper scripts:\n' + + '- Send commands: `./scripts/tmux/tmux-cli.sh send "' + sessionName + '" "..."`\n' + + '- Capture output: `./scripts/tmux/tmux-cli.sh capture "' + sessionName + '" --label "..."`\n' + + '- Stop when done: `./scripts/tmux/tmux-cli.sh stop "' + sessionName + '"`', + }, + includeToolCall: false, + } + + yield 'STEP_ALL' + }, +} + +export default definition diff --git a/.agents/lib/cli-agent-prompts.ts b/.agents/lib/cli-agent-prompts.ts index b6c45e25df..72bbef271f 100644 --- a/.agents/lib/cli-agent-prompts.ts +++ b/.agents/lib/cli-agent-prompts.ts @@ -181,18 +181,15 @@ ${REVIEW_CRITERIA} ### Workflow -1. **Start ${config.cliName}** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") - \`\`\` +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. -2. **Wait for CLI to initialize**, then capture: +1. **Wait for CLI to initialize**, then capture: \`\`\`bash sleep 3 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" \`\`\` -3. **Send a detailed review prompt** (MUST start with "review"): +2. **Send a detailed review prompt** (MUST start with "review"): \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "Review [files/directories from prompt]. Look for: @@ -204,7 +201,7 @@ ${REVIEW_CRITERIA} For each issue found, specify the file, line number, what's wrong, and how to fix it. Be direct and specific." \`\`\` -4. **Wait for and capture the review output** (reviews take longer): +3. **Wait for and capture the review output** (reviews take longer): \`\`\`bash ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output" --wait 60 \`\`\` @@ -214,14 +211,14 @@ ${REVIEW_CRITERIA} ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "review-output-continued" --wait 30 \`\`\` -5. **Parse the review output** and populate \`reviewFindings\` with: +4. **Parse the review output** and populate \`reviewFindings\` with: - \`file\`: Path to the file with the issue - \`severity\`: "critical", "warning", "suggestion", or "info" - \`line\`: Line number if mentioned - \`finding\`: Description of the issue - \`suggestion\`: How to fix it -6. **Clean up**: +5. **Clean up**: \`\`\`bash ./scripts/tmux/tmux-cli.sh stop "$SESSION" \`\`\`` @@ -235,25 +232,22 @@ Use ${config.cliName} to complete implementation tasks like building features, f ### Workflow -1. **Start ${config.cliName}** with permission bypass: - \`\`\`bash - SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") - \`\`\` +**Note:** A tmux session will be started for you automatically after your preparation phase. Use the session name from the assistant message that announces it. -2. **Wait for CLI to initialize**, then capture: +1. **Wait for CLI to initialize**, then capture: \`\`\`bash sleep 3 ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "initial-state" \`\`\` -3. **Send your task** (from the prompt you received) to the CLI: +2. **Send your task** (from the prompt you received) to the CLI: \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "" \`\`\` Use the exact task description from the prompt the parent agent gave you. -4. **Wait for completion and capture output** (implementation tasks may take a while): +3. **Wait for completion and capture output** (implementation tasks may take a while): \`\`\`bash ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-in-progress" --wait 30 \`\`\` @@ -263,19 +257,19 @@ Use ${config.cliName} to complete implementation tasks like building features, f ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "work-continued" --wait 30 \`\`\` -5. **Send follow-up prompts** if needed to refine or continue the work: +4. **Send follow-up prompts** if needed to refine or continue the work: \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "" ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "follow-up" --wait 30 \`\`\` -6. **Verify the changes** by checking files or running commands: +5. **Verify the changes** by checking files or running commands: \`\`\`bash ./scripts/tmux/tmux-cli.sh send "$SESSION" "run the tests to verify the changes" ./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "verification" --wait 60 \`\`\` -7. **Clean up** when done: +6. **Clean up** when done: \`\`\`bash ./scripts/tmux/tmux-cli.sh stop "$SESSION" \`\`\` @@ -299,6 +293,22 @@ export function getInstructionsPrompt(config: CliAgentConfig): string { return `Instructions: +## Two-Phase Workflow + +This agent operates in two phases: + +### Phase 1: Preparation (Current Phase) +You have an opportunity to prepare before the CLI session starts. Use this time to: +- Read relevant files to understand the codebase +- Search for code patterns or implementations +- Understand the task requirements +- Gather context that will help you guide the CLI effectively + +After your preparation turn, a tmux session will be started automatically. + +### Phase 2: CLI Execution +Once the session starts, an assistant message will announce the session name. **Do NOT start a new session** - use the one provided. + Check the \`mode\` parameter to determine your operation: ${modeChecks} - Otherwise: follow **${modeNames[defaultMode]}** instructions (default) @@ -318,9 +328,10 @@ ${reviewModeInstructions} **Report results using set_output** - You MUST call set_output with structured results: - \`overallStatus\`: "success", "failure", or "partial" - \`summary\`: Brief description of what was done +- \`sessionName\`: The tmux session name (REQUIRED - from the session started for you) - \`results\`: Array of task outcomes (for work mode) - \`scriptIssues\`: Array of any problems with the helper scripts -- \`captures\`: Array of capture paths with labels +- \`captures\`: Array of capture paths with labels (MUST have at least one capture) - \`reviewFindings\`: Array of code review findings (for review mode) **If a helper script doesn't work correctly**, report it in \`scriptIssues\` with: diff --git a/.agents/lib/cli-agent-schemas.ts b/.agents/lib/cli-agent-schemas.ts index e67a522aa1..6c063a9902 100644 --- a/.agents/lib/cli-agent-schemas.ts +++ b/.agents/lib/cli-agent-schemas.ts @@ -11,6 +11,10 @@ export const outputSchema = { type: 'string' as const, description: 'Brief summary of what was done and the outcome', }, + sessionName: { + type: 'string' as const, + description: 'The tmux session name that was used for CLI interactions', + }, results: { type: 'array' as const, items: { @@ -68,5 +72,5 @@ export const outputSchema = { description: 'Code review findings (only populated in review mode)', }, }, - required: ['overallStatus', 'summary', 'scriptIssues', 'captures'], + required: ['overallStatus', 'summary', 'sessionName', 'scriptIssues', 'captures'], } diff --git a/.agents/lib/create-cli-agent.ts b/.agents/lib/create-cli-agent.ts index fd26651d14..9e75b9448f 100644 --- a/.agents/lib/create-cli-agent.ts +++ b/.agents/lib/create-cli-agent.ts @@ -61,7 +61,11 @@ export function createCliAgent(config: CliAgentConfig): AgentDefinition { outputSchema, includeMessageHistory: false, - toolNames: ['run_terminal_command', 'read_files', 'code_search', 'set_output'], + toolNames: ['run_terminal_command', 'read_files', 'code_search', 'set_output', 'add_message'], + + // NOTE: handleSteps is NOT defined here - each CLI agent file defines its own + // handleSteps with hardcoded config values following the context-pruner pattern. + // See claude-code-cli.ts, codex-cli.ts, etc. systemPrompt: getSystemPrompt(config), instructionsPrompt: getInstructionsPrompt(config), From 7b81a4a58f8331a57c5767922d66a4f5528acd8e Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 02:34:52 -0800 Subject: [PATCH 0063/1143] refactor(.agents): improve tmux session management with JSON output and robust parsing - tmux-start.sh now outputs JSON: {status:"success",sessionName:"..."} - Add proper JSON escaping for special chars (backslash, quote, newline, tab, cr) - tmux-cli.sh parses JSON internally, returns plain session name for callers - Simplify CLI agent handleSteps to parse plain text instead of JSON - Add TmuxStartResult type for the JSON contract - Fix stdout/stderr consistency: errors now go to stderr in both modes - Remove conflicting manual tmux start instructions from system prompt - Add skipPrepPhase config flag for agents that start sessions immediately --- .agents/claude-code-cli.ts | 42 ++++++---------- .agents/codebuff-local-cli.ts | 44 ++++++----------- .agents/codex-cli.ts | 42 ++++++---------- .agents/gemini-cli.ts | 42 ++++++---------- .agents/lib/cli-agent-prompts.ts | 56 ++++++++------------- .agents/lib/cli-agent-types.ts | 15 ++++++ scripts/tmux/tmux-cli.sh | 30 ++++++++++- scripts/tmux/tmux-start.sh | 85 +++++++++++++++++++++++++------- 8 files changed, 194 insertions(+), 162 deletions(-) diff --git a/.agents/claude-code-cli.ts b/.agents/claude-code-cli.ts index 4366e48740..a1bce0a223 100644 --- a/.agents/claude-code-cli.ts +++ b/.agents/claude-code-cli.ts @@ -42,38 +42,26 @@ const definition: AgentDefinition = { }, } + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) let sessionName = '' let parseError = '' - if (!toolResult || toolResult.length === 0) { - parseError = 'No result returned from run_terminal_command' - } else { - const result = toolResult[0] - if (!result || result.type !== 'json') { - logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') - parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') - } else { - const value = result.value - if (typeof value === 'string') { - sessionName = value.trim() - } else if (value && typeof value === 'object') { - const obj = value as Record - const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined - const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' - const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined - if (exitCode !== undefined && exitCode !== 0) { - logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') - parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') - } else { - const output = typeof obj.output === 'string' ? obj.output : '' - sessionName = (stdout || output).trim() - } - } else { - logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') - parseError = 'Unexpected value format: ' + typeof value - } + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout } + } else { + parseError = 'Unexpected result type from run_terminal_command' } if (!sessionName) { diff --git a/.agents/codebuff-local-cli.ts b/.agents/codebuff-local-cli.ts index 771d511da7..98e7eb8c31 100644 --- a/.agents/codebuff-local-cli.ts +++ b/.agents/codebuff-local-cli.ts @@ -11,6 +11,7 @@ const baseDefinition = createCliAgent({ permissionNote: 'No permission flags needed for Codebuff local dev server.', model: 'anthropic/claude-opus-4.5', + skipPrepPhase: true, spawnerPromptExtras: `**Use this agent after modifying:** - \`cli/src/components/\` - UI components, layouts, rendering - \`cli/src/hooks/\` - hooks that affect what users see @@ -20,7 +21,6 @@ const baseDefinition = createCliAgent({ }) // Constants must be inside handleSteps since it gets serialized via .toString() -// No prep phase needed since this tests Codebuff itself, not an external tool const definition: AgentDefinition = { ...baseDefinition, handleSteps: function* ({ prompt, params, logger }) { @@ -37,38 +37,26 @@ const definition: AgentDefinition = { }, } + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) let sessionName = '' let parseError = '' - if (!toolResult || toolResult.length === 0) { - parseError = 'No result returned from run_terminal_command' - } else { - const result = toolResult[0] - if (!result || result.type !== 'json') { - logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') - parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') - } else { - const value = result.value - if (typeof value === 'string') { - sessionName = value.trim() - } else if (value && typeof value === 'object') { - const obj = value as Record - const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined - const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' - const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined - if (exitCode !== undefined && exitCode !== 0) { - logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') - parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') - } else { - const output = typeof obj.output === 'string' ? obj.output : '' - sessionName = (stdout || output).trim() - } - } else { - logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') - parseError = 'Unexpected value format: ' + typeof value - } + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' + } else { + sessionName = stdout } + } else { + parseError = 'Unexpected result type from run_terminal_command' } if (!sessionName) { diff --git a/.agents/codex-cli.ts b/.agents/codex-cli.ts index 0a31eb7f62..48570ff4c8 100644 --- a/.agents/codex-cli.ts +++ b/.agents/codex-cli.ts @@ -122,38 +122,26 @@ const definition: AgentDefinition = { }, } + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) let sessionName = '' let parseError = '' - if (!toolResult || toolResult.length === 0) { - parseError = 'No result returned from run_terminal_command' - } else { - const result = toolResult[0] - if (!result || result.type !== 'json') { - logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') - parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' } else { - const value = result.value - if (typeof value === 'string') { - sessionName = value.trim() - } else if (value && typeof value === 'object') { - const obj = value as Record - const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined - const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' - const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' - - if (exitCode !== undefined && exitCode !== 0) { - logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') - parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') - } else { - const output = typeof obj.output === 'string' ? obj.output : '' - sessionName = (stdout || output).trim() - } - } else { - logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') - parseError = 'Unexpected value format: ' + typeof value - } + sessionName = stdout } + } else { + parseError = 'Unexpected result type from run_terminal_command' } if (!sessionName) { diff --git a/.agents/gemini-cli.ts b/.agents/gemini-cli.ts index df7d4649fe..9117f87e53 100644 --- a/.agents/gemini-cli.ts +++ b/.agents/gemini-cli.ts @@ -48,38 +48,26 @@ const definition: AgentDefinition = { }, } + // Parse response from tmux-cli.sh (outputs plain session name on success, error to stderr on failure) let sessionName = '' let parseError = '' - if (!toolResult || toolResult.length === 0) { - parseError = 'No result returned from run_terminal_command' - } else { - const result = toolResult[0] - if (!result || result.type !== 'json') { - logger.warn({ resultType: result?.type }, 'Unexpected toolResult type (expected json)') - parseError = 'Unexpected result type: ' + (result?.type ?? 'undefined') + const result = toolResult?.[0] + if (result && result.type === 'json') { + const value = result.value as Record + const stdout = typeof value?.stdout === 'string' ? value.stdout.trim() : '' + const stderr = typeof value?.stderr === 'string' ? value.stderr.trim() : '' + const exitCode = typeof value?.exitCode === 'number' ? value.exitCode : undefined + + if (!stdout && !stderr) { + parseError = 'tmux-cli.sh returned empty output' + } else if (exitCode !== 0 || !stdout) { + parseError = stderr || 'tmux-cli.sh failed with no error message' } else { - const value = result.value - if (typeof value === 'string') { - sessionName = value.trim() - } else if (value && typeof value === 'object') { - const obj = value as Record - const exitCode = typeof obj.exitCode === 'number' ? obj.exitCode : undefined - const stderr = typeof obj.stderr === 'string' ? obj.stderr : '' - const stdout = typeof obj.stdout === 'string' ? obj.stdout : '' - - if (exitCode !== undefined && exitCode !== 0) { - logger.error({ exitCode, stderr }, 'tmux-cli.sh start failed with non-zero exit code') - parseError = 'Command failed with exit code ' + exitCode + (stderr ? ': ' + stderr : '') - } else { - const output = typeof obj.output === 'string' ? obj.output : '' - sessionName = (stdout || output).trim() - } - } else { - logger.warn({ valueType: typeof value }, 'Unexpected toolResult value format') - parseError = 'Unexpected value format: ' + typeof value - } + sessionName = stdout } + } else { + parseError = 'Unexpected result type from run_terminal_command' } if (!sessionName) { diff --git a/.agents/lib/cli-agent-prompts.ts b/.agents/lib/cli-agent-prompts.ts index 72bbef271f..59a24bcedd 100644 --- a/.agents/lib/cli-agent-prompts.ts +++ b/.agents/lib/cli-agent-prompts.ts @@ -102,64 +102,44 @@ export function getSystemPrompt(config: CliAgentConfig): string { return `You are an expert at using ${config.cliName} CLI via tmux for implementation work and code reviews. You have access to helper scripts that handle the complexities of tmux communication with TUI apps. -## ${config.cliName} Startup +## Session Management -To start ${config.cliName}, use the \`--command\` flag with permission bypass: +**A tmux session is started for you automatically.** The session name will be announced in an assistant message. Use that session name (stored in \`$SESSION\`) for all subsequent commands. -\`\`\`bash -# Start ${config.cliName} CLI (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") - -# Or with specific options -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand} --help") -\`\`\` +**Do NOT start a new session** - use the one that was started for you. **Important:** ${config.permissionNote} ${cliSpecificSection} ## Helper Scripts -Use these scripts in \`scripts/tmux/\` for reliable CLI interaction: - -### Unified Script (Recommended) +Use these scripts in \`scripts/tmux/\` to interact with the CLI session: \`\`\`bash -# Start a ${config.cliName} session (with permission bypass) -SESSION=$(./scripts/tmux/tmux-cli.sh start --command "${config.startCommand}") - # Send input to the CLI ./scripts/tmux/tmux-cli.sh send "$SESSION" "/help" # Capture output (optionally wait first) ./scripts/tmux/tmux-cli.sh capture "$SESSION" --wait 3 +# Capture with a descriptive label +./scripts/tmux/tmux-cli.sh capture "$SESSION" --label "after-task" --wait 5 + # Stop the session when done ./scripts/tmux/tmux-cli.sh stop "$SESSION" - -# Stop all test sessions -./scripts/tmux/tmux-cli.sh stop --all \`\`\` -### Individual Scripts (More Options) +### Additional Options \`\`\`bash -# Start with custom settings -./scripts/tmux/tmux-start.sh --command "${config.startCommand}" --name ${config.shortName}-test --width 160 --height 40 - -# Send text (auto-presses Enter) -./scripts/tmux/tmux-send.sh ${config.shortName}-test "your prompt here" - # Send without pressing Enter -./scripts/tmux/tmux-send.sh ${config.shortName}-test "partial" --no-enter +./scripts/tmux/tmux-send.sh "$SESSION" "partial" --no-enter # Send special keys -./scripts/tmux/tmux-send.sh ${config.shortName}-test --key Escape -./scripts/tmux/tmux-send.sh ${config.shortName}-test --key C-c +./scripts/tmux/tmux-send.sh "$SESSION" --key Escape +./scripts/tmux/tmux-send.sh "$SESSION" --key C-c # Capture with colors -./scripts/tmux/tmux-capture.sh ${config.shortName}-test --colors - -# Save capture to file -./scripts/tmux/tmux-capture.sh ${config.shortName}-test -o output.txt +./scripts/tmux/tmux-capture.sh "$SESSION" --colors \`\`\` ## Why These Scripts? @@ -291,9 +271,11 @@ export function getInstructionsPrompt(config: CliAgentConfig): string { const nonDefaultModes = CLI_AGENT_MODES.filter(m => m !== defaultMode) const modeChecks = nonDefaultModes.map(m => `- If \`mode\` is "${m}": follow **${modeNames[m]}** instructions`).join('\n') - return `Instructions: + const workflowSection = config.skipPrepPhase + ? `## Workflow -## Two-Phase Workflow +**A tmux session is started for you immediately.** An assistant message will announce the session name. **Do NOT start a new session** - use the one provided.` + : `## Two-Phase Workflow This agent operates in two phases: @@ -307,7 +289,11 @@ You have an opportunity to prepare before the CLI session starts. Use this time After your preparation turn, a tmux session will be started automatically. ### Phase 2: CLI Execution -Once the session starts, an assistant message will announce the session name. **Do NOT start a new session** - use the one provided. +Once the session starts, an assistant message will announce the session name. **Do NOT start a new session** - use the one provided.` + + return `Instructions: + +${workflowSection} Check the \`mode\` parameter to determine your operation: ${modeChecks} diff --git a/.agents/lib/cli-agent-types.ts b/.agents/lib/cli-agent-types.ts index 6b115fee60..0d8f9771a0 100644 --- a/.agents/lib/cli-agent-types.ts +++ b/.agents/lib/cli-agent-types.ts @@ -1,5 +1,14 @@ export type CliAgentMode = 'work' | 'review' +/** + * Result type for tmux-start.sh JSON output. + * The shell script outputs this JSON format to stdout. + * See: scripts/tmux/tmux-start.sh + */ +export type TmuxStartResult = + | { status: 'success'; sessionName: string } + | { status: 'failure'; error: string } + export const CLI_AGENT_MODES: readonly CliAgentMode[] = ['work', 'review'] as const export interface InputParamDefinition { @@ -34,4 +43,10 @@ export interface CliAgentConfig { /** Custom instructions for review mode. If not provided, uses getDefaultReviewModeInstructions() */ reviewModeInstructions?: string cliSpecificDocs?: string + /** + * If true, skips the preparation phase before starting the tmux session. + * Use this for agents that test the CLI itself (like codebuff-local-cli) + * rather than external tools that need context gathering. + */ + skipPrepPhase?: boolean } diff --git a/scripts/tmux/tmux-cli.sh b/scripts/tmux/tmux-cli.sh index ebc3ab67de..b72d83529c 100755 --- a/scripts/tmux/tmux-cli.sh +++ b/scripts/tmux/tmux-cli.sh @@ -103,7 +103,35 @@ shift case "$COMMAND" in start) - exec "$SCRIPT_DIR/tmux-start.sh" "$@" + # Run tmux-start.sh and parse its JSON output + # This gives callers a plain session name for backward compatibility + JSON_OUTPUT=$("$SCRIPT_DIR/tmux-start.sh" "$@" 2>&1) || true + + # Check if output looks like JSON + if [[ "$JSON_OUTPUT" == "{"* ]]; then + # Parse JSON to extract session name or error + # Use grep/sed for portability (no jq dependency) + if echo "$JSON_OUTPUT" | grep -q '"status":"success"'; then + # Extract sessionName value + SESSION_NAME=$(echo "$JSON_OUTPUT" | sed -n 's/.*"sessionName":"\([^"]*\)".*/\1/p') + if [[ -n "$SESSION_NAME" ]]; then + echo "$SESSION_NAME" + exit 0 + else + echo "Failed to extract session name from: $JSON_OUTPUT" >&2 + exit 1 + fi + else + # Extract error message + ERROR_MSG=$(echo "$JSON_OUTPUT" | sed -n 's/.*"error":"\([^"]*\)".*/\1/p') + echo "${ERROR_MSG:-Failed to start session}" >&2 + exit 1 + fi + else + # Not JSON - pass through as-is (plain mode or unexpected output) + echo "$JSON_OUTPUT" + exit 0 + fi ;; send) exec "$SCRIPT_DIR/tmux-send.sh" "$@" diff --git a/scripts/tmux/tmux-start.sh b/scripts/tmux/tmux-start.sh index 807d5122a5..824d3961c4 100755 --- a/scripts/tmux/tmux-start.sh +++ b/scripts/tmux/tmux-start.sh @@ -56,6 +56,11 @@ # 0 - Success (session name printed to stdout) # 1 - Error (tmux not found or session creation failed) # +# OUTPUT FORMAT: +# By default, outputs JSON: {"status":"success","sessionName":"..."} +# On failure: {"status":"failure","error":"..."} +# Use --plain for backward-compatible plain text output (just session name) +# ####################################################################### set -e @@ -72,6 +77,7 @@ WAIT_SECONDS=4 DEFAULT_BINARY="$PROJECT_ROOT/cli/bin/codebuff" BINARY_PATH="${CODEBUFF_BINARY:-}" # Environment variable takes precedence CUSTOM_COMMAND="" # Custom command to run (takes priority over binary/default) +OUTPUT_FORMAT="json" # json (default) or plain # Parse arguments while [[ $# -gt 0 ]]; do @@ -107,8 +113,16 @@ while [[ $# -gt 0 ]]; do shift fi ;; + --json) + OUTPUT_FORMAT="json" + shift + ;; + --plain) + OUTPUT_FORMAT="plain" + shift + ;; --help) - head -n 55 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' + head -n 60 "$0" | tail -n +2 | sed 's/^# //' | sed 's/^#//' exit 0 ;; *) @@ -124,14 +138,56 @@ if [[ -z "$SESSION_NAME" ]]; then SESSION_NAME="tui-test-$(date +%s)-$$-$RANDOM" fi +# Helper function for JSON string escaping +# Properly escapes backslashes, quotes, newlines, tabs, carriage returns +# Uses character-by-character loop for cross-platform compatibility (BSD/GNU) +json_escape() { + local input="$1" + local result="" + local i char + for (( i=0; i<${#input}; i++ )); do + char="${input:$i:1}" + case "$char" in + '\') result+='\\' ;; + '"') result+='\"' ;; + $'\t') result+='\t' ;; + $'\n') result+='\n' ;; + $'\r') result+='\r' ;; + *) result+="$char" ;; + esac + done + printf '%s' "$result" +} + +# Helper function for JSON output +# In both modes, errors are written to stderr for consistent error handling +output_error() { + local error_msg="$1" + # Always write error to stderr for logging/debugging + echo "$error_msg" >&2 + if [[ "$OUTPUT_FORMAT" == "json" ]]; then + # Also output JSON to stdout for parsing + local escaped_msg + escaped_msg=$(json_escape "$error_msg") + echo "{\"status\":\"failure\",\"error\":\"$escaped_msg\"}" + fi +} + +output_success() { + local session_name="$1" + if [[ "$OUTPUT_FORMAT" == "json" ]]; then + # Session names are safe (alphanumeric + dashes) but escape just in case + local escaped_name + escaped_name=$(json_escape "$session_name") + echo "{\"status\":\"success\",\"sessionName\":\"$escaped_name\"}" + else + echo "$session_name" + fi +} + # Check if tmux is available if ! command -v tmux &> /dev/null; then - echo "❌ tmux not found" >&2 - echo "" >&2 - echo "📦 Installation:" >&2 - echo " macOS: brew install tmux" >&2 - echo " Ubuntu: sudo apt-get install tmux" >&2 - echo " Arch: sudo pacman -S tmux" >&2 + output_error "tmux not found. Install with: brew install tmux (macOS) or apt-get install tmux (Ubuntu)" exit 1 fi @@ -144,16 +200,11 @@ if [[ -n "$CUSTOM_COMMAND" ]]; then elif [[ -n "$BINARY_PATH" ]]; then # Binary mode - validate the binary exists and is executable if [[ ! -f "$BINARY_PATH" ]]; then - echo "❌ Binary not found: $BINARY_PATH" >&2 - echo "" >&2 - echo "💡 Build the binary first:" >&2 - echo " cd cli && bun run build:binary" >&2 + output_error "Binary not found: $BINARY_PATH. Build with: cd cli && bun run build:binary" exit 1 fi if [[ ! -x "$BINARY_PATH" ]]; then - echo "❌ Binary not executable: $BINARY_PATH" >&2 - echo "" >&2 - echo "💡 Fix with: chmod +x '$BINARY_PATH'" >&2 + output_error "Binary not executable: $BINARY_PATH. Fix with: chmod +x '$BINARY_PATH'" exit 1 fi CLI_CMD="cd '$PROJECT_ROOT' && '$BINARY_PATH' 2>&1" @@ -175,7 +226,7 @@ tmux new-session -d -s "$SESSION_NAME" \ # Verify the session was actually created (more reliable than exit code) if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then - echo "❌ Failed to create tmux session '$SESSION_NAME'" >&2 + output_error "Failed to create tmux session '$SESSION_NAME'" exit 1 fi @@ -204,5 +255,5 @@ if [[ "$WAIT_SECONDS" -gt 0 ]]; then sleep "$WAIT_SECONDS" fi -# Output session name for use by other scripts -echo "$SESSION_NAME" +# Output result +output_success "$SESSION_NAME" From 494e4762f765ccb20e3fbfd27a7a994eeb288098 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 12:40:35 -0800 Subject: [PATCH 0064/1143] fix(cli): resolve message queue race conditions and add recovery mechanisms - Fix React concurrent mode race condition by reading from ref before setState - Remove buggy startStreaming line that conflated busy/paused states - Add resetEarlyReturnState helper for DRY queue state resets - Set isChainInProgressRef synchronously to prevent race conditions - Add 5-minute watchdog timer to recover from stuck queue locks - Simplify queuePaused to only reflect user-initiated pause state - Add unit tests for early return queue state reset scenarios - Delete unused use-committed-value.ts file - Update tests to use real resetEarlyReturnState instead of mock --- .../helpers/__tests__/send-message.test.ts | 255 +++++++++++++++++- cli/src/hooks/helpers/send-message.ts | 25 +- cli/src/hooks/use-message-queue.ts | 119 +++++--- cli/src/hooks/use-send-message.ts | 92 +++++-- 4 files changed, 427 insertions(+), 64 deletions(-) diff --git a/cli/src/hooks/helpers/__tests__/send-message.test.ts b/cli/src/hooks/helpers/__tests__/send-message.test.ts index 0eb87d1a5f..9056d359f6 100644 --- a/cli/src/hooks/helpers/__tests__/send-message.test.ts +++ b/cli/src/hooks/helpers/__tests__/send-message.test.ts @@ -28,7 +28,7 @@ ensureEnv() const { useChatStore } = await import('../../../state/chat-store') const { createStreamController } = await import('../../stream-state') -const { setupStreamingContext, handleRunError, finalizeQueueState } = await import( +const { setupStreamingContext, handleRunError, finalizeQueueState, resetEarlyReturnState } = await import( '../send-message' ) const { createBatchedMessageUpdater } = await import( @@ -113,7 +113,7 @@ describe('setupStreamingContext', () => { // Verify stream status reset expect(streamStatus).toBe('idle') - // Verify queue processing enabled (no isQueuePausedRef) + // Verify queue processing enabled (no pause ref) expect(canProcessQueue).toBe(true) // Verify chain in progress reset @@ -170,7 +170,7 @@ describe('setupStreamingContext', () => { // Trigger abort abortController.abort() - // When queue is paused, canProcessQueue should be false + // When queue was paused before streaming, canProcessQueue should be false expect(canProcessQueue).toBe(false) }) @@ -374,7 +374,7 @@ describe('finalizeQueueState', () => { isQueuePausedRef, }) - // When queue is paused, canProcessQueue should be false + // When queue was paused before streaming, canProcessQueue should be false expect(canProcessQueue).toBe(false) }) }) @@ -583,7 +583,7 @@ describe('handleRunError', () => { isQueuePausedRef, }) - // When queue is paused, canProcessQueue should be false + // When queue was paused before streaming, canProcessQueue should be false expect(canProcessQueue).toBe(false) }) @@ -718,3 +718,248 @@ describe('handleRunError', () => { expect(timerController.stopCalls).toContain('error') }) }) + +/** + * Tests for early return queue state reset in sendMessage. + * These test the resetEarlyReturnState helper used across multiple early return paths: + * - prepareUserMessage exception + * - validation failure (success: false) + * - validation exception + */ +describe('resetEarlyReturnState', () => { + describe('prepareUserMessage exception path', () => { + test('resets chain in progress to false', () => { + let chainInProgress = true + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: () => {}, + }) + + expect(chainInProgress).toBe(false) + }) + + test('sets canProcessQueue to true when queue is not paused', () => { + let canProcessQueue = false + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isQueuePausedRef, + }) + + expect(canProcessQueue).toBe(true) + }) + + test('sets canProcessQueue to false when queue is paused', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isQueuePausedRef, + }) + + expect(canProcessQueue).toBe(false) + }) + + test('resets isProcessingQueueRef to false', () => { + const isProcessingQueueRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: () => {}, + isProcessingQueueRef, + }) + + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('handles missing isProcessingQueueRef gracefully', () => { + // Should not throw when isProcessingQueueRef is undefined + expect(() => { + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: () => {}, + }) + }).not.toThrow() + }) + + test('handles missing isQueuePausedRef gracefully (defaults to canProcessQueue=true)', () => { + let canProcessQueue = false + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { canProcessQueue = can }, + // No isQueuePausedRef - should default to !undefined = true + }) + + expect(canProcessQueue).toBe(true) + }) + }) + + describe('validation failure path (success: false)', () => { + test('resets all queue state correctly when processing queued message', () => { + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('respects queue paused state after validation failure', () => { + let chainInProgress = true + let canProcessQueue = true + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(false) // Queue was paused, should stay paused + expect(isProcessingQueueRef.current).toBe(false) + }) + }) + + describe('validation exception path', () => { + test('resets all queue state correctly when validation throws', () => { + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + // Simulating what happens after catching validation exception + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('preserves queue pause state when validation throws', () => { + let canProcessQueue = true + const isQueuePausedRef = { current: true } + const isProcessingQueueRef = { current: true } + + resetEarlyReturnState({ + updateChainInProgress: () => {}, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Queue was explicitly paused before, should remain paused after error + expect(canProcessQueue).toBe(false) + // But processing lock should be released to allow manual resume + expect(isProcessingQueueRef.current).toBe(false) + }) + }) + + describe('complete early return scenarios', () => { + test('queue can process next message after prepareUserMessage exception', () => { + // Scenario: Message was being processed from queue, prepareUserMessage throws + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + // After exception, reset is called + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Queue should be able to process next message + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue can process next message after validation returns success=false', () => { + // Scenario: Message was being processed, validation returns failure + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // All locks released, queue can continue + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue can process next message after validation throws exception', () => { + // Scenario: Message was being processed, validation throws + let chainInProgress = true + let canProcessQueue = false + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: false } + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // All locks released, queue can continue + expect(chainInProgress).toBe(false) + expect(canProcessQueue).toBe(true) + expect(isProcessingQueueRef.current).toBe(false) + }) + + test('queue remains blocked after error if user had paused it', () => { + // Scenario: User paused queue, then an error occurred + // Queue should remain paused after error recovery + let chainInProgress = true + let canProcessQueue = true + const isProcessingQueueRef = { current: true } + const isQueuePausedRef = { current: true } // User explicitly paused + + resetEarlyReturnState({ + updateChainInProgress: (value) => { chainInProgress = value }, + setCanProcessQueue: (can) => { canProcessQueue = can }, + isProcessingQueueRef, + isQueuePausedRef, + }) + + // Chain is no longer in progress + expect(chainInProgress).toBe(false) + // But queue should remain blocked because user paused it + expect(canProcessQueue).toBe(false) + // Processing lock is released though + expect(isProcessingQueueRef.current).toBe(false) + }) + }) +}) diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index c4db1753ef..5b6df8d720 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -35,6 +35,29 @@ import type { StreamStatus } from '../use-message-queue' import type { MessageContent, RunState } from '@codebuff/sdk' import type { MutableRefObject, SetStateAction } from 'react' +/** Resets queue state on early return (before streaming starts). */ +export type ResetEarlyReturnStateParams = { + setCanProcessQueue: (can: boolean) => void + updateChainInProgress: (value: boolean) => void + isProcessingQueueRef?: MutableRefObject + isQueuePausedRef?: MutableRefObject +} + +export const resetEarlyReturnState = (params: ResetEarlyReturnStateParams): void => { + const { + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + } = params + + updateChainInProgress(false) + setCanProcessQueue(!isQueuePausedRef?.current) + if (isProcessingQueueRef) { + isProcessingQueueRef.current = false + } +} + /** Resets queue state after streaming completes, aborts, or errors. */ export type FinalizeQueueStateParams = { setStreamStatus: (status: StreamStatus) => void @@ -164,7 +187,7 @@ export const prepareUserMessage = async (params: { next = postUserMessage(next) } if (next.length > 100) { - return next.slice(-100) + next = next.slice(-100) } return next }) diff --git a/cli/src/hooks/use-message-queue.ts b/cli/src/hooks/use-message-queue.ts index 3139a7c5f6..3f147c65bc 100644 --- a/cli/src/hooks/use-message-queue.ts +++ b/cli/src/hooks/use-message-queue.ts @@ -11,6 +11,9 @@ export type QueuedMessage = { attachments: PendingAttachment[] } +// Watchdog timeout duration: 60 seconds +const QUEUE_WATCHDOG_TIMEOUT_MS = 60 * 1000 + export const useMessageQueue = ( sendMessage: (message: QueuedMessage) => Promise, isChainInProgressRef: React.MutableRefObject, @@ -19,21 +22,24 @@ export const useMessageQueue = ( const [queuedMessages, setQueuedMessages] = useState([]) const [streamStatus, setStreamStatus] = useState('idle') const [canProcessQueue, setCanProcessQueue] = useState(true) - const [queuePaused, setQueuePaused] = useState(false) + // Separate state for user-initiated pause to ensure re-renders when pause status changes + const [queuePausedState, setQueuePausedState] = useState(false) + // Keep a ref so clearQueue can return the current queue synchronously. const queuedMessagesRef = useRef([]) const streamTimeoutRef = useRef | null>(null) const streamIntervalRef = useRef | null>(null) const streamMessageIdRef = useRef(null) - const isQueuePausedRef = useRef(false) const isProcessingQueueRef = useRef(false) + // User-initiated pause state (separate from system-busy state) + const isQueuePausedRef = useRef(false) + // Watchdog timer to recover from stuck queue processing lock + const watchdogTimeoutRef = useRef | null>(null) - // Note: queuedMessagesRef is now updated atomically inside functional setState calls - // (in addToQueue and the queue processing effect), so no sync effect is needed here. - - useEffect(() => { - isQueuePausedRef.current = queuePaused - }, [queuePaused]) + // queuePaused reflects whether the user has explicitly paused the queue + // (not whether the system is temporarily busy processing) + // Use state instead of ref to ensure components re-render when pause status changes + const queuePaused = queuePausedState const clearStreaming = useCallback(() => { if (streamTimeoutRef.current) { @@ -52,20 +58,36 @@ export const useMessageQueue = ( useEffect(() => { return () => { clearStreaming() + // Clean up watchdog timer on unmount + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } } }, [clearStreaming]) - useEffect(() => { + const processNextMessage = useCallback(() => { const queuedList = queuedMessagesRef.current const queueLength = queuedList.length - if (queueLength === 0) return + if (queueLength === 0) { + return + } + + // Check if user has explicitly paused the queue + if (isQueuePausedRef.current) { + logger.debug( + { queueLength }, + '[message-queue] Queue blocked: user paused', + ) + return + } // Log why queue is blocked (only when there are messages waiting) - if (!canProcessQueue || queuePaused) { + if (!canProcessQueue) { logger.debug( - { queueLength, canProcessQueue, queuePaused }, - '[message-queue] Queue blocked: canProcessQueue or paused', + { queueLength, canProcessQueue }, + '[message-queue] Queue blocked: canProcessQueue disabled', ) return } @@ -113,27 +135,49 @@ export const useMessageQueue = ( isProcessingQueueRef.current = true - // IMPORTANT: We must read the message to process INSIDE the functional setState - // to ensure we send the same message we remove. Reading from the ref separately - // can cause a race condition where we send message X but remove message Y. - let messageToProcess: QueuedMessage | undefined + // Start watchdog timer to recover from stuck processing lock + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + } + watchdogTimeoutRef.current = setTimeout(() => { + if (isProcessingQueueRef.current) { + logger.warn( + { stuckDurationMs: QUEUE_WATCHDOG_TIMEOUT_MS }, + '[message-queue] Watchdog: isProcessingQueueRef stuck for too long, forcing reset', + ) + isProcessingQueueRef.current = false + // Also reset canProcessQueue to allow queue to resume (unless user-paused) + setCanProcessQueue(!isQueuePausedRef.current) + } + watchdogTimeoutRef.current = null + }, QUEUE_WATCHDOG_TIMEOUT_MS) + + // Read the message to process from the ref BEFORE calling setState. + // We must NOT assign to outer variables inside functional setState callbacks + // because React can call those callbacks multiple times in concurrent mode, + // which would cause messages to be skipped. + const messageToProcess = queuedMessagesRef.current[0] + + if (!messageToProcess) { + isProcessingQueueRef.current = false + // Clear watchdog timer on early return + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } + return + } + // Now remove the message from the queue setQueuedMessages((prev) => { if (prev.length === 0) { return prev } - messageToProcess = prev[0] const remainingMessages = prev.slice(1) queuedMessagesRef.current = remainingMessages return remainingMessages }) - if (!messageToProcess) { - isProcessingQueueRef.current = false - return - } - - // Use .finally() to ensure lock is always released after sendMessage completes sendMessage(messageToProcess) .catch((err: unknown) => { logger.warn( @@ -142,35 +186,33 @@ export const useMessageQueue = ( ) }) .finally(() => { - // Release the processing lock so the next message can be processed - // The effect will re-run when streamStatus changes or other deps update isProcessingQueueRef.current = false + // Clear watchdog timer when processing completes normally + if (watchdogTimeoutRef.current) { + clearTimeout(watchdogTimeoutRef.current) + watchdogTimeoutRef.current = null + } logger.debug('[message-queue] Processing lock released') }) }, [ canProcessQueue, - queuePaused, streamStatus, - queuedMessages, // Re-run when queue changes to process next message sendMessage, isChainInProgressRef, activeAgentStreamsRef, ]) + useEffect(() => { + processNextMessage() + }, [canProcessQueue, streamStatus, queuedMessages.length, processNextMessage, isChainInProgressRef]) + const addToQueue = useCallback( (message: string, attachments: PendingAttachment[] = []) => { const queuedMessage = { content: message, attachments } // Use functional setState to ensure atomic updates during rapid calls. - // We update queuedMessagesRef inside the callback to keep ref and state - // in sync atomically - this prevents race conditions when multiple - // messages are added before React can process state updates. setQueuedMessages((prev) => { const newQueue = [...prev, queuedMessage] queuedMessagesRef.current = newQueue - logger.info( - { newQueueLength: newQueue.length, messageLength: message.length }, - '[message-queue] Message added to queue', - ) return newQueue }) }, @@ -178,12 +220,14 @@ export const useMessageQueue = ( ) const pauseQueue = useCallback(() => { - setQueuePaused(true) + isQueuePausedRef.current = true + setQueuePausedState(true) setCanProcessQueue(false) }, []) const resumeQueue = useCallback(() => { - setQueuePaused(false) + isQueuePausedRef.current = false + setQueuePausedState(false) setCanProcessQueue(true) }, []) @@ -201,7 +245,6 @@ export const useMessageQueue = ( const stopStreaming = useCallback(() => { setStreamStatus('idle') - // Use ref instead of queuePaused state to avoid stale closure issues setCanProcessQueue(!isQueuePausedRef.current) }, []) diff --git a/cli/src/hooks/use-send-message.ts b/cli/src/hooks/use-send-message.ts index ca62791593..2c60735dc3 100644 --- a/cli/src/hooks/use-send-message.ts +++ b/cli/src/hooks/use-send-message.ts @@ -24,6 +24,7 @@ import { handleRunCompletion, handleRunError, prepareUserMessage as prepareUserMessageHelper, + resetEarlyReturnState, setupStreamingContext, } from './helpers/send-message' import { NETWORK_ERROR_ID } from '../utils/validation-error-helpers' @@ -219,6 +220,14 @@ export const useSendMessage = ({ const sendMessage = useCallback( async ({ content, agentMode, postUserMessage, attachments }) => { + // CRITICAL: Set chain in progress immediately (synchronously) before any async work. + // This ensures the router can detect that we're busy and queue subsequent messages. + // Set the ref directly first to guarantee immediate visibility to other code paths, + // then call updateChainInProgress to also update React state for re-renders. + isChainInProgressRef.current = true + updateChainInProgress(true) + setCanProcessQueue(false) + if (agentMode !== 'PLAN') { setHasReceivedPlanResponse(false) } @@ -232,17 +241,41 @@ export const useSendMessage = ({ setIsRetrying(false) // Prepare user message (bash context, images, text attachments, mode divider) - const { - userMessageId, - messageContent, - bashContextForPrompt, - finalContent, - } = await prepareUserMessage({ - content, - agentMode, - postUserMessage, - attachments, - }) + let userMessageId: string + let messageContent: MessageContent[] | undefined + let bashContextForPrompt: string | undefined + let finalContent: string + + try { + const prepared = await prepareUserMessage({ + content, + agentMode, + postUserMessage, + attachments, + }) + userMessageId = prepared.userMessageId + messageContent = prepared.messageContent + bashContextForPrompt = prepared.bashContextForPrompt + finalContent = prepared.finalContent + } catch (error) { + logger.error( + { error }, + '[send-message] prepareUserMessage failed with exception', + ) + setMessages((prev) => [ + ...prev, + createErrorChatMessage( + '⚠️ Failed to prepare message. Please try again.', + ), + ]) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) + return + } // Validate before sending (e.g., agent config checks) try { @@ -275,6 +308,12 @@ export const useSendMessage = ({ } }), ) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } } catch (error) { @@ -292,6 +331,12 @@ export const useSendMessage = ({ await yieldToEventLoop() setTimeout(() => scrollToLatest(), 0) + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } @@ -317,10 +362,12 @@ export const useSendMessage = ({ ]) await yieldToEventLoop() setTimeout(() => scrollToLatest(), 0) - // Release the queue processing lock since we're returning early (before try block) - if (isProcessingQueueRef) { - isProcessingQueueRef.current = false - } + resetEarlyReturnState({ + setCanProcessQueue, + updateChainInProgress, + isProcessingQueueRef, + isQueuePausedRef, + }) return } @@ -328,8 +375,6 @@ export const useSendMessage = ({ const aiMessageId = generateAiMessageId() const aiMessage = createAiMessageShell(aiMessageId) - setMessages((prev) => autoCollapsePreviousMessages(prev, aiMessageId)) - const { updater, hasReceivedContentRef, abortController } = setupStreamingContext({ aiMessageId, @@ -346,9 +391,15 @@ export const useSendMessage = ({ setStreamingAgents, }) setStreamStatus('waiting') - setMessages((prev) => [...prev, aiMessage]) - setCanProcessQueue(false) - updateChainInProgress(true) + // Combine auto-collapse and AI message addition into single atomic update + // to prevent flicker from intermediate render states + setMessages((prev) => [ + ...autoCollapsePreviousMessages(prev, aiMessageId), + aiMessage, + ]) + // Note: updateChainInProgress(true) and setCanProcessQueue(false) are already + // called at the start of sendMessage to ensure they happen synchronously + // before any async work, so the router can correctly detect busy state. let actualCredits: number | undefined // Execute SDK run with streaming handlers @@ -457,6 +508,7 @@ export const useSendMessage = ({ addSessionCredits, agentId, inputRef, + isChainInProgressRef, isProcessingQueueRef, isQueuePausedRef, mainAgentTimer, From 654eb9bf7addaf097b165f515ac05f1e08e2bb86 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 15:06:55 -0800 Subject: [PATCH 0065/1143] Include model in token count api. Default to opus. track token count in event --- packages/agent-runtime/src/run-agent-step.ts | 1 + web/src/app/api/v1/token-count/_post.ts | 31 ++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/packages/agent-runtime/src/run-agent-step.ts b/packages/agent-runtime/src/run-agent-step.ts index 071b90b7d8..08f80f6254 100644 --- a/packages/agent-runtime/src/run-agent-step.ts +++ b/packages/agent-runtime/src/run-agent-step.ts @@ -767,6 +767,7 @@ export async function loopAgentSteps( const tokenCountResult = await callTokenCountAPI({ messages: messagesWithStepPrompt, system, + model: agentTemplate.model, fetch, logger, env: { clientEnv, ciEnv }, diff --git a/web/src/app/api/v1/token-count/_post.ts b/web/src/app/api/v1/token-count/_post.ts index 63887cf19d..b4335fee0d 100644 --- a/web/src/app/api/v1/token-count/_post.ts +++ b/web/src/app/api/v1/token-count/_post.ts @@ -1,4 +1,5 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' +import { toAnthropicModelId } from '@codebuff/common/constants/claude-oauth' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' import { NextResponse } from 'next/server' @@ -70,17 +71,6 @@ export async function postTokenCount(params: { const { messages, system, model } = bodyResult.data - trackEvent({ - event: AnalyticsEvent.TOKEN_COUNT_REQUEST, - userId, - properties: { - messageCount: messages.length, - hasSystem: !!system, - model: model ?? 'claude-sonnet-4-20250514', - }, - logger, - }) - try { const inputTokens = await countTokensViaAnthropic({ messages, @@ -90,6 +80,18 @@ export async function postTokenCount(params: { logger, }) + trackEvent({ + event: AnalyticsEvent.TOKEN_COUNT_REQUEST, + userId, + properties: { + messageCount: messages.length, + hasSystem: !!system, + model: model ?? 'claude-opus-4-5-20251101', + inputTokens, + }, + logger, + }) + return NextResponse.json({ inputTokens }) } catch (error) { logger.error( @@ -125,6 +127,11 @@ async function countTokensViaAnthropic(params: { // Convert messages to Anthropic format const anthropicMessages = convertToAnthropicMessages(messages) + // Convert model from OpenRouter format (e.g. "anthropic/claude-opus-4.5") to Anthropic format (e.g. "claude-opus-4-5-20251101") + const anthropicModelId = model + ? toAnthropicModelId(model) + : 'claude-opus-4-5-20251101' + // Use the count_tokens endpoint (beta) or make a minimal request const response = await fetch( 'https://api.anthropic.com/v1/messages/count_tokens', @@ -137,7 +144,7 @@ async function countTokensViaAnthropic(params: { 'content-type': 'application/json', }, body: JSON.stringify({ - model: model ?? 'claude-opus-4-5-20251101', + model: anthropicModelId, messages: anthropicMessages, ...(system && { system }), }), From 4567b3b09988722709b4f92bc643ebfb5f62724e Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 15:08:33 -0800 Subject: [PATCH 0066/1143] context pruner: 1k token fudge factor --- agents/context-pruner.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/agents/context-pruner.ts b/agents/context-pruner.ts index 8c200027cd..fb8328a186 100644 --- a/agents/context-pruner.ts +++ b/agents/context-pruner.ts @@ -312,6 +312,9 @@ const definition: AgentDefinition = { const SUMMARY_HEADER = 'This is a summary of the conversation so far. The original messages have been condensed to save context space.' + /** Fudge factor for token count threshold to trigger pruning earlier */ + const TOKEN_COUNT_FUDGE_FACTOR = 1000 + // ============================================================================= // Helper Functions (must be inside handleSteps since it's serialized to a string) // ============================================================================= @@ -598,7 +601,7 @@ const definition: AgentDefinition = { // - Prune when context exceeds max, OR // - Prune when prompt cache will miss (>5 min gap) to take advantage of fresh context // If not, return messages with just the subagent-specific tags removed - if (agentState.contextTokenCount <= maxContextLength && !cacheWillMiss) { + if (agentState.contextTokenCount + TOKEN_COUNT_FUDGE_FACTOR <= maxContextLength && !cacheWillMiss) { yield { toolName: 'set_messages', input: { messages: currentMessages }, From ba4dabb2520cc7bf6279fcd0b4306086c122f440 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 15:16:14 -0800 Subject: [PATCH 0067/1143] fix test --- agents/__tests__/context-pruner.test.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/agents/__tests__/context-pruner.test.ts b/agents/__tests__/context-pruner.test.ts index 80a1d9cb57..c6d4f4ef02 100644 --- a/agents/__tests__/context-pruner.test.ts +++ b/agents/__tests__/context-pruner.test.ts @@ -1409,14 +1409,15 @@ describe('context-pruner threshold behavior', () => { return results } - test('does not prune when exactly at max limit', () => { + test('does not prune when under max limit minus fudge factor', () => { const messages = [ createMessage('user', 'Hello'), createMessage('assistant', 'Hi'), ] - // Set context to exactly max limit - should NOT prune - const results = runHandleSteps(messages, 200000, 200000) + // Set context to max limit minus fudge factor (1000) - should NOT prune + // contextTokenCount + 1000 <= maxContextLength => 199000 + 1000 <= 200000 + const results = runHandleSteps(messages, 199000, 200000) // Should preserve original messages (not summarized) expect(results[0].input.messages).toHaveLength(2) @@ -1424,14 +1425,15 @@ describe('context-pruner threshold behavior', () => { expect(results[0].input.messages[1].role).toBe('assistant') }) - test('prunes when just over max limit', () => { + test('prunes when at max limit due to fudge factor', () => { const messages = [ createMessage('user', 'Hello'), createMessage('assistant', 'Hi'), ] - // Set context to just over max limit - should prune - const results = runHandleSteps(messages, 200001, 200000) + // Set context to exactly max limit - should prune due to 1000 token fudge factor + // contextTokenCount + 1000 > maxContextLength => 200000 + 1000 > 200000 + const results = runHandleSteps(messages, 200000, 200000) // Should have summarized to single message expect(results[0].input.messages).toHaveLength(1) From afdc2e32ebc4c5d2dcf7635984376f3b90a26e08 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 15:34:43 -0800 Subject: [PATCH 0068/1143] fix(ci): split billing integration tests into dedicated job with postgres - Split test-integration into two jobs: one without Postgres (general packages) and test-billing-integration (dedicated for packages/billing with postgres:16-alpine) - Remove redundant include blocks from test and test-integration matrices - Remove .agents from test-integration (was always skipping) - Add fail-fast: false to matrix strategies - Reduce unit test retries from 5 to 3 - Add retry wrapper to db:migrate step - Update billing integration tests to use hardcoded DEFAULT_TEST_DATABASE_URL matching CI postgres container - tests no longer require DATABASE_URL env var to run --- .github/workflows/ci.yml | 117 +- .../balance-calculator.integration.test.ts | 1270 ++++++++--------- 2 files changed, 725 insertions(+), 662 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8fb0528647..fe579adcdc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -81,6 +81,7 @@ jobs: test: needs: [build-and-check] strategy: + fail-fast: false matrix: package: [ @@ -93,15 +94,6 @@ jobs: sdk, web, ] - include: - - package: .agents - - package: cli - - package: common - - package: packages/agent-runtime - - package: packages/billing - - package: packages/internal - - package: sdk - - package: web name: test-${{ matrix.package }} runs-on: ubuntu-latest steps: @@ -147,7 +139,7 @@ jobs: uses: nick-fields/retry@v3 with: timeout_minutes: 10 - max_attempts: 5 + max_attempts: 3 command: | cd ${{ matrix.package }} if [ "${{ matrix.package }}" = ".agents" ]; then @@ -168,31 +160,21 @@ jobs: # uses: mxschmitt/action-tmate@v3 # timeout-minutes: 15 # optional guard - # Integration tests job + # Integration tests job (packages that don't need a database) test-integration: needs: [build-and-check] strategy: + fail-fast: false matrix: package: [ - .agents, cli, common, packages/agent-runtime, - packages/billing, packages/internal, sdk, web, ] - include: - - package: .agents - - package: cli - - package: common - - package: packages/agent-runtime - - package: packages/billing - - package: packages/internal - - package: sdk - - package: web name: test-integration-${{ matrix.package }} runs-on: ubuntu-latest steps: @@ -241,12 +223,93 @@ jobs: max_attempts: 3 command: | cd ${{ matrix.package }} - if [ "${{ matrix.package }}" = ".agents" ]; then - # .agents e2e tests are in e2e/ directory and require real services - # They are skipped in CI - run locally with: bun run test:e2e - echo "Skipping .agents e2e tests in CI (require real services)" + TEST_FILES=$(find src -name '*.integration.test.ts' 2>/dev/null | sort) + if [ -n "$TEST_FILES" ]; then + echo "$TEST_FILES" | xargs -I {} bun test --timeout=60000 {} + else + echo "No integration tests found in ${{ matrix.package }}" + fi + + # Billing integration tests (requires PostgreSQL) + # Tests use a hardcoded default DATABASE_URL matching this container config + test-billing-integration: + needs: [build-and-check] + name: test-integration-packages/billing + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16-alpine + env: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + POSTGRES_DB: testdb + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: '1.3.5' + + - name: Cache dependencies + uses: actions/cache@v4 + with: + path: | + node_modules + */node_modules + packages/*/node_modules + key: ${{ runner.os }}-deps-${{ hashFiles('**/bun.lock*') }} + restore-keys: | + ${{ runner.os }}-deps- + + - name: Install dependencies + run: bun install --frozen-lockfile + + - name: Set environment variables + env: + SECRETS_CONTEXT: ${{ toJSON(secrets) }} + run: | + VAR_NAMES=$(bun scripts/generate-ci-env.ts) + echo "$SECRETS_CONTEXT" | jq -r --argjson vars "$VAR_NAMES" ' + to_entries | .[] | select(.key as $k | $vars | index($k)) | .key + "=" + .value + ' >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_ACTIONS=true" >> $GITHUB_ENV + echo "NEXT_PUBLIC_CB_ENVIRONMENT=test" >> $GITHUB_ENV + echo "NEXT_PUBLIC_INFISICAL_UP=true" >> $GITHUB_ENV + echo "CODEBUFF_GITHUB_TOKEN=${{ secrets.CODEBUFF_GITHUB_TOKEN }}" >> $GITHUB_ENV + + - name: Build SDK before integration tests + run: cd sdk && bun run build + + - name: Setup database schema + uses: nick-fields/retry@v3 + env: + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb + with: + timeout_minutes: 2 + max_attempts: 3 + command: cd packages/internal && bun run db:migrate + + - name: Run billing integration tests + uses: nick-fields/retry@v3 + with: + timeout_minutes: 15 + max_attempts: 3 + command: | + cd packages/billing + TEST_FILES=$(find src -name '*.integration.test.ts' 2>/dev/null | sort) + if [ -n "$TEST_FILES" ]; then + echo "$TEST_FILES" | xargs -I {} bun test --timeout=60000 {} else - find src -name '*.integration.test.ts' | sort | xargs -I {} bun test --timeout=60000 {} + echo "No integration tests found in packages/billing" fi # E2E tests for web intentionally omitted for now. diff --git a/packages/billing/src/__tests__/balance-calculator.integration.test.ts b/packages/billing/src/__tests__/balance-calculator.integration.test.ts index 3647152f23..1b50d1ef32 100644 --- a/packages/billing/src/__tests__/balance-calculator.integration.test.ts +++ b/packages/billing/src/__tests__/balance-calculator.integration.test.ts @@ -5,11 +5,11 @@ * Drizzle ORM generates correct SQL for the UNION query in * getOrderedActiveGrantsForConsumption. * - * To run these tests: - * 1. Ensure the E2E database is running (see packages/internal/src/db/e2e-constants.ts) - * 2. Run: DATABASE_URL= bun test balance-calculator.integration - * - * Tests will be skipped if DATABASE_URL is not available. + * In CI, these tests run against a PostgreSQL container that's spun up + * by the test-billing-integration job. Locally, you can either: + * 1. Run a local Postgres matching the default URL below: + * docker run -p 5432:5432 -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=postgres -e POSTGRES_DB=testdb postgres:16-alpine + * 2. Set DATABASE_URL to point to your test database */ import { afterAll, @@ -38,10 +38,12 @@ const testLogger: Logger = { // Test configuration const TEST_USER_ID = 'integration-test-user-balance-calc' -const TEST_DATABASE_URL = process.env.DATABASE_URL -// Skip all tests if no DATABASE_URL is available -const shouldSkip = !TEST_DATABASE_URL +// Default database URL matches the CI postgres container config +// (see .github/workflows/ci.yml test-billing-integration job) +const DEFAULT_TEST_DATABASE_URL = + 'postgresql://postgres:postgres@127.0.0.1:5432/testdb' +const TEST_DATABASE_URL = process.env.DATABASE_URL || DEFAULT_TEST_DATABASE_URL // Create test database connection let testClient: ReturnType | null = null @@ -120,670 +122,668 @@ async function getOrderedActiveGrantsForConsumption(params: { return grants } -describe.skipIf(shouldSkip)( - 'Balance Calculator - Integration Tests (Real DB)', - () => { - beforeAll(async () => { - if (shouldSkip) return - - // Create test database connection - testClient = postgres(TEST_DATABASE_URL!) - testDb = drizzle(testClient, { schema }) - - // Create test user if not exists - try { - await testDb.insert(schema.user).values({ - id: TEST_USER_ID, - email: 'integration-test@codebuff.test', - name: 'Integration Test User', - }) - } catch { - // User might already exist, that's fine - } - }) +describe('Balance Calculator - Integration Tests (Real DB)', () => { + beforeAll(async () => { + // Create test database connection + testClient = postgres(TEST_DATABASE_URL) + testDb = drizzle(testClient, { schema }) + + // Create test user if not exists + try { + await testDb.insert(schema.user).values({ + id: TEST_USER_ID, + email: 'integration-test@codebuff.test', + name: 'Integration Test User', + }) + } catch { + // User might already exist, that's fine + } + }) + + afterAll(async () => { + if (!testDb || !testClient) return + + // Clean up test user and all their grants + await testDb + .delete(schema.creditLedger) + .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) + await testDb.delete(schema.user).where(eq(schema.user.id, TEST_USER_ID)) + + // Close connection + await testClient.end() + }) + + afterEach(async () => { + if (!testDb) return + + // Clean up grants between tests for isolation + await testDb + .delete(schema.creditLedger) + .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) + }) + + describe('getOrderedActiveGrantsForConsumption UNION query', () => { + it('should return grants ordered by priority ASC, expires_at ASC NULLS LAST, created_at ASC', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants in random order + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-grant-3', + balance: 100, + priority: 30, + expires_at: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-1', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-2', + balance: 100, + priority: 10, // Same priority as grant-1 + expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), // Expires sooner + created_at: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-grant-4', + balance: 100, + priority: 60, // Lowest priority + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - afterAll(async () => { - if (shouldSkip || !testDb || !testClient) return + expect(grants.map((g) => g.operation_id)).toEqual([ + 'int-test-grant-2', // priority 10, expires soonest + 'int-test-grant-1', // priority 10, expires later + 'int-test-grant-3', // priority 30 + 'int-test-grant-4', // priority 60, never expires (NULLS LAST) + ]) + }) - // Clean up test user and all their grants - await testDb - .delete(schema.creditLedger) - .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) - await testDb.delete(schema.user).where(eq(schema.user.id, TEST_USER_ID)) + it('should include zero-balance last grant for debt recording', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-positive', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-zero-last', + balance: 0, // Zero balance + priority: 60, // Lowest priority = last grant + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - // Close connection - await testClient.end() + // Should include both: non-zero + zero-balance last grant + expect(grants.length).toBe(2) + expect(grants.map((g) => g.operation_id)).toEqual([ + 'int-test-positive', + 'int-test-zero-last', + ]) }) - afterEach(async () => { - if (shouldSkip || !testDb) return + it('should deduplicate when last grant has non-zero balance', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-first', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-last-nonzero', + balance: 50, // Non-zero balance + priority: 60, // Lowest priority = last grant + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - // Clean up grants between tests for isolation - await testDb - .delete(schema.creditLedger) - .where(eq(schema.creditLedger.user_id, TEST_USER_ID)) + // UNION should deduplicate - last grant appears only once + expect(grants.length).toBe(2) + expect( + grants.filter((g) => g.operation_id === 'int-test-last-nonzero').length, + ).toBe(1) }) - describe('getOrderedActiveGrantsForConsumption UNION query', () => { - it('should return grants ordered by priority ASC, expires_at ASC NULLS LAST, created_at ASC', async () => { - const db = getTestDb() - const now = new Date() - - // Insert grants in random order - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-grant-3', - balance: 100, - priority: 30, - expires_at: new Date(now.getTime() + 60 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-grant-1', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-grant-2', - balance: 100, - priority: 10, // Same priority as grant-1 - expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), // Expires sooner - created_at: new Date(now.getTime() - 15 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-grant-4', - balance: 100, - priority: 60, // Lowest priority - expires_at: null, // Never expires - created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - expect(grants.map((g) => g.operation_id)).toEqual([ - 'int-test-grant-2', // priority 10, expires soonest - 'int-test-grant-1', // priority 10, expires later - 'int-test-grant-3', // priority 30 - 'int-test-grant-4', // priority 60, never expires (NULLS LAST) - ]) + it('should handle all-zero-balance grants correctly', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-zero-1', + balance: 0, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-zero-2', + balance: 0, + priority: 60, // This is the "last grant" + expires_at: null, + created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should include zero-balance last grant for debt recording', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-positive', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-zero-last', - balance: 0, // Zero balance - priority: 60, // Lowest priority = last grant - expires_at: null, // Never expires - created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Should include both: non-zero + zero-balance last grant - expect(grants.length).toBe(2) - expect(grants.map((g) => g.operation_id)).toEqual([ - 'int-test-positive', - 'int-test-zero-last', - ]) + // Only the last grant should be returned (for debt recording) + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-zero-2') + }) + + it('should correctly order NULL expires_at as NULLS LAST in consumption order', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-expires-soon', + balance: 100, + priority: 60, // Same priority + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-never-expires', + balance: 100, + priority: 60, // Same priority + expires_at: null, // Never expires + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should deduplicate when last grant has non-zero balance', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-first', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-last-nonzero', - balance: 50, // Non-zero balance - priority: 60, // Lowest priority = last grant - expires_at: null, - created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // UNION should deduplicate - last grant appears only once - expect(grants.length).toBe(2) - expect( - grants.filter((g) => g.operation_id === 'int-test-last-nonzero') - .length, - ).toBe(1) + // In consumption order: expires-soon first, never-expires last + expect(grants[0].operation_id).toBe('int-test-expires-soon') + expect(grants[1].operation_id).toBe('int-test-never-expires') + }) + + it('should filter out expired grants', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-active', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'int-test-expired', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() - 1000), // Already expired + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should handle all-zero-balance grants correctly', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-zero-1', - balance: 0, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-zero-2', - balance: 0, - priority: 60, // This is the "last grant" - expires_at: null, - created_at: new Date(now.getTime() - 5 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Only the last grant should be returned (for debt recording) - expect(grants.length).toBe(1) - expect(grants[0].operation_id).toBe('int-test-zero-2') + // Only active grant should be returned + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-active') + }) + + it('should handle empty grants case', async () => { + const db = getTestDb() + const now = new Date() + + // Don't insert any grants + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should correctly order NULL expires_at as NULLS LAST in consumption order', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-expires-soon', - balance: 100, - priority: 60, // Same priority - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-never-expires', - balance: 100, - priority: 60, // Same priority - expires_at: null, // Never expires - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // In consumption order: expires-soon first, never-expires last - expect(grants[0].operation_id).toBe('int-test-expires-soon') - expect(grants[1].operation_id).toBe('int-test-never-expires') + expect(grants).toEqual([]) + }) + + it('should handle single grant case', async () => { + const db = getTestDb() + const now = new Date() + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-single', + balance: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + ]) + + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should filter out expired grants', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-active', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'int-test-expired', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() - 1000), // Already expired - created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Only active grant should be returned - expect(grants.length).toBe(1) - expect(grants[0].operation_id).toBe('int-test-active') + // Single grant should be returned (deduplicated by UNION) + expect(grants.length).toBe(1) + expect(grants[0].operation_id).toBe('int-test-single') + }) + + it('should handle grants with identical priority, expires_at, and created_at deterministically', async () => { + const db = getTestDb() + const now = new Date() + + // Create grants with IDENTICAL sorting fields (priority, expires_at, created_at) + // This tests the known non-determinism issue - without a tiebreaker like operation_id, + // PostgreSQL may return these in any order + const sharedExpiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) + const sharedCreatedAt = new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000) + const sharedPriority = 10 + + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'int-test-identical-a', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + createGrantData({ + operation_id: 'int-test-identical-b', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + createGrantData({ + operation_id: 'int-test-identical-c', + balance: 100, + priority: sharedPriority, + expires_at: sharedExpiresAt, + created_at: sharedCreatedAt, + }), + ]) + + // Query multiple times to verify ordering stability + const grants1 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should handle empty grants case', async () => { - const db = getTestDb() - const now = new Date() + const grants2 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - // Don't insert any grants + const grants3 = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) + // All grants should be returned + expect(grants1.length).toBe(3) + expect(grants2.length).toBe(3) + expect(grants3.length).toBe(3) + + // Extract operation_ids for comparison + const order1 = grants1.map((g) => g.operation_id) + const order2 = grants2.map((g) => g.operation_id) + const order3 = grants3.map((g) => g.operation_id) + + // All should contain the same grants + expect(order1.sort()).toEqual([ + 'int-test-identical-a', + 'int-test-identical-b', + 'int-test-identical-c', + ]) + + // NOTE: This test documents the non-determinism issue. + // Without an operation_id tiebreaker in the ORDER BY clause, + // these assertions may randomly fail as PostgreSQL doesn't guarantee + // a stable order for rows with identical sorting keys. + // If this test fails intermittently, add operation_id as a tiebreaker. + expect(order1).toEqual(order2) + expect(order2).toEqual(order3) + }) + }) + + describe('consumeCredits end-to-end tests', () => { + // Helper to get grant balance from DB + async function getGrantBalance(operationId: string): Promise { + const db = getTestDb() + const result = await db + .select({ balance: schema.creditLedger.balance }) + .from(schema.creditLedger) + .where(eq(schema.creditLedger.operation_id, operationId)) + return result[0]?.balance ?? 0 + } + + it('should consume credits from grants in priority order', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants with different priorities + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-high-priority', + balance: 50, + principal: 50, + priority: 10, // Consumed first + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-low-priority', + balance: 100, + principal: 100, + priority: 60, // Consumed second + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) - expect(grants).toEqual([]) + // Consume 70 credits (should take 50 from high-priority, 20 from low-priority) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 70, + grants, + tx: db as any, + logger: testLogger, }) - it('should handle single grant case', async () => { - const db = getTestDb() - const now = new Date() - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-single', - balance: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - ]) - - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Single grant should be returned (deduplicated by UNION) - expect(grants.length).toBe(1) - expect(grants[0].operation_id).toBe('int-test-single') + expect(result.consumed).toBe(70) + + // Verify balances in database + const highPriorityBalance = await getGrantBalance('e2e-high-priority') + const lowPriorityBalance = await getGrantBalance('e2e-low-priority') + + expect(highPriorityBalance).toBe(0) // 50 - 50 = 0 + expect(lowPriorityBalance).toBe(80) // 100 - 20 = 80 + }) + + it('should record debt on last grant when all credits exhausted', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants with limited balance + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-depleted', + balance: 30, + principal: 30, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-last-grant', + balance: 0, // Already exhausted - this is the "last grant" for debt + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should handle grants with identical priority, expires_at, and created_at deterministically', async () => { - const db = getTestDb() - const now = new Date() - - // Create grants with IDENTICAL sorting fields (priority, expires_at, created_at) - // This tests the known non-determinism issue - without a tiebreaker like operation_id, - // PostgreSQL may return these in any order - const sharedExpiresAt = new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000) - const sharedCreatedAt = new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000) - const sharedPriority = 10 - - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'int-test-identical-a', - balance: 100, - priority: sharedPriority, - expires_at: sharedExpiresAt, - created_at: sharedCreatedAt, - }), - createGrantData({ - operation_id: 'int-test-identical-b', - balance: 100, - priority: sharedPriority, - expires_at: sharedExpiresAt, - created_at: sharedCreatedAt, - }), - createGrantData({ - operation_id: 'int-test-identical-c', - balance: 100, - priority: sharedPriority, - expires_at: sharedExpiresAt, - created_at: sharedCreatedAt, - }), - ]) - - // Query multiple times to verify ordering stability - const grants1 = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - const grants2 = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - const grants3 = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // All grants should be returned - expect(grants1.length).toBe(3) - expect(grants2.length).toBe(3) - expect(grants3.length).toBe(3) - - // Extract operation_ids for comparison - const order1 = grants1.map((g) => g.operation_id) - const order2 = grants2.map((g) => g.operation_id) - const order3 = grants3.map((g) => g.operation_id) - - // All should contain the same grants - expect(order1.sort()).toEqual(['int-test-identical-a', 'int-test-identical-b', 'int-test-identical-c']) - - // NOTE: This test documents the non-determinism issue. - // Without an operation_id tiebreaker in the ORDER BY clause, - // these assertions may randomly fail as PostgreSQL doesn't guarantee - // a stable order for rows with identical sorting keys. - // If this test fails intermittently, add operation_id as a tiebreaker. - expect(order1).toEqual(order2) - expect(order2).toEqual(order3) + // Consume 100 credits (only 30 available, should create 70 debt) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 100, + grants, + tx: db as any, + logger: testLogger, }) + + expect(result.consumed).toBe(100) + + // Verify balances in database + const depletedBalance = await getGrantBalance('e2e-depleted') + const lastGrantBalance = await getGrantBalance('e2e-last-grant') + + expect(depletedBalance).toBe(0) // 30 - 30 = 0 + expect(lastGrantBalance).toBe(-70) // 0 - 70 = -70 (debt) }) - describe('consumeCredits end-to-end tests', () => { - // Helper to get grant balance from DB - async function getGrantBalance(operationId: string): Promise { - const db = getTestDb() - const result = await db - .select({ balance: schema.creditLedger.balance }) - .from(schema.creditLedger) - .where(eq(schema.creditLedger.operation_id, operationId)) - return result[0]?.balance ?? 0 - } - - it('should consume credits from grants in priority order', async () => { - const db = getTestDb() - const now = new Date() - - // Insert grants with different priorities - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'e2e-high-priority', - balance: 50, - principal: 50, - priority: 10, // Consumed first - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'e2e-low-priority', - balance: 100, - principal: 100, - priority: 60, // Consumed second - expires_at: null, - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }), - ]) - - // Get grants in consumption order - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Consume 70 credits (should take 50 from high-priority, 20 from low-priority) - const result = await consumeFromOrderedGrants({ - userId: TEST_USER_ID, - creditsToConsume: 70, - grants, - tx: db as any, - logger: testLogger, - }) - - expect(result.consumed).toBe(70) - - // Verify balances in database - const highPriorityBalance = await getGrantBalance('e2e-high-priority') - const lowPriorityBalance = await getGrantBalance('e2e-low-priority') - - expect(highPriorityBalance).toBe(0) // 50 - 50 = 0 - expect(lowPriorityBalance).toBe(80) // 100 - 20 = 80 + it('should consume partial credits from multiple grants correctly', async () => { + const db = getTestDb() + const now = new Date() + + // Insert three grants + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-grant-1', + balance: 25, + principal: 25, + priority: 10, + expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-grant-2', + balance: 50, + principal: 50, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-grant-3', + balance: 100, + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should record debt on last grant when all credits exhausted', async () => { - const db = getTestDb() - const now = new Date() - - // Insert grants with limited balance - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'e2e-depleted', - balance: 30, - principal: 30, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'e2e-last-grant', - balance: 0, // Already exhausted - this is the "last grant" for debt - principal: 100, - priority: 60, - expires_at: null, - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }), - ]) - - // Get grants in consumption order - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Consume 100 credits (only 30 available, should create 70 debt) - const result = await consumeFromOrderedGrants({ - userId: TEST_USER_ID, - creditsToConsume: 100, - grants, - tx: db as any, - logger: testLogger, - }) - - expect(result.consumed).toBe(100) - - // Verify balances in database - const depletedBalance = await getGrantBalance('e2e-depleted') - const lastGrantBalance = await getGrantBalance('e2e-last-grant') - - expect(depletedBalance).toBe(0) // 30 - 30 = 0 - expect(lastGrantBalance).toBe(-70) // 0 - 70 = -70 (debt) + // Consume 60 credits (should take 25 from grant-1, 35 from grant-2) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 60, + grants, + tx: db as any, + logger: testLogger, }) - it('should consume partial credits from multiple grants correctly', async () => { - const db = getTestDb() - const now = new Date() - - // Insert three grants - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'e2e-grant-1', - balance: 25, - principal: 25, - priority: 10, - expires_at: new Date(now.getTime() + 15 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'e2e-grant-2', - balance: 50, - principal: 50, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'e2e-grant-3', - balance: 100, - principal: 100, - priority: 60, - expires_at: null, - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }), - ]) - - // Get grants in consumption order - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Consume 60 credits (should take 25 from grant-1, 35 from grant-2) - const result = await consumeFromOrderedGrants({ - userId: TEST_USER_ID, - creditsToConsume: 60, - grants, - tx: db as any, - logger: testLogger, - }) - - expect(result.consumed).toBe(60) - - // Verify balances in database - const grant1Balance = await getGrantBalance('e2e-grant-1') - const grant2Balance = await getGrantBalance('e2e-grant-2') - const grant3Balance = await getGrantBalance('e2e-grant-3') - - expect(grant1Balance).toBe(0) // 25 - 25 = 0 - expect(grant2Balance).toBe(15) // 50 - 35 = 15 - expect(grant3Balance).toBe(100) // Untouched + expect(result.consumed).toBe(60) + + // Verify balances in database + const grant1Balance = await getGrantBalance('e2e-grant-1') + const grant2Balance = await getGrantBalance('e2e-grant-2') + const grant3Balance = await getGrantBalance('e2e-grant-3') + + expect(grant1Balance).toBe(0) // 25 - 25 = 0 + expect(grant2Balance).toBe(15) // 50 - 35 = 15 + expect(grant3Balance).toBe(100) // Untouched + }) + + it('should repay debt when consuming from grants with negative balance', async () => { + const db = getTestDb() + const now = new Date() + + // Insert grants: one with debt, one with positive balance + await db.insert(schema.creditLedger).values([ + createGrantData({ + operation_id: 'e2e-debt-grant', + balance: -50, // Has debt + principal: 100, + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), + }), + createGrantData({ + operation_id: 'e2e-positive-grant', + balance: 100, + principal: 100, + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }), + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, + }) + + // Consume 80 credits + // The consumption algorithm works as follows: + // 1. First pass (debt repayment): Uses creditsToConsume to repay debt + // - debt-grant has -50, repay 50 from the 80 requested, debt becomes 0 + // - remainingToConsume = 30, consumed = 50 + // 2. Second pass (consumption): Consumes from positive balances + // - positive-grant has 100, consume 30, becomes 70 + // - remainingToConsume = 0, consumed = 80 + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 80, + grants, + tx: db as any, + logger: testLogger, }) - it('should repay debt when consuming from grants with negative balance', async () => { - const db = getTestDb() - const now = new Date() - - // Insert grants: one with debt, one with positive balance - await db.insert(schema.creditLedger).values([ - createGrantData({ - operation_id: 'e2e-debt-grant', - balance: -50, // Has debt - principal: 100, - priority: 60, - expires_at: null, - created_at: new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000), - }), - createGrantData({ - operation_id: 'e2e-positive-grant', - balance: 100, - principal: 100, - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }), - ]) - - // Get grants in consumption order - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Consume 80 credits - // The consumption algorithm works as follows: - // 1. First pass (debt repayment): Uses creditsToConsume to repay debt - // - debt-grant has -50, repay 50 from the 80 requested, debt becomes 0 - // - remainingToConsume = 30, consumed = 50 - // 2. Second pass (consumption): Consumes from positive balances - // - positive-grant has 100, consume 30, becomes 70 - // - remainingToConsume = 0, consumed = 80 - const result = await consumeFromOrderedGrants({ - userId: TEST_USER_ID, - creditsToConsume: 80, - grants, - tx: db as any, - logger: testLogger, - }) - - expect(result.consumed).toBe(80) - - // Verify balances in database - const debtGrantBalance = await getGrantBalance('e2e-debt-grant') - const positiveGrantBalance = await getGrantBalance('e2e-positive-grant') - - // Debt should be repaid: -50 + 50 = 0 - expect(debtGrantBalance).toBe(0) - // Positive grant: 100 - 30 (consume after debt repayment) = 70 - expect(positiveGrantBalance).toBe(70) + expect(result.consumed).toBe(80) + + // Verify balances in database + const debtGrantBalance = await getGrantBalance('e2e-debt-grant') + const positiveGrantBalance = await getGrantBalance('e2e-positive-grant') + + // Debt should be repaid: -50 + 50 = 0 + expect(debtGrantBalance).toBe(0) + // Positive grant: 100 - 30 (consume after debt repayment) = 70 + expect(positiveGrantBalance).toBe(70) + }) + + it('should track purchased credits consumption correctly', async () => { + const db = getTestDb() + const now = new Date() + + // Insert a mix of free and purchased grants + await db.insert(schema.creditLedger).values([ + { + operation_id: 'e2e-free-grant', + user_id: TEST_USER_ID, + balance: 30, + principal: 30, + type: 'free' as const, + description: 'Free credits', + priority: 10, + expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), + created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), + }, + { + operation_id: 'e2e-purchased-grant', + user_id: TEST_USER_ID, + balance: 100, + principal: 100, + type: 'purchase' as const, + description: 'Purchased credits', + priority: 60, + expires_at: null, + created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), + }, + ]) + + // Get grants in consumption order + const grants = await getOrderedActiveGrantsForConsumption({ + userId: TEST_USER_ID, + now, + conn: db, }) - it('should track purchased credits consumption correctly', async () => { - const db = getTestDb() - const now = new Date() - - // Insert a mix of free and purchased grants - await db.insert(schema.creditLedger).values([ - { - operation_id: 'e2e-free-grant', - user_id: TEST_USER_ID, - balance: 30, - principal: 30, - type: 'free' as const, - description: 'Free credits', - priority: 10, - expires_at: new Date(now.getTime() + 30 * 24 * 60 * 60 * 1000), - created_at: new Date(now.getTime() - 20 * 24 * 60 * 60 * 1000), - }, - { - operation_id: 'e2e-purchased-grant', - user_id: TEST_USER_ID, - balance: 100, - principal: 100, - type: 'purchase' as const, - description: 'Purchased credits', - priority: 60, - expires_at: null, - created_at: new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000), - }, - ]) - - // Get grants in consumption order - const grants = await getOrderedActiveGrantsForConsumption({ - userId: TEST_USER_ID, - now, - conn: db, - }) - - // Consume 50 credits (30 from free, 20 from purchased) - const result = await consumeFromOrderedGrants({ - userId: TEST_USER_ID, - creditsToConsume: 50, - grants, - tx: db as any, - logger: testLogger, - }) - - expect(result.consumed).toBe(50) - expect(result.fromPurchased).toBe(20) // Only 20 came from purchase grant - - // Verify balances in database - const freeBalance = await getGrantBalance('e2e-free-grant') - const purchasedBalance = await getGrantBalance('e2e-purchased-grant') - - expect(freeBalance).toBe(0) // 30 - 30 = 0 - expect(purchasedBalance).toBe(80) // 100 - 20 = 80 + // Consume 50 credits (30 from free, 20 from purchased) + const result = await consumeFromOrderedGrants({ + userId: TEST_USER_ID, + creditsToConsume: 50, + grants, + tx: db as any, + logger: testLogger, }) + + expect(result.consumed).toBe(50) + expect(result.fromPurchased).toBe(20) // Only 20 came from purchase grant + + // Verify balances in database + const freeBalance = await getGrantBalance('e2e-free-grant') + const purchasedBalance = await getGrantBalance('e2e-purchased-grant') + + expect(freeBalance).toBe(0) // 30 - 30 = 0 + expect(purchasedBalance).toBe(80) // 100 - 20 = 80 }) - }, -) + }) +}) From 22320ae1c2c6e43b21ca9516b21d438447284694 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 15:45:02 -0800 Subject: [PATCH 0069/1143] fix(billing): inline consumeFromOrderedGrants in integration test to avoid db side effect The test was failing in CI because importing from balance-calculator.ts triggered a transitive import of @codebuff/internal/db which eagerly creates a database connection using env.DATABASE_URL (manicode_user_local) instead of our test database URL. Inlined the consumeFromOrderedGrants and updateGrantBalance functions directly in the test file to avoid this side effect. --- .../balance-calculator.integration.test.ts | 131 +++++++++++++++++- 1 file changed, 125 insertions(+), 6 deletions(-) diff --git a/packages/billing/src/__tests__/balance-calculator.integration.test.ts b/packages/billing/src/__tests__/balance-calculator.integration.test.ts index 1b50d1ef32..b22e9da80a 100644 --- a/packages/billing/src/__tests__/balance-calculator.integration.test.ts +++ b/packages/billing/src/__tests__/balance-calculator.integration.test.ts @@ -24,10 +24,129 @@ import postgres from 'postgres' import { eq, and, asc, desc, ne, or, gt, isNull, sql } from 'drizzle-orm' import { union } from 'drizzle-orm/pg-core' import * as schema from '@codebuff/internal/db/schema' -import { consumeFromOrderedGrants } from '../balance-calculator' import type { Logger } from '@codebuff/common/types/contracts/logger' +// Inlined from balance-calculator.ts to avoid importing db (which has side effects) +// that would try to connect with env.DATABASE_URL before our test URL is set +interface CreditConsumptionResult { + consumed: number + fromPurchased: number +} + +// Minimal type for database connection that works with both db and tx +type TestDbConn = ReturnType> + +async function updateGrantBalance(params: { + userId: string + grant: typeof schema.creditLedger.$inferSelect + consumed: number + newBalance: number + tx: TestDbConn + logger: Logger +}) { + const { grant, newBalance, tx } = params + await tx + .update(schema.creditLedger) + .set({ balance: newBalance }) + .where(eq(schema.creditLedger.operation_id, grant.operation_id)) +} + +async function consumeFromOrderedGrants(params: { + userId: string + creditsToConsume: number + grants: (typeof schema.creditLedger.$inferSelect)[] + tx: TestDbConn + logger: Logger +}): Promise { + const { userId, creditsToConsume, grants, tx, logger } = params + + let remainingToConsume = creditsToConsume + let consumed = 0 + let fromPurchased = 0 + + // First pass: try to repay any debt + for (const grant of grants) { + if (grant.balance < 0 && remainingToConsume > 0) { + const debtAmount = Math.abs(grant.balance) + const repayAmount = Math.min(debtAmount, remainingToConsume) + const newBalance = grant.balance + repayAmount + remainingToConsume -= repayAmount + consumed += repayAmount + + await updateGrantBalance({ + userId, + grant, + consumed: -repayAmount, + newBalance, + tx, + logger, + }) + + logger.debug( + { userId, grantId: grant.operation_id, repayAmount, newBalance }, + 'Repaid debt in grant', + ) + } + } + + // Second pass: consume from positive balances + for (const grant of grants) { + if (remainingToConsume <= 0) break + if (grant.balance <= 0) continue + + const consumeFromThisGrant = Math.min(remainingToConsume, grant.balance) + const newBalance = grant.balance - consumeFromThisGrant + remainingToConsume -= consumeFromThisGrant + consumed += consumeFromThisGrant + + // Track consumption from purchased credits + if (grant.type === 'purchase') { + fromPurchased += consumeFromThisGrant + } + + await updateGrantBalance({ + userId, + grant, + consumed: consumeFromThisGrant, + newBalance, + tx, + logger, + }) + } + + // If we still have remaining to consume and no grants left, create debt in the last grant + if (remainingToConsume > 0 && grants.length > 0) { + const lastGrant = grants[grants.length - 1] + + if (lastGrant.balance <= 0) { + const newBalance = lastGrant.balance - remainingToConsume + await updateGrantBalance({ + userId, + grant: lastGrant, + consumed: remainingToConsume, + newBalance, + tx, + logger, + }) + consumed += remainingToConsume + + logger.warn( + { + userId, + grantId: lastGrant.operation_id, + requested: remainingToConsume, + consumed: remainingToConsume, + newDebt: Math.abs(newBalance), + }, + 'Created new debt in grant', + ) + } + } + + return { consumed, fromPurchased } +} + // Test logger that silently discards all logs const testLogger: Logger = { debug: () => {}, @@ -546,7 +665,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => { userId: TEST_USER_ID, creditsToConsume: 70, grants, - tx: db as any, + tx: db, logger: testLogger, }) @@ -596,7 +715,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => { userId: TEST_USER_ID, creditsToConsume: 100, grants, - tx: db as any, + tx: db, logger: testLogger, }) @@ -654,7 +773,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => { userId: TEST_USER_ID, creditsToConsume: 60, grants, - tx: db as any, + tx: db, logger: testLogger, }) @@ -713,7 +832,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => { userId: TEST_USER_ID, creditsToConsume: 80, grants, - tx: db as any, + tx: db, logger: testLogger, }) @@ -771,7 +890,7 @@ describe('Balance Calculator - Integration Tests (Real DB)', () => { userId: TEST_USER_ID, creditsToConsume: 50, grants, - tx: db as any, + tx: db, logger: testLogger, }) From 3538d46be5afab9e727019727220ada990c7d9b6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 15:51:51 -0800 Subject: [PATCH 0070/1143] fix(ci): set DATABASE_URL at job level to override secrets injection The secrets injection step was setting DATABASE_URL from GitHub Secrets (manicode_user_local) which overrode the test container credentials. Setting DATABASE_URL at the job level ensures it takes precedence. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fe579adcdc..94864fbbd1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -231,11 +231,13 @@ jobs: fi # Billing integration tests (requires PostgreSQL) - # Tests use a hardcoded default DATABASE_URL matching this container config + # DATABASE_URL is set at job level to override any secrets injection test-billing-integration: needs: [build-and-check] name: test-integration-packages/billing runs-on: ubuntu-latest + env: + DATABASE_URL: postgresql://postgres:postgres@127.0.0.1:5432/testdb services: postgres: image: postgres:16-alpine From 7ed72ffbd93eb81bd6af0ea22b41b1e65a0163c9 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 15:56:51 -0800 Subject: [PATCH 0071/1143] fix(ci): add explicit DATABASE_URL override step after secrets injection The secrets injection step writes DATABASE_URL to GITHUB_ENV which takes precedence over job-level env vars. Added an explicit step to re-set DATABASE_URL to the test container URL after secrets are injected. --- .github/workflows/ci.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 94864fbbd1..ebc65161f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -291,6 +291,10 @@ jobs: - name: Build SDK before integration tests run: cd sdk && bun run build + # Override any DATABASE_URL injected from secrets with our test container URL + - name: Override DATABASE_URL for test container + run: echo "DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/testdb" >> $GITHUB_ENV + - name: Setup database schema uses: nick-fields/retry@v3 env: From c9e4927ff731260145bd87b0df0195d9d3974009 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Jan 2026 00:03:53 +0000 Subject: [PATCH 0072/1143] Bump version to 1.0.591 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index 03996d92dc..fd7f4ac262 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.590", + "version": "1.0.591", "description": "AI coding agent", "license": "MIT", "bin": { From 9641e2934c870049529d8725ae9c6349b7acf5ea Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 16:22:32 -0800 Subject: [PATCH 0073/1143] fix(security): address Dependabot vulnerabilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade lodash 4.17.21 → 4.17.23 (fixes prototype pollution in _.unset/_.omit) - Upgrade diff 8.0.2 → 8.0.3 (fixes DoS in parsePatch/applyPatch) - Upgrade ai 5.0.0 → 5.0.52 (fixes file type whitelist bypass) - Add @ai-sdk/provider and @ai-sdk/provider-utils overrides to fix version conflicts Closes 11 Dependabot alerts (4 medium lodash, 4 low diff, 3 low ai) --- bun.lock | 52 ++++++++++++++++++++++----------------------- common/package.json | 4 ++-- package.json | 6 ++++-- sdk/package.json | 4 ++-- 4 files changed, 34 insertions(+), 32 deletions(-) diff --git a/bun.lock b/bun.lock index bf554b85c3..115076d19e 100644 --- a/bun.lock +++ b/bun.lock @@ -24,7 +24,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-unused-imports": "^4.1.4", "ignore": "^6.0.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "prettier": "^3.7.4", "ts-node": "^10.9.2", "ts-pattern": "^5.5.0", @@ -92,9 +92,9 @@ "@types/pg": "^8.11.10", "@types/readable-stream": "^4.0.18", "@types/seedrandom": "^3.0.8", - "ai": "^5.0.0", + "ai": "^5.0.52", "ignore": "5.3.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "next-auth": "^4.24.11", "partial-json": "^0.1.7", "pg": "^8.14.1", @@ -225,8 +225,8 @@ "@ai-sdk/anthropic": "2.0.50", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", + "ai": "^5.0.52", + "diff": "8.0.3", "ignore": "7.0.5", "micromatch": "^4.0.8", "web-tree-sitter": "0.25.6", @@ -344,6 +344,8 @@ }, }, "overrides": { + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.20", "baseline-browser-mapping": "^2.9.14", "signal-exit": "3.0.7", "zod": "^4.2.1", @@ -353,13 +355,13 @@ "@ai-sdk/anthropic": ["@ai-sdk/anthropic@2.0.50", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.18" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-21PaHfoLmouOXXNINTsZJsMw+wE5oLR2He/1kq/sKokTVKyq7ObGT1LDk6ahwxaz/GoaNaGankMh+EgVcdv2Cw=="], - "@ai-sdk/gateway": ["@ai-sdk/gateway@1.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-VEm87DyRx1yIPywbTy8ntoyh4jEDv1rJ88m+2I7zOm08jJI5BhFtAWh0OF6YzZu1Vu4NxhOWO4ssGdsqydDQ3A=="], + "@ai-sdk/gateway": ["@ai-sdk/gateway@2.0.28", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@vercel/oidc": "3.1.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-YD2p+3rBiuw6z6PNWCNOFpatIBGreuxbmhy92icxIHUtl8uf8G/AYPUcqbibsF51NRP49NZQwgghOCSL1zAmJg=="], "@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@1.0.25", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.15" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-VPylb5ytkOu9Bs1UnVmz4x0wr1VtS30Pw6ghh6GxpGH6lo4GOWqVnYuB+8M755dkof74c5LULZq5C1n/1J4Kvg=="], - "@ai-sdk/provider": ["@ai-sdk/provider@2.0.0", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA=="], + "@ai-sdk/provider": ["@ai-sdk/provider@2.0.1", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-KCUwswvsC5VsW2PWFqF8eJgSCu5Ysj7m1TxiHTVA6g7k360bk0RNQENT8KTMAYEs+8fWPD3Uu4dEmzGHc+jGng=="], - "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.19", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-W41Wc9/jbUVXVwCN/7bWa4IKe8MtxO3EyA0Hfhx6grnmiYlCvpI8neSYWFE0zScXJkgA/YK3BRybzgyiXuu6JA=="], + "@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.20", "", { "dependencies": { "@ai-sdk/provider": "2.0.1", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-iXHVe0apM2zUEzauqJwqmpC37A5rihrStAih5Ks+JE32iTe4LZ58y17UGBjpQQTCRw9YxMeo2UFLxLpBluyvLQ=="], "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], @@ -1145,7 +1147,7 @@ "@sinonjs/fake-timers": ["@sinonjs/fake-timers@10.3.0", "", { "dependencies": { "@sinonjs/commons": "^3.0.0" } }, "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA=="], - "@standard-schema/spec": ["@standard-schema/spec@1.0.0", "", {}, "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@stripe/stripe-js": ["@stripe/stripe-js@4.10.0", "", {}, "sha512-KrMOL+sH69htCIXCaZ4JluJ35bchuCCznyPyrbN8JXSGQfwBI1SuIEMZNwvy8L8ykj29t6sa5BAAiL7fNoLZ8A=="], @@ -1411,6 +1413,8 @@ "@unrs/resolver-binding-win32-x64-msvc": ["@unrs/resolver-binding-win32-x64-msvc@1.11.1", "", { "os": "win32", "cpu": "x64" }, "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g=="], + "@vercel/oidc": ["@vercel/oidc@3.1.0", "", {}, "sha512-Fw28YZpRnA3cAHHDlkt7xQHiJ0fcL+NRcIqsocZQUSmbzeIKRpwttJjik5ZGanXP+vlA4SbTg+AbA3bP363l+w=="], + "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="], "@vscode/tree-sitter-wasm": ["@vscode/tree-sitter-wasm@0.1.4", "", {}, "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA=="], @@ -1445,7 +1449,7 @@ "agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="], - "ai": ["ai@5.0.0", "", { "dependencies": { "@ai-sdk/gateway": "1.0.0", "@ai-sdk/provider": "2.0.0", "@ai-sdk/provider-utils": "3.0.0", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-F4jOhOSeiZD8lXpF4l1hRqyM1jbqoLKGVZNxAP467wmQCsWUtElMa3Ki5PrDMq6qvUNC3deUKfERDAsfj7IDlg=="], + "ai": ["ai@5.0.122", "", { "dependencies": { "@ai-sdk/gateway": "2.0.28", "@ai-sdk/provider": "2.0.1", "@ai-sdk/provider-utils": "3.0.20", "@opentelemetry/api": "1.9.0" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-tbN8j7OQPuML9RQs7nN3l4WQnesZ7g255xgefIAaM7z6RT8eidXPD5/fflhHLIipq8X9ZgTc2pMqXXp0S6O9Qw=="], "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], @@ -1871,7 +1875,7 @@ "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], - "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "diff": ["diff@8.0.3", "", {}, "sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ=="], "diff-sequences": ["diff-sequences@29.6.3", "", {}, "sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q=="], @@ -2555,7 +2559,7 @@ "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], - "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="], "lodash-es": ["lodash-es@4.17.21", "", {}, "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw=="], @@ -3627,12 +3631,6 @@ "zwitch": ["zwitch@2.0.4", "", {}, "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.18", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ypv1xXMsgGcNKUP+hglKqtdDuMg68nWHucPPAhIENrbFAI+xCHiqPVN8Zllxyv1TNZwGWUghPxJXU+Mqps0YRQ=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], - - "@ai-sdk/openai-compatible/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.15", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kOc6Pxb7CsRlNt+sLZKL7/VGQUd7ccl3/tIK+Bqf5/QhHR0Qm3qRBMz1IwU1RmjJEZA73x+KB5cUckbDl2WF7Q=="], - "@auth/core/jose": ["jose@6.1.0", "", {}, "sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA=="], "@auth/core/preact": ["preact@10.24.3", "", {}, "sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA=="], @@ -3649,6 +3647,10 @@ "@codebuff/common/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + "@codebuff/evals/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + + "@codebuff/scripts/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "@codebuff/sdk/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@codebuff/web/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], @@ -3787,12 +3789,18 @@ "@opentelemetry/sdk-trace-base/@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.28.0", "", {}, "sha512-lp4qAiMTD4sNWW4DbKLBkfiMZ4jbAboJIGOQr5DvciMRI494OapieI9qiODpOt0XBr1LjIDy1xAGAnVs5supTA=="], + "@opentui/core/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + + "@sapphire/shapeshift/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], + "@testing-library/dom/aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], "@testing-library/dom/dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], "@testing-library/dom/pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + "@types/diff/diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "@types/request/form-data": ["form-data@2.5.5", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.2", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-jqdObeR2rxZZbPSGL+3VckHMYtu+f9//KXBsVny6JSX/pa38Fy+bGjuG8eW/H6USNQWhLi8Num++cU2yOCNz4A=="], "@typescript-eslint/eslint-plugin/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], @@ -3823,8 +3831,6 @@ "accepts/mime-types": ["mime-types@3.0.1", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-xRc4oEhT6eaBpU1XF7AjpOFD+xQmXNB5OVKwp4tqCuBpHLS/ZbBDrc07mYTDqVMg6PfxUjjNp85O6Cd2Z/5HWA=="], - "ai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@3.0.0", "", { "dependencies": { "@ai-sdk/provider": "2.0.0", "@standard-schema/spec": "^1.0.0", "eventsource-parser": "^3.0.3", "zod-to-json-schema": "^3.24.1" }, "peerDependencies": { "zod": "^3.25.76 || ^4" } }, "sha512-BoQZtGcBxkeSH1zK+SRYNDtJPIPpacTeiMZqnG4Rv6xXjEwM0FH4MGs9c+PlhyEWmQCzjRM2HAotEydFhD4dYw=="], - "ansi-escapes/type-fest": ["type-fest@0.21.3", "", {}, "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w=="], "app-path/execa": ["execa@5.1.1", "", { "dependencies": { "cross-spawn": "^7.0.3", "get-stream": "^6.0.0", "human-signals": "^2.1.0", "is-stream": "^2.0.0", "merge-stream": "^2.0.0", "npm-run-path": "^4.0.1", "onetime": "^5.1.2", "signal-exit": "^3.0.3", "strip-final-newline": "^2.0.0" } }, "sha512-8uSpZZocAZRBAPIEINJj3Lo9HyGitllczc27Eh5YYojjMFMn8yHMDMaUHE2Jqfq05D/wucwI4JGURyXt1vchyg=="], @@ -4189,10 +4195,6 @@ "yargs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], - "@ai-sdk/anthropic/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - - "@ai-sdk/gateway/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "@babel/helper-compilation-targets/lru-cache/yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], "@codebuff/web/@typescript-eslint/eslint-plugin/@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.46.2", "", { "dependencies": { "@typescript-eslint/types": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2" } }, "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA=="], @@ -4365,8 +4367,6 @@ "accepts/mime-types/mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="], - "ai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], - "app-path/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "app-path/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], diff --git a/common/package.json b/common/package.json index 90767118aa..cf4b9757b6 100644 --- a/common/package.json +++ b/common/package.json @@ -26,9 +26,9 @@ "@types/pg": "^8.11.10", "@types/readable-stream": "^4.0.18", "@types/seedrandom": "^3.0.8", - "ai": "^5.0.0", + "ai": "^5.0.52", "ignore": "5.3.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "next-auth": "^4.24.11", "partial-json": "^0.1.7", "pg": "^8.14.1", diff --git a/package.json b/package.json index 8c5038990f..73966fac4f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,9 @@ "overrides": { "baseline-browser-mapping": "^2.9.14", "zod": "^4.2.1", - "signal-exit": "3.0.7" + "signal-exit": "3.0.7", + "@ai-sdk/provider": "2.0.1", + "@ai-sdk/provider-utils": "3.0.20" }, "devDependencies": { "@tanstack/react-query": "^5.90.12", @@ -59,7 +61,7 @@ "eslint-plugin-import": "^2.29.1", "eslint-plugin-unused-imports": "^4.1.4", "ignore": "^6.0.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "prettier": "^3.7.4", "ts-node": "^10.9.2", "ts-pattern": "^5.5.0", diff --git a/sdk/package.json b/sdk/package.json index 8b36c205bd..7365f35242 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -61,8 +61,8 @@ "@ai-sdk/anthropic": "2.0.50", "@jitl/quickjs-wasmfile-release-sync": "0.31.0", "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", + "ai": "^5.0.52", + "diff": "8.0.3", "ignore": "7.0.5", "micromatch": "^4.0.8", "web-tree-sitter": "0.25.6", From 714123bcd2e121b5e856284f48c452a0e90dd553 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 16:20:55 -0800 Subject: [PATCH 0074/1143] Remove dead website code --- web/src/app/home-client.tsx | 8 +-- web/src/app/pricing/pricing-client.tsx | 40 ------------ web/src/components/ui/landing/constants.ts | 64 ------------------- .../components/ui/landing/feature/index.tsx | 2 - web/src/components/ui/landing/types.ts | 7 -- 5 files changed, 1 insertion(+), 120 deletions(-) diff --git a/web/src/app/home-client.tsx b/web/src/app/home-client.tsx index 7c9ec923ea..69fb60eda6 100644 --- a/web/src/app/home-client.tsx +++ b/web/src/app/home-client.tsx @@ -12,10 +12,7 @@ import IDEDemo from '@/components/IDEDemo' import { BlockColor, DecorativeBlocks } from '@/components/ui/decorative-blocks' import { Hero } from '@/components/ui/hero' import { CompetitionSection } from '@/components/ui/landing/competition' -import { - FEATURE_POINTS, - SECTION_THEMES, -} from '@/components/ui/landing/constants' +import { SECTION_THEMES } from '@/components/ui/landing/constants' import { CTASection } from '@/components/ui/landing/cta-section' import { FeatureSection } from '@/components/ui/landing/feature' import { BrowserComparison } from '@/components/ui/landing/feature/browser-comparison' @@ -282,7 +279,6 @@ export default function HomeClient() { highlightText="Indexes your entire codebase in 2 seconds" learnMoreText="See How It Works" learnMoreLink="/docs/advanced" - keyPoints={FEATURE_POINTS.understanding} illustration={ } learnMoreText={status === 'authenticated' ? 'My Usage' : 'Get Started'} learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} - keyPoints={[ - { - icon: '💰', - title: 'Predictable Costs', - description: - 'Only pay for what you actually use. No surprises at the end of the month.', - }, - { - icon: '🔄', - title: 'Monthly Free Credits', - description: - 'Get 500 free credits each month, automatically added to your account.', - }, - { - icon: '🛡️', - title: 'No Failed Call Charges', - description: - 'Only pay for successful API calls. Failed calls cost nothing.', - }, - ]} /> ) diff --git a/web/src/components/ui/landing/constants.ts b/web/src/components/ui/landing/constants.ts index ad745380d8..10d476253f 100644 --- a/web/src/components/ui/landing/constants.ts +++ b/web/src/components/ui/landing/constants.ts @@ -98,67 +98,3 @@ export const ANIMATION = { ease: [0.165, 0.84, 0.44, 1], }, } - -// Feature section key points -export const FEATURE_POINTS = { - understanding: [ - { - icon: '🧠', - title: 'Total Project Awareness', - description: - 'Maps your entire codebase to grasp the architecture, dependencies, and coding patterns that make it tick', - }, - { - icon: '🔍', - title: 'Uncanny Problem Detection', - description: - 'Spots bugs, security issues, and performance bottlenecks that other AI tools completely miss', - }, - { - icon: '⚡', - title: 'Context-Perfect Solutions', - description: - 'Crafts code that fits your project like a glove - matching your style, patterns, and standards exactly', - }, - ], - rightStuff: [ - { - icon: '🛠️', - title: 'Zero-Friction Setup', - description: - 'Handles complex project configuration, dependencies, and scaffolding without making you jump through hoops', - }, - { - icon: '✂️', - title: 'Surgical Code Changes', - description: - 'Makes precise, targeted edits that respect your codebase instead of ham-fisted rewrites that break things', - }, - { - icon: '🔄', - title: 'Works Where You Work', - description: - 'Runs in any terminal with any tech stack - no special environments, no framework limitations, no hassles', - }, - ], - remembers: [ - { - icon: '🧩', - title: "Your Project's Memory", - description: - 'Stores knowledge in smart .md files that grow with each session, eliminating those "let me explain again" moments', - }, - { - icon: '📈', - title: 'Learns Your Style', - description: - 'Adapts to your unique coding patterns and workflow preferences to deliver increasingly personalized help', - }, - { - icon: '⏱️', - title: 'Picks Up Where You Left Off', - description: - 'Remembers previous conversations, decisions, and context - just like working with a human teammate', - }, - ], -} diff --git a/web/src/components/ui/landing/feature/index.tsx b/web/src/components/ui/landing/feature/index.tsx index f54141a6be..ea1362a16e 100644 --- a/web/src/components/ui/landing/feature/index.tsx +++ b/web/src/components/ui/landing/feature/index.tsx @@ -8,7 +8,6 @@ import { HighlightText } from './highlight-text' import { DecorativeBlocks, BlockColor } from '../../decorative-blocks' import { Section } from '../../section' -import type { KeyPoint } from '../types' import type { ReactNode } from 'react' import { useIsMobile } from '@/hooks/use-mobile' @@ -58,7 +57,6 @@ interface FeatureSectionProps { imagePosition?: 'left' | 'right' tagline: string decorativeColors?: BlockColor[] - keyPoints: KeyPoint[] highlightText: string illustration: ReactNode learnMoreText?: string diff --git a/web/src/components/ui/landing/types.ts b/web/src/components/ui/landing/types.ts index c6d6f951d2..3ecbccac48 100644 --- a/web/src/components/ui/landing/types.ts +++ b/web/src/components/ui/landing/types.ts @@ -1,11 +1,5 @@ import type { BlockColor } from '../decorative-blocks' -export interface KeyPoint { - icon: string - title: string - description: string -} - export interface SectionTheme { background: string textColor: string @@ -65,7 +59,6 @@ export interface FeatureSectionProps { imagePosition?: 'left' | 'right' codeSample?: string[] tagline?: string - keyPoints?: KeyPoint[] highlightText?: string illustration?: FeatureIllustration } From 2678045a3398f488ab7a8f20ba0d60dcee6b6fff Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 16:29:07 -0800 Subject: [PATCH 0075/1143] Add section on pricing to connect your Claude subscription --- web/src/app/pricing/pricing-client.tsx | 76 +++++++++++++++++++++++++- 1 file changed, 75 insertions(+), 1 deletion(-) diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index 1e3a7c4602..fba7e71654 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -1,7 +1,7 @@ 'use client' import { DEFAULT_FREE_CREDITS_GRANT } from '@codebuff/common/old-constants' -import { Gift, Shield } from 'lucide-react' +import { Gift, Shield, Link2, Zap, Terminal } from 'lucide-react' import { useSession } from 'next-auth/react' import { BlockColor } from '@/components/ui/decorative-blocks' @@ -62,6 +62,66 @@ function PricingCard() { ) } +function ClaudeSubscriptionIllustration() { + return ( +
+
+ {/* Connection visual */} +
+ {/* Claude card */} +
+
Claude
+
Pro / Max
+
+ + {/* Connection arrow */} +
+
+ +
+
+ + {/* Codebuff card */} +
+
Codebuff
+
CLI
+
+
+ + {/* Benefits grid */} +
+
+
+ +
+
+
Save on credits
+
Use your subscription for Claude model requests
+
+
+ +
+
+ +
+
+
Simple CLI setup
+
Connect with one command
+
+
+
+ + {/* Code snippet */} +
+
$ codebuff
+
{'>'} /connect:claude
+
✓ Connected to Claude subscription
+
+
+
+ ) +} + function TeamPlanIllustration() { return (
@@ -163,6 +223,20 @@ export default function PricingClient() { learnMoreLink={status === 'authenticated' ? '/usage' : '/login'} /> + Connect Your Claude Subscription} + description="Already have a Claude Pro or Max subscription? Connect it to Codebuff and use your existing subscription for Claude model requests. Save credits while enjoying the full power of Claude through Codebuff's intelligent orchestration." + backdropColor={BlockColor.DarkForestGreen} + decorativeColors={[BlockColor.CRTAmber, BlockColor.BetweenGreen]} + textColor="text-white" + tagline="BRING YOUR OWN SUBSCRIPTION" + highlightText="Use your Claude Pro or Max subscription" + illustration={} + learnMoreText="View Documentation" + learnMoreLink="/docs" + imagePosition="left" + /> + Working with others} description="Collaborate with your team more closely using Codebuff by pooling credits and seeing usage analytics." From e629eb0314f02ea8bd5b827a7981acd6ae1cad9a Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 16:30:35 -0800 Subject: [PATCH 0076/1143] fix(security): address remaining Dependabot alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Upgrade lodash 4.17.21 → 4.17.23 in scripts and evals packages - Remove stale package-lock.json files in sdk/test directories that had outdated transitive dependencies (diff, ai) Closes remaining 8 Dependabot alerts --- bun.lock | 8 +- evals/package.json | 2 +- scripts/package.json | 2 +- sdk/test/cjs-compatibility/package-lock.json | 313 ------------------- sdk/test/esm-compatibility/package-lock.json | 287 ----------------- sdk/test/ripgrep-bundling/package-lock.json | 309 ------------------ 6 files changed, 4 insertions(+), 917 deletions(-) delete mode 100644 sdk/test/cjs-compatibility/package-lock.json delete mode 100644 sdk/test/esm-compatibility/package-lock.json delete mode 100644 sdk/test/ripgrep-bundling/package-lock.json diff --git a/bun.lock b/bun.lock index 115076d19e..75bf9ceb4c 100644 --- a/bun.lock +++ b/bun.lock @@ -122,7 +122,7 @@ "@oclif/parser": "^3.8.17", "async": "^3.2.6", "diff": "^8.0.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "p-limit": "^6.2.0", "zod": "^4.2.1", }, @@ -210,7 +210,7 @@ "@ai-sdk/openai-compatible": "^1.0.19", "@codebuff/bigquery": "workspace:*", "@codebuff/common": "workspace:*", - "lodash": "4.17.21", + "lodash": "4.17.23", }, "devDependencies": { "@types/bun": "^1.3.5", @@ -3647,10 +3647,6 @@ "@codebuff/common/ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], - "@codebuff/evals/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - - "@codebuff/scripts/lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], - "@codebuff/sdk/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], "@codebuff/web/@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.46.2", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.46.2", "@typescript-eslint/type-utils": "8.46.2", "@typescript-eslint/utils": "8.46.2", "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w=="], diff --git a/evals/package.json b/evals/package.json index 4f33a8dd03..6116768ca1 100644 --- a/evals/package.json +++ b/evals/package.json @@ -39,7 +39,7 @@ "@oclif/parser": "^3.8.17", "async": "^3.2.6", "diff": "^8.0.2", - "lodash": "4.17.21", + "lodash": "4.17.23", "p-limit": "^6.2.0", "zod": "^4.2.1" }, diff --git a/scripts/package.json b/scripts/package.json index 63dec3904e..98aeb41108 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -24,7 +24,7 @@ "@ai-sdk/openai-compatible": "^1.0.19", "@codebuff/bigquery": "workspace:*", "@codebuff/common": "workspace:*", - "lodash": "4.17.21" + "lodash": "4.17.23" }, "devDependencies": { "@types/bun": "^1.3.5", diff --git a/sdk/test/cjs-compatibility/package-lock.json b/sdk/test/cjs-compatibility/package-lock.json deleted file mode 100644 index 0805d482a4..0000000000 --- a/sdk/test/cjs-compatibility/package-lock.json +++ /dev/null @@ -1,313 +0,0 @@ -{ - "name": "cjs-compatibility-test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "cjs-compatibility-test", - "version": "1.0.0", - "dependencies": { - "@codebuff/sdk": "*" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.0.0" - } - }, - "../..": { - "name": "@codebuff/sdk", - "version": "0.2.0", - "extraneous": true, - "license": "Apache-2.0", - "dependencies": { - "@vscode/ripgrep": "1.15.14", - "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", - "web-tree-sitter": "0.25.6", - "zod": "^4.0.0" - }, - "devDependencies": { - "@types/bun": "^1.2.11", - "@types/diff": "8.0.0", - "@types/node": "22", - "rimraf": "^6.0.1" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "../../dist": { - "extraneous": true - }, - "node_modules/@ai-sdk/gateway": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.24.tgz", - "integrity": "sha512-Mwp0yYXrEnENoDrc7IH9yVRVJ7RrDW0CXWDtyz1BiyqccbtdWhAKu4wtrDMx2FkeK5riiME1kYYdjRnlba3UFw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@ai-sdk/provider": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", - "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.9.tgz", - "integrity": "sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@standard-schema/spec": "^1.0.0", - "eventsource-parser": "^3.0.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@codebuff/sdk": { - "version": "0.1.33", - "resolved": "https://registry.npmjs.org/@codebuff/sdk/-/sdk-0.1.33.tgz", - "integrity": "sha512-k7MG04+vxEELluGK748daUkDQvjX9baX4uwPS1dUi3yjjpNHHxJxpbdTDJ6LsBsJ7eIfT+u/6xbjj7lY3BKsKw==", - "license": "Apache-2.0", - "dependencies": { - "@vscode/ripgrep": "1.15.14", - "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", - "web-tree-sitter": "0.25.6", - "zod": "^4.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vscode/ripgrep": { - "version": "1.15.14", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz", - "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^7.0.2", - "proxy-from-env": "^1.1.0", - "yauzl": "^2.9.2" - } - }, - "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ai": { - "version": "5.0.47", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.47.tgz", - "integrity": "sha512-/DKfU9tTsQVcUYSDCTu1L7jmvEgzUWOr1xf5UHwwDbRf/HED8LDb60QlWYs6f4BkZsVoLvpliCSjliXiRZywFQ==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/gateway": "1.0.24", - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9", - "@opentelemetry/api": "1.9.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/web-tree-sitter": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.6.tgz", - "integrity": "sha512-WG+/YGbxw8r+rLlzzhV+OvgiOJCWdIpOucG3qBf3RCBFMkGDb1CanUi2BxCxjnkpzU3/hLWPT8VO5EKsMk9Fxg==", - "license": "MIT" - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zod": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.9.tgz", - "integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/sdk/test/esm-compatibility/package-lock.json b/sdk/test/esm-compatibility/package-lock.json deleted file mode 100644 index c810f0b43a..0000000000 --- a/sdk/test/esm-compatibility/package-lock.json +++ /dev/null @@ -1,287 +0,0 @@ -{ - "name": "esm-compatibility-test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "esm-compatibility-test", - "version": "1.0.0", - "dependencies": { - "@codebuff/sdk": "*" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.0.0" - } - }, - "node_modules/@ai-sdk/gateway": { - "version": "1.0.24", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.24.tgz", - "integrity": "sha512-Mwp0yYXrEnENoDrc7IH9yVRVJ7RrDW0CXWDtyz1BiyqccbtdWhAKu4wtrDMx2FkeK5riiME1kYYdjRnlba3UFw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@ai-sdk/provider": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", - "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.9.tgz", - "integrity": "sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@standard-schema/spec": "^1.0.0", - "eventsource-parser": "^3.0.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@codebuff/sdk": { - "version": "0.1.33", - "resolved": "https://registry.npmjs.org/@codebuff/sdk/-/sdk-0.1.33.tgz", - "integrity": "sha512-k7MG04+vxEELluGK748daUkDQvjX9baX4uwPS1dUi3yjjpNHHxJxpbdTDJ6LsBsJ7eIfT+u/6xbjj7lY3BKsKw==", - "license": "Apache-2.0", - "dependencies": { - "@vscode/ripgrep": "1.15.14", - "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", - "web-tree-sitter": "0.25.6", - "zod": "^4.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vscode/ripgrep": { - "version": "1.15.14", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz", - "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^7.0.2", - "proxy-from-env": "^1.1.0", - "yauzl": "^2.9.2" - } - }, - "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ai": { - "version": "5.0.47", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.47.tgz", - "integrity": "sha512-/DKfU9tTsQVcUYSDCTu1L7jmvEgzUWOr1xf5UHwwDbRf/HED8LDb60QlWYs6f4BkZsVoLvpliCSjliXiRZywFQ==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/gateway": "1.0.24", - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9", - "@opentelemetry/api": "1.9.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/web-tree-sitter": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.6.tgz", - "integrity": "sha512-WG+/YGbxw8r+rLlzzhV+OvgiOJCWdIpOucG3qBf3RCBFMkGDb1CanUi2BxCxjnkpzU3/hLWPT8VO5EKsMk9Fxg==", - "license": "MIT" - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zod": { - "version": "4.1.9", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.9.tgz", - "integrity": "sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} diff --git a/sdk/test/ripgrep-bundling/package-lock.json b/sdk/test/ripgrep-bundling/package-lock.json deleted file mode 100644 index cf8ae360c3..0000000000 --- a/sdk/test/ripgrep-bundling/package-lock.json +++ /dev/null @@ -1,309 +0,0 @@ -{ - "name": "ripgrep-bundling-test", - "version": "1.0.0", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "ripgrep-bundling-test", - "version": "1.0.0", - "dependencies": { - "@codebuff/sdk": "*" - }, - "devDependencies": { - "@types/node": "^22.0.0", - "typescript": "^5.0.0" - } - }, - "node_modules/@ai-sdk/gateway": { - "version": "1.0.25", - "resolved": "https://registry.npmjs.org/@ai-sdk/gateway/-/gateway-1.0.25.tgz", - "integrity": "sha512-eI/6LLmn1tWFzuhjxgcPEqUFXwLjyRuGFrwkCoqLaTKe/qMYBEAV3iddnGUM0AV+Hp4NEykzP4ly5tibOLDMXw==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@ai-sdk/provider": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider/-/provider-2.0.0.tgz", - "integrity": "sha512-6o7Y2SeO9vFKB8lArHXehNuusnpddKPk7xqL7T2/b+OvXMRIXUO1rR4wcv1hAFUAT9avGZshty3Wlua/XA7TvA==", - "license": "Apache-2.0", - "dependencies": { - "json-schema": "^0.4.0" - }, - "engines": { - "node": ">=18" - } - }, - "node_modules/@ai-sdk/provider-utils": { - "version": "3.0.9", - "resolved": "https://registry.npmjs.org/@ai-sdk/provider-utils/-/provider-utils-3.0.9.tgz", - "integrity": "sha512-Pm571x5efqaI4hf9yW4KsVlDBDme8++UepZRnq+kqVBWWjgvGhQlzU8glaFq0YJEB9kkxZHbRRyVeHoV2sRYaQ==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/provider": "2.0.0", - "@standard-schema/spec": "^1.0.0", - "eventsource-parser": "^3.0.5" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/@codebuff/sdk": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@codebuff/sdk/-/sdk-0.2.2.tgz", - "integrity": "sha512-YxdCi5xItFRi2anEsyI8by/8vUkMueM4U8H92V16XbUIKAj4ji0hmL2w67B6xfd+Q1tyBvs6yy/x5x/2BEvfIw==", - "license": "Apache-2.0", - "dependencies": { - "@vscode/ripgrep": "1.15.14", - "@vscode/tree-sitter-wasm": "0.1.4", - "ai": "^5.0.0", - "diff": "8.0.2", - "web-tree-sitter": "0.25.6", - "ws": "8.18.0", - "zod": "^4.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/@opentelemetry/api": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", - "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", - "license": "Apache-2.0", - "engines": { - "node": ">=8.0.0" - } - }, - "node_modules/@standard-schema/spec": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz", - "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA==", - "license": "MIT" - }, - "node_modules/@types/node": { - "version": "22.18.6", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.6.tgz", - "integrity": "sha512-r8uszLPpeIWbNKtvWRt/DbVi5zbqZyj1PTmhRMqBMvDnaz1QpmSKujUtJLrqGZeoM8v72MfYggDceY4K1itzWQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/@vscode/ripgrep": { - "version": "1.15.14", - "resolved": "https://registry.npmjs.org/@vscode/ripgrep/-/ripgrep-1.15.14.tgz", - "integrity": "sha512-/G1UJPYlm+trBWQ6cMO3sv6b8D1+G16WaJH1/DSqw32JOVlzgZbLkDxRyzIpTpv30AcYGMkCf5tUqGlW6HbDWw==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "https-proxy-agent": "^7.0.2", - "proxy-from-env": "^1.1.0", - "yauzl": "^2.9.2" - } - }, - "node_modules/@vscode/tree-sitter-wasm": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/@vscode/tree-sitter-wasm/-/tree-sitter-wasm-0.1.4.tgz", - "integrity": "sha512-kQVVg/CamCYDM+/XYCZuNTQyixjZd8ts/Gf84UzjEY0eRnbg6kiy5I9z2/2i3XdqwhI87iG07rkMR2KwhqcSbA==", - "license": "MIT" - }, - "node_modules/agent-base": { - "version": "7.1.4", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", - "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, - "node_modules/ai": { - "version": "5.0.48", - "resolved": "https://registry.npmjs.org/ai/-/ai-5.0.48.tgz", - "integrity": "sha512-+oYhbN3NGRXayGfTFI8k1Fu4rhiJcQ0mbgiAOJGFkzvCxunRRQu5cyDl7y6cHNTj1QvHmIBROK5u655Ss2oI0g==", - "license": "Apache-2.0", - "dependencies": { - "@ai-sdk/gateway": "1.0.25", - "@ai-sdk/provider": "2.0.0", - "@ai-sdk/provider-utils": "3.0.9", - "@opentelemetry/api": "1.9.0" - }, - "engines": { - "node": ">=18" - }, - "peerDependencies": { - "zod": "^3.25.76 || ^4" - } - }, - "node_modules/buffer-crc32": { - "version": "0.2.13", - "resolved": "https://registry.npmjs.org/buffer-crc32/-/buffer-crc32-0.2.13.tgz", - "integrity": "sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==", - "license": "MIT", - "engines": { - "node": "*" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/diff": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.2.tgz", - "integrity": "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg==", - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.3.1" - } - }, - "node_modules/eventsource-parser": { - "version": "3.0.6", - "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.0.6.tgz", - "integrity": "sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==", - "license": "MIT", - "engines": { - "node": ">=18.0.0" - } - }, - "node_modules/fd-slicer": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/fd-slicer/-/fd-slicer-1.1.0.tgz", - "integrity": "sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==", - "license": "MIT", - "dependencies": { - "pend": "~1.2.0" - } - }, - "node_modules/https-proxy-agent": { - "version": "7.0.6", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", - "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", - "license": "MIT", - "dependencies": { - "agent-base": "^7.1.2", - "debug": "4" - }, - "engines": { - "node": ">= 14" - } - }, - "node_modules/json-schema": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/json-schema/-/json-schema-0.4.0.tgz", - "integrity": "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==", - "license": "(AFL-2.1 OR BSD-3-Clause)" - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "license": "MIT" - }, - "node_modules/pend": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/pend/-/pend-1.2.0.tgz", - "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==", - "license": "MIT" - }, - "node_modules/proxy-from-env": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", - "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==", - "license": "MIT" - }, - "node_modules/typescript": { - "version": "5.9.2", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", - "integrity": "sha512-CWBzXQrc/qOkhidw1OzBTQuYRbfyxDXJMVJ1XNwUHGROVmuaeiEm3OslpZ1RV96d7SKKjZKrSJu3+t/xlw3R9A==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/undici-types": { - "version": "6.21.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", - "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/web-tree-sitter": { - "version": "0.25.6", - "resolved": "https://registry.npmjs.org/web-tree-sitter/-/web-tree-sitter-0.25.6.tgz", - "integrity": "sha512-WG+/YGbxw8r+rLlzzhV+OvgiOJCWdIpOucG3qBf3RCBFMkGDb1CanUi2BxCxjnkpzU3/hLWPT8VO5EKsMk9Fxg==", - "license": "MIT" - }, - "node_modules/ws": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz", - "integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==", - "license": "MIT", - "engines": { - "node": ">=10.0.0" - }, - "peerDependencies": { - "bufferutil": "^4.0.1", - "utf-8-validate": ">=5.0.2" - }, - "peerDependenciesMeta": { - "bufferutil": { - "optional": true - }, - "utf-8-validate": { - "optional": true - } - } - }, - "node_modules/yauzl": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/yauzl/-/yauzl-2.10.0.tgz", - "integrity": "sha512-p4a9I6X6nu6IhoGmBqAcbJy1mlC4j27vEPZX9F4L4/vZT3Lyq1VkFHw/V/PUcB9Buo+DG3iHkT0x3Qya58zc3g==", - "license": "MIT", - "dependencies": { - "buffer-crc32": "~0.2.3", - "fd-slicer": "~1.1.0" - } - }, - "node_modules/zod": { - "version": "4.1.11", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.11.tgz", - "integrity": "sha512-WPsqwxITS2tzx1bzhIKsEs19ABD5vmCVa4xBo2tq/SrV4RNZtfws1EnCWQXM6yh8bD08a1idvkB5MZSBiZsjwg==", - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/colinhacks" - } - } - } -} From e4d4cdf775b022a95d6291cd8ed41189e7c946ce Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 16:38:47 -0800 Subject: [PATCH 0077/1143] refactor(deps): consolidate duplicate dependencies across packages - Remove duplicate devDependencies (@types/bun, @types/node) from 7 packages - hoisted from root - Remove @types/lodash from scripts - hoisted from root - Remove stripe, @auth/drizzle-adapter, pg from web - already in common - Remove lodash from evals and scripts - they import common which has it - Add lodash to agent-runtime (was using without declaring) - Align ts-pattern versions to ^5.9.0 across root, cli, web This reduces dependency duplication and ensures packages properly declare what they use. --- bun.lock | 39 +++-------------------------- cli/package.json | 2 -- evals/package.json | 1 - package.json | 2 +- packages/agent-runtime/package.json | 8 +++--- packages/bigquery/package.json | 5 +--- packages/billing/package.json | 5 +--- packages/code-map/package.json | 5 +--- packages/internal/package.json | 5 +--- scripts/package.json | 9 ++----- sdk/package.json | 2 -- web/package.json | 5 +--- 12 files changed, 14 insertions(+), 74 deletions(-) diff --git a/bun.lock b/bun.lock index 75bf9ceb4c..fba88feab8 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ "lodash": "4.17.23", "prettier": "^3.7.4", "ts-node": "^10.9.2", - "ts-pattern": "^5.5.0", + "ts-pattern": "^5.9.0", "tsc-alias": "^1.8.16", "tsconfig-paths": "4.2.0", "types": "^0.1.1", @@ -75,8 +75,6 @@ "zustand": "^5.0.8", }, "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", "@types/react": "^18.3.12", "@types/react-reconciler": "^0.32.0", "react-dom": "^19.0.0", @@ -122,7 +120,6 @@ "@oclif/parser": "^3.8.17", "async": "^3.2.6", "diff": "^8.0.2", - "lodash": "4.17.23", "p-limit": "^6.2.0", "zod": "^4.2.1", }, @@ -135,12 +132,9 @@ "version": "0.0.0", "dependencies": { "gpt-tokenizer": "^2.8.1", + "lodash": "4.17.23", "zod-from-json-schema": "0.4.2", }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", - }, }, "packages/bigquery": { "name": "@codebuff/bigquery", @@ -149,10 +143,6 @@ "@codebuff/common": "workspace:*", "@google-cloud/bigquery": "^7.9.4", }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", - }, }, "packages/billing": { "name": "@codebuff/billing", @@ -160,10 +150,6 @@ "dependencies": { "@codebuff/common": "workspace:*", }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", - }, }, "packages/build-tools": { "name": "@codebuff/build-tools", @@ -180,10 +166,6 @@ "@vscode/tree-sitter-wasm": "0.1.4", "web-tree-sitter": "0.25.6", }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", - }, }, "packages/internal": { "name": "@codebuff/internal", @@ -198,10 +180,6 @@ "postgres": "^3.4.7", "server-only": "0.0.1", }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", - }, }, "scripts": { "name": "@codebuff/scripts", @@ -210,12 +188,6 @@ "@ai-sdk/openai-compatible": "^1.0.19", "@codebuff/bigquery": "workspace:*", "@codebuff/common": "workspace:*", - "lodash": "4.17.23", - }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/lodash": "^4.17.21", - "@types/node": "22", }, }, "sdk": { @@ -234,10 +206,8 @@ "zod": "^4.2.1", }, "devDependencies": { - "@types/bun": "^1.3.5", "@types/diff": "8.0.0", "@types/micromatch": "^4.0.9", - "@types/node": "22", "adm-zip": "^0.5.12", "dts-bundle-generator": "^9.5.1", "node-fetch": "^3.3.2", @@ -247,7 +217,6 @@ "name": "@codebuff/web", "version": "1.0.0", "dependencies": { - "@auth/drizzle-adapter": "^1.8.0", "@codebuff/billing": "workspace:*", "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", @@ -287,7 +256,6 @@ "next-contentlayer2": "^0.5.8", "next-themes": "^0.3.0", "nextjs-linkedin-insight-tag": "^0.0.6", - "pg": "^8.14.1", "pino": "^9.6.0", "posthog-js": "^1.234.10", "prism-react-renderer": "^2.4.1", @@ -295,9 +263,8 @@ "react-dom": "18.3.1", "react-hook-form": "^7.55.0", "server-only": "^0.0.1", - "stripe": "^16.11.0", "tailwind-merge": "^2.5.2", - "ts-pattern": "^5.7.0", + "ts-pattern": "^5.9.0", "use-debounce": "^10.0.4", "zod": "^4.2.1", }, diff --git a/cli/package.json b/cli/package.json index 4f2520147f..90380ae092 100644 --- a/cli/package.json +++ b/cli/package.json @@ -53,8 +53,6 @@ "zustand": "^5.0.8" }, "devDependencies": { - "@types/bun": "^1.3.5", - "@types/node": "22", "@types/react": "^18.3.12", "@types/react-reconciler": "^0.32.0", "react-dom": "^19.0.0", diff --git a/evals/package.json b/evals/package.json index 6116768ca1..9f14702943 100644 --- a/evals/package.json +++ b/evals/package.json @@ -39,7 +39,6 @@ "@oclif/parser": "^3.8.17", "async": "^3.2.6", "diff": "^8.0.2", - "lodash": "4.17.23", "p-limit": "^6.2.0", "zod": "^4.2.1" }, diff --git a/package.json b/package.json index 73966fac4f..6ac81a887e 100644 --- a/package.json +++ b/package.json @@ -64,7 +64,7 @@ "lodash": "4.17.23", "prettier": "^3.7.4", "ts-node": "^10.9.2", - "ts-pattern": "^5.5.0", + "ts-pattern": "^5.9.0", "tsc-alias": "^1.8.16", "tsconfig-paths": "4.2.0", "types": "^0.1.1", diff --git a/packages/agent-runtime/package.json b/packages/agent-runtime/package.json index 00d1089839..8fc30c1c3d 100644 --- a/packages/agent-runtime/package.json +++ b/packages/agent-runtime/package.json @@ -27,10 +27,8 @@ }, "dependencies": { "gpt-tokenizer": "^2.8.1", - "zod-from-json-schema": "0.4.2" + "zod-from-json-schema": "0.4.2", + "lodash": "4.17.23" }, - "devDependencies": { - "@types/node": "22", - "@types/bun": "^1.3.5" - } + "devDependencies": {} } diff --git a/packages/bigquery/package.json b/packages/bigquery/package.json index 652ff46cd3..4adc4fe758 100644 --- a/packages/bigquery/package.json +++ b/packages/bigquery/package.json @@ -29,8 +29,5 @@ "@google-cloud/bigquery": "^7.9.4", "@codebuff/common": "workspace:*" }, - "devDependencies": { - "@types/node": "22", - "@types/bun": "^1.3.5" - } + "devDependencies": {} } diff --git a/packages/billing/package.json b/packages/billing/package.json index 12a4d1e695..2414a26763 100644 --- a/packages/billing/package.json +++ b/packages/billing/package.json @@ -28,8 +28,5 @@ "dependencies": { "@codebuff/common": "workspace:*" }, - "devDependencies": { - "@types/node": "22", - "@types/bun": "^1.3.5" - } + "devDependencies": {} } diff --git a/packages/code-map/package.json b/packages/code-map/package.json index 9e1431d31d..cf5fe1f8de 100644 --- a/packages/code-map/package.json +++ b/packages/code-map/package.json @@ -29,8 +29,5 @@ "@vscode/tree-sitter-wasm": "0.1.4", "web-tree-sitter": "0.25.6" }, - "devDependencies": { - "@types/node": "22", - "@types/bun": "^1.3.5" - } + "devDependencies": {} } diff --git a/packages/internal/package.json b/packages/internal/package.json index 86b7d64f83..024f9103a5 100644 --- a/packages/internal/package.json +++ b/packages/internal/package.json @@ -67,8 +67,5 @@ "postgres": "^3.4.7", "server-only": "0.0.1" }, - "devDependencies": { - "@types/node": "22", - "@types/bun": "^1.3.5" - } + "devDependencies": {} } diff --git a/scripts/package.json b/scripts/package.json index 98aeb41108..12662d6b74 100644 --- a/scripts/package.json +++ b/scripts/package.json @@ -23,12 +23,7 @@ "dependencies": { "@ai-sdk/openai-compatible": "^1.0.19", "@codebuff/bigquery": "workspace:*", - "@codebuff/common": "workspace:*", - "lodash": "4.17.23" + "@codebuff/common": "workspace:*" }, - "devDependencies": { - "@types/bun": "^1.3.5", - "@types/lodash": "^4.17.21", - "@types/node": "22" - } + "devDependencies": {} } diff --git a/sdk/package.json b/sdk/package.json index 7365f35242..77bf13b66b 100644 --- a/sdk/package.json +++ b/sdk/package.json @@ -70,10 +70,8 @@ "zod": "^4.2.1" }, "devDependencies": { - "@types/bun": "^1.3.5", "@types/diff": "8.0.0", "@types/micromatch": "^4.0.9", - "@types/node": "22", "adm-zip": "^0.5.12", "dts-bundle-generator": "^9.5.1", "node-fetch": "^3.3.2" diff --git a/web/package.json b/web/package.json index f0c59ac9cc..e5c7a3a463 100644 --- a/web/package.json +++ b/web/package.json @@ -38,7 +38,6 @@ "bun": "^1.3.5" }, "dependencies": { - "@auth/drizzle-adapter": "^1.8.0", "@codebuff/billing": "workspace:*", "@codebuff/common": "workspace:*", "@codebuff/internal": "workspace:*", @@ -78,7 +77,6 @@ "next-contentlayer2": "^0.5.8", "next-themes": "^0.3.0", "nextjs-linkedin-insight-tag": "^0.0.6", - "pg": "^8.14.1", "pino": "^9.6.0", "posthog-js": "^1.234.10", "prism-react-renderer": "^2.4.1", @@ -86,9 +84,8 @@ "react-dom": "18.3.1", "react-hook-form": "^7.55.0", "server-only": "^0.0.1", - "stripe": "^16.11.0", "tailwind-merge": "^2.5.2", - "ts-pattern": "^5.7.0", + "ts-pattern": "^5.9.0", "use-debounce": "^10.0.4", "zod": "^4.2.1" }, From d8c47f32578d8effff5412f63adf8db0e9ce0586 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 17:02:53 -0800 Subject: [PATCH 0078/1143] fix(web): resolve healthz cache warning by using lightweight count query The getCachedAgentsLite function was trying to cache ~19MB of agent data, exceeding the unstable_cache 2MB limit. Created a new getAgentCount() function that only performs a COUNT(*) query, avoiding the cache entirely. This fixes the warning: "Failed to set Next.js data cache for unstable_cache /api/healthz, items over 2MB can not be cached" --- web/src/app/api/healthz/route.ts | 16 ++++++++-------- web/src/server/agents-data.ts | 14 ++++++++++++++ 2 files changed, 22 insertions(+), 8 deletions(-) diff --git a/web/src/app/api/healthz/route.ts b/web/src/app/api/healthz/route.ts index 7d27880c9d..949f035939 100644 --- a/web/src/app/api/healthz/route.ts +++ b/web/src/app/api/healthz/route.ts @@ -1,24 +1,24 @@ import { NextResponse } from 'next/server' -import { getCachedAgentsLite } from '@/server/agents-data' +import { getAgentCount } from '@/server/agents-data' export const GET = async () => { try { - // Warm the cache by fetching agents data - // This ensures SEO-critical data is available immediately - const agents = await getCachedAgentsLite() + // Get a lightweight count of agents without caching the full data + // This avoids the unstable_cache 2MB limit warning + const agentCount = await getAgentCount() return NextResponse.json({ status: 'ok', - cached_agents: agents.length, + cached_agents: agentCount, timestamp: new Date().toISOString(), }) } catch (error) { - console.error('[Healthz] Failed to warm cache:', error) + console.error('[Healthz] Failed to get agent count:', error) - // Still return 200 so health check passes, but indicate cache warming failed + // Still return 200 so health check passes, but indicate the error return NextResponse.json({ status: 'ok', - cache_warm: false, + agent_count_error: true, error: error instanceof Error ? error.message : 'Unknown error', }) } diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts index a343f7f5e8..9bbe865a3f 100644 --- a/web/src/server/agents-data.ts +++ b/web/src/server/agents-data.ts @@ -455,3 +455,17 @@ export const getCachedAgentsMetrics = unstable_cache( tags: ['agents', 'metrics'], }, ) + +// ============================================================================ +// LIGHTWEIGHT COUNT - For healthz endpoint, avoids unstable_cache 2MB limit +// ============================================================================ + +export const getAgentCount = async (): Promise => { + const result = await db + .select({ + count: sql`COUNT(*)`, + }) + .from(schema.agentConfig) + + return Number(result[0]?.count ?? 0) +} From 5172265c2cc6ad859f3583d3e88cb52d8d251ef1 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 17:09:28 -0800 Subject: [PATCH 0079/1143] test(web): add unit tests for healthz endpoint - Refactor healthz route to use dependency injection pattern for testability - Add comprehensive tests for success and error cases - Tests verify response structure, error handling, and timestamp format Prevents regressions in health check behavior. --- .../app/api/healthz/__tests__/healthz.test.ts | 98 +++++++++++++++++++ web/src/app/api/healthz/_get.ts | 28 ++++++ web/src/app/api/healthz/route.ts | 23 +---- 3 files changed, 128 insertions(+), 21 deletions(-) create mode 100644 web/src/app/api/healthz/__tests__/healthz.test.ts create mode 100644 web/src/app/api/healthz/_get.ts diff --git a/web/src/app/api/healthz/__tests__/healthz.test.ts b/web/src/app/api/healthz/__tests__/healthz.test.ts new file mode 100644 index 0000000000..1753554dca --- /dev/null +++ b/web/src/app/api/healthz/__tests__/healthz.test.ts @@ -0,0 +1,98 @@ +import { describe, test, expect } from 'bun:test' + +import { getHealthz } from '../_get' + +import type { HealthzDeps } from '../_get' + +describe('/api/healthz route', () => { + describe('Success cases', () => { + test('returns 200 with status ok and agent count', async () => { + const mockGetAgentCount = async () => 42 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(42) + expect(body.timestamp).toBeDefined() + expect(typeof body.timestamp).toBe('string') + }) + + test('returns correct count when no agents exist', async () => { + const mockGetAgentCount = async () => 0 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(0) + }) + + test('returns correct count for large number of agents', async () => { + const mockGetAgentCount = async () => 10000 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.cached_agents).toBe(10000) + }) + }) + + describe('Error handling', () => { + test('returns 200 with error flag when getAgentCount throws', async () => { + const mockGetAgentCount = async () => { + throw new Error('Database connection failed') + } + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + // Should still return 200 so health check passes + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.agent_count_error).toBe(true) + expect(body.error).toBe('Database connection failed') + expect(body.cached_agents).toBeUndefined() + }) + + test('handles non-Error exceptions gracefully', async () => { + const mockGetAgentCount = async () => { + throw 'String error' + } + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.status).toBe(200) + const body = await response.json() + expect(body.status).toBe('ok') + expect(body.agent_count_error).toBe(true) + expect(body.error).toBe('Unknown error') + }) + }) + + describe('Response format', () => { + test('response has correct Content-Type header', async () => { + const mockGetAgentCount = async () => 100 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + + expect(response.headers.get('content-type')).toContain('application/json') + }) + + test('timestamp is in ISO format', async () => { + const mockGetAgentCount = async () => 50 + + const response = await getHealthz({ getAgentCount: mockGetAgentCount }) + const body = await response.json() + + // Verify timestamp is valid ISO date + const timestamp = new Date(body.timestamp) + expect(timestamp.toString()).not.toBe('Invalid Date') + expect(body.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/) + }) + }) +}) diff --git a/web/src/app/api/healthz/_get.ts b/web/src/app/api/healthz/_get.ts new file mode 100644 index 0000000000..62fe23a437 --- /dev/null +++ b/web/src/app/api/healthz/_get.ts @@ -0,0 +1,28 @@ +import { NextResponse } from 'next/server' + +export interface HealthzDeps { + getAgentCount: () => Promise +} + +export const getHealthz = async ({ getAgentCount }: HealthzDeps) => { + try { + // Get a lightweight count of agents without caching the full data + // This avoids the unstable_cache 2MB limit warning + const agentCount = await getAgentCount() + + return NextResponse.json({ + status: 'ok', + cached_agents: agentCount, + timestamp: new Date().toISOString(), + }) + } catch (error) { + console.error('[Healthz] Failed to get agent count:', error) + + // Still return 200 so health check passes, but indicate the error + return NextResponse.json({ + status: 'ok', + agent_count_error: true, + error: error instanceof Error ? error.message : 'Unknown error', + }) + } +} diff --git a/web/src/app/api/healthz/route.ts b/web/src/app/api/healthz/route.ts index 949f035939..6949272993 100644 --- a/web/src/app/api/healthz/route.ts +++ b/web/src/app/api/healthz/route.ts @@ -1,25 +1,6 @@ -import { NextResponse } from 'next/server' import { getAgentCount } from '@/server/agents-data' +import { getHealthz } from './_get' export const GET = async () => { - try { - // Get a lightweight count of agents without caching the full data - // This avoids the unstable_cache 2MB limit warning - const agentCount = await getAgentCount() - - return NextResponse.json({ - status: 'ok', - cached_agents: agentCount, - timestamp: new Date().toISOString(), - }) - } catch (error) { - console.error('[Healthz] Failed to get agent count:', error) - - // Still return 200 so health check passes, but indicate the error - return NextResponse.json({ - status: 'ok', - agent_count_error: true, - error: error instanceof Error ? error.message : 'Unknown error', - }) - } + return getHealthz({ getAgentCount }) } From 25c5a3b63a1b2770c67217d4ef21d7a477ca06ae Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 17:23:34 -0800 Subject: [PATCH 0080/1143] refactor(web): remove unused getCachedAgentsLite and buildAgentsDataLite These functions were defined but never used anywhere in the codebase. Removing them as dead code cleanup to reduce maintenance burden. --- .../server/__tests__/agents-transform.test.ts | 157 ------------------ web/src/server/agents-data.ts | 91 ---------- web/src/server/agents-transform.ts | 122 -------------- 3 files changed, 370 deletions(-) diff --git a/web/src/server/__tests__/agents-transform.test.ts b/web/src/server/__tests__/agents-transform.test.ts index f29b0b9c29..b0af1b7f2e 100644 --- a/web/src/server/__tests__/agents-transform.test.ts +++ b/web/src/server/__tests__/agents-transform.test.ts @@ -1,9 +1,7 @@ import { describe, it, expect } from '@jest/globals' import { buildAgentsData, - buildAgentsDataLite, type AgentRow, - type AgentRowSlim, } from '../agents-transform' describe('buildAgentsData', () => { @@ -261,158 +259,3 @@ describe('buildAgentsData', () => { }) }) -describe('buildAgentsDataLite', () => { - it('dedupes by latest, merges metrics, and omits version_stats', () => { - // AgentRowSlim has pre-extracted fields (name, description, tags) instead of data blob - const agents: AgentRowSlim[] = [ - { - id: 'base', - version: '1.0.0', - name: 'Base', - description: 'desc', - tags: ['x'], - created_at: '2025-01-01T00:00:00.000Z', - publisher: { - id: 'codebuff', - name: 'Codebuff', - verified: true, - avatar_url: null, - }, - }, - // older duplicate by name should be ignored due to first-seen is latest ordering - { - id: 'base-old', - version: '0.9.0', - name: 'Base', - description: 'old', - tags: null, - created_at: '2024-12-01T00:00:00.000Z', - publisher: { - id: 'codebuff', - name: 'Codebuff', - verified: true, - avatar_url: null, - }, - }, - { - id: 'reviewer', - version: '2.1.0', - name: 'Reviewer', - description: null, - tags: null, - created_at: '2025-01-03T00:00:00.000Z', - publisher: { - id: 'codebuff', - name: 'Codebuff', - verified: true, - avatar_url: null, - }, - }, - ] - - const usageMetrics = [ - { - publisher_id: 'codebuff', - agent_name: 'Base', - total_invocations: 50, - total_dollars: 100, - avg_cost_per_run: 2, - unique_users: 4, - last_used: new Date('2025-01-05T00:00:00.000Z'), - }, - { - publisher_id: 'codebuff', - agent_name: 'reviewer', - total_invocations: 5, - total_dollars: 5, - avg_cost_per_run: 1, - unique_users: 1, - last_used: new Date('2025-01-04T00:00:00.000Z'), - }, - ] - - const weeklyMetrics = [ - { - publisher_id: 'codebuff', - agent_name: 'Base', - weekly_runs: 10, - weekly_dollars: 20, - }, - { - publisher_id: 'codebuff', - agent_name: 'reviewer', - weekly_runs: 2, - weekly_dollars: 1, - }, - ] - - const out = buildAgentsDataLite({ - agents, - usageMetrics: usageMetrics as any, - weeklyMetrics: weeklyMetrics as any, - }) - - // should have deduped to two agents - expect(out.length).toBe(2) - - const base = out.find((a) => a.id === 'base')! - expect(base.name).toBe('Base') - expect(base.weekly_spent).toBe(20) - expect(base.weekly_runs).toBe(10) - expect(base.total_spent).toBe(100) - expect(base.usage_count).toBe(50) - expect(base.avg_cost_per_invocation).toBe(2) - expect(base.unique_users).toBe(4) - expect(base.version_stats).toBeUndefined() - expect(Object.prototype.hasOwnProperty.call(base, 'version_stats')).toBe( - false, - ) - - // sorted by weekly_spent desc - expect(out[0].weekly_spent! >= out[1].weekly_spent!).toBe(true) - }) - - it('handles missing metrics gracefully and omits version_stats', () => { - // AgentRowSlim with null name (should fall back to id) - const agents: AgentRowSlim[] = [ - { - id: 'solo', - version: '0.1.0', - name: null, - description: 'no name provided', - tags: null, - created_at: new Date('2025-02-01T00:00:00.000Z'), - publisher: { - id: 'codebuff', - name: 'Codebuff', - verified: true, - avatar_url: null, - }, - }, - ] - - const out = buildAgentsDataLite({ - agents, - usageMetrics: [], - weeklyMetrics: [], - }) - - expect(out).toHaveLength(1) - const a = out[0] - // falls back to id when name missing - expect(a.name).toBe('solo') - // defaults present - expect(a.weekly_spent).toBe(0) - expect(a.weekly_runs).toBe(0) - expect(a.total_spent).toBe(0) - expect(a.usage_count).toBe(0) - expect(a.avg_cost_per_invocation).toBe(0) - expect(a.unique_users).toBe(0) - expect(a.last_used).toBeUndefined() - expect(a.version_stats).toBeUndefined() - expect(Object.prototype.hasOwnProperty.call(a, 'version_stats')).toBe(false) - expect(a.tags).toEqual([]) - // created_at normalized to string - expect(typeof a.created_at).toBe('string') - }) -}) diff --git a/web/src/server/agents-data.ts b/web/src/server/agents-data.ts index 9bbe865a3f..2236d1078c 100644 --- a/web/src/server/agents-data.ts +++ b/web/src/server/agents-data.ts @@ -4,7 +4,6 @@ import { unstable_cache } from 'next/cache' import { sql, eq, and, gte } from 'drizzle-orm' import { buildAgentsData, - buildAgentsDataLite, buildAgentsDataForSitemap, buildAgentsBasicInfo, buildAgentsMetricsMap, @@ -164,87 +163,6 @@ export const fetchAgentsWithMetrics = async (): Promise => { }) } -export const fetchAgentsWithMetricsLite = async (): Promise => { - const oneWeekAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000) - - // Only extract the specific fields we need from the data JSON blob - // This avoids fetching the entire agent config (prompts, tools, etc.) - const agentsPromise = db - .select({ - id: schema.agentConfig.id, - version: schema.agentConfig.version, - // Extract only needed fields from data JSON instead of entire blob - name: sql`${schema.agentConfig.data}->>'name'`, - description: sql`${schema.agentConfig.data}->>'description'`, - tags: sql`${schema.agentConfig.data}->'tags'`, - created_at: schema.agentConfig.created_at, - publisher: { - id: schema.publisher.id, - name: schema.publisher.name, - verified: schema.publisher.verified, - avatar_url: schema.publisher.avatar_url, - }, - }) - .from(schema.agentConfig) - .innerJoin( - schema.publisher, - eq(schema.agentConfig.publisher_id, schema.publisher.id), - ) - .orderBy(sql`${schema.agentConfig.created_at} DESC`) - - const usageMetricsPromise = db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - total_invocations: sql`COUNT(*)`, - total_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - avg_cost_per_run: sql`COALESCE(AVG(${schema.agentRun.total_credits}) / 100.0, 0)`, - unique_users: sql`COUNT(DISTINCT ${schema.agentRun.user_id})`, - last_used: sql`MAX(${schema.agentRun.created_at})`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - const weeklyMetricsPromise = db - .select({ - publisher_id: schema.agentRun.publisher_id, - agent_name: schema.agentRun.agent_name, - weekly_runs: sql`COUNT(*)`, - weekly_dollars: sql`COALESCE(SUM(${schema.agentRun.total_credits}) / 100.0, 0)`, - }) - .from(schema.agentRun) - .where( - and( - eq(schema.agentRun.status, 'completed'), - gte(schema.agentRun.created_at, oneWeekAgo), - sql`${schema.agentRun.agent_id} != 'test-agent'`, - sql`${schema.agentRun.publisher_id} IS NOT NULL`, - sql`${schema.agentRun.agent_name} IS NOT NULL`, - ), - ) - .groupBy(schema.agentRun.publisher_id, schema.agentRun.agent_name) - - const [agents, usageMetrics, weeklyMetrics] = await Promise.all([ - agentsPromise, - usageMetricsPromise, - weeklyMetricsPromise, - ]) - - return buildAgentsDataLite({ - agents, - usageMetrics, - weeklyMetrics, - }) -} - export const getCachedAgents = unstable_cache( fetchAgentsWithMetrics, ['agents-data'], @@ -254,15 +172,6 @@ export const getCachedAgents = unstable_cache( }, ) -export const getCachedAgentsLite = unstable_cache( - fetchAgentsWithMetricsLite, - ['agents-data-lite'], - { - revalidate: 600, // 10 minutes - tags: ['agents', 'store'], - }, -) - // Minimal data for sitemap - only URL components and dates, no agent data blob export interface SitemapAgentData { id: string diff --git a/web/src/server/agents-transform.ts b/web/src/server/agents-transform.ts index e04bfa224e..e87bdd6e15 100644 --- a/web/src/server/agents-transform.ts +++ b/web/src/server/agents-transform.ts @@ -308,128 +308,6 @@ export function buildAgentsData(params: { return result } -export function buildAgentsDataLite(params: { - agents: AgentRowSlim[] - usageMetrics: UsageMetricRow[] - weeklyMetrics: WeeklyMetricRow[] -}): AgentDataOut[] { - const { agents, usageMetrics, weeklyMetrics } = params - - const weeklyMap = new Map< - string, - { weekly_runs: number; weekly_dollars: number } - >() - weeklyMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - weeklyMap.set(key, { - weekly_runs: Number(metric.weekly_runs), - weekly_dollars: Number(metric.weekly_dollars), - }) - } - }) - - const metricsMap = new Map< - string, - { - weekly_runs: number - weekly_dollars: number - total_dollars: number - total_invocations: number - avg_cost_per_run: number - unique_users: number - last_used: Date | string | null - } - >() - usageMetrics.forEach((metric) => { - if (metric.publisher_id && metric.agent_name) { - const key = `${metric.publisher_id}/${metric.agent_name}` - const weeklyData = weeklyMap.get(key) || { - weekly_runs: 0, - weekly_dollars: 0, - } - metricsMap.set(key, { - weekly_runs: weeklyData.weekly_runs, - weekly_dollars: weeklyData.weekly_dollars, - total_dollars: Number(metric.total_dollars), - total_invocations: Number(metric.total_invocations), - avg_cost_per_run: Number(metric.avg_cost_per_run), - unique_users: Number(metric.unique_users), - last_used: metric.last_used ?? null, - }) - } - }) - - // With slim rows, name/description/tags are pre-extracted from the JSON - const latestAgents = new Map< - string, - { agent: AgentRowSlim; agentName: string } - >() - agents.forEach((agent) => { - const agentName = agent.name || agent.id - const key = `${agent.publisher.id}/${agentName}` - if (!latestAgents.has(key)) { - latestAgents.set(key, { agent, agentName }) - } - }) - - const result = Array.from(latestAgents.values()).map( - ({ agent, agentName }) => { - const agentKey = `${agent.publisher.id}/${agentName}` - const metrics = metricsMap.get(agentKey) || { - weekly_runs: 0, - weekly_dollars: 0, - total_dollars: 0, - total_invocations: 0, - avg_cost_per_run: 0, - unique_users: 0, - last_used: null, - } - - // Parse tags if they came as a JSON string from the database - let tags: string[] = [] - if (agent.tags) { - if (typeof agent.tags === 'string') { - try { - tags = JSON.parse(agent.tags) - } catch { - tags = [] - } - } else { - tags = agent.tags - } - } - - return { - id: agent.id, - name: agentName, - description: agent.description || undefined, - publisher: agent.publisher, - version: agent.version, - created_at: - agent.created_at instanceof Date - ? agent.created_at.toISOString() - : (agent.created_at as string), - usage_count: metrics.total_invocations, - weekly_runs: metrics.weekly_runs, - weekly_spent: metrics.weekly_dollars, - total_spent: metrics.total_dollars, - avg_cost_per_invocation: metrics.avg_cost_per_run, - unique_users: metrics.unique_users, - last_used: metrics.last_used - ? typeof metrics.last_used === 'string' - ? metrics.last_used - : metrics.last_used.toISOString() - : undefined, - tags, - } - }, - ) - - result.sort((a, b) => (b.weekly_spent || 0) - (a.weekly_spent || 0)) - return result -} - // Build basic agent info without any metrics - for lightweight initial page load export function buildAgentsBasicInfo(params: { agents: AgentRowSlim[] From fa8f915ff52faaee43c51eb1960d3d432beb44dc Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 17:41:57 -0800 Subject: [PATCH 0081/1143] fix(web): exclude healthz test from Jest to fix CI failure The healthz test uses bun:test and NextResponse.json() which requires Web API globals (Request) not available in Jest jsdom environment. Other API route tests are already excluded from Jest for similar reasons. --- web/jest.config.cjs | 1 + 1 file changed, 1 insertion(+) diff --git a/web/jest.config.cjs b/web/jest.config.cjs index 5e3e055d76..ee2434aca4 100644 --- a/web/jest.config.cjs +++ b/web/jest.config.cjs @@ -23,6 +23,7 @@ const config = { '/src/lib/__tests__/ban-conditions.test.ts', '/src/app/api/v1/.*/__tests__', '/src/app/api/agents/publish/__tests__', + '/src/app/api/healthz/__tests__', ], } From 1f89575f06184046c40bb50109dbff4fb2016211 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 16:34:16 -0800 Subject: [PATCH 0082/1143] Update faq to say you can use Claude subscription --- web/src/app/docs/[category]/[slug]/page.tsx | 5 +++++ web/src/content/help/faq.mdx | 4 ++++ 2 files changed, 9 insertions(+) diff --git a/web/src/app/docs/[category]/[slug]/page.tsx b/web/src/app/docs/[category]/[slug]/page.tsx index f81612aaac..16d601e4cf 100644 --- a/web/src/app/docs/[category]/[slug]/page.tsx +++ b/web/src/app/docs/[category]/[slug]/page.tsx @@ -32,6 +32,11 @@ const FAQ_ITEMS = [ answer: 'Multiple. The orchestrator ("Buffy") uses Claude Opus 4.5 in Default and Max modes, or Grok 4.1 Fast in Lite mode. Subagents are matched to their tasks: GPT-5.1 and Claude Opus 4.5 for code editing, Gemini 2.5 Pro for deep reasoning, Grok 4 Fast for terminal commands and research, and Relace AI for fast file rewrites.', }, + { + question: 'Can I use my Claude Pro or Max subscription with Codebuff?', + answer: + "Yes! If you have a Claude Pro or Max subscription, you can connect it to Codebuff and use your subscription for Claude model requests. This lets you save credits while still benefiting from Codebuff's intelligent orchestration. Run /connect:claude in the CLI to link your subscription.", + }, { question: 'Is Codebuff open source?', answer: diff --git a/web/src/content/help/faq.mdx b/web/src/content/help/faq.mdx index 67b2022072..d222f561ca 100644 --- a/web/src/content/help/faq.mdx +++ b/web/src/content/help/faq.mdx @@ -15,6 +15,10 @@ Software development: Writing features, tests, and scripts across common languag Multiple. The orchestrator ("Buffy") uses Claude Opus 4.5 in Default and Max modes, or Grok 4.1 Fast in Lite mode. Subagents are matched to their tasks: GPT-5.1 and Claude Opus 4.5 for code editing, Gemini 2.5 Pro for deep reasoning, Grok 4 Fast for terminal commands and research, and Relace AI for fast file rewrites. See [What models do you use?](/docs/advanced/what-models) for the full breakdown. +## Can I use my Claude Pro or Max subscription with Codebuff? + +Yes! If you have a Claude Pro or Max subscription, you can connect it to Codebuff with the command `/connect:claude`. Codebuff will use your subscription for Claude model requests, saving you credits. + ## Is Codebuff open source? Yes. It's Apache 2.0 at [github.com/CodebuffAI/codebuff](https://github.com/CodebuffAI/codebuff). From 030d4362054c2b53d2fc472ec4d988ef18b4bd2d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 18:03:56 -0800 Subject: [PATCH 0083/1143] Token count endpoint: handle non-anthropic models by using default model and adding 30% tokens --- web/src/app/api/v1/token-count/_post.ts | 26 ++++++++++++++++++++----- 1 file changed, 21 insertions(+), 5 deletions(-) diff --git a/web/src/app/api/v1/token-count/_post.ts b/web/src/app/api/v1/token-count/_post.ts index b4335fee0d..df9f83f383 100644 --- a/web/src/app/api/v1/token-count/_post.ts +++ b/web/src/app/api/v1/token-count/_post.ts @@ -1,5 +1,8 @@ import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events' -import { toAnthropicModelId } from '@codebuff/common/constants/claude-oauth' +import { + isClaudeModel, + toAnthropicModelId, +} from '@codebuff/common/constants/claude-oauth' import { getErrorObject } from '@codebuff/common/util/error' import { env } from '@codebuff/internal/env' import { NextResponse } from 'next/server' @@ -115,6 +118,9 @@ export async function postTokenCount(params: { } } +// Buffer to add to token count for non-Anthropic models since tokenizers differ +const NON_ANTHROPIC_TOKEN_BUFFER = 0.3 + async function countTokensViaAnthropic(params: { messages: TokenCountRequest['messages'] system: string | undefined @@ -128,9 +134,12 @@ async function countTokensViaAnthropic(params: { const anthropicMessages = convertToAnthropicMessages(messages) // Convert model from OpenRouter format (e.g. "anthropic/claude-opus-4.5") to Anthropic format (e.g. "claude-opus-4-5-20251101") - const anthropicModelId = model - ? toAnthropicModelId(model) - : 'claude-opus-4-5-20251101' + // For non-Anthropic models, use the default Anthropic model for token counting + const DEFAULT_ANTHROPIC_MODEL = 'claude-opus-4-5-20251101' + const isNonAnthropicModel = !model || !isClaudeModel(model) + const anthropicModelId = isNonAnthropicModel + ? DEFAULT_ANTHROPIC_MODEL + : toAnthropicModelId(model) // Use the count_tokens endpoint (beta) or make a minimal request const response = await fetch( @@ -167,7 +176,14 @@ async function countTokensViaAnthropic(params: { } const data = await response.json() - return data.input_tokens + const baseTokens = data.input_tokens + + // Add 30% buffer for non-Anthropic models since tokenizers differ + if (isNonAnthropicModel) { + return Math.ceil(baseTokens * (1 + NON_ANTHROPIC_TOKEN_BUFFER)) + } + + return baseTokens } export function convertToAnthropicMessages( From eed92fb5096c2530ddf5a5d07afa3314dfb8a9dc Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 18:26:35 -0800 Subject: [PATCH 0084/1143] feat(cli): add timeout display to terminal commands - Show timeout value next to command (e.g., "60s timeout", "2m timeout", "1h timeout") - Hide default 30s timeout to reduce visual noise - Handle edge cases: negative values, NaN, Infinity, floating points - Extract formatTimeout to shared utility for reusability - Add comprehensive unit tests for formatting logic --- .../components/terminal-command-display.tsx | 17 +++- .../__tests__/run-terminal-command.test.ts | 36 +++++++- .../components/tools/run-terminal-command.tsx | 10 +-- .../utils/__tests__/format-timeout.test.ts | 87 +++++++++++++++++++ cli/src/utils/format-timeout.ts | 28 ++++++ 5 files changed, 171 insertions(+), 7 deletions(-) create mode 100644 cli/src/utils/__tests__/format-timeout.test.ts create mode 100644 cli/src/utils/format-timeout.ts diff --git a/cli/src/components/terminal-command-display.tsx b/cli/src/components/terminal-command-display.tsx index 465a721946..a2fdc2b4c5 100644 --- a/cli/src/components/terminal-command-display.tsx +++ b/cli/src/components/terminal-command-display.tsx @@ -4,6 +4,7 @@ import { useState } from 'react' import { Button } from './button' import { useTerminalDimensions } from '../hooks/use-terminal-dimensions' import { useTheme } from '../hooks/use-theme' +import { formatTimeout } from '../utils/format-timeout' import { getLastNVisualLines } from '../utils/text-layout' interface TerminalCommandDisplayProps { @@ -17,19 +18,21 @@ interface TerminalCommandDisplayProps { isRunning?: boolean /** Working directory where the command was run */ cwd?: string + /** Timeout in seconds for the command */ + timeoutSeconds?: number } /** * Shared component for displaying terminal command with output. * Used in both the ghost message (pending bash) and message history. */ - export const TerminalCommandDisplay = ({ command, output, expandable = true, maxVisibleLines, isRunning = false, + timeoutSeconds, }: TerminalCommandDisplayProps) => { const theme = useTheme() const { contentMaxWidth } = useTerminalDimensions() @@ -40,6 +43,13 @@ export const TerminalCommandDisplay = ({ const defaultMaxLines = expandable ? 5 : 10 const maxLines = maxVisibleLines ?? defaultMaxLines + // Format timeout display - show when provided and not the default (30s) + const DEFAULT_TIMEOUT_SECONDS = 30 + const timeoutLabel = + timeoutSeconds !== undefined && timeoutSeconds !== DEFAULT_TIMEOUT_SECONDS + ? formatTimeout(timeoutSeconds) + : null + // Command header - shared between output and no-output cases const commandHeader = ( @@ -47,6 +57,11 @@ export const TerminalCommandDisplay = ({ {command} + {timeoutLabel && ( + + {' '}({timeoutLabel}) + + )} ) diff --git a/cli/src/components/tools/__tests__/run-terminal-command.test.ts b/cli/src/components/tools/__tests__/run-terminal-command.test.ts index d34dc32670..deaa20b6bc 100644 --- a/cli/src/components/tools/__tests__/run-terminal-command.test.ts +++ b/cli/src/components/tools/__tests__/run-terminal-command.test.ts @@ -8,11 +8,12 @@ import type { ToolBlock } from '../types' const createToolBlock = ( command: string, output?: string, + timeoutSeconds?: number, ): ToolBlock & { toolName: 'run_terminal_command' } => ({ type: 'tool', toolName: 'run_terminal_command', toolCallId: 'test-tool-call-id', - input: { command }, + input: { command, ...(timeoutSeconds !== undefined && { timeout_seconds: timeoutSeconds }) }, output, }) @@ -144,6 +145,39 @@ describe('RunTerminalCommandComponent', () => { }) }) + describe('timeout extraction', () => { + const mockTheme = {} as any + const mockOptions = { + availableWidth: 80, + indentationOffset: 0, + labelWidth: 10, + } + + test('passes undefined timeoutSeconds when timeout_seconds not provided', () => { + const toolBlock = createToolBlock('ls -la', createJsonOutput('output')) + + const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions) + + expect((result.content as any).props.timeoutSeconds).toBeUndefined() + }) + + test('passes timeoutSeconds for positive timeout', () => { + const toolBlock = createToolBlock('npm test', createJsonOutput('tests passed'), 60) + + const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions) + + expect((result.content as any).props.timeoutSeconds).toBe(60) + }) + + test('passes timeoutSeconds for no timeout (-1)', () => { + const toolBlock = createToolBlock('long-running-task', createJsonOutput('done'), -1) + + const result = RunTerminalCommandComponent.render(toolBlock, mockTheme, mockOptions) + + expect((result.content as any).props.timeoutSeconds).toBe(-1) + }) + }) + describe('parseTerminalOutput', () => { test('handles error messages', () => { const errorPayload = JSON.stringify([ diff --git a/cli/src/components/tools/run-terminal-command.tsx b/cli/src/components/tools/run-terminal-command.tsx index 6c630d39e3..c8fc491851 100644 --- a/cli/src/components/tools/run-terminal-command.tsx +++ b/cli/src/components/tools/run-terminal-command.tsx @@ -50,11 +50,10 @@ export const RunTerminalCommandComponent = defineToolComponent({ toolName: 'run_terminal_command', render(toolBlock): ToolRenderConfig { - // Extract command from input - const command = - toolBlock.input && typeof (toolBlock.input as any).command === 'string' - ? (toolBlock.input as any).command.trim() - : '' + // Extract command and timeout from input + const input = toolBlock.input as { command?: string; timeout_seconds?: number } | undefined + const command = typeof input?.command === 'string' ? input.command.trim() : '' + const timeoutSeconds = typeof input?.timeout_seconds === 'number' ? input.timeout_seconds : undefined // Extract output and startingCwd from tool result const { output, startingCwd } = parseTerminalOutput(toolBlock.output) @@ -67,6 +66,7 @@ export const RunTerminalCommandComponent = defineToolComponent({ expandable={true} maxVisibleLines={5} cwd={startingCwd} + timeoutSeconds={timeoutSeconds} /> ) diff --git a/cli/src/utils/__tests__/format-timeout.test.ts b/cli/src/utils/__tests__/format-timeout.test.ts new file mode 100644 index 0000000000..78127e03fd --- /dev/null +++ b/cli/src/utils/__tests__/format-timeout.test.ts @@ -0,0 +1,87 @@ +import { describe, expect, test } from 'bun:test' + +import { formatTimeout } from '../format-timeout' + +describe('formatTimeout', () => { + describe('normal values', () => { + test('returns seconds for values less than 60', () => { + expect(formatTimeout(10)).toBe('10s timeout') + expect(formatTimeout(30)).toBe('30s timeout') + expect(formatTimeout(45)).toBe('45s timeout') + }) + + test('returns minutes for values evenly divisible by 60', () => { + expect(formatTimeout(60)).toBe('1m timeout') + expect(formatTimeout(120)).toBe('2m timeout') + expect(formatTimeout(300)).toBe('5m timeout') + }) + + test('returns hours for values evenly divisible by 3600', () => { + expect(formatTimeout(3600)).toBe('1h timeout') + expect(formatTimeout(7200)).toBe('2h timeout') + expect(formatTimeout(10800)).toBe('3h timeout') + }) + + test('returns minutes for large values divisible by 60 but not 3600', () => { + expect(formatTimeout(5400)).toBe('90m timeout') + }) + + test('returns seconds for large values not evenly divisible by 60', () => { + expect(formatTimeout(3700)).toBe('3700s timeout') + }) + + test('returns seconds for values >= 60 not evenly divisible by 60', () => { + expect(formatTimeout(90)).toBe('90s timeout') + expect(formatTimeout(150)).toBe('150s timeout') + }) + + test('returns "0s timeout" for 0', () => { + expect(formatTimeout(0)).toBe('0s timeout') + }) + }) + + describe('negative values', () => { + test('returns "no timeout" for -1', () => { + expect(formatTimeout(-1)).toBe('no timeout') + }) + + test('returns "no timeout" for other negative values', () => { + expect(formatTimeout(-5)).toBe('no timeout') + expect(formatTimeout(-100)).toBe('no timeout') + expect(formatTimeout(-0.5)).toBe('no timeout') + }) + }) + + describe('non-finite values', () => { + test('returns "no timeout" for NaN', () => { + expect(formatTimeout(NaN)).toBe('no timeout') + }) + + test('returns "no timeout" for Infinity', () => { + expect(formatTimeout(Infinity)).toBe('no timeout') + }) + + test('returns "no timeout" for -Infinity', () => { + expect(formatTimeout(-Infinity)).toBe('no timeout') + }) + }) + + describe('floating point values', () => { + test('rounds floating point values to nearest integer', () => { + expect(formatTimeout(30.4)).toBe('30s timeout') + expect(formatTimeout(30.5)).toBe('31s timeout') + expect(formatTimeout(30.9)).toBe('31s timeout') + }) + + test('rounds floating point values for minute display', () => { + expect(formatTimeout(59.5)).toBe('1m timeout') + expect(formatTimeout(60.4)).toBe('1m timeout') + expect(formatTimeout(119.6)).toBe('2m timeout') + }) + + test('handles floating point values that round to non-minute values', () => { + expect(formatTimeout(60.6)).toBe('61s timeout') + expect(formatTimeout(89.5)).toBe('90s timeout') + }) + }) +}) diff --git a/cli/src/utils/format-timeout.ts b/cli/src/utils/format-timeout.ts new file mode 100644 index 0000000000..73f9cd454f --- /dev/null +++ b/cli/src/utils/format-timeout.ts @@ -0,0 +1,28 @@ +/** + * Formats a timeout value for display. + * - Returns "no timeout" for non-finite values (NaN, Infinity, -Infinity) + * - Returns "no timeout" for negative values (including -1) + * - Returns hours (e.g., "1h timeout") for values >= 3600 that are evenly divisible by 3600 + * - Returns minutes (e.g., "2m timeout") for values >= 60 that are evenly divisible by 60 + * - Returns seconds (e.g., "90s timeout") otherwise + * - Rounds floating point values to nearest integer + */ +export function formatTimeout(timeoutSeconds: number): string { + // Handle NaN, Infinity, -Infinity + if (!Number.isFinite(timeoutSeconds)) { + return 'no timeout' + } + // Handle all negative values (including -1) + if (timeoutSeconds < 0) { + return 'no timeout' + } + // Round floating point values + const rounded = Math.round(timeoutSeconds) + if (rounded >= 3600 && rounded % 3600 === 0) { + return `${rounded / 3600}h timeout` + } + if (rounded >= 60 && rounded % 60 === 0) { + return `${rounded / 60}m timeout` + } + return `${rounded}s timeout` +} From ada908f13952d48b1c3890dccc7475b384470522 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Wed, 21 Jan 2026 19:43:23 -0800 Subject: [PATCH 0085/1143] fix(cli): resolve masonry grid disappearing on terminal resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes bug where parallel subagents in masonry grid would disappear when terminal shrinks dramatically (2→1 column transition). Root cause: Different DOM structures for single vs multi-column layouts caused React reconciliation issues during resize. Flex columns with minWidth: 0 could collapse to zero before React re-rendered. Fix: - Use unified DOM structure for all column counts - Set minWidth: MIN_COLUMN_WIDTH to prevent column collapse - Upgrade @opentui/core and @opentui/react from 0.1.70 to 0.1.74 Also: - Add React reconciliation integration tests for resize transitions - Document the fix pattern in cli/knowledge.md --- bun.lock | 20 +- cli/knowledge.md | 50 +++ cli/package.json | 4 +- .../grid-layout.integration.test.tsx | 304 +++++++++++++ .../components/__tests__/grid-layout.test.tsx | 423 ++++++++++++++++++ cli/src/components/grid-layout.tsx | 37 +- .../hooks/__tests__/use-grid-layout.test.ts | 10 +- cli/src/hooks/use-grid-layout.ts | 7 +- 8 files changed, 810 insertions(+), 45 deletions(-) create mode 100644 cli/src/components/__tests__/grid-layout.integration.test.tsx diff --git a/bun.lock b/bun.lock index fba88feab8..c99b6f462a 100644 --- a/bun.lock +++ b/bun.lock @@ -52,8 +52,8 @@ "dependencies": { "@codebuff/sdk": "workspace:*", "@gravity-ai/api": "^0.1.2", - "@opentui/core": "^0.1.70", - "@opentui/react": "^0.1.70", + "@opentui/core": "0.1.74", + "@opentui/react": "0.1.74", "@tanstack/react-query": "^5.90.12", "commander": "^14.0.1", "immer": "^10.1.3", @@ -964,21 +964,21 @@ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.38.0", "", {}, "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg=="], - "@opentui/core": ["@opentui/core@0.1.70", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.70", "@opentui/core-darwin-x64": "0.1.70", "@opentui/core-linux-arm64": "0.1.70", "@opentui/core-linux-x64": "0.1.70", "@opentui/core-win32-arm64": "0.1.70", "@opentui/core-win32-x64": "0.1.70", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-6cPAlbCnaiUUtQtvZNpkr0Xv8AdVAgJuy2VAwIsDN1pIv0zMpa0ZG+mr7afCGygw1eeDRveefrjfgFAB1r0SVw=="], + "@opentui/core": ["@opentui/core@0.1.74", "", { "dependencies": { "bun-ffi-structs": "0.1.2", "diff": "8.0.2", "jimp": "1.6.0", "yoga-layout": "3.2.1" }, "optionalDependencies": { "@dimforge/rapier2d-simd-compat": "^0.17.3", "@opentui/core-darwin-arm64": "0.1.74", "@opentui/core-darwin-x64": "0.1.74", "@opentui/core-linux-arm64": "0.1.74", "@opentui/core-linux-x64": "0.1.74", "@opentui/core-win32-arm64": "0.1.74", "@opentui/core-win32-x64": "0.1.74", "bun-webgpu": "0.1.4", "planck": "^1.4.2", "three": "0.177.0" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-g4W16ymv12JdgZ+9B4t7mpIICvzWy2+eHERfmDf80ALduOQCUedKQdULcBFhVCYUXIkDRtIy6CID5thMAah3FA=="], - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.70", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rM8EnvW1tOAXWnp2Iy2M82I+ViSmRwUagx3v1/ni6N8GCcw/3mE0C6eB3sVlYNXVMwBEgiKpWFn85RCe4+qXQw=="], + "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.1.74", "", { "os": "darwin", "cpu": "arm64" }, "sha512-rfmlDLtm/u17CnuhJgCxPeYMvOST+A2MOdVOk46IurtHO849bdYqK6iudKNlFRs1FOrymgSKF9GlWBHAOKeRjg=="], - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.70", "", { "os": "darwin", "cpu": "x64" }, "sha512-XdBgW+em8J+YGSUpaKF8/NxPjikJygK3dIkeMAw5xQ2lt7jXKxeM5MMmN/V4MfK3pLMtO56rLJlXaLH/h50uQA=="], + "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.1.74", "", { "os": "darwin", "cpu": "x64" }, "sha512-WAD8orsDV0ZdW/5GwjOOB4FY96772xbkz+rcV7WRzEFUVaqoBaC04IuqYzS9d5s+cjkbT5Cpj47hrVYkkVQKng=="], - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.70", "", { "os": "linux", "cpu": "arm64" }, "sha512-oSVWNMSOx0Na0M0LCqtWCxeh4SuLSK5lg8ZwVzsEoimIAxh0snp9nRUo/Qi8yD9BP0DSDmXuM/B3ONtzFaf0dw=="], + "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.1.74", "", { "os": "linux", "cpu": "arm64" }, "sha512-lgmHzrzLy4e+rgBS+lhtsMLLgIMLbtLNMm6EzVPyYVDlLDGjM7+ulXMem7AtpaRrWrUUl4REiG9BoQUsCFDwYA=="], - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.70", "", { "os": "linux", "cpu": "x64" }, "sha512-WUrhukefMghcZ7sAjkxEy50vA6ii0X21xh7m8c4omXyYYfQXyDs25pNExB8cwoCrZEaC8RTlF4lRSNPIXsZKhA=="], + "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.1.74", "", { "os": "linux", "cpu": "x64" }, "sha512-8Mn2WbdBQ29xCThuPZezjDhd1N3+fXwKkGvCBOdTI0le6h2A/vCNbfUVjwfr/EGZSRXxCG+Yapol34BAULGpOA=="], - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.70", "", { "os": "win32", "cpu": "arm64" }, "sha512-p1K2VJXGmZqSV7mR61v7KJpT1Zth7DS99wEtaqqfK68OWt33K2XxLmGO0KD142R2JLfXu32NnRmBHxmVx8IjBA=="], + "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.1.74", "", { "os": "win32", "cpu": "arm64" }, "sha512-dvYUXz03avnI6ZluyLp00HPmR0UT/IE/6QS97XBsgJlUTtpnbKkBtB5jD1NHwWkElaRj1Qv2QP36ngFoJqbl9g=="], - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.70", "", { "os": "win32", "cpu": "x64" }, "sha512-G6b8te1twMeDhjg1oZa0IcUjhOJZFCSdlQt+q5gu5vVtjCrIwAn9o7m5EwNMPakc31pDWUZ7v0ktgv0Xw1AQVA=="], + "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.1.74", "", { "os": "win32", "cpu": "x64" }, "sha512-3wfWXaAKOIlDQz6ZZIESf2M+YGZ7uFHijjTEM8w/STRlLw8Y6+QyGYi1myHSM4d6RSO+/s2EMDxvjDf899W9vQ=="], - "@opentui/react": ["@opentui/react@0.1.70", "", { "dependencies": { "@opentui/core": "0.1.70", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-pOADUf5nipBnp7p8z/IsIm0XvVXN6zu2DVYDTbRi1JbtL8Gg8MV8iq8CDaxYjyMMEb9Bv8oZ2MlZgv1aliR/fg=="], + "@opentui/react": ["@opentui/react@0.1.74", "", { "dependencies": { "@opentui/core": "0.1.74", "react-reconciler": "^0.32.0" }, "peerDependencies": { "react": ">=19.0.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-2wiTVtBcbjNuWJjVDaSNdfVM9x9Cs7U+wCRPMmzVrYYCbWGjYQcA0Ump+XSKJpN+swzZRDBYHIw9xBlgUUnoLw=="], "@panva/hkdf": ["@panva/hkdf@1.2.1", "", {}, "sha512-6oclG6Y3PiDFcoyk8srjLfVKyMfVCKJ27JwNPViuXziFpmdz+MZnZN/aKY0JGXgYuO/VghU0jcOAZgWXZ1Dmrw=="], diff --git a/cli/knowledge.md b/cli/knowledge.md index a084836a50..144551d01a 100644 --- a/cli/knowledge.md +++ b/cli/knowledge.md @@ -154,6 +154,56 @@ For columns that share space equally within a container, use the **flex trio pat - Use `width: '100%'` (string) for parent containers, not numeric values - `alignItems: 'flex-start'` prevents children from stretching to fill row height +### Resize Transitions: Unified DOM Structure + +**Problem**: When terminal resizes cause column count changes (e.g., 2→1 columns), content can disappear if the component renders different DOM structures for different column counts. + +**Root cause**: When transitioning from multi-column to single-column: +1. The multi-column flex structure renders with shrinking width +2. Flex columns with `minWidth: 0` collapse to zero width +3. Content disappears before React can re-render with the new single-column structure + +**Solution**: Use a **unified DOM structure** for all column counts + defensive `minWidth`: + +```tsx +// ✅ CORRECT: Same structure for 1, 2, 3, or N columns +const isMultiColumn = columns > 1 + + + {columnGroups.map((columnItems, idx) => ( + + {/* Column content */} + + ))} + +``` + +**Why this works:** +1. **Unified structure** = React doesn't need to reconcile different DOM trees during transitions +2. **`minWidth: MIN_COLUMN_WIDTH`** = columns can't collapse to zero during the brief resize window +3. Overflow protection in the layout hook handles edge cases by reducing columns when needed + +**Anti-pattern:** +```tsx +// ❌ WRONG: Different DOM structures for different column counts +if (columns === 1) { + return // Different structure! +} else { + return // React must reconcile between these +} +``` + +The key insight: during resize, there's a timing window where the old structure is rendered with new (smaller) dimensions. A unified structure with defensive `minWidth` survives this window gracefully. + ## OpenTUI Text Rendering Constraints **CRITICAL**: OpenTUI has strict requirements for text rendering that must be followed: diff --git a/cli/package.json b/cli/package.json index 90380ae092..51d54a4dca 100644 --- a/cli/package.json +++ b/cli/package.json @@ -30,8 +30,8 @@ "dependencies": { "@codebuff/sdk": "workspace:*", "@gravity-ai/api": "^0.1.2", - "@opentui/core": "^0.1.70", - "@opentui/react": "^0.1.70", + "@opentui/core": "0.1.74", + "@opentui/react": "0.1.74", "@tanstack/react-query": "^5.90.12", "commander": "^14.0.1", "immer": "^10.1.3", diff --git a/cli/src/components/__tests__/grid-layout.integration.test.tsx b/cli/src/components/__tests__/grid-layout.integration.test.tsx new file mode 100644 index 0000000000..c7ba81215b --- /dev/null +++ b/cli/src/components/__tests__/grid-layout.integration.test.tsx @@ -0,0 +1,304 @@ +/** + * Integration tests for GridLayout React reconciliation during resize. + * + * These tests verify that the unified DOM structure fix properly handles + * column transitions (2→1) without losing content during React reconciliation. + * + * Unlike the static rendering tests in grid-layout.test.tsx, these tests + * simulate actual re-renders with changing props to catch reconciliation bugs. + */ +import { describe, test, expect } from 'bun:test' +import React, { useState, useCallback, useRef, useEffect } from 'react' +import { renderToString } from 'react-dom/server' + +import { GridLayout } from '../grid-layout' + +interface TestItem { + id: string + name: string +} + +const createTestItem = (id: string, name: string): TestItem => ({ id, name }) + +/** + * Test wrapper that simulates resize by rendering at multiple widths + * and tracking which items were rendered at each width. + */ +interface RenderTracker { + renderedItems: Map // width -> item names rendered + renderCounts: Map // item id -> render count +} + +function createRenderTracker(): RenderTracker { + return { + renderedItems: new Map(), + renderCounts: new Map(), + } +} + +/** + * Component that renders GridLayout and tracks rendered items. + * This simulates what happens during actual React reconciliation. + */ +function TrackedGridLayout({ + items, + availableWidth, + tracker, +}: { + items: TestItem[] + availableWidth: number + tracker: RenderTracker +}) { + const renderItem = useCallback( + (item: TestItem, _idx: number, _columnWidth: number) => { + // Track this item was rendered + const currentCount = tracker.renderCounts.get(item.id) || 0 + tracker.renderCounts.set(item.id, currentCount + 1) + + // Track items rendered at this width + const widthItems = tracker.renderedItems.get(availableWidth) || [] + if (!widthItems.includes(item.name)) { + widthItems.push(item.name) + tracker.renderedItems.set(availableWidth, widthItems) + } + + return {item.name} + }, + [availableWidth, tracker], + ) + + const getItemKey = useCallback((item: TestItem) => item.id, []) + + return ( + + ) +} + +describe('GridLayout React Reconciliation', () => { + describe('column transition (2→1) reconciliation', () => { + test('all items survive rerender when width changes from 120 to 80', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + const tracker = createRenderTracker() + + // First render at 2-column width (120) + const markup1 = renderToString( + , + ) + + // Verify all items rendered at width 120 (order may vary due to round-robin distribution) + expect(tracker.renderedItems.get(120)?.sort()).toEqual(['Alpha', 'Beta', 'Gamma']) + expect(markup1).toContain('Alpha') + expect(markup1).toContain('Beta') + expect(markup1).toContain('Gamma') + + // Second render at 1-column width (80) - simulates resize + const markup2 = renderToString( + , + ) + + // Verify all items rendered at width 80 + expect(tracker.renderedItems.get(80)?.sort()).toEqual(['Alpha', 'Beta', 'Gamma']) + expect(markup2).toContain('Alpha') + expect(markup2).toContain('Beta') + expect(markup2).toContain('Gamma') + + // Verify each item was rendered exactly twice (once per width) + expect(tracker.renderCounts.get('a')).toBe(2) + expect(tracker.renderCounts.get('b')).toBe(2) + expect(tracker.renderCounts.get('c')).toBe(2) + }) + + test('item order is preserved after 2→1 transition', () => { + const items = [ + createTestItem('1', 'First'), + createTestItem('2', 'Second'), + createTestItem('3', 'Third'), + createTestItem('4', 'Fourth'), + ] + const tracker = createRenderTracker() + + // Render at 2-column width first + renderToString( + , + ) + + // Then render at 1-column width + const markup = renderToString( + , + ) + + // Check order in final markup + const firstPos = markup.indexOf('First') + const secondPos = markup.indexOf('Second') + const thirdPos = markup.indexOf('Third') + const fourthPos = markup.indexOf('Fourth') + + expect(firstPos).toBeLessThan(secondPos) + expect(secondPos).toBeLessThan(thirdPos) + expect(thirdPos).toBeLessThan(fourthPos) + }) + + test('multiple rapid width changes preserve all items', () => { + const items = [ + createTestItem('a', 'Apple'), + createTestItem('b', 'Banana'), + createTestItem('c', 'Cherry'), + ] + const tracker = createRenderTracker() + + // Simulate rapid resize: 2-col → 1-col → 2-col → 1-col → 2-col + const widthSequence = [120, 80, 120, 80, 120] + + for (const width of widthSequence) { + const markup = renderToString( + , + ) + + // Every render should contain all items + expect(markup).toContain('Apple') + expect(markup).toContain('Banana') + expect(markup).toContain('Cherry') + } + + // Verify items were rendered correct number of times + // 5 renders total, each item should be rendered 5 times + expect(tracker.renderCounts.get('a')).toBe(5) + expect(tracker.renderCounts.get('b')).toBe(5) + expect(tracker.renderCounts.get('c')).toBe(5) + }) + + test('3→2→1 column transition preserves all items', () => { + const items = [ + createTestItem('a', 'One'), + createTestItem('b', 'Two'), + createTestItem('c', 'Three'), + createTestItem('d', 'Four'), + createTestItem('e', 'Five'), + createTestItem('f', 'Six'), + ] + const tracker = createRenderTracker() + + // Start at 3-column width (150+) + renderToString( + , + ) + expect(tracker.renderedItems.get(180)?.length).toBe(6) + + // Transition to 2-column width (100-149) + renderToString( + , + ) + expect(tracker.renderedItems.get(120)?.length).toBe(6) + + // Transition to 1-column width (<100) + const finalMarkup = renderToString( + , + ) + expect(tracker.renderedItems.get(80)?.length).toBe(6) + + // All items present in final render + expect(finalMarkup).toContain('One') + expect(finalMarkup).toContain('Two') + expect(finalMarkup).toContain('Three') + expect(finalMarkup).toContain('Four') + expect(finalMarkup).toContain('Five') + expect(finalMarkup).toContain('Six') + }) + + test('1→2 column expansion also works correctly', () => { + const items = [ + createTestItem('x', 'Xray'), + createTestItem('y', 'Yankee'), + createTestItem('z', 'Zulu'), + ] + const tracker = createRenderTracker() + + // Start at 1-column width + renderToString( + , + ) + expect(tracker.renderedItems.get(80)?.sort()).toEqual(['Xray', 'Yankee', 'Zulu']) + + // Expand to 2-column width + const expandedMarkup = renderToString( + , + ) + expect(tracker.renderedItems.get(120)?.sort()).toEqual(['Xray', 'Yankee', 'Zulu']) + + // All items present + expect(expandedMarkup).toContain('Xray') + expect(expandedMarkup).toContain('Yankee') + expect(expandedMarkup).toContain('Zulu') + }) + }) + + describe('unified DOM structure verification', () => { + test('both column layouts produce valid markup', () => { + const items = [ + createTestItem('a', 'Item1'), + createTestItem('b', 'Item2'), + ] + + // 2-column layout + const twoColMarkup = renderToString( + item.id} + renderItem={(item) => {item.name}} + />, + ) + + // 1-column layout + const oneColMarkup = renderToString( + item.id} + renderItem={(item) => {item.name}} + />, + ) + + // Both should produce valid, non-empty markup + expect(twoColMarkup.length).toBeGreaterThan(0) + expect(oneColMarkup.length).toBeGreaterThan(0) + + // Both should contain the items + expect(twoColMarkup).toContain('Item1') + expect(twoColMarkup).toContain('Item2') + expect(oneColMarkup).toContain('Item1') + expect(oneColMarkup).toContain('Item2') + }) + + test('no items lost even with dramatic width reduction', () => { + const items = Array.from({ length: 10 }, (_, i) => + createTestItem(`item-${i}`, `Content${i}`), + ) + const tracker = createRenderTracker() + + // Start at 4-column width (200+) + renderToString( + , + ) + + // Dramatically reduce to 1-column + const finalMarkup = renderToString( + , + ) + + // All 10 items should be present + for (let i = 0; i < 10; i++) { + expect(finalMarkup).toContain(`Content${i}`) + } + }) + }) +}) diff --git a/cli/src/components/__tests__/grid-layout.test.tsx b/cli/src/components/__tests__/grid-layout.test.tsx index 243ca0ddc8..a599077dae 100644 --- a/cli/src/components/__tests__/grid-layout.test.tsx +++ b/cli/src/components/__tests__/grid-layout.test.tsx @@ -528,6 +528,429 @@ describe('GridLayout', () => { }) }) + describe('narrow terminal rendering', () => { + test('renders all items with very narrow width (15 chars)', () => { + const items = [ + createTestItem('a', 'Item A'), + createTestItem('b', 'Item B'), + createTestItem('c', 'Item C'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('Item A') + expect(markup).toContain('Item B') + expect(markup).toContain('Item C') + }) + + test('renders all items with narrow width (20 chars)', () => { + const items = [ + createTestItem('a', 'First'), + createTestItem('b', 'Second'), + createTestItem('c', 'Third'), + createTestItem('d', 'Fourth'), + ] + + const markup = renderToStaticMarkup( + , + ) + + expect(markup).toContain('First') + expect(markup).toContain('Second') + expect(markup).toContain('Third') + expect(markup).toContain('Fourth') + }) + + test('uses single column for narrow width with multiple items', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + const widths: number[] = [] + + renderToStaticMarkup( + { + widths.push(width) + return {item.name} + }} + />, + ) + + // All items should receive the full availableWidth (single column) + expect(widths).toEqual([18, 18, 18]) + }) + + test('renders items in correct order with narrow width', () => { + const items = [ + createTestItem('a', 'One'), + createTestItem('b', 'Two'), + createTestItem('c', 'Three'), + createTestItem('d', 'Four'), + ] + + const markup = renderToStaticMarkup( + , + ) + + const onePos = markup.indexOf('One') + const twoPos = markup.indexOf('Two') + const threePos = markup.indexOf('Three') + const fourPos = markup.indexOf('Four') + + expect(onePos).toBeLessThan(twoPos) + expect(twoPos).toBeLessThan(threePos) + expect(threePos).toBeLessThan(fourPos) + }) + + test('handles boundary width (21 chars) - still single column due to threshold', () => { + const items = [ + createTestItem('a', 'A'), + createTestItem('b', 'B'), + ] + const widths: number[] = [] + + renderToStaticMarkup( + { + widths.push(width) + return {item.name} + }} + />, + ) + + // 21 passes the minWidthForTwoColumns check (21 >= 21), but + // maxColumns is still 1 because 21 < WIDTH_MD_THRESHOLD (100) + // So it uses single column with full availableWidth + expect(widths[0]).toBe(21) + expect(widths[1]).toBe(21) + }) + + test('forces single column when width is just below threshold (20 chars)', () => { + const items = [ + createTestItem('a', 'A'), + createTestItem('b', 'B'), + ] + const widths: number[] = [] + + renderToStaticMarkup( + { + widths.push(width) + return {item.name} + }} + />, + ) + + // 20 is below minWidthForTwoColumns (21), so single column + // columnWidth = availableWidth = 20 + expect(widths[0]).toBe(20) + expect(widths[1]).toBe(20) + }) + }) + + describe('column transition (2→1)', () => { + // These tests verify the fix for the resize bug where content would disappear + // when transitioning from 2 columns to 1 column during terminal resize. + // The fix uses a unified DOM structure for all column counts. + + test('all items render when transitioning from 2-column to 1-column width', () => { + const items = [ + createTestItem('a', 'Alpha'), + createTestItem('b', 'Beta'), + createTestItem('c', 'Gamma'), + ] + + // First render at 2-column width (120 is in the 100-149 range = 2 columns max) + const twoColumnMarkup = renderToStaticMarkup( + , + ) + + // Then render at 1-column width (80 is below 100 = 1 column) + const oneColumnMarkup = renderToStaticMarkup( + , + ) + + // All items should be present in both renders + expect(twoColumnMarkup).toContain('Alpha') + expect(twoColumnMarkup).toContain('Beta') + expect(twoColumnMarkup).toContain('Gamma') + + expect(oneColumnMarkup).toContain('Alpha') + expect(oneColumnMarkup).toContain('Beta') + expect(oneColumnMarkup).toContain('Gamma') + }) + + test('items maintain correct order during 2→1 transition', () => { + const items = [ + createTestItem('a', 'First'), + createTestItem('b', 'Second'), + createTestItem('c', 'Third'), + createTestItem('d', 'Fourth'), + ] + + // Render at 1-column width (simulating post-transition state) + const markup = renderToStaticMarkup( + , + ) + + const firstPos = markup.indexOf('First') + const secondPos = markup.indexOf('Second') + const thirdPos = markup.indexOf('Third') + const fourthPos = markup.indexOf('Fourth') + + // Items should be in order in single-column mode + expect(firstPos).toBeLessThan(secondPos) + expect(secondPos).toBeLessThan(thirdPos) + expect(thirdPos).toBeLessThan(fourthPos) + }) + + test('same items rendered in both 2-column and 1-column layouts', () => { + const items = [ + createTestItem('item-1', 'Apple'), + createTestItem('item-2', 'Banana'), + createTestItem('item-3', 'Cherry'), + ] + + const twoColumnMarkup = renderToStaticMarkup( + , + ) + + const oneColumnMarkup = renderToStaticMarkup( + , + ) + + // Extract item names from both renders - they should be identical sets + const itemNames = ['Apple', 'Banana', 'Cherry'] + for (const name of itemNames) { + expect(twoColumnMarkup).toContain(name) + expect(oneColumnMarkup).toContain(name) + } + }) + + test('transition works with 2 items', () => { + const items = [ + createTestItem('a', 'One'), + createTestItem('b', 'Two'), + ] + + // 2-column layout + const twoCol = renderToStaticMarkup( + , + ) + + // 1-column layout + const oneCol = renderToStaticMarkup( + , + ) + + expect(twoCol).toContain('One') + expect(twoCol).toContain('Two') + expect(oneCol).toContain('One') + expect(oneCol).toContain('Two') + }) + + test('transition works with 3 items', () => { + const items = [ + createTestItem('a', 'Red'), + createTestItem('b', 'Green'), + createTestItem('c', 'Blue'), + ] + + const twoCol = renderToStaticMarkup( + , + ) + + const oneCol = renderToStaticMarkup( + , + ) + + expect(twoCol).toContain('Red') + expect(twoCol).toContain('Green') + expect(twoCol).toContain('Blue') + expect(oneCol).toContain('Red') + expect(oneCol).toContain('Green') + expect(oneCol).toContain('Blue') + }) + + test('transition works with 4 items', () => { + const items = [ + createTestItem('a', 'North'), + createTestItem('b', 'South'), + createTestItem('c', 'East'), + createTestItem('d', 'West'), + ] + + const twoCol = renderToStaticMarkup( + , + ) + + const oneCol = renderToStaticMarkup( + , + ) + + expect(twoCol).toContain('North') + expect(twoCol).toContain('South') + expect(twoCol).toContain('East') + expect(twoCol).toContain('West') + expect(oneCol).toContain('North') + expect(oneCol).toContain('South') + expect(oneCol).toContain('East') + expect(oneCol).toContain('West') + }) + + test('columnWidth is passed correctly in both layouts', () => { + const items = [ + createTestItem('a', 'A'), + createTestItem('b', 'B'), + ] + + const twoColWidths: number[] = [] + const oneColWidths: number[] = [] + + renderToStaticMarkup( + { + twoColWidths.push(width) + return {item.name} + }} + />, + ) + + renderToStaticMarkup( + { + oneColWidths.push(width) + return {item.name} + }} + />, + ) + + // 2-column: (120 - 1 gap) / 2 = 59.5 -> 59 + expect(twoColWidths[0]).toBe(59) + expect(twoColWidths[1]).toBe(59) + + // 1-column: full width + expect(oneColWidths[0]).toBe(80) + expect(oneColWidths[1]).toBe(80) + }) + + test('unified structure handles rapid width changes', () => { + const items = [ + createTestItem('a', 'Item1'), + createTestItem('b', 'Item2'), + createTestItem('c', 'Item3'), + ] + + // Simulate rapid resize: 2-col -> 1-col -> 2-col -> 1-col + const widths = [120, 80, 120, 80] + + for (const width of widths) { + const markup = renderToStaticMarkup( + , + ) + + // All items should always be present regardless of width + expect(markup).toContain('Item1') + expect(markup).toContain('Item2') + expect(markup).toContain('Item3') + } + }) + }) + describe('edge cases', () => { test('handles very narrow width', () => { const items = [createTestItem('item-1', 'Narrow')] diff --git a/cli/src/components/grid-layout.tsx b/cli/src/components/grid-layout.tsx index 1897782f6d..606b115b69 100644 --- a/cli/src/components/grid-layout.tsx +++ b/cli/src/components/grid-layout.tsx @@ -1,6 +1,7 @@ import React, { memo, type ReactNode } from 'react' import { useGridLayout } from '../hooks/use-grid-layout' +import { MIN_COLUMN_WIDTH } from '../utils/layout-helpers' export interface GridLayoutProps { items: T[] @@ -23,35 +24,15 @@ function GridLayoutInner({ if (items.length === 0) return null - // Single column layout - if (columns === 1) { - return ( - - - {items.map((item, idx) => ( - - {renderItem(item, idx, availableWidth)} - - ))} - - {footer} - - ) - } + // Unified structure for both single and multi-column layouts + // Using a consistent DOM structure prevents reconciliation issues during resize transitions + const isMultiColumn = columns > 1 - // Multi-column layout return ( ({ ({ flexGrow: 1, flexShrink: 1, flexBasis: 0, - minWidth: 0, + // Use MIN_COLUMN_WIDTH instead of 0 to prevent columns from collapsing + // to zero during resize transitions (prevents 2→1 column transition bug) + minWidth: MIN_COLUMN_WIDTH, }} > {columnItems.map((item, idx) => ( - + {renderItem(item, idx, columnWidth)} ))} diff --git a/cli/src/hooks/__tests__/use-grid-layout.test.ts b/cli/src/hooks/__tests__/use-grid-layout.test.ts index daf4db53b4..5870f81065 100644 --- a/cli/src/hooks/__tests__/use-grid-layout.test.ts +++ b/cli/src/hooks/__tests__/use-grid-layout.test.ts @@ -291,16 +291,18 @@ describe('computeGridLayout', () => { expect(result.columnWidth).toBe(5) }) - test('zero availableWidth', () => { + test('zero availableWidth clamps columnWidth to 1', () => { const result = computeGridLayout(['a'], 0) expect(result.columns).toBe(1) - expect(result.columnWidth).toBe(0) + // columnWidth is clamped to at least 1 to prevent layout issues + expect(result.columnWidth).toBe(1) }) - test('negative availableWidth', () => { + test('negative availableWidth clamps columnWidth to 1', () => { const result = computeGridLayout(['a'], -10) expect(result.columns).toBe(1) - expect(result.columnWidth).toBe(-10) + // columnWidth is clamped to at least 1 to prevent layout issues + expect(result.columnWidth).toBe(1) }) test('large number of items', () => { diff --git a/cli/src/hooks/use-grid-layout.ts b/cli/src/hooks/use-grid-layout.ts index 0223aa4803..f8514e6f79 100644 --- a/cli/src/hooks/use-grid-layout.ts +++ b/cli/src/hooks/use-grid-layout.ts @@ -23,21 +23,24 @@ export interface GridLayoutResult { columnGroups: T[][] } +/** Gap between columns in multi-column layout */ +const COLUMN_GAP = 1 + export function computeGridLayout( items: T[], availableWidth: number, ): GridLayoutResult { // Force single column for very narrow terminals where multi-column wouldn't fit - const COLUMN_GAP = 1 const minWidthForTwoColumns = MIN_COLUMN_WIDTH * 2 + COLUMN_GAP if (availableWidth < minWidthForTwoColumns) { return { columns: 1, - columnWidth: availableWidth, + columnWidth: Math.max(1, availableWidth), columnGroups: [items], } } + // Determine max columns from width thresholds const maxColumns = WIDTH_THRESHOLDS.filter(t => availableWidth >= t).length + 1 const columns = computeSmartColumns(items.length, maxColumns) From 290da2dacc283b5c2badf2013ad801d972cdb5b5 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 22 Jan 2026 03:49:23 +0000 Subject: [PATCH 0086/1143] Bump version to 1.0.592 --- cli/release/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/release/package.json b/cli/release/package.json index fd7f4ac262..8e3a08443b 100644 --- a/cli/release/package.json +++ b/cli/release/package.json @@ -1,6 +1,6 @@ { "name": "codebuff", - "version": "1.0.591", + "version": "1.0.592", "description": "AI coding agent", "license": "MIT", "bin": { From 8c6b959dec44048041ab9c41e571215794c3032d Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 23:18:36 -0800 Subject: [PATCH 0087/1143] Update help banner to explain credits. Move /help first. Double help timeout --- cli/src/components/help-banner.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/cli/src/components/help-banner.tsx b/cli/src/components/help-banner.tsx index fdaefe5873..72087d1f2a 100644 --- a/cli/src/components/help-banner.tsx +++ b/cli/src/components/help-banner.tsx @@ -3,7 +3,7 @@ import React from 'react' import { BottomBanner } from './bottom-banner' import { useChatStore } from '../state/chat-store' -const HELP_TIMEOUT = 30 * 1000 // 30 seconds +const HELP_TIMEOUT = 60 * 1000 // 60 seconds /** Help banner showing keyboard shortcuts and tips. */ export const HelpBanner = () => { @@ -20,7 +20,9 @@ export const HelpBanner = () => { return ( setInputMode('default')} /> ) From 7b151034edc90a0405a9cf414bc23c2afd138e9a Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 23:36:32 -0800 Subject: [PATCH 0088/1143] move /help first --- cli/src/data/slash-commands.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/cli/src/data/slash-commands.ts b/cli/src/data/slash-commands.ts index 3876a97fc7..08b07028f9 100644 --- a/cli/src/data/slash-commands.ts +++ b/cli/src/data/slash-commands.ts @@ -20,6 +20,13 @@ const MODE_COMMANDS: SlashCommand[] = AGENT_MODES.map((mode) => ({ })) export const SLASH_COMMANDS: SlashCommand[] = [ + { + id: 'help', + label: 'help', + description: 'Display keyboard shortcuts and tips', + aliases: ['h', '?'], + implicitCommand: true, + }, { id: 'connect:claude', label: 'connect:claude', @@ -93,13 +100,6 @@ export const SLASH_COMMANDS: SlashCommand[] = [ description: 'Attach an image file (or Ctrl+V to paste from clipboard)', aliases: ['img', 'attach'], }, - { - id: 'help', - label: 'help', - description: 'Display keyboard shortcuts and tips', - aliases: ['h', '?'], - implicitCommand: true, - }, ...MODE_COMMANDS, { id: 'referral', From 0d600e9b6c20d263d5020f537858382743a37ed9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Wed, 21 Jan 2026 23:39:32 -0800 Subject: [PATCH 0089/1143] Fix mcp tools: use __ instead of / --- packages/agent-runtime/src/mcp-constants.ts | 12 ++++++++++++ packages/agent-runtime/src/mcp.ts | 13 +++++++++---- packages/agent-runtime/src/tools/tool-executor.ts | 15 ++++++++------- 3 files changed, 29 insertions(+), 11 deletions(-) create mode 100644 packages/agent-runtime/src/mcp-constants.ts diff --git a/packages/agent-runtime/src/mcp-constants.ts b/packages/agent-runtime/src/mcp-constants.ts new file mode 100644 index 0000000000..9d572b4d2c --- /dev/null +++ b/packages/agent-runtime/src/mcp-constants.ts @@ -0,0 +1,12 @@ +/** + * Separator used between MCP server name and tool name. + * + * LLM APIs (OpenRouter/Anthropic) only allow tool names matching the pattern + * ^[a-zA-Z0-9_-]{1,128}$, which doesn't include forward slashes. + * + * We use double underscore as the separator since it's: + * - Allowed by the LLM API pattern + * - Unlikely to conflict with existing tool names + * - Clearly identifiable as a separator + */ +export const MCP_TOOL_SEPARATOR = '__' diff --git a/packages/agent-runtime/src/mcp.ts b/packages/agent-runtime/src/mcp.ts index 00ec16e7d4..56a2ba56af 100644 --- a/packages/agent-runtime/src/mcp.ts +++ b/packages/agent-runtime/src/mcp.ts @@ -1,5 +1,7 @@ import { convertJsonSchemaToZod } from 'zod-from-json-schema' +import { MCP_TOOL_SEPARATOR } from './mcp-constants' + import type { AgentTemplate } from './templates/types' import type { RequestMcpToolDataFn } from '@codebuff/common/types/contracts/client' import type { OptionalFields } from '@codebuff/common/types/function-params' @@ -22,13 +24,16 @@ export async function getMCPToolData( const withDefaults = { writeTo: {}, ...params } const { toolNames, mcpServers, writeTo, requestMcpToolData } = withDefaults + // User-facing toolNames use '/' as separator (e.g., 'supabase/list_tables') + // but internally we use MCP_TOOL_SEPARATOR ('__') for LLM API compatibility + const USER_INPUT_SEPARATOR = '/' const requestedToolsByMcp: Record = {} for (const t of toolNames) { - if (!t.includes('/')) { + if (!t.includes(USER_INPUT_SEPARATOR)) { continue } - const [mcpName, ...remaining] = t.split('/') - const toolName = remaining.join('/') + const [mcpName, ...remaining] = t.split(USER_INPUT_SEPARATOR) + const toolName = remaining.join(USER_INPUT_SEPARATOR) if (!requestedToolsByMcp[mcpName]) { requestedToolsByMcp[mcpName] = [] } @@ -45,7 +50,7 @@ export async function getMCPToolData( }) for (const { name, description, inputSchema } of mcpData) { - writeTo[mcpName + '/' + name] = { + writeTo[mcpName + MCP_TOOL_SEPARATOR + name] = { inputSchema: convertJsonSchemaToZod(inputSchema as any) as any, endsAgentStep: true, description, diff --git a/packages/agent-runtime/src/tools/tool-executor.ts b/packages/agent-runtime/src/tools/tool-executor.ts index 3f8b33b40b..0a941b72bb 100644 --- a/packages/agent-runtime/src/tools/tool-executor.ts +++ b/packages/agent-runtime/src/tools/tool-executor.ts @@ -3,6 +3,7 @@ import { toolParams } from '@codebuff/common/tools/list' import { generateCompactId } from '@codebuff/common/util/string' import { cloneDeep } from 'lodash' +import { MCP_TOOL_SEPARATOR } from '../mcp-constants' import { getMCPToolData } from '../mcp' import { getAgentShortName } from '../templates/prompts' import { codebuffToolHandlers } from './handlers/list' @@ -274,7 +275,7 @@ export function parseRawCustomToolCall(params: { if ( !(customToolDefs && toolName in customToolDefs) && - !toolName.includes('/') + !toolName.includes(MCP_TOOL_SEPARATOR) ) { return { toolName, @@ -370,8 +371,8 @@ export async function executeCustomToolCall( !(agentTemplate.toolNames as string[]).includes(toolCall.toolName) && !fromHandleSteps && !( - toolCall.toolName.includes('/') && - toolCall.toolName.split('/')[0] in agentTemplate.mcpServers + toolCall.toolName.includes(MCP_TOOL_SEPARATOR) && + toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0] in agentTemplate.mcpServers ) ) { // Emit an error event instead of tool call/result pair @@ -415,15 +416,15 @@ export async function executeCustomToolCall( return null } - const toolName = toolCall.toolName.includes('/') - ? toolCall.toolName.split('/').slice(1).join('/') + const toolName = toolCall.toolName.includes(MCP_TOOL_SEPARATOR) + ? toolCall.toolName.split(MCP_TOOL_SEPARATOR).slice(1).join(MCP_TOOL_SEPARATOR) : toolCall.toolName const clientToolResult = await requestToolCall({ userInputId, toolName, input: toolCall.input, - mcpConfig: toolCall.toolName.includes('/') - ? agentTemplate.mcpServers[toolCall.toolName.split('/')[0]] + mcpConfig: toolCall.toolName.includes(MCP_TOOL_SEPARATOR) + ? agentTemplate.mcpServers[toolCall.toolName.split(MCP_TOOL_SEPARATOR)[0]] : undefined, }) return clientToolResult.output satisfies ToolResultOutput[] From 6e9820d1a773bddba0926720e5b751a3c9db24a6 Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 22 Jan 2026 01:56:00 -0800 Subject: [PATCH 0090/1143] feat(web): add streaming buffer caps to prevent OOM Caps responseText and reasoningText buffers at 1MB during streaming. Adds truncation markers when buffers exceed limit. --- web/src/llm-api/openrouter.ts | 41 ++++++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 3 deletions(-) diff --git a/web/src/llm-api/openrouter.ts b/web/src/llm-api/openrouter.ts index d804113ca1..2281642660 100644 --- a/web/src/llm-api/openrouter.ts +++ b/web/src/llm-api/openrouter.ts @@ -372,8 +372,13 @@ export async function handleOpenRouterStream({ cancel() { clearInterval(heartbeatInterval) clientDisconnected = true + // Log truncated state to prevent OOM during logging (state can be up to 2MB) logger.warn( - { clientDisconnected, state }, + { + clientDisconnected, + responseTextLength: state.responseText.length, + reasoningTextLength: state.reasoningText.length, + }, 'Client cancelled stream, continuing OpenRouter consumption for billing', ) }, @@ -549,6 +554,10 @@ async function handleStreamChunk({ agentId: string model: string | undefined }): Promise { + // Define a safe buffer limit to prevent OOM errors on the server while + // still storing enough data for logging and billing. 1MB is a generous limit. + const MAX_BUFFER_SIZE = 1 * 1024 * 1024 // 1MB + if ('error' in data) { // Log detailed error information for stream errors (e.g., Forbidden from Anthropic) const errorData = data.error as { @@ -581,8 +590,34 @@ async function handleStreamChunk({ return state } const choice = data.choices[0] - state.responseText += choice.delta?.content ?? '' - state.reasoningText += choice.delta?.reasoning ?? '' + + // Append content and reasoning, but only up to the buffer limit. + const contentDelta = choice.delta?.content ?? '' + if (state.responseText.length < MAX_BUFFER_SIZE) { + state.responseText += contentDelta + if (state.responseText.length >= MAX_BUFFER_SIZE) { + state.responseText = + state.responseText.slice(0, MAX_BUFFER_SIZE) + '\n---[TRUNCATED]---' + logger.warn( + { userId, agentId, model }, + 'Response text buffer truncated at 1MB', + ) + } + } + + const reasoningDelta = choice.delta?.reasoning ?? '' + if (state.reasoningText.length < MAX_BUFFER_SIZE) { + state.reasoningText += reasoningDelta + if (state.reasoningText.length >= MAX_BUFFER_SIZE) { + state.reasoningText = + state.reasoningText.slice(0, MAX_BUFFER_SIZE) + '\n---[TRUNCATED]---' + logger.warn( + { userId, agentId, model }, + 'Reasoning text buffer truncated at 1MB', + ) + } + } + return state } From 48afaa690a1b347d9572c5b5ad6dc9bdfa36c3ea Mon Sep 17 00:00:00 2001 From: brandonkachen Date: Thu, 22 Jan 2026 02:20:43 -0800 Subject: [PATCH 0091/1143] style: apply Prettier formatting across web package --- web/scripts/discord/index.ts | 23 +++-- web/scripts/prebuild-agents-cache.ts | 8 +- .../__tests__/docs/content-integrity.test.ts | 32 +++++-- web/src/__tests__/e2e/docs.spec.ts | 26 ++++-- web/src/__tests__/e2e/store-hydration.spec.ts | 8 +- web/src/app/affiliates/affiliates-client.tsx | 9 +- .../app/api/admin/relabel-for-user/route.ts | 8 +- .../[agentId]/[version]/dependencies/_get.ts | 16 ++-- .../auth/cli/logout/__tests__/helpers.test.ts | 8 +- web/src/app/api/auth/cli/logout/_helpers.ts | 4 +- .../app/api/orgs/[orgId]/publishers/route.ts | 5 +- web/src/app/api/v1/_helpers.ts | 5 +- web/src/app/api/v1/ads/route.ts | 5 +- .../token-count/__tests__/token-count.test.ts | 4 +- web/src/app/api/v1/token-count/_post.ts | 5 +- web/src/app/docs/[category]/[slug]/page.tsx | 14 ++- web/src/app/docs/[category]/page.tsx | 2 +- web/src/app/onboard/__tests__/helpers.test.ts | 83 +++++++++++++++--- web/src/app/onboard/_db.ts | 4 +- web/src/app/pricing/pricing-client.tsx | 24 +++-- .../agent/agent-dependency-tree.tsx | 87 ++++++++++++------- .../components/agent/typescript-viewer.tsx | 29 ++++--- web/src/components/docs/doc-sidebar.tsx | 13 +-- web/src/components/docs/mdx/code-demo.tsx | 4 +- web/src/components/navbar/navbar.tsx | 8 +- web/src/lib/__tests__/agent-tree.test.ts | 12 ++- web/src/lib/agent-tree.ts | 8 +- .../server/__tests__/agents-transform.test.ts | 6 +- web/src/server/agents-transform.ts | 52 +++++------ web/src/test-stubs/bun-test.ts | 7 +- 30 files changed, 334 insertions(+), 185 deletions(-) diff --git a/web/scripts/discord/index.ts b/web/scripts/discord/index.ts index b0864315e3..7af3f34882 100644 --- a/web/scripts/discord/index.ts +++ b/web/scripts/discord/index.ts @@ -24,7 +24,7 @@ async function shutdown(exitCode: number = 0) { isShuttingDown = true console.log('Shutting down Discord bot...') - + if (discordClient) { try { discordClient.destroy() @@ -33,12 +33,12 @@ async function shutdown(exitCode: number = 0) { } discordClient = null } - + if (lockHandle) { await lockHandle.release() lockHandle = null } - + process.exit(exitCode) } @@ -51,7 +51,9 @@ async function main() { while (!isShuttingDown) { attemptCount++ - console.log(`Attempting to acquire Discord bot lock (attempt ${attemptCount})...`) + console.log( + `Attempting to acquire Discord bot lock (attempt ${attemptCount})...`, + ) let acquired = false let handle: LockHandle | null = null @@ -63,14 +65,17 @@ async function main() { consecutiveErrors = 0 // Reset on successful DB connection } catch (error) { consecutiveErrors++ - console.error(`Error acquiring lock (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, error) - + console.error( + `Error acquiring lock (${consecutiveErrors}/${MAX_CONSECUTIVE_ERRORS}):`, + error, + ) + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { console.error('Too many consecutive errors, exiting...') await shutdown(1) return } - + await sleep(LOCK_RETRY_INTERVAL_MS) continue } @@ -112,12 +117,12 @@ async function main() { return } catch (error) { console.error('Failed to start Discord bot:', error) - + // Release the lock so another instance can try await handle.release() lockHandle = null discordClient = null - + // Continue polling - maybe another instance will have better luck, // or maybe the issue is transient (Discord outage) console.log(`Will retry in ${LOCK_RETRY_INTERVAL_MS / 1000} seconds...`) diff --git a/web/scripts/prebuild-agents-cache.ts b/web/scripts/prebuild-agents-cache.ts index 8f1528fdd2..2e5fcbf2b4 100644 --- a/web/scripts/prebuild-agents-cache.ts +++ b/web/scripts/prebuild-agents-cache.ts @@ -17,14 +17,18 @@ async function main() { const agents = await fetchAgentsWithMetrics() const duration = Date.now() - startTime - console.log(`[Prebuild] Successfully fetched ${agents.length} agents in ${duration}ms`) + console.log( + `[Prebuild] Successfully fetched ${agents.length} agents in ${duration}ms`, + ) console.log('[Prebuild] Data pipeline validated - ready for deployment') process.exit(0) } catch (error) { console.error('[Prebuild] Failed to fetch agents data:', error) // Don't fail the build - health check will warm cache at runtime - console.error('[Prebuild] WARNING: Data fetch failed, relying on runtime health check') + console.error( + '[Prebuild] WARNING: Data fetch failed, relying on runtime health check', + ) process.exit(0) } } diff --git a/web/src/__tests__/docs/content-integrity.test.ts b/web/src/__tests__/docs/content-integrity.test.ts index b8bf86ef78..e0a2dc04f6 100644 --- a/web/src/__tests__/docs/content-integrity.test.ts +++ b/web/src/__tests__/docs/content-integrity.test.ts @@ -10,7 +10,14 @@ import path from 'path' import matter from 'gray-matter' const CONTENT_DIR = path.join(process.cwd(), 'src/content') -const VALID_SECTIONS = ['help', 'tips', 'advanced', 'agents', 'walkthroughs', 'case-studies'] +const VALID_SECTIONS = [ + 'help', + 'tips', + 'advanced', + 'agents', + 'walkthroughs', + 'case-studies', +] // Get all MDX files recursively function getMdxFiles(dir: string): string[] { @@ -38,7 +45,12 @@ function extractInternalLinks(content: string): string[] { while ((match = linkRegex.exec(content)) !== null) { const url = match[2] // Only collect internal links (starting with / or relative paths to docs) - if (url.startsWith('/docs/') || url.startsWith('/publishers/') || url.startsWith('/pricing') || url.startsWith('/store')) { + if ( + url.startsWith('/docs/') || + url.startsWith('/publishers/') || + url.startsWith('/pricing') || + url.startsWith('/store') + ) { links.push(url) } } @@ -63,12 +75,12 @@ describe('Documentation Content Integrity', () => { mdxFiles.map((f) => { const relative = path.relative(CONTENT_DIR, f) return relative.split(path.sep)[0] - }) + }), ) // At least some expected sections should exist const hasExpectedSections = VALID_SECTIONS.some((section) => - categories.has(section) + categories.has(section), ) expect(hasExpectedSections).toBe(true) }) @@ -76,7 +88,7 @@ describe('Documentation Content Integrity', () => { describe('Frontmatter Validation', () => { it.each( - getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]) + getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]), )('%s has valid frontmatter', (relativePath, filePath) => { const content = fs.readFileSync(filePath as string, 'utf-8') const { data: frontmatter } = matter(content) @@ -120,7 +132,9 @@ describe('Documentation Content Integrity', () => { // Check for duplicates if (slugsByCategory[category].includes(slug)) { - throw new Error(`Duplicate slug "${slug}" found in category "${category}"`) + throw new Error( + `Duplicate slug "${slug}" found in category "${category}"`, + ) } slugsByCategory[category].push(slug) @@ -148,7 +162,7 @@ describe('Documentation Content Integrity', () => { }) it.each( - getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]) + getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]), )('%s has valid internal doc links', (relativePath, filePath) => { const content = fs.readFileSync(filePath as string, 'utf-8') const links = extractInternalLinks(content) @@ -181,7 +195,7 @@ describe('Documentation Content Integrity', () => { describe('Content Quality', () => { it.each( - getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]) + getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]), )('%s has non-empty content', (relativePath, filePath) => { const content = fs.readFileSync(filePath as string, 'utf-8') const { content: mdxContent } = matter(content) @@ -191,7 +205,7 @@ describe('Documentation Content Integrity', () => { }) it.each( - getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]) + getMdxFiles(CONTENT_DIR).map((f) => [path.relative(CONTENT_DIR, f), f]), )('%s has a heading', (relativePath, filePath) => { const content = fs.readFileSync(filePath as string, 'utf-8') const { content: mdxContent } = matter(content) diff --git a/web/src/__tests__/e2e/docs.spec.ts b/web/src/__tests__/e2e/docs.spec.ts index d346f44673..b19ce91168 100644 --- a/web/src/__tests__/e2e/docs.spec.ts +++ b/web/src/__tests__/e2e/docs.spec.ts @@ -11,7 +11,7 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { test.describe('Doc Landing Page', () => { test('loads the docs index page', async ({ page }) => { await page.goto('/docs') - + // Should have documentation content or redirect to first doc await expect(page).toHaveURL(/\/docs/) }) @@ -57,7 +57,9 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { // Click and verify navigation await firstLink.click() - await expect(page).toHaveURL(new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))) + await expect(page).toHaveURL( + new RegExp(href!.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')), + ) } }) }) @@ -67,7 +69,9 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { await page.goto('/docs/help/quick-start') // Look for next button - const nextButton = page.locator('a:has-text("Next"), a[href*="/docs/"]:has(svg)') + const nextButton = page.locator( + 'a:has-text("Next"), a[href*="/docs/"]:has(svg)', + ) const count = await nextButton.count() if (count > 0) { @@ -107,11 +111,15 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { await expect(heading).toContainText(/best practices/i) }) - test('agents overview renders mermaid diagrams or code', async ({ page }) => { + test('agents overview renders mermaid diagrams or code', async ({ + page, + }) => { await page.goto('/docs/agents/overview') // Should have either mermaid diagram or code block for the flowchart - const mermaidOrCode = page.locator('.mermaid, pre:has-text("flowchart"), [class*="mermaid"]') + const mermaidOrCode = page.locator( + '.mermaid, pre:has-text("flowchart"), [class*="mermaid"]', + ) const count = await mermaidOrCode.count() // Page should at least render without errors - mermaid may or may not render in test env @@ -128,7 +136,9 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { await page.goto('/docs/help/quick-start') // Should have a mobile menu trigger (bottom sheet or hamburger) - const mobileMenu = page.locator('button:has(svg), [class*="lg:hidden"]').first() + const mobileMenu = page + .locator('button:has(svg), [class*="lg:hidden"]') + .first() await expect(mobileMenu).toBeVisible() }) }) @@ -142,7 +152,9 @@ test.describe('Documentation Pages', { tag: '@docs' }, () => { expect(h1Count).toBeGreaterThanOrEqual(1) // h1 should come before h2s in the main content - const headings = await page.locator('article h1, article h2, article h3').allTextContents() + const headings = await page + .locator('article h1, article h2, article h3') + .allTextContents() expect(headings.length).toBeGreaterThan(0) }) diff --git a/web/src/__tests__/e2e/store-hydration.spec.ts b/web/src/__tests__/e2e/store-hydration.spec.ts index a157a03b26..5a958392ad 100644 --- a/web/src/__tests__/e2e/store-hydration.spec.ts +++ b/web/src/__tests__/e2e/store-hydration.spec.ts @@ -59,15 +59,11 @@ if (isBun) { if (html.match(/Copy: .*--agent/)) { // SSR already provided agents; hydration fetch is not expected. - await expect( - page.getByTitle(/Copy: .*--agent/).first(), - ).toBeVisible() + await expect(page.getByTitle(/Copy: .*--agent/).first()).toBeVisible() return } // Expect the agent card to render after hydration by checking the copy button title - await expect( - page.getByTitle(/Copy: .*--agent/).first(), - ).toBeVisible() + await expect(page.getByTitle(/Copy: .*--agent/).first()).toBeVisible() }) } diff --git a/web/src/app/affiliates/affiliates-client.tsx b/web/src/app/affiliates/affiliates-client.tsx index 4ee90ac42c..906e5877f4 100644 --- a/web/src/app/affiliates/affiliates-client.tsx +++ b/web/src/app/affiliates/affiliates-client.tsx @@ -107,12 +107,9 @@ function SetHandleForm({ export default function AffiliatesClient() { const { status: sessionStatus } = useSession() - const [ - userProfile, - setUserProfile, - ] = useState<{ handle: string | null; referralCode: string | null } | undefined>( - undefined, - ) + const [userProfile, setUserProfile] = useState< + { handle: string | null; referralCode: string | null } | undefined + >(undefined) const [fetchError, setFetchError] = useState(null) const fetchUserProfile = useCallback(() => { diff --git a/web/src/app/api/admin/relabel-for-user/route.ts b/web/src/app/api/admin/relabel-for-user/route.ts index 62f3d1dc97..804d4efd05 100644 --- a/web/src/app/api/admin/relabel-for-user/route.ts +++ b/web/src/app/api/admin/relabel-for-user/route.ts @@ -40,7 +40,6 @@ interface BigQueryTimestamp { value?: string | number } - const STATIC_SESSION_ID = 'relabel-trace-api' const DEFAULT_RELABEL_LIMIT = 10 const FULL_FILE_CONTEXT_SUFFIX = '-with-full-file-context' @@ -115,9 +114,10 @@ export async function POST(req: NextRequest) { const apiKey = getApiKeyFromRequest(req) if (!apiKey) { return NextResponse.json( - { + { error: 'API key required', - details: 'Provide your API key via Authorization header (Bearer token).', + details: + 'Provide your API key via Authorization header (Bearer token).', hint: 'Visit /usage in the web app to create an API key.', }, { status: 401 }, @@ -317,7 +317,7 @@ async function relabelUsingFullFilesForUser(params: { } const results = await Promise.allSettled(relabelPromises) - + // Log any failures from parallel relabeling for (const result of results) { if (result.status === 'rejected') { diff --git a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts index 3f488d947e..9a8438f94c 100644 --- a/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts +++ b/web/src/app/api/agents/[publisherId]/[agentId]/[version]/dependencies/_get.ts @@ -32,17 +32,14 @@ interface PendingLookup { /** * Creates a batching agent lookup function that automatically batches * concurrent requests into a single database query. - * + * * This solves the N+1 query problem: when the tree builder processes siblings * in parallel with Promise.all, all their lookupAgent calls will be queued * and executed in a single batch query. - * + * * Query reduction: ~2N queries -> ~maxDepth queries (typically ≤6 total) */ -function createBatchingAgentLookup( - publisherSet: Set, - logger: Logger, -) { +function createBatchingAgentLookup(publisherSet: Set, logger: Logger) { const cache = new Map() const pending: PendingLookup[] = [] let batchScheduled = false @@ -95,13 +92,16 @@ function createBatchingAgentLookup( // Create lookup map for quick access const agentMap = new Map() for (const agent of agents) { - agentMap.set(`${agent.publisher_id}:${agent.id}:${agent.version}`, agent) + agentMap.set( + `${agent.publisher_id}:${agent.id}:${agent.version}`, + agent, + ) } // Resolve all pending requests for (const req of batch) { const cacheKey = `${req.publisher}/${req.agentId}@${req.version}` - + // Resolve duplicates from cache if (cache.has(cacheKey)) { req.resolve(cache.get(cacheKey) ?? null) diff --git a/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts b/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts index f23ecf6019..26359b2d07 100644 --- a/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts +++ b/web/src/app/api/auth/cli/logout/__tests__/helpers.test.ts @@ -15,11 +15,15 @@ describe('logout/_helpers', () => { describe('when fingerprintMatchFound is false', () => { test('returns true when stored hash matches provided hash', () => { - expect(shouldUnclaim(false, 'matching-hash', 'matching-hash')).toBe(true) + expect(shouldUnclaim(false, 'matching-hash', 'matching-hash')).toBe( + true, + ) }) test('returns false when stored hash does not match provided hash', () => { - expect(shouldUnclaim(false, 'stored-hash', 'different-hash')).toBe(false) + expect(shouldUnclaim(false, 'stored-hash', 'different-hash')).toBe( + false, + ) }) test('returns false when stored hash is null', () => { diff --git a/web/src/app/api/auth/cli/logout/_helpers.ts b/web/src/app/api/auth/cli/logout/_helpers.ts index 9ea4db82ad..0241858d5e 100644 --- a/web/src/app/api/auth/cli/logout/_helpers.ts +++ b/web/src/app/api/auth/cli/logout/_helpers.ts @@ -3,5 +3,7 @@ export function shouldUnclaim( storedHash: string | null | undefined, providedHash: string, ): boolean { - return fingerprintMatchFound || (storedHash != null && storedHash === providedHash) + return ( + fingerprintMatchFound || (storedHash != null && storedHash === providedHash) + ) } diff --git a/web/src/app/api/orgs/[orgId]/publishers/route.ts b/web/src/app/api/orgs/[orgId]/publishers/route.ts index 0ffb50c1b7..1496e7184a 100644 --- a/web/src/app/api/orgs/[orgId]/publishers/route.ts +++ b/web/src/app/api/orgs/[orgId]/publishers/route.ts @@ -78,10 +78,7 @@ export async function GET( return NextResponse.json({ publishers: response }) } catch (error) { - logger.error( - { error }, - 'Error fetching organization publishers', - ) + logger.error({ error }, 'Error fetching organization publishers') return NextResponse.json( { error: 'Internal server error' }, { status: 500 }, diff --git a/web/src/app/api/v1/_helpers.ts b/web/src/app/api/v1/_helpers.ts index ac705ac46d..c94d55f723 100644 --- a/web/src/app/api/v1/_helpers.ts +++ b/web/src/app/api/v1/_helpers.ts @@ -10,7 +10,10 @@ import type { GetUserUsageDataFn, } from '@codebuff/common/types/contracts/billing' import type { GetUserInfoFromApiKeyFn } from '@codebuff/common/types/contracts/database' -import type { Logger, LoggerWithContextFn } from '@codebuff/common/types/contracts/logger' +import type { + Logger, + LoggerWithContextFn, +} from '@codebuff/common/types/contracts/logger' import type { NextRequest } from 'next/server' export type HandlerResult = diff --git a/web/src/app/api/v1/ads/route.ts b/web/src/app/api/v1/ads/route.ts index 7e64fe50d5..6023c1483b 100644 --- a/web/src/app/api/v1/ads/route.ts +++ b/web/src/app/api/v1/ads/route.ts @@ -16,6 +16,9 @@ export async function POST(req: NextRequest) { loggerWithContext, trackEvent, fetch, - serverEnv: { GRAVITY_API_KEY: env.GRAVITY_API_KEY, CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT }, + serverEnv: { + GRAVITY_API_KEY: env.GRAVITY_API_KEY, + CB_ENVIRONMENT: env.NEXT_PUBLIC_CB_ENVIRONMENT, + }, }) } diff --git a/web/src/app/api/v1/token-count/__tests__/token-count.test.ts b/web/src/app/api/v1/token-count/__tests__/token-count.test.ts index 7e1dc5973b..903521b91f 100644 --- a/web/src/app/api/v1/token-count/__tests__/token-count.test.ts +++ b/web/src/app/api/v1/token-count/__tests__/token-count.test.ts @@ -447,9 +447,7 @@ describe('formatToolContent', () => { }) it('formats array content with json parts', () => { - const content = [ - { type: 'json', value: { key: 'value' } }, - ] + const content = [{ type: 'json', value: { key: 'value' } }] expect(formatToolContent(content)).toBe('{"key":"value"}') }) diff --git a/web/src/app/api/v1/token-count/_post.ts b/web/src/app/api/v1/token-count/_post.ts index df9f83f383..643ac22614 100644 --- a/web/src/app/api/v1/token-count/_post.ts +++ b/web/src/app/api/v1/token-count/_post.ts @@ -258,7 +258,10 @@ export function convertContentToAnthropic( // Handle image content - the image field can be base64 data or a URL string const imageData = part.image if (typeof imageData === 'string' && imageData) { - if (imageData.startsWith('http://') || imageData.startsWith('https://')) { + if ( + imageData.startsWith('http://') || + imageData.startsWith('https://') + ) { // URL-based image anthropicContent.push({ type: 'image', diff --git a/web/src/app/docs/[category]/[slug]/page.tsx b/web/src/app/docs/[category]/[slug]/page.tsx index 16d601e4cf..6d637bb95d 100644 --- a/web/src/app/docs/[category]/[slug]/page.tsx +++ b/web/src/app/docs/[category]/[slug]/page.tsx @@ -11,7 +11,10 @@ import { getDocsByCategory } from '@/lib/docs' import { allDocs } from '.contentlayer/generated' // Generate static params for all doc pages at build time -export function generateStaticParams(): Array<{ category: string; slug: string }> { +export function generateStaticParams(): Array<{ + category: string + slug: string +}> { return allDocs .filter((doc) => !doc.slug.startsWith('_')) .map((doc) => ({ @@ -39,8 +42,7 @@ const FAQ_ITEMS = [ }, { question: 'Is Codebuff open source?', - answer: - "Yes. It's Apache 2.0 at github.com/CodebuffAI/codebuff.", + answer: "Yes. It's Apache 2.0 at github.com/CodebuffAI/codebuff.", }, { question: 'Do you store my data?', @@ -205,7 +207,11 @@ const DocNavigation = ({ ) } -export default async function DocPage({ params }: { params: Promise<{ category: string; slug: string }> }) { +export default async function DocPage({ + params, +}: { + params: Promise<{ category: string; slug: string }> +}) { const { category, slug } = await params const docs = getDocsByCategory(category) const doc = docs.find((d: Doc) => d.slug === slug) diff --git a/web/src/app/docs/[category]/page.tsx b/web/src/app/docs/[category]/page.tsx index 02c1664098..8cc0ba5a8b 100644 --- a/web/src/app/docs/[category]/page.tsx +++ b/web/src/app/docs/[category]/page.tsx @@ -8,7 +8,7 @@ export function generateStaticParams(): Array<{ category: string }> { const categories = new Set( allDocs .filter((doc) => !doc.slug.startsWith('_')) - .map((doc) => doc.category) + .map((doc) => doc.category), ) return Array.from(categories).map((category) => ({ category })) } diff --git a/web/src/app/onboard/__tests__/helpers.test.ts b/web/src/app/onboard/__tests__/helpers.test.ts index 292041ab1b..0912ffaa77 100644 --- a/web/src/app/onboard/__tests__/helpers.test.ts +++ b/web/src/app/onboard/__tests__/helpers.test.ts @@ -69,8 +69,17 @@ describe('onboard/_helpers', () => { const testExpiresAt = '1704067200000' test('returns valid=true when hash matches', () => { - const expectedHash = genAuthCode(testFingerprintId, testExpiresAt, testSecret) - const result = validateAuthCode(expectedHash, testFingerprintId, testExpiresAt, testSecret) + const expectedHash = genAuthCode( + testFingerprintId, + testExpiresAt, + testSecret, + ) + const result = validateAuthCode( + expectedHash, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(true) expect(result.expectedHash).toBe(expectedHash) @@ -78,29 +87,61 @@ describe('onboard/_helpers', () => { test('returns valid=false when hash does not match', () => { const wrongHash = 'wrong-hash-value' - const result = validateAuthCode(wrongHash, testFingerprintId, testExpiresAt, testSecret) + const result = validateAuthCode( + wrongHash, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(false) expect(result.expectedHash).not.toBe(wrongHash) }) test('returns valid=false when secret is different', () => { - const hashWithDifferentSecret = genAuthCode(testFingerprintId, testExpiresAt, 'different-secret') - const result = validateAuthCode(hashWithDifferentSecret, testFingerprintId, testExpiresAt, testSecret) + const hashWithDifferentSecret = genAuthCode( + testFingerprintId, + testExpiresAt, + 'different-secret', + ) + const result = validateAuthCode( + hashWithDifferentSecret, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(false) }) test('returns valid=false when fingerprintId is different', () => { - const hashWithDifferentFp = genAuthCode('different-fp', testExpiresAt, testSecret) - const result = validateAuthCode(hashWithDifferentFp, testFingerprintId, testExpiresAt, testSecret) + const hashWithDifferentFp = genAuthCode( + 'different-fp', + testExpiresAt, + testSecret, + ) + const result = validateAuthCode( + hashWithDifferentFp, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(false) }) test('returns valid=false when expiresAt is different', () => { - const hashWithDifferentExpiry = genAuthCode(testFingerprintId, '9999999999999', testSecret) - const result = validateAuthCode(hashWithDifferentExpiry, testFingerprintId, testExpiresAt, testSecret) + const hashWithDifferentExpiry = genAuthCode( + testFingerprintId, + '9999999999999', + testSecret, + ) + const result = validateAuthCode( + hashWithDifferentExpiry, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(false) }) @@ -108,19 +149,33 @@ describe('onboard/_helpers', () => { test('hash is deterministic for same inputs', () => { const hash1 = genAuthCode(testFingerprintId, testExpiresAt, testSecret) const hash2 = genAuthCode(testFingerprintId, testExpiresAt, testSecret) - + expect(hash1).toBe(hash2) - - const result = validateAuthCode(hash1, testFingerprintId, testExpiresAt, testSecret) + + const result = validateAuthCode( + hash1, + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.valid).toBe(true) }) test('returns the expected hash for verification', () => { const wrongHash = 'attacker-supplied-hash' - const result = validateAuthCode(wrongHash, testFingerprintId, testExpiresAt, testSecret) + const result = validateAuthCode( + wrongHash, + testFingerprintId, + testExpiresAt, + testSecret, + ) // The expectedHash should be what we'd generate for these inputs - const actualExpected = genAuthCode(testFingerprintId, testExpiresAt, testSecret) + const actualExpected = genAuthCode( + testFingerprintId, + testExpiresAt, + testSecret, + ) expect(result.expectedHash).toBe(actualExpected) }) }) diff --git a/web/src/app/onboard/_db.ts b/web/src/app/onboard/_db.ts index 97d4fcbd88..ed97da2cce 100644 --- a/web/src/app/onboard/_db.ts +++ b/web/src/app/onboard/_db.ts @@ -61,7 +61,9 @@ export async function checkFingerprintConflict( return { hasConflict: false } } -export async function getSessionTokenFromCookies(): Promise { +export async function getSessionTokenFromCookies(): Promise< + string | undefined +> { const cookieStore = await cookies() return ( cookieStore.get('authjs.session-token')?.value ?? diff --git a/web/src/app/pricing/pricing-client.tsx b/web/src/app/pricing/pricing-client.tsx index fba7e71654..01bf931d1e 100644 --- a/web/src/app/pricing/pricing-client.tsx +++ b/web/src/app/pricing/pricing-client.tsx @@ -95,8 +95,12 @@ function ClaudeSubscriptionIllustration() {
-
Save on credits
-
Use your subscription for Claude model requests
+
+ Save on credits +
+
+ Use your subscription for Claude model requests +
@@ -105,8 +109,12 @@ function ClaudeSubscriptionIllustration() {
-
Simple CLI setup
-
Connect with one command
+
+ Simple CLI setup +
+
+ Connect with one command +
@@ -114,8 +122,12 @@ function ClaudeSubscriptionIllustration() { {/* Code snippet */}
$ codebuff
-
{'>'} /connect:claude
-
✓ Connected to Claude subscription
+
+ {'>'} /connect:claude +
+
+ ✓ Connected to Claude subscription +
diff --git a/web/src/components/agent/agent-dependency-tree.tsx b/web/src/components/agent/agent-dependency-tree.tsx index 927f0fd1c0..c121ad7479 100644 --- a/web/src/components/agent/agent-dependency-tree.tsx +++ b/web/src/components/agent/agent-dependency-tree.tsx @@ -1,7 +1,15 @@ 'use client' import React, { useEffect, useState, useMemo } from 'react' -import { GitBranch, ChevronDown, ChevronRight, ExternalLink, LayoutList, Network, AlertCircle } from 'lucide-react' +import { + GitBranch, + ChevronDown, + ChevronRight, + ExternalLink, + LayoutList, + Network, + AlertCircle, +} from 'lucide-react' import Link from 'next/link' import { MermaidDiagram } from '@/components/docs/mdx/mermaid-diagram' @@ -71,7 +79,7 @@ export function AgentDependencyTree({ // Memoize expensive Mermaid generation const mermaidCode = useMemo( () => (treeData ? generateMermaidDiagram(treeData) : ''), - [treeData] + [treeData], ) const subagentCount = treeData ? treeData.totalAgents - 1 : 0 @@ -86,11 +94,13 @@ export function AgentDependencyTree({ const response = await fetch( `/api/agents/${publisherId}/${agentId}/${version}/dependencies`, - { signal: abortController.signal } + { signal: abortController.signal }, ) if (!response.ok) { - throw new Error(`Failed to fetch dependencies: ${response.statusText}`) + throw new Error( + `Failed to fetch dependencies: ${response.statusText}`, + ) } const data: AgentTreeData = await response.json() @@ -193,18 +203,17 @@ export function AgentDependencyTree({ {viewMode === 'list' ? (
{treeData.root.children.map((node) => ( - + ))}
) : (
- +
@@ -215,7 +224,13 @@ export function AgentDependencyTree({ ) } -function ViewDetailsLink({ href, className }: { href: string; className?: string }) { +function ViewDetailsLink({ + href, + className, +}: { + href: string + className?: string +}) { return ( View details @@ -232,15 +247,15 @@ function ViewDetailsLink({ href, className }: { href: string; className?: string ) } -function SubagentTreeNode({ - node, +function SubagentTreeNode({ + node, depth, -}: { +}: { node: AgentTreeNode depth: number }) { const [isExpanded, setIsExpanded] = useState(false) - + const agentUrl = node.isAvailable ? `/publishers/${node.publisher}/agents/${node.agentId}/${node.version}` : null @@ -263,19 +278,23 @@ function SubagentTreeNode({ onClick={() => isExpandable && setIsExpanded(!isExpanded)} > {/* Depth-level indicator bar */} -
{/* Expand/collapse chevron */} -
- {isExpandable && ( - isExpanded ? ( +
+ {isExpandable && + (isExpanded ? ( ) : ( - ) - )} + ))}
@@ -297,20 +316,26 @@ function SubagentTreeNode({ )} {hasChildren && ( - {node.children.length} subagent{node.children.length !== 1 ? 's' : ''} + {node.children.length} subagent + {node.children.length !== 1 ? 's' : ''} )}
- @{node.publisher} + + @{node.publisher} +
{isExpanded && ( <> {(node.spawnerPrompt || agentUrl) && ( -
{node.spawnerPrompt ? (
@@ -319,17 +344,17 @@ function SubagentTreeNode({ )}
- ) : agentUrl && ( - + ) : ( + agentUrl && )}
)} {hasChildren && !node.isCyclic && (
{node.children.map((child) => ( - ))} diff --git a/web/src/components/agent/typescript-viewer.tsx b/web/src/components/agent/typescript-viewer.tsx index 5892f5a00d..e733c2c91f 100644 --- a/web/src/components/agent/typescript-viewer.tsx +++ b/web/src/components/agent/typescript-viewer.tsx @@ -22,20 +22,24 @@ function isValidAgentIdComponent(value: string): boolean { return SAFE_ID_PATTERN.test(value) && value.length > 0 && value.length <= 128 } -function parseAgentIdFromToken(tokenContent: string): { publisher: string; agentId: string; version: string } | null { +function parseAgentIdFromToken( + tokenContent: string, +): { publisher: string; agentId: string; version: string } | null { const match = tokenContent.match(AGENT_ID_PATTERN) if (match) { const publisher = match[1] const agentId = match[2] const version = match[3] - + // Validate all components contain only safe characters - if (!isValidAgentIdComponent(publisher) || - !isValidAgentIdComponent(agentId) || - !isValidAgentIdComponent(version)) { + if ( + !isValidAgentIdComponent(publisher) || + !isValidAgentIdComponent(agentId) || + !isValidAgentIdComponent(version) + ) { return null } - + return { publisher, agentId, version } } return null @@ -181,13 +185,16 @@ export function TypeScriptViewer({
{line.map((token, tokenIndex) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { key: _tokenKey, ...tokenProps } = getTokenProps({ token, key: tokenIndex }) - + const { key: _tokenKey, ...tokenProps } = getTokenProps({ + token, + key: tokenIndex, + }) + // Check if this token is an agent ID string - const agentInfo = token.types.includes('string') + const agentInfo = token.types.includes('string') ? parseAgentIdFromToken(token.content) : null - + if (agentInfo) { const agentUrl = `/publishers/${agentInfo.publisher}/agents/${agentInfo.agentId}/${agentInfo.version}` return ( @@ -215,7 +222,7 @@ export function TypeScriptViewer({ ) } - + return })}
diff --git a/web/src/components/docs/doc-sidebar.tsx b/web/src/components/docs/doc-sidebar.tsx index 9c7f5b7d3f..548b9fde8a 100644 --- a/web/src/components/docs/doc-sidebar.tsx +++ b/web/src/components/docs/doc-sidebar.tsx @@ -75,7 +75,11 @@ const referenceSections = [ ] // Flat list of all sections for compatibility with layout.tsx -export const sections = [...learnSections, ...buildSections, ...referenceSections] +export const sections = [ + ...learnSections, + ...buildSections, + ...referenceSections, +] export function DocSidebar({ className, @@ -110,9 +114,7 @@ export function DocSidebar({