From 77fd945474236ca7596ca2d8d95ad38fe2a87487 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Fri, 13 Feb 2026 16:34:44 +0800 Subject: [PATCH 01/82] feat: add /context command to display context window token usage breakdown Co-authored-by: Qwen-Coder --- .../cli/src/services/BuiltinCommandLoader.ts | 2 + .../cli/src/ui/commands/CONTEXT_COMMAND.md | 293 ++++++++++++++ .../cli/src/ui/commands/contextCommand.ts | 310 +++++++++++++++ .../src/ui/components/HistoryItemDisplay.tsx | 14 + .../src/ui/components/views/ContextUsage.tsx | 361 ++++++++++++++++++ packages/cli/src/ui/types.ts | 46 ++- packages/core/src/index.ts | 1 + 7 files changed, 1026 insertions(+), 1 deletion(-) create mode 100644 packages/cli/src/ui/commands/CONTEXT_COMMAND.md create mode 100644 packages/cli/src/ui/commands/contextCommand.ts create mode 100644 packages/cli/src/ui/components/views/ContextUsage.tsx diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index dc4c1f8d9..ada1ae0eb 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -14,6 +14,7 @@ import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; import { clearCommand } from '../ui/commands/clearCommand.js'; import { compressCommand } from '../ui/commands/compressCommand.js'; +import { contextCommand } from '../ui/commands/contextCommand.js'; import { copyCommand } from '../ui/commands/copyCommand.js'; import { docsCommand } from '../ui/commands/docsCommand.js'; import { directoryCommand } from '../ui/commands/directoryCommand.js'; @@ -64,6 +65,7 @@ export class BuiltinCommandLoader implements ICommandLoader { bugCommand, clearCommand, compressCommand, + contextCommand, copyCommand, docsCommand, directoryCommand, diff --git a/packages/cli/src/ui/commands/CONTEXT_COMMAND.md b/packages/cli/src/ui/commands/CONTEXT_COMMAND.md new file mode 100644 index 000000000..de768d4b9 --- /dev/null +++ b/packages/cli/src/ui/commands/CONTEXT_COMMAND.md @@ -0,0 +1,293 @@ +# `/context` 命令 — 上下文窗口用量分解 + +## 概述 + +`/context` 命令展示当前模型上下文窗口的 token 使用情况。它将整个上下文窗口拆分为多个分类,帮助用户理解 token 花在了哪里,以及还剩多少空间。 + +## 上下文窗口的组成 + +一次 API 请求发送给模型的完整 prompt 包含以下部分: + +``` +┌─────────────────────────────────────────────┐ +│ Context Window (总容量) │ +│ │ +│ ┌─────────────────────────────────────┐ │ +│ │ System Prompt (系统提示词) │ │ +│ │ └─ 核心指令 + 行为规则 │ │ +│ ├─────────────────────────────────────┤ │ +│ │ Tool Declarations (工具声明) │ │ +│ │ ├─ Built-in tools (内置工具) │ │ +│ │ ├─ MCP tools (MCP 工具) │ │ +│ │ └─ SkillTool (技能工具) ◄──────────┼─── 包含所有 skill 的名称+描述 +│ ├─────────────────────────────────────┤ │ +│ │ Memory (用户记忆) │ │ +│ │ └─ QWEN.md + extension configs │ │ +│ ├─────────────────────────────────────┤ │ +│ │ Messages (对话消息) │ │ +│ │ ├─ 用户消息 │ │ +│ │ ├─ 模型回复 │ │ +│ │ └─ 工具调用 & 工具结果 ◄───────────┼─── skill body 在此加载 +│ ├─────────────────────────────────────┤ │ +│ │ Free Space (可用空间) │ │ +│ ├─────────────────────────────────────┤ │ +│ │ Autocompact Buffer (自动压缩缓冲) │ │ +│ └─────────────────────────────────────┘ │ +└─────────────────────────────────────────────┘ +``` + +**不变量**:所有分类之和 = Context Window 总容量。 + +## 各分类详解 + +### 1. System Prompt(系统提示词) + +| 属性 | 说明 | +| ------------ | ------------------------------------------------------------------ | +| **数据来源** | `getCoreSystemPrompt(undefined, modelName)` | +| **包含内容** | 模型的核心行为指令、输出格式要求、安全规则等 | +| **不包含** | Memory 内容(单独计算) | +| **计算方式** | 对系统提示词文本调用 `estimateTokens()` | +| **变化频率** | 基本固定,除非修改了 `QWEN_SYSTEM_MD` 环境变量或 `.qwen/system.md` | + +> **注意**:`getCoreSystemPrompt` 接受 `userMemory` 参数,这里传入 `undefined` 以排除 memory,因为 memory 作为独立分类统计。 + +### 2. Built-in Tools(内置工具) + +| 属性 | 说明 | +| ------------ | ----------------------------------------------------------------------------------------------------- | +| **数据来源** | `toolRegistry.getAllTools()` 中非 MCP、非 SkillTool 的工具 | +| **包含内容** | `read_file`、`edit`、`run_shell_command`、`grep_search`、`glob`、`list_directory` 等核心工具的 schema | +| **计算方式** | `allToolsTokens - skillsTokens - mcpToolsTotalTokens` | +| **详情列表** | 逐项展示每个内置工具的名称和 token 占用,按 token 数降序排列 | + +> **SkillTool** 虽然也是内置工具,但因其内容动态性(嵌入所有 skill 列表),独立作为 **Skills** 分类展示,不在 Built-in tools 中出现。 + +### 2b. MCP Tools(MCP 工具) + +| 属性 | 说明 | +| ------------ | ----------------------------------------------------------------------- | +| **数据来源** | `toolRegistry.getAllTools()` 中 `DiscoveredMCPTool` 实例 | +| **包含内容** | 通过 MCP 协议连接的外部工具服务器提供的工具 schema | +| **计算方式** | 各 MCP 工具 `estimateTokens(JSON.stringify(tool.schema))` 之和 | +| **详情列表** | 逐项展示每个 MCP 工具的名称(`serverName__toolName` 格式)和 token 占用 | +| **条件显示** | 仅当存在 MCP 工具时才显示此分类行和详情 | + +### 3. Skills(技能)⭐ 渐进式披露 + +Skills 采用**两阶段加载**设计: + +| 阶段 | 加载内容 | Token 归属 | 何时加载 | +| ------------ | ---------------------------------------------- | ----------------- | ------------------------------- | +| **第一阶段** | 每个 skill 的 name + 短 description + 使用说明 | **Skills 分类** | 每次 API 请求都发送 | +| **第二阶段** | 完整的 SKILL.md body 内容(详细指令、模板等) | **Messages 分类** | 模型调用 `skill` 工具后按需注入 | + +**`/context` 中 Skills 分类展示的是第一阶段的常驻开销。** + +#### 第一阶段的实现细节 + +SkillTool 在初始化时将所有 skill 信息嵌入其 `description` 字段: + +``` +Execute a skill within the main conversation + + +... 使用说明(~600 字符)... + + + + +pdf +Convert PDF files to text (project) +project + + +xlsx +Process Excel spreadsheets (user) +user + +...更多 skills... + +``` + +这整块文本是 SkillTool 的 tool declaration 的一部分,每次 API 请求都会发送。 + +#### Token 计算方式 + +``` +skillsTokens = estimateTokens(JSON.stringify(skillTool.schema)) +``` + +直接从 ToolRegistry 中获取 SkillTool 的完整 schema 进行估算,确保包含: + +- 使用说明文本(``) +- 所有 skill 的 XML 列表(``) +- schema 参数定义 + +#### 第二阶段(按需加载) + +当模型调用 `skill` 工具时,`SkillToolInvocation.execute()` 会加载完整的 SKILL.md: + +```typescript +const skill = await this.skillManager.loadSkillForRuntime(this.params.skill); +const llmContent = `Base directory: ${baseDir}\n\n${skill.body}\n`; +``` + +这个 body 内容作为工具调用结果注入到对话中,token 开销归入 **Messages** 分类。 + +#### Skills 详情列表 + +每个 skill 的详情行展示该 skill 在第一阶段中的大致占用,按 token 数降序排列。注意: + +- 各 skill 详情的 token 之和 **< Skills 分类总数**,差值是 skills_instructions 指令文本的开销 +- 详情仅展示名称和描述的 token,不包含 schema 参数定义部分 + +### 4. Memory Files(用户记忆) + +| 属性 | 说明 | +| ------------ | -------------------------------------------------------------------------- | +| **数据来源** | `config.getUserMemory()` | +| **包含内容** | `QWEN.md`、extension 配置、`output-language` 等用户级配置文件 | +| **加载位置** | 拼接到 System Prompt 末尾(通过 `getCoreSystemPrompt(userMemory, model)`) | +| **计算方式** | 解析 memory 文本中的 `--- Context from: ---` 标记,分文件估算 token | + +**Memory 内容格式**: + +``` +--- Context from: ~/.qwen/QWEN.md --- +用户自定义规则和偏好... +--- End of Context from: ~/.qwen/QWEN.md --- +--- Context from: ~/.qwen/extensions/config.md --- +扩展配置内容... +--- End of Context from: ~/.qwen/extensions/config.md --- +``` + +> **为什么 System Prompt 不包含 Memory?** 计算 System Prompt token 时传入 `userMemory = undefined`,Memory 作为独立分类展示,避免两个分类重叠。实际 API 请求中 memory 是拼接在 system prompt 末尾的。 + +### 5. Messages(对话消息) + +| 属性 | 说明 | +| ------------ | ---------------------------------------------------------------- | +| **数据来源** | 反推:`totalTokens - systemPrompt - allTools - memory` | +| **包含内容** | 所有用户消息、模型回复、工具调用参数、工具返回结果 | +| **特别包含** | skill body(第二阶段按需加载的内容)、文件读取结果、shell 输出等 | +| **计算方式** | `max(0, apiTotalTokens - estimatedOverhead)` | + +> **注意**:Messages 是通过 API 返回的 `totalTokens` 减去其他分类的估算值得出的,因此它吸收了估算误差。如果 overhead 被高估,Messages 会被相应低估。 + +### 6. Free Space(可用空间) + +| 属性 | 说明 | +| ------------ | ----------------------------------------------------- | +| **计算方式** | `contextWindowSize - totalTokens - autocompactBuffer` | +| **含义** | 在触发自动压缩之前,还能容纳多少 token 的对话内容 | + +### 7. Autocompact Buffer(自动压缩缓冲区) + +| 属性 | 说明 | +| ------------ | ----------------------------------------------------------------- | +| **计算方式** | `(1 - compressionThreshold) × contextWindowSize` | +| **默认值** | `(1 - 0.7) × 131072 = 39322`(约 30% 的上下文窗口) | +| **含义** | 当 token 用量达到 70% 时触发自动压缩,这 30% 的空间作为缓冲区预留 | + +## 两种展示模式 + +### 模式 A:无 API 数据(首次使用,尚未发送消息) + +``` +Context Usage + + No API response yet. Send a message to see actual usage. + + Estimated pre-conversation overhead + Model: glm-5 Context window: 131.1k tokens + + █ System prompt 4.8k tokens (3.7%) + █ System tools 5.2k tokens (4.0%) + █ Memory files 845 tokens (0.6%) + █ Skills 5.1k tokens (3.9%) + ░ Free space 75.8k tokens (57.8%) + ░ Autocompact buffer 39.3k tokens (30.0%) +``` + +- **不显示进度条和 total 数字**:避免估算值与后续 API 实际值产生不合理的对比 +- **不显示 Messages 行**:尚无对话 +- 各分类基于本地启发式估算(`estimateTokens`),可能与实际 API tokenizer 有 ~10% 偏差 + +### 模式 B:有 API 数据(已进行对话) + +``` +Context Usage + + ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ glm-5 + 25.3k/131.1k tokens (19.3%) + + Usage by category + █ System prompt 4.5k tokens (3.4%) + █ System tools 4.9k tokens (3.7%) + █ Memory files 790 tokens (0.6%) + █ Skills 4.8k tokens (3.7%) + █ Messages 10.3k tokens (7.9%) + ░ Free space 66.5k tokens (50.7%) + ░ Autocompact buffer 39.3k tokens (30.0%) +``` + +- **`totalTokens` 来自 API 响应**(`usageMetadata.promptTokenCount`),是最准确的值 +- **当本地估算 > API total 时**:按比例缩放各 overhead 分类,确保分类之和 = totalTokens +- **Messages** = `totalTokens - scaledOverhead`,包含所有对话内容 + 按需加载的 skill body + +## Token 估算方法 + +由于无法直接访问模型的 tokenizer,使用基于字符的启发式估算: + +``` +tokens ≈ ⌈asciiChars / 4 + nonAsciiChars × 1.5⌉ +``` + +| 字符类型 | 比例 | 依据 | +| --------------------------------- | --------------- | -------------------------------- | +| ASCII(英文、JSON 结构字符等) | ~4 字符/token | BPE tokenizer 对英文的平均压缩率 | +| 非 ASCII(中文、日文等 CJK 字符) | ~1.5 token/字符 | CJK 字符通常映射为 1-2 个 token | + +**已知局限**: + +- 不同模型的 tokenizer 有差异,估算可能偏差 ±10-20% +- JSON 结构字符(`{`, `"`, `:` 等)的实际 token 化比率与自然语言不同 +- 当估算偏高时,通过 `overheadScale` 按比例缩放校正 + +## 数据流图 + +``` + ┌──────────────────┐ + │ API Response │ + │ promptTokenCount │ ─── totalTokens (ground truth) + └──────────────────┘ + │ + ┌──────────────────────────┼──────────────────────────┐ + │ │ │ + ▼ ▼ ▼ +estimateTokens() estimateTokens() estimateTokens() + │ │ │ + ▼ ▼ ▼ +systemPromptTokens allToolsTokens memoryFilesTokens + │ + ┌─────┴──────┐ + │ │ + ▼ ▼ + systemToolsTokens skillsTokens + (allTools - skills) (from SkillTool schema) + │ │ + └─────┬──────┘ + │ + ▼ + rawOverhead = systemPrompt + allTools + memory + │ + ┌───────────┼───────────┐ + │ overheadScale │ (= min(1, totalTokens/rawOverhead)) + ▼ ▼ + scaled categories messages = totalTokens - scaledOverhead + │ │ + └───────────┬───────────┘ + ▼ + breakdown output +``` diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts new file mode 100644 index 000000000..e4df88029 --- /dev/null +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -0,0 +1,310 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + type CommandContext, + type SlashCommand, + CommandKind, +} from './types.js'; +import { + MessageType, + type HistoryItemContextUsage, + type ContextCategoryBreakdown, + type ContextToolDetail, + type ContextMemoryDetail, + type ContextSkillDetail, +} from '../types.js'; +import { + DiscoveredMCPTool, + uiTelemetryService, + getCoreSystemPrompt, + DEFAULT_TOKEN_LIMIT, + ToolNames, +} from '@qwen-code/qwen-code-core'; +import { t } from '../../i18n/index.js'; + +/** + * Default compression token threshold (triggers compression at 70% usage). + * The autocompact buffer is (1 - threshold) * contextWindowSize. + */ +const DEFAULT_COMPRESSION_THRESHOLD = 0.7; + +/** + * Estimate token count for a string using a character-based heuristic. + * ASCII chars ≈ 4 chars/token, CJK/non-ASCII chars ≈ 1.5 tokens/char. + */ +function estimateTokens(text: string): number { + if (!text || text.length === 0) return 0; + let asciiChars = 0; + let nonAsciiChars = 0; + for (let i = 0; i < text.length; i++) { + const charCode = text.charCodeAt(i); + if (charCode < 128) { + asciiChars++; + } else { + nonAsciiChars++; + } + } + // CJK and other non-ASCII characters typically produce 1.5-2 tokens each + return Math.ceil(asciiChars / 4 + nonAsciiChars * 1.5); +} + +/** + * Parse concatenated memory content into individual file entries. + * Memory content format: "--- Context from: ---\n\n--- End of Context from: ---" + */ +function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { + if (!memoryContent || memoryContent.trim().length === 0) return []; + + const results: ContextMemoryDetail[] = []; + // Use backreference (\1) to ensure start/end path markers match + const regex = + /--- Context from: (.+?) ---\n([\s\S]*?)--- End of Context from: \1 ---/g; + let match: RegExpExecArray | null; + + while ((match = regex.exec(memoryContent)) !== null) { + const filePath = match[1]!; + const content = match[2]!; + results.push({ + path: filePath, + tokens: estimateTokens(content), + }); + } + + // If no structured markers found, treat as a single memory block + if (results.length === 0 && memoryContent.trim().length > 0) { + results.push({ + path: t('memory'), + tokens: estimateTokens(memoryContent), + }); + } + + return results; +} + +export const contextCommand: SlashCommand = { + name: 'context', + get description() { + return t('Show context window usage breakdown.'); + }, + kind: CommandKind.BUILT_IN, + action: async (context: CommandContext) => { + const { config } = context.services; + if (!config) { + context.ui.addItem( + { + type: MessageType.ERROR, + text: t('Config not loaded.'), + }, + Date.now(), + ); + return; + } + + // --- Gather data --- + + const modelName = config.getModel() || 'unknown'; + const contentGeneratorConfig = config.getContentGeneratorConfig(); + const contextWindowSize = + contentGeneratorConfig.contextWindowSize ?? DEFAULT_TOKEN_LIMIT; + + // Total prompt token count from API (most accurate) + const apiTotalTokens = uiTelemetryService.getLastPromptTokenCount(); + + // 1. System prompt tokens (without memory, as memory is counted separately) + const systemPromptText = getCoreSystemPrompt(undefined, modelName); + const systemPromptTokens = estimateTokens(systemPromptText); + + // 2. Tool declarations tokens (includes ALL tools: built-in, MCP, skill tool) + const toolRegistry = config.getToolRegistry(); + const allTools = toolRegistry ? toolRegistry.getAllTools() : []; + const toolDeclarations = toolRegistry + ? toolRegistry.getFunctionDeclarations() + : []; + const toolsJsonStr = JSON.stringify(toolDeclarations); + const allToolsTokens = estimateTokens(toolsJsonStr); + + // 3. Per-tool details (for breakdown display) + const builtinTools: ContextToolDetail[] = []; + const mcpTools: ContextToolDetail[] = []; + for (const tool of allTools) { + const toolJsonStr = JSON.stringify(tool.schema); + const tokens = estimateTokens(toolJsonStr); + if (tool instanceof DiscoveredMCPTool) { + mcpTools.push({ + name: `${tool.serverName}__${tool.serverToolName || tool.name}`, + tokens, + }); + } else if (tool.name !== ToolNames.SKILL) { + // Built-in tool (exclude SkillTool, which is shown under Skills) + builtinTools.push({ + name: tool.name, + tokens, + }); + } + } + + // 4. Memory files + const memoryContent = config.getUserMemory(); + const memoryFiles = parseMemoryFiles(memoryContent); + const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0); + + // 5. Skills (progressive disclosure) + // The SkillTool's description embeds all skill name+description listings + // plus ~600 chars of instruction text. This is the "always in context" + // cost. The full SKILL.md body is only loaded on-demand when the model + // invokes the skill tool (and that cost appears in Messages). + // + // To get an accurate total, we read the SkillTool's actual schema from + // the registry rather than reconstructing from a template. + const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL); + const skillToolTotalTokens = skillTool + ? estimateTokens(JSON.stringify(skillTool.schema)) + : 0; + + // Per-skill breakdown for detail display (proportional to description length) + const skillManager = config.getSkillManager(); + const skillConfigs = skillManager ? await skillManager.listSkills() : []; + const skills: ContextSkillDetail[] = skillConfigs.map((skill) => ({ + name: skill.name, + tokens: estimateTokens( + `\n\n${skill.name}\n\n\n${skill.description} (${skill.level})\n\n\n${skill.level}\n\n`, + ), + })); + // Use the SkillTool's actual schema tokens as the total, not the sum of + // individual estimates (which would miss the instruction wrapper text). + const skillsTokens = skillToolTotalTokens; + + // 6. Autocompact buffer + const compressionThreshold = + config.getChatCompression()?.contextPercentageThreshold ?? + DEFAULT_COMPRESSION_THRESHOLD; + const autocompactBuffer = + compressionThreshold > 0 + ? Math.round((1 - compressionThreshold) * contextWindowSize) + : 0; + + // 7. Calculate raw overhead (allToolsTokens already includes skills) + const rawOverhead = systemPromptTokens + allToolsTokens + memoryFilesTokens; + + // 8. Determine total tokens and build breakdown + const isEstimated = apiTotalTokens === 0; + + // Sum of MCP tool tokens for category-level display + const mcpToolsTotalTokens = mcpTools.reduce( + (sum, tool) => sum + tool.tokens, + 0, + ); + + let totalTokens: number; + let displaySystemPrompt: number; + let displayBuiltinTools: number; + let displayMcpTools: number; + let displayMemoryFiles: number; + let displaySkills: number; + let messagesTokens: number; + let freeSpace: number; + let detailBuiltinTools: ContextToolDetail[]; + let detailMcpTools: ContextToolDetail[]; + let detailMemoryFiles: ContextMemoryDetail[]; + let detailSkills: ContextSkillDetail[]; + + if (isEstimated) { + // No API data yet: show raw overhead estimates only. + // Use 0 as totalTokens so the progress bar stays empty — + // avoids showing an inflated estimate that would "decrease" + // once real API data arrives. + totalTokens = 0; + displaySystemPrompt = systemPromptTokens; + // builtinTools category = allTools - skills - mcpTools + displayBuiltinTools = Math.max( + 0, + allToolsTokens - skillsTokens - mcpToolsTotalTokens, + ); + displayMcpTools = mcpToolsTotalTokens; + displayMemoryFiles = memoryFilesTokens; + displaySkills = skillsTokens; + messagesTokens = 0; + // Free space accounts for the estimated overhead + freeSpace = Math.max( + 0, + contextWindowSize - rawOverhead - autocompactBuffer, + ); + detailBuiltinTools = builtinTools; + detailMcpTools = mcpTools; + detailMemoryFiles = memoryFiles; + detailSkills = skills; + } else { + // API data available: use actual total with proportional scaling + totalTokens = apiTotalTokens; + + // When estimates overshoot API total, scale down proportionally + // so the breakdown categories add up to totalTokens. + const overheadScale = + rawOverhead > totalTokens ? totalTokens / rawOverhead : 1; + + displaySystemPrompt = Math.round(systemPromptTokens * overheadScale); + const scaledAllTools = Math.round(allToolsTokens * overheadScale); + displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale); + displaySkills = Math.round(skillsTokens * overheadScale); + const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale); + displayMcpTools = scaledMcpTotal; + displayBuiltinTools = Math.max( + 0, + scaledAllTools - displaySkills - scaledMcpTotal, + ); + + const scaledOverhead = + displaySystemPrompt + scaledAllTools + displayMemoryFiles; + messagesTokens = Math.max(0, totalTokens - scaledOverhead); + + freeSpace = Math.max( + 0, + contextWindowSize - totalTokens - autocompactBuffer, + ); + + // Scale detail items to match their parent categories + const scaleDetail = (items: T[]): T[] => + overheadScale < 1 + ? items.map((item) => ({ + ...item, + tokens: Math.round(item.tokens * overheadScale), + })) + : items; + + detailBuiltinTools = scaleDetail(builtinTools); + detailMcpTools = scaleDetail(mcpTools); + detailMemoryFiles = scaleDetail(memoryFiles); + detailSkills = scaleDetail(skills); + } + + const breakdown: ContextCategoryBreakdown = { + systemPrompt: displaySystemPrompt, + builtinTools: displayBuiltinTools, + mcpTools: displayMcpTools, + memoryFiles: displayMemoryFiles, + skills: displaySkills, + messages: messagesTokens, + freeSpace, + autocompactBuffer, + }; + + const contextUsageItem: HistoryItemContextUsage = { + type: MessageType.CONTEXT_USAGE, + modelName, + totalTokens, + contextWindowSize, + breakdown, + builtinTools: detailBuiltinTools, + mcpTools: detailMcpTools, + memoryFiles: detailMemoryFiles, + skills: detailSkills, + isEstimated, + }; + + context.ui.addItem(contextUsageItem, Date.now()); + }, +}; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index a4fa9ee7c..5eb1e7bc9 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -33,6 +33,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; +import { ContextUsage } from './views/ContextUsage.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -176,6 +177,19 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'mcp_status' && ( )} + {itemForDisplay.type === 'context_usage' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/views/ContextUsage.tsx b/packages/cli/src/ui/components/views/ContextUsage.tsx new file mode 100644 index 000000000..67f4bf282 --- /dev/null +++ b/packages/cli/src/ui/components/views/ContextUsage.tsx @@ -0,0 +1,361 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import type { + ContextCategoryBreakdown, + ContextToolDetail, + ContextMemoryDetail, + ContextSkillDetail, +} from '../../types.js'; +import { t } from '../../../i18n/index.js'; + +// Progress bar characters +const FILLED = '\u2588'; // █ - filled block +const BUFFER = '\u2592'; // ▒ - medium shade (autocompact buffer) +const EMPTY = '\u2591'; // ░ - light shade (free space) + +const CONTENT_WIDTH = 56; + +interface ContextUsageProps { + modelName: string; + totalTokens: number; + contextWindowSize: number; + breakdown: ContextCategoryBreakdown; + builtinTools: ContextToolDetail[]; + mcpTools: ContextToolDetail[]; + memoryFiles: ContextMemoryDetail[]; + skills: ContextSkillDetail[]; + /** True when totalTokens is estimated (no API call yet) */ + isEstimated?: boolean; +} + +/** + * Truncate a string to maxLen, appending '…' if truncated. + */ +function truncateName(name: string, maxLen: number): string { + if (name.length <= maxLen) return name; + return name.slice(0, maxLen - 1) + '\u2026'; +} + +/** + * Format token count for display (e.g. 1234 -> "1.2k", 123456 -> "123.5k") + */ +function formatTokens(tokens: number): string { + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(1)}k`; + } + return `${tokens}`; +} + +/** + * Render a three-segment progress bar: used | autocompact buffer | free space. + */ +const ProgressBar: React.FC<{ + usedPercentage: number; + bufferPercentage: number; + width: number; +}> = ({ usedPercentage, bufferPercentage, width }) => { + const usedCount = Math.round((Math.min(usedPercentage, 100) / 100) * width); + const bufferCount = Math.round( + (Math.min(bufferPercentage, 100 - usedPercentage) / 100) * width, + ); + const freeCount = Math.max(0, width - usedCount - bufferCount); + + const usedStr = FILLED.repeat(Math.max(0, usedCount)); + const freeStr = EMPTY.repeat(Math.max(0, freeCount)); + const bufferStr = BUFFER.repeat(Math.max(0, bufferCount)); + + // Used color: accent by default, warning/error at high usage. + let usedColor = theme.text.accent; + if (usedPercentage > 80) { + usedColor = theme.status.error; + } else if (usedPercentage > 60) { + usedColor = theme.status.warning; + } + + return ( + + {usedStr} + {freeStr} + {bufferStr} + + ); +}; + +/** + * A row showing a category with its token count and percentage. + */ +const CategoryRow: React.FC<{ + symbol: string; + label: string; + tokens: number; + contextWindowSize: number; + symbolColor?: string; +}> = ({ symbol, label, tokens, contextWindowSize, symbolColor }) => { + const percentage = ((tokens / contextWindowSize) * 100).toFixed(1); + const tokenStr = `${formatTokens(tokens)} ${t('tokens')} (${percentage}%)`; + + return ( + + + {symbol} + + + {label} + + + {tokenStr} + + + ); +}; + +/** + * A detail row for individual items (MCP tools, memory files, skills). + */ +const DETAIL_NAME_MAX_LEN = 30; + +const DetailRow: React.FC<{ + name: string; + tokens: number; +}> = ({ name, tokens }) => { + const tokenStr = + tokens > 0 ? `${formatTokens(tokens)} ${t('tokens')}` : `0 ${t('tokens')}`; + return ( + + {'\u2514'} + + + {truncateName(name, DETAIL_NAME_MAX_LEN)} + + + + {tokenStr} + + + ); +}; + +export const ContextUsage: React.FC = ({ + modelName, + totalTokens, + contextWindowSize, + breakdown, + builtinTools, + mcpTools, + memoryFiles, + skills, + isEstimated, +}) => { + const percentage = + contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0; + + // Sort detail items by token count (descending) for better readability + const sortedBuiltinTools = [...builtinTools].sort( + (a, b) => b.tokens - a.tokens, + ); + const sortedMcpTools = [...mcpTools].sort((a, b) => b.tokens - a.tokens); + const sortedMemoryFiles = [...memoryFiles].sort( + (a, b) => b.tokens - a.tokens, + ); + const sortedSkills = [...skills].sort((a, b) => b.tokens - a.tokens); + + return ( + + {/* Title */} + + {t('Context Usage')} + + + + {isEstimated ? ( + <> + {/* No API data yet — show hint instead of progress bar */} + + + {t('No API response yet. Send a message to see actual usage.')} + + + + {/* Estimated overhead categories */} + + {t('Estimated pre-conversation overhead')} + + + {t('Model')}: {modelName} + {' '} + {t('Context window')}: {formatTokens(contextWindowSize)}{' '} + {t('tokens')} + + + + ) : ( + <> + {/* Model name + context window info */} + + {modelName} + + + {t('Context window')}: {formatTokens(contextWindowSize)}{' '} + {t('tokens')} + + + + {/* Progress bar — three segments: used | free | buffer */} + + 0 + ? (breakdown.autocompactBuffer / contextWindowSize) * 100 + : 0 + } + width={CONTENT_WIDTH} + /> + + + {/* Legend — same layout as CategoryRow for alignment */} + + + + + + {/* Breakdown header */} + + {t('Usage by category')} + + + )} + + + + {breakdown.mcpTools > 0 && ( + + )} + + + {/* Only show Messages when we have real API data */} + {!isEstimated && ( + + )} + + {/* Built-in tools detail */} + {sortedBuiltinTools.length > 0 && ( + + + {t('Built-in tools')} + + {sortedBuiltinTools.map((tool) => ( + + ))} + + )} + + {/* MCP Tools detail */} + {sortedMcpTools.length > 0 && ( + + + {t('MCP tools')} + + {sortedMcpTools.map((tool) => ( + + ))} + + )} + + {/* Memory files detail */} + {sortedMemoryFiles.length > 0 && ( + + + {t('Memory files')} + + {sortedMemoryFiles.map((file) => ( + + ))} + + )} + + {/* Skills detail */} + {sortedSkills.length > 0 && ( + + + {t('Skills')} + + {sortedSkills.map((skill) => ( + + ))} + + )} + + ); +}; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index b111f9ac7..fc452d7f6 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -251,6 +251,48 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showTips: boolean; }; +// --- Context Usage types --- + +export interface ContextCategoryBreakdown { + systemPrompt: number; + builtinTools: number; + mcpTools: number; + memoryFiles: number; + skills: number; + messages: number; + freeSpace: number; + autocompactBuffer: number; +} + +export interface ContextToolDetail { + name: string; + tokens: number; +} + +export interface ContextMemoryDetail { + path: string; + tokens: number; +} + +export interface ContextSkillDetail { + name: string; + tokens: number; +} + +export type HistoryItemContextUsage = HistoryItemBase & { + type: 'context_usage'; + modelName: string; + totalTokens: number; + contextWindowSize: number; + breakdown: ContextCategoryBreakdown; + builtinTools: ContextToolDetail[]; + mcpTools: ContextToolDetail[]; + memoryFiles: ContextMemoryDetail[]; + skills: ContextSkillDetail[]; + /** True when totalTokens is estimated (no API call yet) rather than from API response */ + isEstimated?: boolean; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -278,7 +320,8 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList - | HistoryItemMcpStatus; + | HistoryItemMcpStatus + | HistoryItemContextUsage; export type HistoryItem = HistoryItemWithoutId & { id: number }; @@ -301,6 +344,7 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', + CONTEXT_USAGE = 'context_usage', } // Simplified message structure for internal feedback diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c76fd2f8d..c2112fbd3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -272,6 +272,7 @@ export * from './utils/projectSummary.js'; export * from './utils/quotaErrorDetection.js'; export * from './utils/readManyFiles.js'; export * from './utils/request-tokenizer/supportedImageFormats.js'; +export { TextTokenizer } from './utils/request-tokenizer/textTokenizer.js'; export * from './utils/retry.js'; export * from './utils/ripgrepUtils.js'; export * from './utils/schemaValidator.js'; From 6b55c8161f628f63a35f1f9cff9961120b2133b4 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 18 Feb 2026 10:51:35 +0800 Subject: [PATCH 02/82] feat(arena): Add agent collaboration arena feature Introduces a new Arena system for running multiple AI agents in parallel terminal sessions with support for iTerm and Tmux backends. Core: - Add ArenaManager and ArenaAgentClient for orchestrating multi-agent sessions - Add terminal backends (ITermBackend, TmuxBackend) with feature detection - Add git worktree service for isolated agent workspaces - Add arena event system for real-time status updates CLI: - Add /arena command with start, stop, status, and select subcommands - Add Arena dialogs (Select, Start, Status, Stop) - Add ArenaCards component for displaying parallel agent outputs - Consolidate message components into StatusMessages and ConversationMessages - Add MultiSelect component for agent selection Config: - Add arena-related settings to schema and config Co-authored-by: Qwen-Coder --- eslint.config.js | 1 + packages/cli/src/acp-integration/acpAgent.ts | 5 +- packages/cli/src/config/config.ts | 12 + packages/cli/src/config/settingsSchema.ts | 79 ++ .../cli/src/services/BuiltinCommandLoader.ts | 2 + packages/cli/src/ui/AppContainer.tsx | 25 + .../cli/src/ui/commands/arenaCommand.test.ts | 395 ++++++ packages/cli/src/ui/commands/arenaCommand.ts | 620 +++++++++ packages/cli/src/ui/commands/types.ts | 4 + .../src/ui/components/ArenaSelectDialog.tsx | 245 ++++ .../src/ui/components/ArenaStartDialog.tsx | 144 ++ .../src/ui/components/ArenaStatusDialog.tsx | 253 ++++ .../cli/src/ui/components/ArenaStopDialog.tsx | 198 +++ .../cli/src/ui/components/DialogManager.tsx | 46 + .../src/ui/components/HistoryItemDisplay.tsx | 55 +- .../HistoryItemDisplay.test.tsx.snap | 6 +- .../src/ui/components/messages/ArenaCards.tsx | 279 ++++ .../messages/ConversationMessages.tsx | 261 ++++ .../ui/components/messages/ErrorMessage.tsx | 31 - .../ui/components/messages/GeminiMessage.tsx | 46 - .../messages/GeminiMessageContent.tsx | 43 - .../messages/GeminiThoughtMessage.tsx | 48 - .../messages/GeminiThoughtMessageContent.tsx | 40 - .../ui/components/messages/InfoMessage.tsx | 37 - .../messages/RetryCountdownMessage.tsx | 41 - .../ui/components/messages/StatusMessages.tsx | 97 ++ .../ui/components/messages/UserMessage.tsx | 38 - .../components/messages/UserShellMessage.tsx | 25 - .../ui/components/messages/WarningMessage.tsx | 32 - .../shared/DescriptiveRadioButtonSelect.tsx | 8 +- .../src/ui/components/shared/MultiSelect.tsx | 193 +++ .../src/ui/components/shared/text-buffer.ts | 4 +- .../cli/src/ui/contexts/UIActionsContext.tsx | 4 + .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../cli/src/ui/hooks/slashCommandProcessor.ts | 14 + packages/cli/src/ui/hooks/useArenaCommand.ts | 37 + packages/cli/src/ui/hooks/useDialogClose.ts | 10 + .../cli/src/ui/hooks/useGeminiStream.test.tsx | 1 + packages/cli/src/ui/hooks/useGeminiStream.ts | 10 + .../cli/src/ui/hooks/useSelectionList.test.ts | 32 + packages/cli/src/ui/hooks/useSelectionList.ts | 41 +- packages/cli/src/ui/themes/no-color.ts | 1 + packages/cli/src/ui/themes/semantic-tokens.ts | 4 + packages/cli/src/ui/themes/theme.ts | 5 +- packages/cli/src/ui/types.ts | 44 +- .../src/ui/utils/InlineMarkdownRenderer.tsx | 2 +- packages/cli/src/ui/utils/displayUtils.ts | 33 + .../arena/ArenaAgentClient.test.ts | 542 ++++++++ .../agents-collab/arena/ArenaAgentClient.ts | 273 ++++ .../agents-collab/arena/ArenaManager.test.ts | 433 ++++++ .../src/agents-collab/arena/ArenaManager.ts | 1215 +++++++++++++++++ .../src/agents-collab/arena/arena-events.ts | 246 ++++ .../core/src/agents-collab/arena/index.ts | 14 + .../core/src/agents-collab/arena/types.ts | 293 ++++ .../backends/ITermBackend.test.ts | 569 ++++++++ .../agents-collab/backends/ITermBackend.ts | 431 ++++++ .../backends/TmuxBackend.test.ts | 482 +++++++ .../src/agents-collab/backends/TmuxBackend.ts | 813 +++++++++++ .../core/src/agents-collab/backends/detect.ts | 74 + .../core/src/agents-collab/backends/index.ts | 17 + .../agents-collab/backends/iterm-it2.test.ts | 318 +++++ .../src/agents-collab/backends/iterm-it2.ts | 141 ++ .../backends/tmux-commands.test.ts | 60 + .../agents-collab/backends/tmux-commands.ts | 503 +++++++ .../core/src/agents-collab/backends/types.ts | 228 ++++ packages/core/src/agents-collab/index.ts | 17 + packages/core/src/config/config.ts | 60 + packages/core/src/core/client.test.ts | 1 + packages/core/src/core/client.ts | 44 + packages/core/src/index.ts | 4 + .../src/services/gitWorktreeService.test.ts | 491 +++++++ .../core/src/services/gitWorktreeService.ts | 803 +++++++++++ packages/core/src/utils/terminalSerializer.ts | 17 +- 73 files changed, 11225 insertions(+), 417 deletions(-) create mode 100644 packages/cli/src/ui/commands/arenaCommand.test.ts create mode 100644 packages/cli/src/ui/commands/arenaCommand.ts create mode 100644 packages/cli/src/ui/components/ArenaSelectDialog.tsx create mode 100644 packages/cli/src/ui/components/ArenaStartDialog.tsx create mode 100644 packages/cli/src/ui/components/ArenaStatusDialog.tsx create mode 100644 packages/cli/src/ui/components/ArenaStopDialog.tsx create mode 100644 packages/cli/src/ui/components/messages/ArenaCards.tsx create mode 100644 packages/cli/src/ui/components/messages/ConversationMessages.tsx delete mode 100644 packages/cli/src/ui/components/messages/ErrorMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/GeminiMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/GeminiMessageContent.tsx delete mode 100644 packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx delete mode 100644 packages/cli/src/ui/components/messages/InfoMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx create mode 100644 packages/cli/src/ui/components/messages/StatusMessages.tsx delete mode 100644 packages/cli/src/ui/components/messages/UserMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/UserShellMessage.tsx delete mode 100644 packages/cli/src/ui/components/messages/WarningMessage.tsx create mode 100644 packages/cli/src/ui/components/shared/MultiSelect.tsx create mode 100644 packages/cli/src/ui/hooks/useArenaCommand.ts create mode 100644 packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts create mode 100644 packages/core/src/agents-collab/arena/ArenaAgentClient.ts create mode 100644 packages/core/src/agents-collab/arena/ArenaManager.test.ts create mode 100644 packages/core/src/agents-collab/arena/ArenaManager.ts create mode 100644 packages/core/src/agents-collab/arena/arena-events.ts create mode 100644 packages/core/src/agents-collab/arena/index.ts create mode 100644 packages/core/src/agents-collab/arena/types.ts create mode 100644 packages/core/src/agents-collab/backends/ITermBackend.test.ts create mode 100644 packages/core/src/agents-collab/backends/ITermBackend.ts create mode 100644 packages/core/src/agents-collab/backends/TmuxBackend.test.ts create mode 100644 packages/core/src/agents-collab/backends/TmuxBackend.ts create mode 100644 packages/core/src/agents-collab/backends/detect.ts create mode 100644 packages/core/src/agents-collab/backends/index.ts create mode 100644 packages/core/src/agents-collab/backends/iterm-it2.test.ts create mode 100644 packages/core/src/agents-collab/backends/iterm-it2.ts create mode 100644 packages/core/src/agents-collab/backends/tmux-commands.test.ts create mode 100644 packages/core/src/agents-collab/backends/tmux-commands.ts create mode 100644 packages/core/src/agents-collab/backends/types.ts create mode 100644 packages/core/src/agents-collab/index.ts create mode 100644 packages/core/src/services/gitWorktreeService.test.ts create mode 100644 packages/core/src/services/gitWorktreeService.ts diff --git a/eslint.config.js b/eslint.config.js index 1d0ed2af9..5c796a256 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -59,6 +59,7 @@ export default tseslint.config( ...importPlugin.configs.typescript.rules, 'import/no-default-export': 'warn', 'import/no-unresolved': 'off', // Disable for now, can be noisy with monorepos/paths + 'import/namespace': 'off', // Disabled due to https://github.com/import-js/eslint-plugin-import/issues/2866 }, }, { diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index a7ae2cf4c..865ad4677 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -21,7 +21,6 @@ import { type ConversationRecord, type DeviceAuthorizationData, } from '@qwen-code/qwen-code-core'; -import type { ApprovalModeValue } from './schema.js'; import * as acp from './acp.js'; import { buildAuthMethods } from './authMethods.js'; import { AcpFileSystemService } from './service/filesystem.js'; @@ -81,7 +80,7 @@ class GeminiAgent { // Build available modes from shared APPROVAL_MODE_INFO const availableModes = APPROVAL_MODES.map((mode) => ({ - id: mode as ApprovalModeValue, + id: mode as acp.ApprovalModeValue, name: APPROVAL_MODE_INFO[mode].name, description: APPROVAL_MODE_INFO[mode].description, })); @@ -97,7 +96,7 @@ class GeminiAgent { }, authMethods, modes: { - currentModeId: currentApprovalMode as ApprovalModeValue, + currentModeId: currentApprovalMode as acp.ApprovalModeValue, availableModes, }, agentCapabilities: { diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index c31ffa216..6819c64b0 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -1036,6 +1036,18 @@ export async function loadCliConfig( lsp: { enabled: lspEnabled, }, + agents: settings.agents + ? { + displayMode: settings.agents.displayMode, + arena: settings.agents.arena + ? { + worktreeBaseDir: settings.agents.arena.worktreeBaseDir, + preserveArtifacts: + settings.agents.arena.preserveArtifacts ?? false, + } + : undefined, + } + : undefined, }); if (lspEnabled) { diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 283baee26..ca86ea0a5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1177,6 +1177,85 @@ const SETTINGS_SCHEMA = { showInDialog: false, }, + agents: { + type: 'object', + label: 'Agents', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for multi-agent collaboration features (Arena, Team, Swarm).', + showInDialog: false, + properties: { + displayMode: { + type: 'enum', + label: 'Display Mode', + category: 'Advanced', + requiresRestart: false, + default: undefined as string | undefined, + description: + 'Display mode for multi-agent sessions. "tmux" uses tmux panes, "iterm2" uses iTerm2 tabs, "in-process" runs in the current terminal.', + showInDialog: false, + options: [ + { value: 'in-process', label: 'In-process' }, + { value: 'tmux', label: 'tmux' }, + { value: 'iterm2', label: 'iTerm2' }, + ], + }, + arena: { + type: 'object', + label: 'Arena', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: 'Settings for Arena (multi-model competitive execution).', + showInDialog: false, + properties: { + worktreeBaseDir: { + type: 'string', + label: 'Worktree Base Directory', + category: 'Advanced', + requiresRestart: true, + default: undefined as string | undefined, + description: + 'Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.', + showInDialog: false, + }, + preserveArtifacts: { + type: 'boolean', + label: 'Preserve Arena Artifacts', + category: 'Advanced', + requiresRestart: false, + default: false, + description: + 'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.', + showInDialog: true, + }, + }, + }, + team: { + type: 'object', + label: 'Team', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for Agent Team (role-based collaborative execution). Reserved for future use.', + showInDialog: false, + }, + swarm: { + type: 'object', + label: 'Swarm', + category: 'Advanced', + requiresRestart: false, + default: {}, + description: + 'Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.', + showInDialog: false, + }, + }, + }, + experimental: { type: 'object', label: 'Experimental', diff --git a/packages/cli/src/services/BuiltinCommandLoader.ts b/packages/cli/src/services/BuiltinCommandLoader.ts index dc4c1f8d9..aa02f3c3c 100644 --- a/packages/cli/src/services/BuiltinCommandLoader.ts +++ b/packages/cli/src/services/BuiltinCommandLoader.ts @@ -9,6 +9,7 @@ import type { SlashCommand } from '../ui/commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; import { aboutCommand } from '../ui/commands/aboutCommand.js'; import { agentsCommand } from '../ui/commands/agentsCommand.js'; +import { arenaCommand } from '../ui/commands/arenaCommand.js'; import { approvalModeCommand } from '../ui/commands/approvalModeCommand.js'; import { authCommand } from '../ui/commands/authCommand.js'; import { bugCommand } from '../ui/commands/bugCommand.js'; @@ -59,6 +60,7 @@ export class BuiltinCommandLoader implements ICommandLoader { const allDefinitions: Array = [ aboutCommand, agentsCommand, + arenaCommand, approvalModeCommand, authCommand, bugCommand, diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 53e1ea9e3..663a0782a 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -52,6 +52,7 @@ import { useAuthCommand } from './auth/useAuth.js'; import { useEditorSettings } from './hooks/useEditorSettings.js'; import { useSettingsCommand } from './hooks/useSettingsCommand.js'; import { useModelCommand } from './hooks/useModelCommand.js'; +import { useArenaCommand } from './hooks/useArenaCommand.js'; import { useApprovalModeCommand } from './hooks/useApprovalModeCommand.js'; import { useResumeCommand } from './hooks/useResumeCommand.js'; import { useSlashCommandProcessor } from './hooks/slashCommandProcessor.js'; @@ -470,6 +471,8 @@ export const AppContainer = (props: AppContainerProps) => { const { isModelDialogOpen, openModelDialog, closeModelDialog } = useModelCommand(); + const { activeArenaDialog, openArenaDialog, closeArenaDialog } = + useArenaCommand(); const { isResumeDialogOpen, @@ -515,6 +518,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openSettingsDialog, openModelDialog, + openArenaDialog, openPermissionsDialog, openApprovalModeDialog, quit: (messages: HistoryItem[]) => { @@ -537,6 +541,7 @@ export const AppContainer = (props: AppContainerProps) => { openEditorDialog, openSettingsDialog, openModelDialog, + openArenaDialog, setDebugMessage, dispatchExtensionStateUpdate, openPermissionsDialog, @@ -720,6 +725,15 @@ export const AppContainer = (props: AppContainerProps) => { [addMessage], ); + const handleArenaModelsSelected = useCallback( + (models: string[]) => { + const value = models.join(','); + buffer.setText(`/arena start --models ${value} `); + closeArenaDialog(); + }, + [buffer, closeArenaDialog], + ); + // Welcome back functionality (must be after handleFinalSubmit) const { welcomeBackInfo, @@ -1077,6 +1091,8 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, isSettingsDialogOpen, closeSettingsDialog, + activeArenaDialog, + closeArenaDialog, isFolderTrustDialogOpen, showWelcomeBackDialog, handleWelcomeBackClose, @@ -1334,6 +1350,7 @@ export const AppContainer = (props: AppContainerProps) => { isThemeDialogOpen || isSettingsDialogOpen || isModelDialogOpen || + activeArenaDialog !== null || isVisionSwitchDialogOpen || isPermissionsDialogOpen || isAuthDialogOpen || @@ -1383,6 +1400,7 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isModelDialogOpen, + activeArenaDialog, isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, @@ -1474,6 +1492,7 @@ export const AppContainer = (props: AppContainerProps) => { quittingMessages, isSettingsDialogOpen, isModelDialogOpen, + activeArenaDialog, isPermissionsDialogOpen, isApprovalModeDialogOpen, isResumeDialogOpen, @@ -1568,6 +1587,9 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + openArenaDialog, + closeArenaDialog, + handleArenaModelsSelected, dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, @@ -1614,6 +1636,9 @@ export const AppContainer = (props: AppContainerProps) => { exitEditorDialog, closeSettingsDialog, closeModelDialog, + openArenaDialog, + closeArenaDialog, + handleArenaModelsSelected, dismissCodingPlanUpdate, closePermissionsDialog, setShellModeActive, diff --git a/packages/cli/src/ui/commands/arenaCommand.test.ts b/packages/cli/src/ui/commands/arenaCommand.test.ts new file mode 100644 index 000000000..12def97bb --- /dev/null +++ b/packages/cli/src/ui/commands/arenaCommand.test.ts @@ -0,0 +1,395 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { + type ArenaManager, + ArenaAgentStatus, + ArenaSessionStatus, +} from '@qwen-code/qwen-code-core'; +import { arenaCommand } from './arenaCommand.js'; +import type { + CommandContext, + OpenDialogActionReturn, + SlashCommand, +} from './types.js'; +import { createMockCommandContext } from '../../test-utils/mockCommandContext.js'; + +function getArenaSubCommand( + name: 'start' | 'stop' | 'status' | 'select', +): SlashCommand { + const command = arenaCommand.subCommands?.find((item) => item.name === name); + if (!command?.action) { + throw new Error(`Arena subcommand "${name}" is missing an action`); + } + return command; +} + +describe('arenaCommand stop subcommand', () => { + let mockContext: CommandContext; + let mockConfig: { + getArenaManager: ReturnType; + setArenaManager: ReturnType; + cleanupArenaRuntime: ReturnType; + getAgentsSettings: ReturnType; + }; + + beforeEach(() => { + mockConfig = { + getArenaManager: vi.fn(() => null), + setArenaManager: vi.fn(), + cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined), + getAgentsSettings: vi.fn(() => ({})), + }; + + mockContext = createMockCommandContext({ + invocation: { + raw: '/arena stop', + name: 'arena', + args: 'stop', + }, + executionMode: 'interactive', + services: { + config: mockConfig as never, + }, + }); + }); + + it('returns an error when no arena session is running', async () => { + const stopCommand = getArenaSubCommand('stop'); + const result = await stopCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No running Arena session found.', + }); + }); + + it('opens stop dialog when a running session exists', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const stopCommand = getArenaSubCommand('stop'); + const result = (await stopCommand.action!( + mockContext, + '', + )) as OpenDialogActionReturn; + + expect(result).toEqual({ + type: 'dialog', + dialog: 'arena_stop', + }); + }); + + it('opens stop dialog when a completed session exists', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const stopCommand = getArenaSubCommand('stop'); + const result = (await stopCommand.action!( + mockContext, + '', + )) as OpenDialogActionReturn; + + expect(result).toEqual({ + type: 'dialog', + dialog: 'arena_stop', + }); + }); +}); + +describe('arenaCommand status subcommand', () => { + let mockContext: CommandContext; + let mockConfig: { + getArenaManager: ReturnType; + }; + + beforeEach(() => { + mockConfig = { + getArenaManager: vi.fn(() => null), + }; + + mockContext = createMockCommandContext({ + invocation: { + raw: '/arena status', + name: 'arena', + args: 'status', + }, + executionMode: 'interactive', + services: { + config: mockConfig as never, + }, + }); + }); + + it('returns an error when no arena session exists', async () => { + const statusCommand = getArenaSubCommand('status'); + const result = await statusCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No Arena session found. Start one with /arena start.', + }); + }); + + it('opens status dialog when a session exists', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const statusCommand = getArenaSubCommand('status'); + const result = (await statusCommand.action!( + mockContext, + '', + )) as OpenDialogActionReturn; + + expect(result).toEqual({ + type: 'dialog', + dialog: 'arena_status', + }); + }); + + it('opens status dialog for completed session', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const statusCommand = getArenaSubCommand('status'); + const result = (await statusCommand.action!( + mockContext, + '', + )) as OpenDialogActionReturn; + + expect(result).toEqual({ + type: 'dialog', + dialog: 'arena_status', + }); + }); +}); + +describe('arenaCommand select subcommand', () => { + let mockContext: CommandContext; + let mockConfig: { + getArenaManager: ReturnType; + setArenaManager: ReturnType; + cleanupArenaRuntime: ReturnType; + getAgentsSettings: ReturnType; + }; + + beforeEach(() => { + mockConfig = { + getArenaManager: vi.fn(() => null), + setArenaManager: vi.fn(), + cleanupArenaRuntime: vi.fn().mockResolvedValue(undefined), + getAgentsSettings: vi.fn(() => ({})), + }; + + mockContext = createMockCommandContext({ + invocation: { + raw: '/arena select', + name: 'arena', + args: 'select', + }, + executionMode: 'interactive', + services: { + config: mockConfig as never, + }, + }); + }); + + it('returns error when no arena session exists', async () => { + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No arena session found. Start one with /arena start.', + }); + }); + + it('returns error when arena is still running', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.RUNNING), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'Arena session is still running. Wait for it to complete or use /arena stop first.', + }); + }); + + it('returns error when all agents failed', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.TERMINATED, + model: { modelId: 'model-1' }, + }, + ]), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: + 'No successful agent results to select from. All agents failed or were cancelled.\n' + + 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + }); + }); + + it('opens dialog when no args provided and agents have results', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'model-1' }, + }, + { + agentId: 'agent-2', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'model-2' }, + }, + ]), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, ''); + + expect(result).toEqual({ + type: 'dialog', + dialog: 'arena_select', + }); + }); + + it('applies changes directly when model name is provided', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'gpt-4o', displayName: 'gpt-4o' }, + }, + { + agentId: 'agent-2', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' }, + }, + ]), + applyAgentResult: vi.fn().mockResolvedValue({ success: true }), + cleanup: vi.fn().mockResolvedValue(undefined), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, 'gpt-4o'); + + expect(mockManager.applyAgentResult).toHaveBeenCalledWith('agent-1'); + expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: + 'Applied changes from gpt-4o to workspace. Arena session complete.', + }); + }); + + it('returns error when specified model not found', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'gpt-4o', displayName: 'gpt-4o' }, + }, + ]), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, 'nonexistent'); + + expect(result).toEqual({ + type: 'message', + messageType: 'error', + content: 'No idle agent found matching "nonexistent".', + }); + }); + + it('asks for confirmation when --discard flag is used', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'gpt-4o' }, + }, + ]), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, '--discard'); + + expect(result).toEqual({ + type: 'confirm_action', + prompt: 'Discard all Arena results and clean up worktrees?', + originalInvocation: { raw: '/arena select' }, + }); + }); + + it('discards results after --discard confirmation', async () => { + const mockManager = { + getSessionStatus: vi.fn(() => ArenaSessionStatus.COMPLETED), + getAgentStates: vi.fn(() => [ + { + agentId: 'agent-1', + status: ArenaAgentStatus.COMPLETED, + model: { modelId: 'gpt-4o' }, + }, + ]), + cleanup: vi.fn().mockResolvedValue(undefined), + } as unknown as ArenaManager; + mockConfig.getArenaManager = vi.fn(() => mockManager); + mockContext.overwriteConfirmed = true; + + const selectCommand = getArenaSubCommand('select'); + const result = await selectCommand.action!(mockContext, '--discard'); + + expect(mockConfig.cleanupArenaRuntime).toHaveBeenCalled(); + expect(result).toEqual({ + type: 'message', + messageType: 'info', + content: 'Arena results discarded. All worktrees cleaned up.', + }); + }); +}); diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts new file mode 100644 index 000000000..b71b81596 --- /dev/null +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -0,0 +1,620 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { + SlashCommand, + CommandContext, + ConfirmActionReturn, + MessageActionReturn, + OpenDialogActionReturn, + SlashCommandActionReturn, +} from './types.js'; +import { CommandKind } from './types.js'; +import { + ArenaManager, + ArenaEventType, + ArenaAgentStatus, + ArenaSessionStatus, + AuthType, + createDebugLogger, + type Config, + type ArenaModelConfig, + type ArenaAgentErrorEvent, + type ArenaAgentCompleteEvent, + type ArenaAgentStartEvent, + type ArenaSessionCompleteEvent, + type ArenaSessionErrorEvent, + type ArenaSessionStartEvent, + type ArenaSessionWarningEvent, +} from '@qwen-code/qwen-code-core'; +import { + MessageType, + type ArenaAgentCardData, + type HistoryItemWithoutId, +} from '../types.js'; + +/** + * Parsed model entry with optional auth type. + */ +interface ParsedModel { + authType?: string; + modelId: string; +} + +/** + * Parses arena command arguments. + * + * Supported formats: + * /arena start --models model1,model2 + * /arena start --models authType1:model1,authType2:model2 + * + * Model format: [authType:]modelId + * - "gpt-4o" → uses default auth type + * - "openai:gpt-4o" → uses "openai" auth type + */ +function parseArenaArgs(args: string): { + models: ParsedModel[]; + task: string; +} { + const modelsMatch = args.match(/--models\s+(\S+)/); + + let models: ParsedModel[] = []; + let task = args; + + if (modelsMatch) { + const modelStrings = modelsMatch[1]!.split(',').filter(Boolean); + models = modelStrings.map((str) => { + // Check for authType:modelId format + const colonIndex = str.indexOf(':'); + if (colonIndex > 0) { + return { + authType: str.substring(0, colonIndex), + modelId: str.substring(colonIndex + 1), + }; + } + return { modelId: str }; + }); + task = task.replace(/--models\s+\S+/, '').trim(); + } + + // Strip surrounding quotes from task + task = task.replace(/^["']|["']$/g, '').trim(); + + return { models, task }; +} + +const debugLogger = createDebugLogger('ARENA_COMMAND'); + +interface ArenaExecutionInput { + task: string; + models: ArenaModelConfig[]; + approvalMode?: string; +} + +function buildArenaExecutionInput( + parsed: ReturnType, + config: Config, +): ArenaExecutionInput | MessageActionReturn { + if (!parsed.task) { + return { + type: 'message', + messageType: 'error', + content: + 'Usage: /arena start --models model1,model2 \n' + + '\n' + + 'Options:\n' + + ' --models [authType:]model1,[authType:]model2\n' + + ' Models to compete (required, at least 2)\n' + + ' Format: authType:modelId or just modelId\n' + + '\n' + + 'Examples:\n' + + ' /arena start --models openai:gpt-4o,anthropic:claude-3 "implement sorting"\n' + + ' /arena start --models qwen-coder-plus,kimi-for-coding "fix the bug"', + }; + } + + if (parsed.models.length < 2) { + return { + type: 'message', + messageType: 'error', + content: + 'Arena requires at least 2 models. Use --models model1,model2 to specify.\n' + + 'Format: [authType:]modelId (e.g., openai:gpt-4o or just gpt-4o)', + }; + } + + // Get the current auth type as default for models without explicit auth type + const contentGeneratorConfig = config.getContentGeneratorConfig(); + const defaultAuthType = + contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI; + + // Build ArenaModelConfig for each model + const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => ({ + modelId: parsedModel.modelId, + authType: parsedModel.authType ?? defaultAuthType, + displayName: parsedModel.authType + ? `${parsedModel.authType}:${parsedModel.modelId}` + : parsedModel.modelId, + })); + + return { + task: parsed.task, + models, + approvalMode: config.getApprovalMode(), + }; +} + +function executeArenaCommand( + config: Config, + ui: CommandContext['ui'], + input: ArenaExecutionInput, +): void { + const manager = new ArenaManager(config); + const emitter = manager.getEventEmitter(); + const detachListeners: Array<() => void> = []; + const agentLabels = new Map(); + + const addArenaMessage = ( + type: 'info' | 'warning' | 'error' | 'success', + text: string, + ) => { + ui.addItem({ type, text }, Date.now()); + }; + + const handleSessionStart = (event: ArenaSessionStartEvent) => { + const modelList = event.models + .map( + (model, index) => + ` ${index + 1}. ${model.displayName || model.modelId}`, + ) + .join('\n'); + addArenaMessage( + MessageType.INFO, + `Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`, + ); + }; + + const handleAgentStart = (event: ArenaAgentStartEvent) => { + const label = event.model.displayName || event.model.modelId; + agentLabels.set(event.agentId, label); + debugLogger.debug(`Arena agent started: ${label} (${event.agentId})`); + }; + + const handleSessionWarning = (event: ArenaSessionWarningEvent) => { + const attachHintPrefix = 'To view agent panes, run: '; + if (event.message.startsWith(attachHintPrefix)) { + const command = event.message.slice(attachHintPrefix.length).trim(); + addArenaMessage( + MessageType.INFO, + `Arena panes are running in tmux. Attach with: \`${command}\``, + ); + return; + } + addArenaMessage(MessageType.WARNING, `Arena warning: ${event.message}`); + }; + + const handleAgentError = (event: ArenaAgentErrorEvent) => { + const label = agentLabels.get(event.agentId) || event.agentId; + addArenaMessage(MessageType.ERROR, `[${label}] failed: ${event.error}`); + }; + + const buildAgentCardData = ( + result: ArenaAgentCompleteEvent['result'], + ): ArenaAgentCardData => { + let status: ArenaAgentCardData['status']; + switch (result.status) { + case ArenaAgentStatus.COMPLETED: + status = 'completed'; + break; + case ArenaAgentStatus.CANCELLED: + status = 'cancelled'; + break; + default: + status = 'terminated'; + break; + } + return { + label: result.model.displayName || result.model.modelId, + status, + durationMs: result.stats.durationMs, + totalTokens: result.stats.totalTokens, + inputTokens: result.stats.inputTokens, + outputTokens: result.stats.outputTokens, + toolCalls: result.stats.toolCalls, + successfulToolCalls: result.stats.successfulToolCalls, + failedToolCalls: result.stats.failedToolCalls, + rounds: result.stats.rounds, + error: result.error, + diff: result.diff, + }; + }; + + const handleAgentComplete = (event: ArenaAgentCompleteEvent) => { + // Show message for completed (success), cancelled, and terminated (error) agents + if ( + event.result.status !== ArenaAgentStatus.COMPLETED && + event.result.status !== ArenaAgentStatus.CANCELLED && + event.result.status !== ArenaAgentStatus.TERMINATED + ) { + return; + } + + const agent = buildAgentCardData(event.result); + ui.addItem( + { + type: 'arena_agent_complete', + agent, + } as HistoryItemWithoutId, + Date.now(), + ); + }; + + const handleSessionError = (event: ArenaSessionErrorEvent) => { + addArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); + }; + + const handleSessionComplete = (event: ArenaSessionCompleteEvent) => { + ui.addItem( + { + type: 'arena_session_complete', + sessionStatus: event.result.status, + task: event.result.task, + totalDurationMs: event.result.totalDurationMs ?? 0, + agents: event.result.agents.map(buildAgentCardData), + } as HistoryItemWithoutId, + Date.now(), + ); + }; + + emitter.on(ArenaEventType.SESSION_START, handleSessionStart); + detachListeners.push(() => + emitter.off(ArenaEventType.SESSION_START, handleSessionStart), + ); + emitter.on(ArenaEventType.AGENT_START, handleAgentStart); + detachListeners.push(() => + emitter.off(ArenaEventType.AGENT_START, handleAgentStart), + ); + emitter.on(ArenaEventType.SESSION_WARNING, handleSessionWarning); + detachListeners.push(() => + emitter.off(ArenaEventType.SESSION_WARNING, handleSessionWarning), + ); + emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError); + detachListeners.push(() => + emitter.off(ArenaEventType.AGENT_ERROR, handleAgentError), + ); + emitter.on(ArenaEventType.AGENT_COMPLETE, handleAgentComplete); + detachListeners.push(() => + emitter.off(ArenaEventType.AGENT_COMPLETE, handleAgentComplete), + ); + emitter.on(ArenaEventType.SESSION_ERROR, handleSessionError); + detachListeners.push(() => + emitter.off(ArenaEventType.SESSION_ERROR, handleSessionError), + ); + emitter.on(ArenaEventType.SESSION_COMPLETE, handleSessionComplete); + detachListeners.push(() => + emitter.off(ArenaEventType.SESSION_COMPLETE, handleSessionComplete), + ); + + config.setArenaManager(manager); + + const cols = process.stdout.columns || 120; + const rows = Math.max((process.stdout.rows || 40) - 2, 1); + + const lifecycle = manager + .start({ + task: input.task, + models: input.models, + cols, + rows, + approvalMode: input.approvalMode, + }) + .then( + () => { + debugLogger.debug('Arena session completed'); + }, + (error) => { + const message = error instanceof Error ? error.message : String(error); + addArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); + debugLogger.error('Arena session failed:', error); + + // Clear the stored manager so subsequent /arena start calls + // are not blocked by the stale reference after a startup failure. + config.setArenaManager(null); + }, + ) + .finally(() => { + for (const detach of detachListeners) { + detach(); + } + }); + + // Store so that stop can wait for start() to fully unwind before cleanup + manager.setLifecyclePromise(lifecycle); +} + +export const arenaCommand: SlashCommand = { + name: 'arena', + description: 'Manage Arena sessions', + kind: CommandKind.BUILT_IN, + subCommands: [ + { + name: 'start', + description: + 'Start an Arena session with multiple models competing on the same task', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise => { + const executionMode = context.executionMode ?? 'interactive'; + if (executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: + 'Arena is not supported in non-interactive mode. Use interactive mode to start an Arena session.', + }; + } + + const { services, ui } = context; + const { config } = services; + + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + // Refuse to start if a session already exists (regardless of status) + const existingManager = config.getArenaManager(); + if (existingManager) { + return { + type: 'message', + messageType: 'error', + content: + 'An Arena session exists. Use /arena stop or /arena select to end it before starting a new one.', + }; + } + + const parsed = parseArenaArgs(args); + if (parsed.models.length === 0) { + return { + type: 'dialog', + dialog: 'arena_start', + }; + } + + const executionInput = buildArenaExecutionInput(parsed, config); + if ('type' in executionInput) { + return executionInput; + } + + executeArenaCommand(config, ui, executionInput); + }, + }, + { + name: 'stop', + description: 'Stop the current Arena session', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + ): Promise => { + const executionMode = context.executionMode ?? 'interactive'; + if (executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: + 'Arena is not supported in non-interactive mode. Use interactive mode to stop an Arena session.', + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const manager = config.getArenaManager(); + if (!manager) { + return { + type: 'message', + messageType: 'error', + content: 'No running Arena session found.', + }; + } + + return { + type: 'dialog', + dialog: 'arena_stop', + }; + }, + }, + { + name: 'status', + description: 'Show the current Arena session status', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + ): Promise => { + const executionMode = context.executionMode ?? 'interactive'; + if (executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: 'Arena is not supported in non-interactive mode.', + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const manager = config.getArenaManager(); + if (!manager) { + return { + type: 'message', + messageType: 'error', + content: 'No Arena session found. Start one with /arena start.', + }; + } + + return { + type: 'dialog', + dialog: 'arena_status', + }; + }, + }, + { + name: 'select', + altNames: ['choose'], + description: + 'Select a model result and merge its diff into the current workspace', + kind: CommandKind.BUILT_IN, + action: async ( + context: CommandContext, + args: string, + ): Promise< + | void + | MessageActionReturn + | OpenDialogActionReturn + | ConfirmActionReturn + > => { + const executionMode = context.executionMode ?? 'interactive'; + if (executionMode !== 'interactive') { + return { + type: 'message', + messageType: 'error', + content: 'Arena is not supported in non-interactive mode.', + }; + } + + const { config } = context.services; + if (!config) { + return { + type: 'message', + messageType: 'error', + content: 'Configuration not available.', + }; + } + + const manager = config.getArenaManager(); + + if (!manager) { + return { + type: 'message', + messageType: 'error', + content: 'No arena session found. Start one with /arena start.', + }; + } + + const sessionStatus = manager.getSessionStatus(); + if ( + sessionStatus === ArenaSessionStatus.RUNNING || + sessionStatus === ArenaSessionStatus.INITIALIZING + ) { + return { + type: 'message', + messageType: 'error', + content: + 'Arena session is still running. Wait for it to complete or use /arena stop first.', + }; + } + + // Handle --discard flag before checking for successful agents, + // so users can clean up worktrees even when all agents failed. + const trimmedArgs = args.trim(); + if (trimmedArgs === '--discard') { + if (!context.overwriteConfirmed) { + return { + type: 'confirm_action', + prompt: 'Discard all Arena results and clean up worktrees?', + originalInvocation: { + raw: context.invocation?.raw || '/arena select --discard', + }, + }; + } + + await config.cleanupArenaRuntime(true); + return { + type: 'message', + messageType: 'info', + content: 'Arena results discarded. All worktrees cleaned up.', + }; + } + + const agents = manager.getAgentStates(); + const hasSuccessful = agents.some( + (a) => a.status === ArenaAgentStatus.COMPLETED, + ); + + if (!hasSuccessful) { + return { + type: 'message', + messageType: 'error', + content: + 'No successful agent results to select from. All agents failed or were cancelled.\n' + + 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + }; + } + + // Handle direct model selection via args + if (trimmedArgs) { + const matchingAgent = agents.find((a) => { + const label = a.model.displayName || a.model.modelId; + return ( + a.status === ArenaAgentStatus.COMPLETED && + (label.toLowerCase() === trimmedArgs.toLowerCase() || + a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase()) + ); + }); + + if (!matchingAgent) { + return { + type: 'message', + messageType: 'error', + content: `No idle agent found matching "${trimmedArgs}".`, + }; + } + + const label = + matchingAgent.model.displayName || matchingAgent.model.modelId; + const result = await manager.applyAgentResult(matchingAgent.agentId); + if (!result.success) { + return { + type: 'message', + messageType: 'error', + content: `Failed to apply changes from ${label}: ${result.error}`, + }; + } + + await config.cleanupArenaRuntime(true); + return { + type: 'message', + messageType: 'info', + content: `Applied changes from ${label} to workspace. Arena session complete.`, + }; + } + + // No args → open the select dialog + return { + type: 'dialog', + dialog: 'arena_select', + }; + }, + }, + ], +}; diff --git a/packages/cli/src/ui/commands/types.ts b/packages/cli/src/ui/commands/types.ts index 6c03ec136..25cf33a3b 100644 --- a/packages/cli/src/ui/commands/types.ts +++ b/packages/cli/src/ui/commands/types.ts @@ -137,6 +137,10 @@ export interface OpenDialogActionReturn { dialog: | 'help' + | 'arena_start' + | 'arena_select' + | 'arena_stop' + | 'arena_status' | 'auth' | 'theme' | 'editor' diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/ArenaSelectDialog.tsx new file mode 100644 index 000000000..222d884e5 --- /dev/null +++ b/packages/cli/src/ui/components/ArenaSelectDialog.tsx @@ -0,0 +1,245 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo } from 'react'; +import { Box, Text } from 'ink'; +import { + type ArenaManager, + ArenaAgentStatus, + type Config, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { MessageType } from '../types.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { formatDuration } from '../utils/formatters.js'; +import { getArenaStatusLabel } from '../utils/displayUtils.js'; +import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; + +interface ArenaSelectDialogProps { + manager: ArenaManager; + config: Config; + addItem: UseHistoryManagerReturn['addItem']; + closeArenaDialog: () => void; +} + +export function ArenaSelectDialog({ + manager, + config, + addItem, + closeArenaDialog, +}: ArenaSelectDialogProps): React.JSX.Element { + const pushMessage = useCallback( + (result: { messageType: 'info' | 'error'; content: string }) => { + addItem( + { + type: + result.messageType === 'info' + ? MessageType.INFO + : MessageType.ERROR, + text: result.content, + }, + Date.now(), + ); + }, + [addItem], + ); + + const onSelect = useCallback( + async (agentId: string) => { + closeArenaDialog(); + const mgr = config.getArenaManager(); + if (!mgr) { + pushMessage({ + messageType: 'error', + content: 'No arena session found. Start one with /arena start.', + }); + return; + } + + const agent = + mgr.getAgentState(agentId) ?? + mgr.getAgentStates().find((item) => item.agentId === agentId); + const label = agent?.model.displayName || agent?.model.modelId || agentId; + + const result = await mgr.applyAgentResult(agentId); + if (!result.success) { + pushMessage({ + messageType: 'error', + content: `Failed to apply changes from ${label}: ${result.error}`, + }); + return; + } + + try { + await config.cleanupArenaRuntime(true); + } catch (err) { + pushMessage({ + messageType: 'error', + content: `Warning: failed to clean up arena resources: ${err instanceof Error ? err.message : String(err)}`, + }); + } + pushMessage({ + messageType: 'info', + content: `Applied changes from ${label} to workspace. Arena session complete.`, + }); + }, + [closeArenaDialog, config, pushMessage], + ); + + const onDiscard = useCallback(async () => { + closeArenaDialog(); + const mgr = config.getArenaManager(); + if (!mgr) { + pushMessage({ + messageType: 'error', + content: 'No arena session found. Start one with /arena start.', + }); + return; + } + + try { + await config.cleanupArenaRuntime(true); + pushMessage({ + messageType: 'info', + content: 'Arena results discarded. All worktrees cleaned up.', + }); + } catch (err) { + pushMessage({ + messageType: 'error', + content: `Failed to clean up arena worktrees: ${err instanceof Error ? err.message : String(err)}`, + }); + } + }, [closeArenaDialog, config, pushMessage]); + + const result = manager.getResult(); + const agents = manager.getAgentStates(); + + const items: Array> = useMemo( + () => + agents.map((agent) => { + const label = agent.model.displayName || agent.model.modelId; + const statusInfo = getArenaStatusLabel(agent.status); + const duration = formatDuration(agent.stats.durationMs); + const tokens = agent.stats.totalTokens.toLocaleString(); + + // Build diff summary from cached result if available + let diffAdditions = 0; + let diffDeletions = 0; + if (agent.status === ArenaAgentStatus.COMPLETED && result) { + const agentResult = result.agents.find( + (a) => a.agentId === agent.agentId, + ); + if (agentResult?.diff) { + const lines = agentResult.diff.split('\n'); + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) { + diffAdditions++; + } else if (line.startsWith('-') && !line.startsWith('---')) { + diffDeletions++; + } + } + } + } + + // Title: full model name (not truncated) + const title = {label}; + + // Description: status, time, tokens, changes (unified with Arena Complete columns) + const description = ( + + {statusInfo.text} + · + {duration} + · + {tokens} tokens + {(diffAdditions > 0 || diffDeletions > 0) && ( + <> + · + +{diffAdditions} + / + -{diffDeletions} + lines + + )} + + ); + + return { + key: agent.agentId, + value: agent.agentId, + title, + description, + disabled: agent.status !== ArenaAgentStatus.COMPLETED, + }; + }), + [agents, result], + ); + + useKeypress( + (key) => { + if (key.name === 'escape') { + closeArenaDialog(); + } + if (key.name === 'd' && !key.ctrl && !key.meta) { + onDiscard(); + } + }, + { isActive: true }, + ); + + const task = result?.task || ''; + + return ( + + {/* Neutral title color (not green) */} + + Arena Results + + + + + Task: + {`"${task.length > 60 ? task.slice(0, 59) + '…' : task}"`} + + + + + + Select a winner to apply changes: + + + + + !item.disabled)} + onSelect={(agentId: string) => { + onSelect(agentId); + }} + isFocused={true} + showNumbers={false} + /> + + + + + Enter to select, d to discard all, Esc to cancel + + + + ); +} diff --git a/packages/cli/src/ui/components/ArenaStartDialog.tsx b/packages/cli/src/ui/components/ArenaStartDialog.tsx new file mode 100644 index 000000000..2641dcba6 --- /dev/null +++ b/packages/cli/src/ui/components/ArenaStartDialog.tsx @@ -0,0 +1,144 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import Link from 'ink-link'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { useConfig } from '../contexts/ConfigContext.js'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { MultiSelect } from './shared/MultiSelect.js'; +import { t } from '../../i18n/index.js'; + +interface ArenaStartDialogProps { + onClose: () => void; + onConfirm: (selectedModels: string[]) => void; +} + +const MODEL_PROVIDERS_DOCUMENTATION_URL = + 'https://qwenlm.github.io/qwen-code-docs/en/users/configuration/settings/#modelproviders'; + +export function ArenaStartDialog({ + onClose, + onConfirm, +}: ArenaStartDialogProps): React.JSX.Element { + const config = useConfig(); + const [errorMessage, setErrorMessage] = useState(null); + + const modelItems = useMemo(() => { + const allModels = config.getAllConfiguredModels(); + const selectableModels = allModels.filter((model) => !model.isRuntimeModel); + + return selectableModels.map((model) => { + const token = `${model.authType}:${model.id}`; + const isQwenOauth = model.authType === AuthType.QWEN_OAUTH; + return { + key: token, + value: token, + label: `[${model.authType}] ${model.label}`, + disabled: isQwenOauth, + }; + }); + }, [config]); + const hasDisabledQwenOauth = modelItems.some((item) => item.disabled); + const selectableModelCount = modelItems.filter( + (item) => !item.disabled, + ).length; + const shouldShowMoreModelsHint = selectableModelCount < 3; + + useKeypress( + (key) => { + if (key.name === 'escape') { + onClose(); + } + }, + { isActive: true }, + ); + + const handleConfirm = (values: string[]) => { + if (values.length < 2) { + setErrorMessage( + t('Please select at least 2 models to start an Arena session.'), + ); + return; + } + + setErrorMessage(null); + onConfirm(values); + }; + + return ( + + {t('Select Models')} + + {modelItems.length === 0 ? ( + + + {t('No models available. Please configure models first.')} + + + ) : ( + + + + )} + + {errorMessage && ( + + {errorMessage} + + )} + + {hasDisabledQwenOauth && ( + + + {t( + 'qwen-oauth models are disabled because they are not supported in Arena.', + )} + + + )} + + {shouldShowMoreModelsHint && ( + <> + + + {t('Configure more models with the modelProviders guide:')} + + + + + + {MODEL_PROVIDERS_DOCUMENTATION_URL} + + + + + )} + + + + {t('Space to toggle, Enter to confirm, Esc to cancel')} + + + + ); +} diff --git a/packages/cli/src/ui/components/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/ArenaStatusDialog.tsx new file mode 100644 index 000000000..221e2f3e6 --- /dev/null +++ b/packages/cli/src/ui/components/ArenaStatusDialog.tsx @@ -0,0 +1,253 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useEffect, useState } from 'react'; +import { Box, Text } from 'ink'; +import { + type ArenaManager, + type ArenaAgentState, + ArenaAgentStatus, + ArenaSessionStatus, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { formatDuration } from '../utils/formatters.js'; +import { getArenaStatusLabel } from '../utils/displayUtils.js'; + +const STATUS_REFRESH_INTERVAL_MS = 2000; + +interface ArenaStatusDialogProps { + manager: ArenaManager; + closeArenaDialog: () => void; + width?: number; +} + +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '…'; +} + +function pad( + str: string, + len: number, + align: 'left' | 'right' = 'left', +): string { + if (str.length >= len) return str.slice(0, len); + const padding = ' '.repeat(len - str.length); + return align === 'right' ? padding + str : str + padding; +} + +function getElapsedMs(agent: ArenaAgentState): number { + if ( + agent.status === ArenaAgentStatus.COMPLETED || + agent.status === ArenaAgentStatus.TERMINATED || + agent.status === ArenaAgentStatus.CANCELLED + ) { + return agent.stats.durationMs; + } + return Date.now() - agent.startedAt; +} + +function getSessionStatusLabel(status: ArenaSessionStatus): { + text: string; + color: string; +} { + switch (status) { + case ArenaSessionStatus.RUNNING: + return { text: 'Running', color: theme.status.success }; + case ArenaSessionStatus.INITIALIZING: + return { text: 'Initializing', color: theme.status.warning }; + case ArenaSessionStatus.COMPLETED: + return { text: 'Completed', color: theme.status.success }; + case ArenaSessionStatus.CANCELLED: + return { text: 'Cancelled', color: theme.status.warning }; + case ArenaSessionStatus.FAILED: + return { text: 'Failed', color: theme.status.error }; + default: + return { text: String(status), color: theme.text.secondary }; + } +} + +const MAX_MODEL_NAME_LENGTH = 35; + +export function ArenaStatusDialog({ + manager, + closeArenaDialog, + width, +}: ArenaStatusDialogProps): React.JSX.Element { + const [tick, setTick] = useState(0); + + useEffect(() => { + const timer = setInterval(() => { + setTick((prev) => prev + 1); + }, STATUS_REFRESH_INTERVAL_MS); + return () => clearInterval(timer); + }, []); + + // Force re-read on every tick + void tick; + + const sessionStatus = manager.getSessionStatus(); + const sessionLabel = getSessionStatusLabel(sessionStatus); + const agents = manager.getAgentStates(); + const task = manager.getTask() ?? ''; + + const maxTaskLen = 60; + const displayTask = + task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task; + + const colStatus = 14; + const colTime = 8; + const colTokens = 10; + const colRounds = 8; + const colTools = 8; + + useKeypress( + (key) => { + if (key.name === 'escape' || key.name === 'q' || key.name === 'return') { + closeArenaDialog(); + } + }, + { isActive: true }, + ); + + // Inner content width: total width minus border (2) and paddingX (2*2) + const innerWidth = (width ?? 80) - 6; + + return ( + + {/* Title */} + + + Arena Status + + · + {sessionLabel.text} + + + + + {/* Task */} + + + Task: + "{displayTask}" + + + + + + {/* Table header */} + + + + Agent + + + + + Status + + + + + Time + + + + + Tokens + + + + + Rounds + + + + + Tools + + + + + {/* Separator */} + + {'─'.repeat(innerWidth)} + + + {/* Agent rows */} + {agents.map((agent) => { + const label = agent.model.displayName || agent.model.modelId; + const { text: statusText, color } = getArenaStatusLabel(agent.status); + const elapsed = getElapsedMs(agent); + + return ( + + + + {truncate(label, MAX_MODEL_NAME_LENGTH)} + + + + {statusText} + + + + {pad(formatDuration(elapsed), colTime - 1, 'right')} + + + + + {pad( + agent.stats.totalTokens.toLocaleString(), + colTokens - 1, + 'right', + )} + + + + + {pad(String(agent.stats.rounds), colRounds - 1, 'right')} + + + + {agent.stats.failedToolCalls > 0 ? ( + + + {agent.stats.successfulToolCalls} + + / + + {agent.stats.failedToolCalls} + + + ) : ( + + {pad(String(agent.stats.toolCalls), colTools - 1, 'right')} + + )} + + + ); + })} + + {agents.length === 0 && ( + + No agents registered yet. + + )} + + ); +} diff --git a/packages/cli/src/ui/components/ArenaStopDialog.tsx b/packages/cli/src/ui/components/ArenaStopDialog.tsx new file mode 100644 index 000000000..24ad2eeb7 --- /dev/null +++ b/packages/cli/src/ui/components/ArenaStopDialog.tsx @@ -0,0 +1,198 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { + ArenaSessionStatus, + createDebugLogger, + type Config, +} from '@qwen-code/qwen-code-core'; +import { theme } from '../semantic-colors.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { MessageType } from '../types.js'; +import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; +import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; + +const debugLogger = createDebugLogger('ARENA_STOP_DIALOG'); + +type StopAction = 'cleanup' | 'preserve'; + +interface ArenaStopDialogProps { + config: Config; + addItem: UseHistoryManagerReturn['addItem']; + closeArenaDialog: () => void; +} + +export function ArenaStopDialog({ + config, + addItem, + closeArenaDialog, +}: ArenaStopDialogProps): React.JSX.Element { + const [isProcessing, setIsProcessing] = useState(false); + + const pushMessage = useCallback( + (result: { messageType: 'info' | 'error'; content: string }) => { + addItem( + { + type: + result.messageType === 'info' + ? MessageType.INFO + : MessageType.ERROR, + text: result.content, + }, + Date.now(), + ); + }, + [addItem], + ); + + const onStop = useCallback( + async (action: StopAction) => { + if (isProcessing) return; + setIsProcessing(true); + closeArenaDialog(); + + const mgr = config.getArenaManager(); + if (!mgr) { + pushMessage({ + messageType: 'error', + content: 'No running Arena session found.', + }); + return; + } + + try { + const sessionStatus = mgr.getSessionStatus(); + if ( + sessionStatus === ArenaSessionStatus.RUNNING || + sessionStatus === ArenaSessionStatus.INITIALIZING + ) { + await mgr.cancel(); + } + await mgr.waitForSettled(); + + if (action === 'preserve') { + await mgr.cleanupRuntime(); + } else { + await mgr.cleanup(); + } + config.setArenaManager(null); + + if (action === 'preserve') { + pushMessage({ + messageType: 'info', + content: + 'Arena session stopped. Worktrees and session files were preserved. ' + + 'Use /arena select --discard to manually clean up later.', + }); + } else { + pushMessage({ + messageType: 'info', + content: + 'Arena session stopped. All Arena resources (including Git worktrees) were cleaned up.', + }); + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + debugLogger.error('Failed to stop Arena session:', error); + pushMessage({ + messageType: 'error', + content: `Failed to stop Arena session: ${message}`, + }); + } + }, + [isProcessing, closeArenaDialog, config, pushMessage], + ); + + const configPreserve = + config.getAgentsSettings().arena?.preserveArtifacts ?? false; + + const items: Array> = useMemo( + () => [ + { + key: 'cleanup', + value: 'cleanup' as StopAction, + title: Stop and clean up, + description: ( + + Remove all worktrees and session files + + ), + }, + { + key: 'preserve', + value: 'preserve' as StopAction, + title: Stop and preserve artifacts, + description: ( + + Keep worktrees and session files for later inspection + + ), + }, + ], + [], + ); + + const defaultIndex = configPreserve ? 1 : 0; + + useKeypress( + (key) => { + if (key.name === 'escape') { + closeArenaDialog(); + } + }, + { isActive: !isProcessing }, + ); + + return ( + + + Stop Arena Session + + + + + Choose what to do with Arena artifacts: + + + + + { + onStop(action); + }} + isFocused={!isProcessing} + showNumbers={false} + /> + + + {configPreserve && ( + + + Default: preserve (agents.arena.preserveArtifacts is enabled) + + + )} + + + + Enter to confirm, Esc to cancel + + + + ); +} diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index dbb6f2207..cb88ba76f 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -20,6 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; +import { ArenaStartDialog } from './ArenaStartDialog.js'; +import { ArenaSelectDialog } from './ArenaSelectDialog.js'; +import { ArenaStopDialog } from './ArenaStopDialog.js'; +import { ArenaStatusDialog } from './ArenaStatusDialog.js'; import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; @@ -236,6 +240,48 @@ export const DialogManager = ({ if (uiState.isModelDialogOpen) { return ; } + if (uiState.activeArenaDialog === 'start') { + return ( + uiActions.closeArenaDialog()} + onConfirm={(models) => uiActions.handleArenaModelsSelected?.(models)} + /> + ); + } + if (uiState.activeArenaDialog === 'status') { + const arenaManager = config.getArenaManager(); + if (arenaManager) { + return ( + + ); + } + } + if (uiState.activeArenaDialog === 'stop') { + return ( + + ); + } + if (uiState.activeArenaDialog === 'select') { + const arenaManager = config.getArenaManager(); + if (arenaManager) { + return ( + + ); + } + } if (uiState.isVisionSwitchDialogOpen) { return ; } diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 73bdd6de3..55b678739 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -8,19 +8,24 @@ import type React from 'react'; import { useMemo } from 'react'; import { escapeAnsiCtrlCodes } from '../utils/textUtils.js'; import type { HistoryItem } from '../types.js'; -import { UserMessage } from './messages/UserMessage.js'; -import { UserShellMessage } from './messages/UserShellMessage.js'; -import { GeminiMessage } from './messages/GeminiMessage.js'; -import { InfoMessage } from './messages/InfoMessage.js'; -import { ErrorMessage } from './messages/ErrorMessage.js'; +import { + UserMessage, + UserShellMessage, + AssistantMessage, + AssistantMessageContent, + ThinkMessage, + ThinkMessageContent, +} from './messages/ConversationMessages.js'; import { ToolGroupMessage } from './messages/ToolGroupMessage.js'; -import { GeminiMessageContent } from './messages/GeminiMessageContent.js'; -import { GeminiThoughtMessage } from './messages/GeminiThoughtMessage.js'; -import { GeminiThoughtMessageContent } from './messages/GeminiThoughtMessageContent.js'; import { CompressionMessage } from './messages/CompressionMessage.js'; import { SummaryMessage } from './messages/SummaryMessage.js'; -import { WarningMessage } from './messages/WarningMessage.js'; -import { RetryCountdownMessage } from './messages/RetryCountdownMessage.js'; +import { + InfoMessage, + WarningMessage, + ErrorMessage, + RetryCountdownMessage, + SuccessMessage, +} from './messages/StatusMessages.js'; import { Box } from 'ink'; import { AboutBox } from './AboutBox.js'; import { StatsDisplay } from './StatsDisplay.js'; @@ -34,6 +39,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; +import { ArenaAgentCard, ArenaSessionCard } from './messages/ArenaCards.js'; interface HistoryItemDisplayProps { item: HistoryItem; @@ -60,6 +66,11 @@ const HistoryItemDisplayComponent: React.FC = ({ embeddedShellFocused, availableTerminalHeightGemini, }) => { + const marginTop = + item.type === 'gemini_content' || item.type === 'gemini_thought_content' + ? 0 + : 1; + const itemForDisplay = useMemo(() => escapeAnsiCtrlCodes(item), [item]); const contentWidth = terminalWidth - 4; const boxWidth = mainAreaWidth || contentWidth; @@ -68,6 +79,7 @@ const HistoryItemDisplayComponent: React.FC = ({ @@ -79,7 +91,7 @@ const HistoryItemDisplayComponent: React.FC = ({ )} {itemForDisplay.type === 'gemini' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_content' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought' && ( - = ({ /> )} {itemForDisplay.type === 'gemini_thought_content' && ( - = ({ {itemForDisplay.type === 'info' && ( )} + {itemForDisplay.type === 'success' && ( + + )} {itemForDisplay.type === 'warning' && ( )} @@ -180,6 +195,18 @@ const HistoryItemDisplayComponent: React.FC = ({ {itemForDisplay.type === 'mcp_status' && ( )} + {itemForDisplay.type === 'arena_agent_complete' && ( + + )} + {itemForDisplay.type === 'arena_session_complete' && ( + + )} ); }; diff --git a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap index c22e5cace..c58c38dca 100644 --- a/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/HistoryItemDisplay.test.tsx.snap @@ -1,7 +1,8 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should render a full gemini item when using availableTerminalHeightGemini 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: 1 Line 1 2 Line 2 3 Line 3 @@ -109,7 +110,8 @@ exports[` > should render a full gemini_content item when `; exports[` > should render a truncated gemini item 1`] = ` -" ✦ Example code block: +" + ✦ Example code block: ... first 41 lines hidden ... 42 Line 42 43 Line 43 diff --git a/packages/cli/src/ui/components/messages/ArenaCards.tsx b/packages/cli/src/ui/components/messages/ArenaCards.tsx new file mode 100644 index 000000000..ae4be3c68 --- /dev/null +++ b/packages/cli/src/ui/components/messages/ArenaCards.tsx @@ -0,0 +1,279 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { formatDuration } from '../../utils/formatters.js'; +import { getArenaStatusLabel } from '../../utils/displayUtils.js'; +import type { ArenaAgentCardData } from '../../types.js'; + +// ─── Helpers ──────────────────────────────────────────────── + +// ─── Agent Complete Card ──────────────────────────────────── + +interface ArenaAgentCardProps { + agent: ArenaAgentCardData; + width?: number; +} + +export const ArenaAgentCard: React.FC = ({ + agent, + width, +}) => { + const { icon, text, color } = getArenaStatusLabel(agent.status); + const duration = formatDuration(agent.durationMs); + const tokens = agent.totalTokens.toLocaleString(); + const inTokens = agent.inputTokens.toLocaleString(); + const outTokens = agent.outputTokens.toLocaleString(); + + return ( + + {/* Line 1: Status icon + text + label + duration */} + + + {icon} {text}: {agent.label} · {duration} + + + + {/* Line 2: Tokens */} + + + Tokens: {tokens} (in {inTokens}, out {outTokens}) + + + + {/* Line 3: Tool Calls with colored success/error counts */} + + + Tool Calls: {agent.toolCalls} + {agent.failedToolCalls > 0 && ( + <> + {' '} + ( + + ✓ {agent.successfulToolCalls} + + + ✕ {agent.failedToolCalls}) + + )} + + + + {/* Error line (if terminated with error) */} + {agent.error && ( + + {agent.error} + + )} + + ); +}; + +// ─── Session Complete Card ────────────────────────────────── + +interface ArenaSessionCardProps { + sessionStatus: string; + task: string; + totalDurationMs: number; + agents: ArenaAgentCardData[]; + width?: number; +} + +/** + * Pad or truncate a string to a fixed visual width. + */ +function pad( + str: string, + len: number, + align: 'left' | 'right' = 'left', +): string { + if (str.length >= len) return str.slice(0, len); + const padding = ' '.repeat(len - str.length); + return align === 'right' ? padding + str : str + padding; +} + +/** + * Truncate a string to a maximum length, adding ellipsis if truncated. + */ +function truncate(str: string, maxLen: number): string { + if (str.length <= maxLen) return str; + return str.slice(0, maxLen - 1) + '…'; +} + +/** + * Calculate diff stats from a unified diff string. + * Returns the stats string and individual counts for colored rendering. + */ +function getDiffStats(diff: string | undefined): { + text: string; + additions: number; + deletions: number; +} { + if (!diff) return { text: '', additions: 0, deletions: 0 }; + const lines = diff.split('\n'); + let additions = 0; + let deletions = 0; + for (const line of lines) { + if (line.startsWith('+') && !line.startsWith('+++')) { + additions++; + } else if (line.startsWith('-') && !line.startsWith('---')) { + deletions++; + } + } + return { text: `+${additions}/-${deletions}`, additions, deletions }; +} + +const MAX_MODEL_NAME_LENGTH = 35; + +export const ArenaSessionCard: React.FC = ({ + sessionStatus, + task, + agents, + width, +}) => { + // Truncate task for display + const maxTaskLen = 60; + const displayTask = + task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task; + + // Column widths for the agent table (unified with Arena Results) + const colStatus = 14; + const colTime = 8; + const colTokens = 10; + const colChanges = 10; + + const titleLabel = + sessionStatus === 'completed' + ? 'Arena Complete' + : sessionStatus === 'cancelled' + ? 'Arena Cancelled' + : 'Arena Failed'; + + return ( + + {/* Title - neutral color (not green) */} + + + {titleLabel} + + + + + + {/* Task */} + + + Task: + "{displayTask}" + + + + + + {/* Table header - unified columns: Agent, Status, Time, Tokens, Changes */} + + + + Agent + + + + + Status + + + + + Time + + + + + Tokens + + + + + Changes + + + + + {/* Table separator */} + + + {'─'.repeat((width ?? 60) - 8)} + + + + {/* Agent rows */} + {agents.map((agent) => { + const { text: statusText, color } = getArenaStatusLabel(agent.status); + const diffStats = getDiffStats(agent.diff); + return ( + + + + {truncate(agent.label, MAX_MODEL_NAME_LENGTH)} + + + + {statusText} + + + + {pad(formatDuration(agent.durationMs), colTime - 1, 'right')} + + + + + {pad( + agent.totalTokens.toLocaleString(), + colTokens - 1, + 'right', + )} + + + + {diffStats.additions > 0 || diffStats.deletions > 0 ? ( + + + +{diffStats.additions} + + / + -{diffStats.deletions} + + ) : ( + - + )} + + + ); + })} + + + + {/* Hint */} + {sessionStatus === 'completed' && ( + + + Run /arena select to pick a + winner. + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/messages/ConversationMessages.tsx b/packages/cli/src/ui/components/messages/ConversationMessages.tsx new file mode 100644 index 000000000..526bc9cfe --- /dev/null +++ b/packages/cli/src/ui/components/messages/ConversationMessages.tsx @@ -0,0 +1,261 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; +import { theme } from '../../semantic-colors.js'; +import { + SCREEN_READER_MODEL_PREFIX, + SCREEN_READER_USER_PREFIX, +} from '../../textConstants.js'; + +interface UserMessageProps { + text: string; +} + +interface UserShellMessageProps { + text: string; +} + +interface AssistantMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface AssistantMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface ThinkMessageContentProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; +} + +interface PrefixedTextMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; + ariaLabel?: string; + marginTop?: number; + alignSelf?: 'auto' | 'flex-start' | 'center' | 'flex-end'; +} + +interface PrefixedMarkdownMessageProps { + text: string; + prefix: string; + prefixColor: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + ariaLabel?: string; + textColor?: string; +} + +interface ContinuationMarkdownMessageProps { + text: string; + isPending: boolean; + availableTerminalHeight?: number; + contentWidth: number; + basePrefix: string; + textColor?: string; +} + +function getPrefixWidth(prefix: string): number { + // Reserve one extra column so text never touches the prefix glyph. + return stringWidth(prefix) + 1; +} + +const PrefixedTextMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, + ariaLabel, + marginTop = 0, + alignSelf, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + {text} + + + + ); +}; + +const PrefixedMarkdownMessage: React.FC = ({ + text, + prefix, + prefixColor, + isPending, + availableTerminalHeight, + contentWidth, + ariaLabel, + textColor, +}) => { + const prefixWidth = getPrefixWidth(prefix); + + return ( + + + + {prefix} + + + + + + + ); +}; + +const ContinuationMarkdownMessage: React.FC< + ContinuationMarkdownMessageProps +> = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, + basePrefix, + textColor, +}) => { + const prefixWidth = getPrefixWidth(basePrefix); + + return ( + + + + ); +}; + +export const UserMessage: React.FC = ({ text }) => ( + +); + +export const UserShellMessage: React.FC = ({ text }) => { + const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; + + return ( + + ); +}; + +export const AssistantMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const AssistantMessageContent: React.FC< + AssistantMessageContentProps +> = ({ text, isPending, availableTerminalHeight, contentWidth }) => ( + +); + +export const ThinkMessage: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); + +export const ThinkMessageContent: React.FC = ({ + text, + isPending, + availableTerminalHeight, + contentWidth, +}) => ( + +); diff --git a/packages/cli/src/ui/components/messages/ErrorMessage.tsx b/packages/cli/src/ui/components/messages/ErrorMessage.tsx deleted file mode 100644 index 8e10a4fed..000000000 --- a/packages/cli/src/ui/components/messages/ErrorMessage.tsx +++ /dev/null @@ -1,31 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface ErrorMessageProps { - text: string; -} - -export const ErrorMessage: React.FC = ({ text }) => { - const prefix = '✕ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessage.tsx b/packages/cli/src/ui/components/messages/GeminiMessage.tsx deleted file mode 100644 index 987cbf38a..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessage.tsx +++ /dev/null @@ -1,46 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; -import { SCREEN_READER_MODEL_PREFIX } from '../../textConstants.js'; - -interface GeminiMessageProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -export const GeminiMessage: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; - - return ( - - - - {prefix} - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx deleted file mode 100644 index 29a82298f..000000000 --- a/packages/cli/src/ui/components/messages/GeminiMessageContent.tsx +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; - -interface GeminiMessageContentProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/* - * Gemini message content is a semi-hacked component. The intention is to represent a partial - * of GeminiMessage and is only used when a response gets too long. In that instance messages - * are split into multiple GeminiMessageContent's to enable the root component in - * App.tsx to be as performant as humanly possible. - */ -export const GeminiMessageContent: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx deleted file mode 100644 index b595c9d06..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessage.tsx +++ /dev/null @@ -1,48 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; - -interface GeminiThoughtMessageProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/** - * Displays model thinking/reasoning text with a softer, dimmed style - * to visually distinguish it from regular content output. - */ -export const GeminiThoughtMessage: React.FC = ({ - text, - isPending, - availableTerminalHeight, - contentWidth, -}) => { - const prefix = '✦ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx b/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx deleted file mode 100644 index 0f20c45d2..000000000 --- a/packages/cli/src/ui/components/messages/GeminiThoughtMessageContent.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box } from 'ink'; -import { MarkdownDisplay } from '../../utils/MarkdownDisplay.js'; -import { theme } from '../../semantic-colors.js'; - -interface GeminiThoughtMessageContentProps { - text: string; - isPending: boolean; - availableTerminalHeight?: number; - contentWidth: number; -} - -/** - * Continuation component for thought messages, similar to GeminiMessageContent. - * Used when a thought response gets too long and needs to be split for performance. - */ -export const GeminiThoughtMessageContent: React.FC< - GeminiThoughtMessageContentProps -> = ({ text, isPending, availableTerminalHeight, contentWidth }) => { - const originalPrefix = '✦ '; - const prefixWidth = originalPrefix.length; - - return ( - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/InfoMessage.tsx b/packages/cli/src/ui/components/messages/InfoMessage.tsx deleted file mode 100644 index fb03fbef1..000000000 --- a/packages/cli/src/ui/components/messages/InfoMessage.tsx +++ /dev/null @@ -1,37 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; - -interface InfoMessageProps { - text: string; -} - -export const InfoMessage: React.FC = ({ text }) => { - // Don't render anything if text is empty - if (!text || text.trim() === '') { - return null; - } - - const prefix = 'ℹ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx b/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx deleted file mode 100644 index 0f4727574..000000000 --- a/packages/cli/src/ui/components/messages/RetryCountdownMessage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface RetryCountdownMessageProps { - text: string; -} - -/** - * Displays a retry countdown message in a dimmed/secondary style - * to visually distinguish it from error messages. - */ -export const RetryCountdownMessage: React.FC = ({ - text, -}) => { - if (!text || text.trim() === '') { - return null; - } - - const prefix = '↻ '; - const prefixWidth = prefix.length; - - return ( - - - {prefix} - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx new file mode 100644 index 000000000..20ff1ced8 --- /dev/null +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -0,0 +1,97 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import stringWidth from 'string-width'; +import { theme } from '../../semantic-colors.js'; +import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; + +interface StatusMessageProps { + text: string; + prefix: string; + prefixColor: string; + textColor: string; +} + +interface StatusTextProps { + text: string; +} + +/** + * Shared renderer for status-like history messages (info/warning/error/retry). + * Keeps prefix spacing and wrapping behavior consistent across variants. + */ +export const StatusMessage: React.FC = ({ + text, + prefix, + prefixColor, + textColor, +}) => { + if (!text || text.trim() === '') { + return null; + } + + const prefixWidth = stringWidth(prefix) + 1; + + return ( + + + {prefix} + + + + + + + + ); +}; + +export const InfoMessage: React.FC = ({ text }) => ( + +); + +export const SuccessMessage: React.FC = ({ text }) => ( + +); + +export const WarningMessage: React.FC = ({ text }) => ( + +); + +export const ErrorMessage: React.FC = ({ text }) => ( + +); + +export const RetryCountdownMessage: React.FC = ({ text }) => ( + +); diff --git a/packages/cli/src/ui/components/messages/UserMessage.tsx b/packages/cli/src/ui/components/messages/UserMessage.tsx deleted file mode 100644 index 5cc2b965c..000000000 --- a/packages/cli/src/ui/components/messages/UserMessage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Text, Box } from 'ink'; -import { theme } from '../../semantic-colors.js'; -import { SCREEN_READER_USER_PREFIX } from '../../textConstants.js'; -import { isSlashCommand as checkIsSlashCommand } from '../../utils/commandUtils.js'; - -interface UserMessageProps { - text: string; -} - -export const UserMessage: React.FC = ({ text }) => { - const prefix = '> '; - const prefixWidth = prefix.length; - const isSlashCommand = checkIsSlashCommand(text); - - const textColor = isSlashCommand ? theme.text.accent : theme.text.secondary; - - return ( - - - - {prefix} - - - - - {text} - - - - ); -}; diff --git a/packages/cli/src/ui/components/messages/UserShellMessage.tsx b/packages/cli/src/ui/components/messages/UserShellMessage.tsx deleted file mode 100644 index 3b7bc7724..000000000 --- a/packages/cli/src/ui/components/messages/UserShellMessage.tsx +++ /dev/null @@ -1,25 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { theme } from '../../semantic-colors.js'; - -interface UserShellMessageProps { - text: string; -} - -export const UserShellMessage: React.FC = ({ text }) => { - // Remove leading '!' if present, as App.tsx adds it for the processor. - const commandToDisplay = text.startsWith('!') ? text.substring(1) : text; - - return ( - - $ - {commandToDisplay} - - ); -}; diff --git a/packages/cli/src/ui/components/messages/WarningMessage.tsx b/packages/cli/src/ui/components/messages/WarningMessage.tsx deleted file mode 100644 index 4bc2c899c..000000000 --- a/packages/cli/src/ui/components/messages/WarningMessage.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/** - * @license - * Copyright 2025 Google LLC - * SPDX-License-Identifier: Apache-2.0 - */ - -import type React from 'react'; -import { Box, Text } from 'ink'; -import { Colors } from '../../colors.js'; -import { RenderInline } from '../../utils/InlineMarkdownRenderer.js'; - -interface WarningMessageProps { - text: string; -} - -export const WarningMessage: React.FC = ({ text }) => { - const prefix = '⚠ '; - const prefixWidth = 3; - - return ( - - - {prefix} - - - - - - - - ); -}; diff --git a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx index 89bf4c03b..32cf0a136 100644 --- a/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx +++ b/packages/cli/src/ui/components/shared/DescriptiveRadioButtonSelect.tsx @@ -12,7 +12,7 @@ import type { SelectionListItem } from '../../hooks/useSelectionList.js'; export interface DescriptiveRadioSelectItem extends SelectionListItem { title: React.ReactNode; - description: string; + description: React.ReactNode; } export interface DescriptiveRadioButtonSelectProps { @@ -62,7 +62,11 @@ export function DescriptiveRadioButtonSelect({ renderItem={(item, { titleColor }) => ( {item.title} - {item.description} + {typeof item.description === 'string' ? ( + {item.description} + ) : ( + item.description + )} )} /> diff --git a/packages/cli/src/ui/components/shared/MultiSelect.tsx b/packages/cli/src/ui/components/shared/MultiSelect.tsx new file mode 100644 index 000000000..b910430ba --- /dev/null +++ b/packages/cli/src/ui/components/shared/MultiSelect.tsx @@ -0,0 +1,193 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type React from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { Box, Text } from 'ink'; +import { theme } from '../../semantic-colors.js'; +import { useSelectionList } from '../../hooks/useSelectionList.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import type { SelectionListItem } from '../../hooks/useSelectionList.js'; + +export interface MultiSelectItem extends SelectionListItem { + label: string; +} + +export interface MultiSelectProps { + items: Array>; + initialIndex?: number; + initialSelectedKeys?: string[]; + onConfirm: (selectedValues: T[]) => void; + onChange?: (selectedValues: T[]) => void; + onHighlight?: (value: T) => void; + isFocused?: boolean; + showNumbers?: boolean; + showScrollArrows?: boolean; + maxItemsToShow?: number; +} + +const EMPTY_SELECTED_KEYS: string[] = []; + +function getSelectedValues( + items: Array>, + selectedKeys: Set, +): T[] { + return items + .filter((item) => selectedKeys.has(item.key)) + .map((item) => item.value); +} + +export function MultiSelect({ + items, + initialIndex = 0, + initialSelectedKeys = EMPTY_SELECTED_KEYS, + onConfirm, + onChange, + onHighlight, + isFocused = true, + showNumbers = true, + showScrollArrows = false, + maxItemsToShow = 10, +}: MultiSelectProps): React.JSX.Element { + const [selectedKeys, setSelectedKeys] = useState>( + () => new Set(initialSelectedKeys), + ); + const [scrollOffset, setScrollOffset] = useState(0); + + useEffect(() => { + setSelectedKeys((prev) => { + const next = new Set(initialSelectedKeys); + if ( + prev.size === next.size && + Array.from(next).every((key) => prev.has(key)) + ) { + return prev; + } + return next; + }); + }, [initialSelectedKeys]); + + const { activeIndex } = useSelectionList({ + items, + initialIndex, + isFocused, + // Disable numeric quick-select in useSelectionList — in a multi-select + // context, onSelect triggers onConfirm (submit), so numeric keys would + // accidentally submit the dialog instead of toggling checkboxes. + // Numbers are still rendered visually via the showNumbers prop below. + showNumbers: false, + onHighlight, + onSelect: () => { + onConfirm(getSelectedValues(items, selectedKeys)); + }, + }); + + const toggleSelectionAtIndex = useCallback( + (index: number) => { + const item = items[index]; + if (!item || item.disabled) { + return; + } + + setSelectedKeys((prev) => { + const next = new Set(prev); + if (next.has(item.key)) { + next.delete(item.key); + } else { + next.add(item.key); + } + return next; + }); + }, + [items], + ); + + useEffect(() => { + onChange?.(getSelectedValues(items, selectedKeys)); + }, [items, selectedKeys, onChange]); + + useKeypress( + (key) => { + if (key.name === 'space' || key.sequence === ' ') { + toggleSelectionAtIndex(activeIndex); + } + }, + { isActive: isFocused }, + ); + + useEffect(() => { + const newScrollOffset = Math.max( + 0, + Math.min(activeIndex - maxItemsToShow + 1, items.length - maxItemsToShow), + ); + if (activeIndex < scrollOffset) { + setScrollOffset(activeIndex); + } else if (activeIndex >= scrollOffset + maxItemsToShow) { + setScrollOffset(newScrollOffset); + } + }, [activeIndex, items.length, scrollOffset, maxItemsToShow]); + + const visibleItems = useMemo( + () => items.slice(scrollOffset, scrollOffset + maxItemsToShow), + [items, scrollOffset, maxItemsToShow], + ); + const numberColumnWidth = String(items.length).length; + const hasMoreAbove = scrollOffset > 0; + const hasMoreBelow = scrollOffset + maxItemsToShow < items.length; + const moreAboveCount = scrollOffset; + const moreBelowCount = Math.max( + 0, + items.length - (scrollOffset + maxItemsToShow), + ); + + return ( + + {showScrollArrows && hasMoreAbove && ( + ↑ {moreAboveCount} more above + )} + + {visibleItems.map((item, index) => { + const itemIndex = scrollOffset + index; + const isActive = activeIndex === itemIndex; + const isChecked = selectedKeys.has(item.key); + + const itemNumberText = `${String(itemIndex + 1).padStart( + numberColumnWidth, + )}.`; + const checkboxText = item.disabled ? '[x]' : isChecked ? '[✓]' : '[ ]'; + + let textColor = theme.text.primary; + if (item.disabled) { + textColor = theme.text.secondary; + } else if (isActive) { + textColor = theme.status.success; + } else if (isChecked) { + textColor = theme.text.accent; + } + + return ( + + + {checkboxText} + + {showNumbers && ( + + {itemNumberText} + + )} + + {item.label} + + + ); + })} + + {showScrollArrows && hasMoreBelow && ( + ↓ {moreBelowCount} more below + )} + + ); +} diff --git a/packages/cli/src/ui/components/shared/text-buffer.ts b/packages/cli/src/ui/components/shared/text-buffer.ts index baed1c192..b07c06706 100644 --- a/packages/cli/src/ui/components/shared/text-buffer.ts +++ b/packages/cli/src/ui/components/shared/text-buffer.ts @@ -1907,8 +1907,8 @@ export function useTextBuffer({ else if (key.ctrl && key.name === 'b') move('left'); else if (key.name === 'right' && !key.meta && !key.ctrl) move('right'); else if (key.ctrl && key.name === 'f') move('right'); - else if (key.name === 'up') move('up'); - else if (key.name === 'down') move('down'); + else if (key.name === 'up' && !key.shift) move('up'); + else if (key.name === 'down' && !key.shift) move('down'); else if ((key.ctrl || key.meta) && key.name === 'left') move('wordLeft'); else if (key.meta && key.name === 'b') move('wordLeft'); else if ((key.ctrl || key.meta) && key.name === 'right') diff --git a/packages/cli/src/ui/contexts/UIActionsContext.tsx b/packages/cli/src/ui/contexts/UIActionsContext.tsx index 7534b6d3a..8a2dc8caa 100644 --- a/packages/cli/src/ui/contexts/UIActionsContext.tsx +++ b/packages/cli/src/ui/contexts/UIActionsContext.tsx @@ -18,6 +18,7 @@ import { type SettingScope } from '../../config/settings.js'; import { type CodingPlanRegion } from '../../constants/codingPlan.js'; import type { AuthState } from '../types.js'; import { type VisionSwitchOutcome } from '../components/ModelSwitchDialog.js'; +import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) export interface OpenAICredentials { apiKey: string; @@ -55,6 +56,9 @@ export interface UIActions { exitEditorDialog: () => void; closeSettingsDialog: () => void; closeModelDialog: () => void; + openArenaDialog: (type: Exclude) => void; + closeArenaDialog: () => void; + handleArenaModelsSelected?: (models: string[]) => void; dismissCodingPlanUpdate: () => void; closePermissionsDialog: () => void; setShellModeActive: (value: boolean) => void; diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index f8d52faa1..a94c53de4 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -33,6 +33,7 @@ import type { UpdateObject } from '../utils/updateCheck.js'; import { type UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { type RestartReason } from '../hooks/useIdeTrustListener.js'; import { type CodingPlanUpdateRequest } from '../hooks/useCodingPlanUpdates.js'; +import { type ArenaDialogType } from '../hooks/useArenaCommand.js'; export interface UIState { history: HistoryItem[]; @@ -52,6 +53,7 @@ export interface UIState { quittingMessages: HistoryItem[] | null; isSettingsDialogOpen: boolean; isModelDialogOpen: boolean; + activeArenaDialog: ArenaDialogType; isPermissionsDialogOpen: boolean; isApprovalModeDialogOpen: boolean; isResumeDialogOpen: boolean; diff --git a/packages/cli/src/ui/hooks/slashCommandProcessor.ts b/packages/cli/src/ui/hooks/slashCommandProcessor.ts index 59ff06bcf..a8e02912e 100644 --- a/packages/cli/src/ui/hooks/slashCommandProcessor.ts +++ b/packages/cli/src/ui/hooks/slashCommandProcessor.ts @@ -7,6 +7,7 @@ import { useCallback, useMemo, useEffect, useState } from 'react'; import { type PartListUnion } from '@google/genai'; import type { UseHistoryManagerReturn } from './useHistoryManager.js'; +import type { ArenaDialogType } from './useArenaCommand.js'; import { type Logger, type Config, @@ -64,6 +65,7 @@ const SLASH_COMMANDS_SKIP_RECORDING = new Set([ interface SlashCommandProcessorActions { openAuthDialog: () => void; + openArenaDialog?: (type: Exclude) => void; openThemeDialog: () => void; openEditorDialog: () => void; openSettingsDialog: () => void; @@ -395,6 +397,18 @@ export const useSlashCommandProcessor = ( return { type: 'handled' }; case 'dialog': switch (result.dialog) { + case 'arena_start': + actions.openArenaDialog?.('start'); + return { type: 'handled' }; + case 'arena_select': + actions.openArenaDialog?.('select'); + return { type: 'handled' }; + case 'arena_stop': + actions.openArenaDialog?.('stop'); + return { type: 'handled' }; + case 'arena_status': + actions.openArenaDialog?.('status'); + return { type: 'handled' }; case 'auth': actions.openAuthDialog(); return { type: 'handled' }; diff --git a/packages/cli/src/ui/hooks/useArenaCommand.ts b/packages/cli/src/ui/hooks/useArenaCommand.ts new file mode 100644 index 000000000..0392a0f1f --- /dev/null +++ b/packages/cli/src/ui/hooks/useArenaCommand.ts @@ -0,0 +1,37 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState } from 'react'; + +export type ArenaDialogType = 'start' | 'select' | 'stop' | 'status' | null; + +interface UseArenaCommandReturn { + activeArenaDialog: ArenaDialogType; + openArenaDialog: (type: Exclude) => void; + closeArenaDialog: () => void; +} + +export function useArenaCommand(): UseArenaCommandReturn { + const [activeArenaDialog, setActiveArenaDialog] = + useState(null); + + const openArenaDialog = useCallback( + (type: Exclude) => { + setActiveArenaDialog(type); + }, + [], + ); + + const closeArenaDialog = useCallback(() => { + setActiveArenaDialog(null); + }, []); + + return { + activeArenaDialog, + openArenaDialog, + closeArenaDialog, + }; +} diff --git a/packages/cli/src/ui/hooks/useDialogClose.ts b/packages/cli/src/ui/hooks/useDialogClose.ts index d71a21190..119d1c96c 100644 --- a/packages/cli/src/ui/hooks/useDialogClose.ts +++ b/packages/cli/src/ui/hooks/useDialogClose.ts @@ -7,6 +7,7 @@ import { useCallback } from 'react'; import { SettingScope } from '../../config/settings.js'; import type { AuthType, ApprovalMode } from '@qwen-code/qwen-code-core'; +import type { ArenaDialogType } from './useArenaCommand.js'; // OpenAICredentials type (previously imported from OpenAIKeyPrompt) interface OpenAICredentials { apiKey: string; @@ -42,6 +43,10 @@ export interface DialogCloseOptions { isSettingsDialogOpen: boolean; closeSettingsDialog: () => void; + // Arena dialogs + activeArenaDialog: ArenaDialogType; + closeArenaDialog: () => void; + // Folder trust dialog isFolderTrustDialogOpen: boolean; @@ -83,6 +88,11 @@ export function useDialogClose(options: DialogCloseOptions) { return true; } + if (options.activeArenaDialog !== null) { + options.closeArenaDialog(); + return true; + } + if (options.isFolderTrustDialogOpen) { // FolderTrustDialog doesn't expose close function, but ESC would prevent exit // We follow the same pattern - prevent exit behavior diff --git a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx index edf0e0576..cd4c3e93b 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.test.tsx +++ b/packages/cli/src/ui/hooks/useGeminiStream.test.tsx @@ -217,6 +217,7 @@ describe('useGeminiStream', () => { .fn() .mockReturnValue(contentGeneratorConfig), getMaxSessionTurns: vi.fn(() => 50), + getArenaAgentClient: vi.fn(() => null), } as unknown as Config; mockOnDebugMessage = vi.fn(); mockHandleSlashCommand = vi.fn().mockResolvedValue(false); diff --git a/packages/cli/src/ui/hooks/useGeminiStream.ts b/packages/cli/src/ui/hooks/useGeminiStream.ts index 5bebbac7e..79ca03625 100644 --- a/packages/cli/src/ui/hooks/useGeminiStream.ts +++ b/packages/cli/src/ui/hooks/useGeminiStream.ts @@ -347,6 +347,12 @@ export const useGeminiStream = ( isSubmittingQueryRef.current = false; abortControllerRef.current?.abort(); + // Report cancellation to arena status reporter (if in arena mode). + // This is needed because cancellation during tool execution won't + // flow through sendMessageStream where the inline reportCancelled() + // lives — tools get cancelled and handleCompletedTools returns early. + config.getArenaAgentClient()?.reportCancelled(); + // Log API cancellation const prompt_id = config.getSessionId() + '########' + getPromptCount(); const cancellationEvent = new ApiCancelEvent( @@ -1264,6 +1270,9 @@ export const useGeminiStream = ( role: 'user', parts: combinedParts, }); + + // Report cancellation to arena (safety net — cancelOngoingRequest + config.getArenaAgentClient()?.reportCancelled(); } const callIdsToMarkAsSubmitted = geminiTools.map( @@ -1306,6 +1315,7 @@ export const useGeminiStream = ( geminiClient, performMemoryRefresh, modelSwitchedFromQuotaError, + config, ], ); diff --git a/packages/cli/src/ui/hooks/useSelectionList.test.ts b/packages/cli/src/ui/hooks/useSelectionList.test.ts index 8383d89c9..e488fe175 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.test.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.test.ts @@ -5,6 +5,7 @@ */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { useEffect, useState } from 'react'; import { renderHook, act } from '@testing-library/react'; import { useSelectionList, @@ -915,6 +916,37 @@ describe('useSelectionList', () => { expect(result.current.activeIndex).toBe(2); }); + + it('should handle equivalent items regenerated on each render', () => { + const { result } = renderHook(() => { + const [tick, setTick] = useState(0); + const regeneratedItems = [ + { value: 'A', key: 'A' }, + { value: 'B', disabled: true, key: 'B' }, + { value: 'C', key: 'C' }, + ]; + + const selection = useSelectionList({ + items: regeneratedItems, + onSelect: mockOnSelect, + initialIndex: 0, + }); + + useEffect(() => { + if (tick === 0) { + setTick(1); + } + }, [tick]); + + return { + tick, + activeIndex: selection.activeIndex, + }; + }); + + expect(result.current.tick).toBe(1); + expect(result.current.activeIndex).toBe(0); + }); }); describe('Manual Control', () => { diff --git a/packages/cli/src/ui/hooks/useSelectionList.ts b/packages/cli/src/ui/hooks/useSelectionList.ts index c09aec802..81045a5bf 100644 --- a/packages/cli/src/ui/hooks/useSelectionList.ts +++ b/packages/cli/src/ui/hooks/useSelectionList.ts @@ -133,6 +133,27 @@ const computeInitialIndex = ( return targetIndex; }; +const areItemsStructurallyEqual = ( + a: Array>, + b: Array>, +): boolean => { + if (a === b) { + return true; + } + + if (a.length !== b.length) { + return false; + } + + for (let i = 0; i < a.length; i++) { + if (a[i]?.key !== b[i]?.key || a[i]?.disabled !== b[i]?.disabled) { + return false; + } + } + + return true; +}; + function selectionListReducer( state: SelectionListState, action: SelectionListAction, @@ -176,22 +197,30 @@ function selectionListReducer( case 'INITIALIZE': { const { initialIndex, items } = action.payload; + const initialIndexChanged = initialIndex !== state.initialIndex; const activeKey = - initialIndex === state.initialIndex && - state.activeIndex !== state.initialIndex + !initialIndexChanged && state.activeIndex !== state.initialIndex ? state.items[state.activeIndex]?.key : undefined; + const targetIndex = computeInitialIndex(initialIndex, items, activeKey); + const itemsStructurallyEqual = areItemsStructurallyEqual( + items, + state.items, + ); - if (items === state.items && initialIndex === state.initialIndex) { + if ( + !initialIndexChanged && + targetIndex === state.activeIndex && + itemsStructurallyEqual + ) { return state; } - const targetIndex = computeInitialIndex(initialIndex, items, activeKey); - return { ...state, - items, + items: itemsStructurallyEqual ? state.items : items, activeIndex: targetIndex, + initialIndex, pendingHighlight: false, }; } diff --git a/packages/cli/src/ui/themes/no-color.ts b/packages/cli/src/ui/themes/no-color.ts index 3d5b4d4e7..c3a7cbce4 100644 --- a/packages/cli/src/ui/themes/no-color.ts +++ b/packages/cli/src/ui/themes/no-color.ts @@ -33,6 +33,7 @@ const noColorSemanticColors: SemanticColors = { secondary: '', link: '', accent: '', + code: '', }, background: { primary: '', diff --git a/packages/cli/src/ui/themes/semantic-tokens.ts b/packages/cli/src/ui/themes/semantic-tokens.ts index 2aa27a09c..d3047f0f0 100644 --- a/packages/cli/src/ui/themes/semantic-tokens.ts +++ b/packages/cli/src/ui/themes/semantic-tokens.ts @@ -12,6 +12,7 @@ export interface SemanticColors { secondary: string; link: string; accent: string; + code: string; }; background: { primary: string; @@ -45,6 +46,7 @@ export const lightSemanticColors: SemanticColors = { secondary: lightTheme.Gray, link: lightTheme.AccentBlue, accent: lightTheme.AccentPurple, + code: lightTheme.LightBlue, }, background: { primary: lightTheme.Background, @@ -77,6 +79,7 @@ export const darkSemanticColors: SemanticColors = { secondary: darkTheme.Gray, link: darkTheme.AccentBlue, accent: darkTheme.AccentPurple, + code: darkTheme.LightBlue, }, background: { primary: darkTheme.Background, @@ -109,6 +112,7 @@ export const ansiSemanticColors: SemanticColors = { secondary: ansiTheme.Gray, link: ansiTheme.AccentBlue, accent: ansiTheme.AccentPurple, + code: ansiTheme.LightBlue, }, background: { primary: ansiTheme.Background, diff --git a/packages/cli/src/ui/themes/theme.ts b/packages/cli/src/ui/themes/theme.ts index 3ae3bbead..5fee07729 100644 --- a/packages/cli/src/ui/themes/theme.ts +++ b/packages/cli/src/ui/themes/theme.ts @@ -40,6 +40,7 @@ export interface CustomTheme { secondary?: string; link?: string; accent?: string; + code?: string; }; background?: { primary?: string; @@ -174,6 +175,7 @@ export class Theme { secondary: this.colors.Gray, link: this.colors.AccentBlue, accent: this.colors.AccentPurple, + code: this.colors.LightBlue, }, background: { primary: this.colors.Background, @@ -269,7 +271,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { type: 'custom', Background: customTheme.background?.primary ?? customTheme.Background ?? '', Foreground: customTheme.text?.primary ?? customTheme.Foreground ?? '', - LightBlue: customTheme.text?.link ?? customTheme.LightBlue ?? '', + LightBlue: customTheme.text?.code ?? customTheme.LightBlue ?? '', AccentBlue: customTheme.text?.link ?? customTheme.AccentBlue ?? '', AccentPurple: customTheme.text?.accent ?? customTheme.AccentPurple ?? '', AccentCyan: customTheme.text?.link ?? customTheme.AccentCyan ?? '', @@ -433,6 +435,7 @@ export function createCustomTheme(customTheme: CustomTheme): Theme { secondary: customTheme.text?.secondary ?? colors.Gray, link: customTheme.text?.link ?? colors.AccentBlue, accent: customTheme.text?.accent ?? colors.AccentPurple, + code: customTheme.text?.code ?? colors.LightBlue, }, background: { primary: customTheme.background?.primary ?? colors.Background, diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ae799bfa6..ea3c53ad6 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -128,6 +128,11 @@ export type HistoryItemWarning = HistoryItemBase & { text: string; }; +export type HistoryItemSuccess = HistoryItemBase & { + type: 'success'; + text: string; +}; + export type HistoryItemRetryCountdown = HistoryItemBase & { type: 'retry_countdown'; text: string; @@ -256,6 +261,37 @@ export type HistoryItemMcpStatus = HistoryItemBase & { showTips: boolean; }; +/** + * Arena agent completion card data. + */ +export interface ArenaAgentCardData { + label: string; + status: 'completed' | 'cancelled' | 'terminated'; + durationMs: number; + totalTokens: number; + inputTokens: number; + outputTokens: number; + toolCalls: number; + successfulToolCalls: number; + failedToolCalls: number; + rounds: number; + error?: string; + diff?: string; +} + +export type HistoryItemArenaAgentComplete = HistoryItemBase & { + type: 'arena_agent_complete'; + agent: ArenaAgentCardData; +}; + +export type HistoryItemArenaSessionComplete = HistoryItemBase & { + type: 'arena_session_complete'; + sessionStatus: string; + task: string; + totalDurationMs: number; + agents: ArenaAgentCardData[]; +}; + // Using Omit seems to have some issues with typescript's // type inference e.g. historyItem.type === 'tool_group' isn't auto-inferring that // 'tools' in historyItem. @@ -270,6 +306,7 @@ export type HistoryItemWithoutId = | HistoryItemInfo | HistoryItemError | HistoryItemWarning + | HistoryItemSuccess | HistoryItemRetryCountdown | HistoryItemAbout | HistoryItemHelp @@ -284,13 +321,16 @@ export type HistoryItemWithoutId = | HistoryItemExtensionsList | HistoryItemToolsList | HistoryItemSkillsList - | HistoryItemMcpStatus; + | HistoryItemMcpStatus + | HistoryItemArenaAgentComplete + | HistoryItemArenaSessionComplete; export type HistoryItem = HistoryItemWithoutId & { id: number }; // Message types used by internal command feedback (subset of HistoryItem types) export enum MessageType { INFO = 'info', + SUCCESS = 'success', ERROR = 'error', WARNING = 'warning', USER = 'user', @@ -307,6 +347,8 @@ export enum MessageType { TOOLS_LIST = 'tools_list', SKILLS_LIST = 'skills_list', MCP_STATUS = 'mcp_status', + ARENA_AGENT_COMPLETE = 'arena_agent_complete', + ARENA_SESSION_COMPLETE = 'arena_session_complete', } // Simplified message structure for internal feedback diff --git a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx index ce31078d1..2403db96f 100644 --- a/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx +++ b/packages/cli/src/ui/utils/InlineMarkdownRenderer.tsx @@ -103,7 +103,7 @@ const RenderInlineInternal: React.FC = ({ const codeMatch = fullMatch.match(/^(`+)(.+?)\1$/s); if (codeMatch && codeMatch[2]) { renderedNode = ( - + {codeMatch[2]} ); diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts index b8f603170..2e8f22078 100644 --- a/packages/cli/src/ui/utils/displayUtils.ts +++ b/packages/cli/src/ui/utils/displayUtils.ts @@ -5,6 +5,39 @@ */ import { theme } from '../semantic-colors.js'; +import { ArenaAgentStatus } from '@qwen-code/qwen-code-core'; + +// --- Status Labels --- + +export interface StatusLabel { + icon: string; + text: string; + color: string; +} + +export function getArenaStatusLabel( + status: ArenaAgentStatus | string, +): StatusLabel { + switch (status) { + case ArenaAgentStatus.COMPLETED: + case 'completed': + return { icon: '✓', text: 'Done', color: theme.status.success }; + case ArenaAgentStatus.CANCELLED: + case 'cancelled': + return { icon: '⊘', text: 'Cancelled', color: theme.status.warning }; + case ArenaAgentStatus.TERMINATED: + case 'terminated': + return { icon: '✗', text: 'Terminated', color: theme.status.error }; + case ArenaAgentStatus.RUNNING: + case 'running': + return { icon: '○', text: 'Running', color: theme.text.secondary }; + case ArenaAgentStatus.INITIALIZING: + case 'initializing': + return { icon: '○', text: 'Initializing', color: theme.text.secondary }; + default: + return { icon: '○', text: status, color: theme.text.secondary }; + } +} // --- Thresholds --- export const TOOL_SUCCESS_RATE_HIGH = 95; diff --git a/packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts b/packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts new file mode 100644 index 000000000..d5a5f5f91 --- /dev/null +++ b/packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts @@ -0,0 +1,542 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ArenaAgentClient } from './ArenaAgentClient.js'; +import { safeAgentId } from './types.js'; +import type { ArenaControlSignal } from './types.js'; +import { uiTelemetryService } from '../../telemetry/uiTelemetry.js'; +import type { SessionMetrics } from '../../telemetry/uiTelemetry.js'; +import { ToolCallDecision } from '../../telemetry/tool-call-decision.js'; + +const createMockMetrics = ( + overrides: Partial<{ + totalRequests: number; + totalTokens: number; + promptTokens: number; + candidatesTokens: number; + totalLatencyMs: number; + totalCalls: number; + totalSuccess: number; + totalFail: number; + }> = {}, +): SessionMetrics => ({ + models: { + 'test-model': { + api: { + totalRequests: overrides.totalRequests ?? 0, + totalErrors: 0, + totalLatencyMs: overrides.totalLatencyMs ?? 0, + }, + tokens: { + prompt: overrides.promptTokens ?? 0, + candidates: overrides.candidatesTokens ?? 0, + total: overrides.totalTokens ?? 0, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: overrides.totalCalls ?? 0, + totalSuccess: overrides.totalSuccess ?? 0, + totalFail: overrides.totalFail ?? 0, + totalDurationMs: 0, + totalDecisions: { + [ToolCallDecision.ACCEPT]: 0, + [ToolCallDecision.REJECT]: 0, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, + }, + byName: {}, + }, + files: { + totalLinesAdded: 0, + totalLinesRemoved: 0, + }, +}); + +describe('ArenaAgentClient', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-reporter-test-')); + vi.spyOn(uiTelemetryService, 'getMetrics').mockReturnValue( + createMockMetrics(), + ); + }); + + afterEach(async () => { + vi.restoreAllMocks(); + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('create() factory', () => { + it('should return null when ARENA_AGENT_ID is not set', () => { + const original = process.env['ARENA_AGENT_ID']; + const originalSession = process.env['ARENA_SESSION_ID']; + const originalDir = process.env['ARENA_SESSION_DIR']; + delete process.env['ARENA_AGENT_ID']; + delete process.env['ARENA_SESSION_ID']; + delete process.env['ARENA_SESSION_DIR']; + + const reporter = ArenaAgentClient.create(); + expect(reporter).toBeNull(); + + // Restore + if (original !== undefined) { + process.env['ARENA_AGENT_ID'] = original; + } + if (originalSession !== undefined) { + process.env['ARENA_SESSION_ID'] = originalSession; + } + if (originalDir !== undefined) { + process.env['ARENA_SESSION_DIR'] = originalDir; + } + }); + + it('should return null when ARENA_SESSION_ID is not set', () => { + const originalAgent = process.env['ARENA_AGENT_ID']; + const originalSession = process.env['ARENA_SESSION_ID']; + const originalDir = process.env['ARENA_SESSION_DIR']; + + process.env['ARENA_AGENT_ID'] = 'test-agent'; + delete process.env['ARENA_SESSION_ID']; + process.env['ARENA_SESSION_DIR'] = tempDir; + + const reporter = ArenaAgentClient.create(); + expect(reporter).toBeNull(); + + // Restore + if (originalAgent !== undefined) { + process.env['ARENA_AGENT_ID'] = originalAgent; + } else { + delete process.env['ARENA_AGENT_ID']; + } + if (originalSession !== undefined) { + process.env['ARENA_SESSION_ID'] = originalSession; + } + if (originalDir !== undefined) { + process.env['ARENA_SESSION_DIR'] = originalDir; + } else { + delete process.env['ARENA_SESSION_DIR']; + } + }); + + it('should return null when ARENA_SESSION_DIR is not set', () => { + const originalAgent = process.env['ARENA_AGENT_ID']; + const originalSession = process.env['ARENA_SESSION_ID']; + const originalDir = process.env['ARENA_SESSION_DIR']; + + process.env['ARENA_AGENT_ID'] = 'test-agent'; + process.env['ARENA_SESSION_ID'] = 'test-session'; + delete process.env['ARENA_SESSION_DIR']; + + const reporter = ArenaAgentClient.create(); + expect(reporter).toBeNull(); + + // Restore + if (originalAgent !== undefined) { + process.env['ARENA_AGENT_ID'] = originalAgent; + } else { + delete process.env['ARENA_AGENT_ID']; + } + if (originalSession !== undefined) { + process.env['ARENA_SESSION_ID'] = originalSession; + } else { + delete process.env['ARENA_SESSION_ID']; + } + if (originalDir !== undefined) { + process.env['ARENA_SESSION_DIR'] = originalDir; + } else { + delete process.env['ARENA_SESSION_DIR']; + } + }); + + it('should return an instance when all env vars are set', () => { + const originalAgent = process.env['ARENA_AGENT_ID']; + const originalSession = process.env['ARENA_SESSION_ID']; + const originalDir = process.env['ARENA_SESSION_DIR']; + + process.env['ARENA_AGENT_ID'] = 'test-agent'; + process.env['ARENA_SESSION_ID'] = 'test-session'; + process.env['ARENA_SESSION_DIR'] = tempDir; + + const reporter = ArenaAgentClient.create(); + expect(reporter).toBeInstanceOf(ArenaAgentClient); + + // Restore + if (originalAgent !== undefined) { + process.env['ARENA_AGENT_ID'] = originalAgent; + } else { + delete process.env['ARENA_AGENT_ID']; + } + if (originalSession !== undefined) { + process.env['ARENA_SESSION_ID'] = originalSession; + } else { + delete process.env['ARENA_SESSION_ID']; + } + if (originalDir !== undefined) { + process.env['ARENA_SESSION_DIR'] = originalDir; + } else { + delete process.env['ARENA_SESSION_DIR']; + } + }); + }); + + describe('init()', () => { + it('should create the agents/ and control/ directories', async () => { + const reporter = new ArenaAgentClient('agent-1', tempDir); + await reporter.init(); + + const agentsDir = path.join(tempDir, 'agents'); + const controlDir = path.join(tempDir, 'control'); + const agentsStat = await fs.stat(agentsDir); + const controlStat = await fs.stat(controlDir); + expect(agentsStat.isDirectory()).toBe(true); + expect(controlStat.isDirectory()).toBe(true); + }); + + it('should be idempotent', async () => { + const reporter = new ArenaAgentClient('agent-1', tempDir); + await reporter.init(); + await reporter.init(); // Should not throw + + const agentsDir = path.join(tempDir, 'agents'); + const stat = await fs.stat(agentsDir); + expect(stat.isDirectory()).toBe(true); + }); + }); + + describe('updateStatus()', () => { + it('should write per-agent status file with stats from telemetry', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + createMockMetrics({ + totalRequests: 3, + totalTokens: 1500, + promptTokens: 1000, + candidatesTokens: 500, + totalCalls: 7, + totalSuccess: 6, + totalFail: 1, + }), + ); + + await reporter.updateStatus('Editing files'); + + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.agentId).toBe(agentId); + expect(content.status).toBe('running'); + expect(content.rounds).toBe(3); + expect(content.currentActivity).toBe('Editing files'); + expect(content.stats.totalTokens).toBe(1500); + expect(content.stats.inputTokens).toBe(1000); + expect(content.stats.outputTokens).toBe(500); + expect(content.stats.toolCalls).toBe(7); + expect(content.stats.successfulToolCalls).toBe(6); + expect(content.stats.failedToolCalls).toBe(1); + expect(content.finalSummary).toBeNull(); + expect(content.error).toBeNull(); + expect(content.updatedAt).toBeTypeOf('number'); + }); + + it('should perform atomic write (no partial reads)', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + // Write status multiple times rapidly + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(reporter.updateStatus()); + } + await Promise.all(promises); + + // The file should be valid JSON (no corruption from concurrent writes) + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + expect(content.agentId).toBe(agentId); + expect(content.status).toBe('running'); + }); + + it('should reflect latest telemetry on each call', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + // First update + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + createMockMetrics({ + totalRequests: 1, + totalTokens: 100, + totalCalls: 5, + }), + ); + await reporter.updateStatus(); + + // Second update with updated telemetry + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + createMockMetrics({ + totalRequests: 2, + totalTokens: 200, + totalCalls: 8, + }), + ); + await reporter.updateStatus(); + + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.rounds).toBe(2); + expect(content.stats.totalTokens).toBe(200); + expect(content.stats.toolCalls).toBe(8); + }); + + it('should auto-initialize if not yet initialized', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + // Skip init() call + + await reporter.updateStatus(); + + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + expect(content.agentId).toBe(agentId); + }); + }); + + describe('checkControlSignal()', () => { + it('should return null when no control file exists', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + const signal = await reporter.checkControlSignal(); + expect(signal).toBeNull(); + }); + + it('should read and delete control file', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + // Write a control signal + const controlSignal: ArenaControlSignal = { + type: 'shutdown', + reason: 'User cancelled', + timestamp: Date.now(), + }; + const controlPath = path.join( + tempDir, + 'control', + `${safeAgentId(agentId)}.json`, + ); + await fs.writeFile(controlPath, JSON.stringify(controlSignal), 'utf-8'); + + // Read it + const signal = await reporter.checkControlSignal(); + expect(signal).not.toBeNull(); + expect(signal!.type).toBe('shutdown'); + expect(signal!.reason).toBe('User cancelled'); + + // File should be deleted (consumed) + await expect(fs.access(controlPath)).rejects.toThrow(); + }); + + it('should return null on subsequent reads (consume-once)', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + // Write a control signal + const controlSignal: ArenaControlSignal = { + type: 'cancel', + reason: 'Timeout', + timestamp: Date.now(), + }; + const controlPath = path.join( + tempDir, + 'control', + `${safeAgentId(agentId)}.json`, + ); + await fs.writeFile(controlPath, JSON.stringify(controlSignal), 'utf-8'); + + // First read should return the signal + const first = await reporter.checkControlSignal(); + expect(first).not.toBeNull(); + + // Second read should return null + const second = await reporter.checkControlSignal(); + expect(second).toBeNull(); + }); + }); + + describe('reportCompleted()', () => { + it('should write status with completed state and optional summary', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + await reporter.reportCompleted('Successfully implemented feature X'); + + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.status).toBe('completed'); + expect(content.finalSummary).toBe('Successfully implemented feature X'); + expect(content.error).toBeNull(); + }); + + it('should write status with idle state and no summary', async () => { + const agentId = 'model-a'; + const reporter = new ArenaAgentClient(agentId, tempDir); + await reporter.init(); + + await reporter.reportCompleted(); + + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId(agentId)}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.status).toBe('completed'); + expect(content.finalSummary).toBeNull(); + expect(content.error).toBeNull(); + }); + }); + + describe('buildStatsFromMetrics()', () => { + it('should aggregate stats across multiple models', () => { + const metrics: SessionMetrics = { + models: { + 'model-a': { + api: { + totalRequests: 3, + totalErrors: 0, + totalLatencyMs: 1000, + }, + tokens: { + prompt: 100, + candidates: 50, + total: 150, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + 'model-b': { + api: { + totalRequests: 2, + totalErrors: 1, + totalLatencyMs: 500, + }, + tokens: { + prompt: 200, + candidates: 100, + total: 300, + cached: 0, + thoughts: 0, + tool: 0, + }, + }, + }, + tools: { + totalCalls: 10, + totalSuccess: 8, + totalFail: 2, + totalDurationMs: 2000, + totalDecisions: { + [ToolCallDecision.ACCEPT]: 0, + [ToolCallDecision.REJECT]: 0, + [ToolCallDecision.MODIFY]: 0, + [ToolCallDecision.AUTO_ACCEPT]: 0, + }, + byName: {}, + }, + files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, + }; + + const stats = ArenaAgentClient.buildStatsFromMetrics(metrics); + + expect(stats.rounds).toBe(5); + expect(stats.totalTokens).toBe(450); + expect(stats.inputTokens).toBe(300); + expect(stats.outputTokens).toBe(150); + expect(stats.durationMs).toBe(1500); + expect(stats.toolCalls).toBe(10); + expect(stats.successfulToolCalls).toBe(8); + expect(stats.failedToolCalls).toBe(2); + }); + + it('should return zeros when no models exist', () => { + const metrics = createMockMetrics(); + // Override with empty models + metrics.models = {}; + + const stats = ArenaAgentClient.buildStatsFromMetrics(metrics); + + expect(stats.rounds).toBe(0); + expect(stats.totalTokens).toBe(0); + expect(stats.inputTokens).toBe(0); + expect(stats.outputTokens).toBe(0); + expect(stats.durationMs).toBe(0); + }); + }); + + describe('safeAgentId()', () => { + it('should pass through typical model IDs unchanged', () => { + expect(safeAgentId('qwen-coder-plus')).toBe('qwen-coder-plus'); + }); + + it('should handle IDs without unsafe characters', () => { + expect(safeAgentId('simple-id')).toBe('simple-id'); + }); + + it('should replace slashes with double dashes', () => { + expect(safeAgentId('org/model-name')).toBe('org--model-name'); + }); + + it('should handle multiple unsafe characters', () => { + expect(safeAgentId('a/b\\c:d')).toBe('a--b--c--d'); + }); + }); +}); diff --git a/packages/core/src/agents-collab/arena/ArenaAgentClient.ts b/packages/core/src/agents-collab/arena/ArenaAgentClient.ts new file mode 100644 index 000000000..8b1eb8ba1 --- /dev/null +++ b/packages/core/src/agents-collab/arena/ArenaAgentClient.ts @@ -0,0 +1,273 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as crypto from 'node:crypto'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { isNodeError } from '../../utils/errors.js'; +import { + uiTelemetryService, + type SessionMetrics, +} from '../../telemetry/uiTelemetry.js'; +import type { + ArenaAgentStats, + ArenaControlSignal, + ArenaStatusFile, +} from './types.js'; +import { safeAgentId } from './types.js'; + +const debugLogger = createDebugLogger('ARENA_AGENT_CLIENT'); + +const AGENTS_SUBDIR = 'agents'; +const CONTROL_SUBDIR = 'control'; + +/** + * ArenaAgentClient is used by child agent processes to communicate + * their status back to the main ArenaManager process via file-based IPC. + * + * Status files are written to a centralized arena session directory: + * `/agents/.json` + * + * Control signals are read from: + * `/control/.json` + * + * It self-activates based on the ARENA_AGENT_ID environment variable. + * When running outside an Arena session, `ArenaAgentClient.create()` + * returns null. + */ +export class ArenaAgentClient { + private readonly agentsDir: string; + private readonly controlDir: string; + private readonly statusFilePath: string; + private readonly controlFilePath: string; + private initialized = false; + + /** + * Static factory - returns an instance if ARENA_AGENT_ID, ARENA_SESSION_ID, + * and ARENA_SESSION_DIR env vars are present, null otherwise. + */ + static create(): ArenaAgentClient | null { + const agentId = process.env['ARENA_AGENT_ID']; + const sessionId = process.env['ARENA_SESSION_ID']; + const sessionDir = process.env['ARENA_SESSION_DIR']; + + if (!agentId || !sessionId || !sessionDir) { + return null; + } + + return new ArenaAgentClient(agentId, sessionDir); + } + + constructor( + private readonly agentId: string, + arenaSessionDir: string, + ) { + const safe = safeAgentId(agentId); + this.agentsDir = path.join(arenaSessionDir, AGENTS_SUBDIR); + this.controlDir = path.join(arenaSessionDir, CONTROL_SUBDIR); + this.statusFilePath = path.join(this.agentsDir, `${safe}.json`); + this.controlFilePath = path.join(this.controlDir, `${safe}.json`); + } + + /** + * Initialize the agents/ and control/ directories under the arena session + * dir. Called automatically on first use if not invoked explicitly. + */ + async init(): Promise { + await fs.mkdir(this.agentsDir, { recursive: true }); + await fs.mkdir(this.controlDir, { recursive: true }); + this.initialized = true; + debugLogger.info( + `ArenaAgentClient initialized for agent ${this.agentId} at ${this.agentsDir}`, + ); + } + + /** + * Write current status to the per-agent status file using atomic write + * (write to temp file then rename). + * + * Stats are derived automatically from uiTelemetryService which is the + * canonical source for token counts, tool calls, and API request counts. + */ + async updateStatus(currentActivity?: string): Promise { + await this.ensureInitialized(); + + const stats = this.getStatsFromTelemetry(); + + const statusFile: ArenaStatusFile = { + agentId: this.agentId, + status: 'running', + updatedAt: Date.now(), + rounds: stats.rounds, + currentActivity, + stats, + finalSummary: null, + error: null, + }; + + await this.atomicWrite(this.statusFilePath, statusFile); + } + + /** + * Read and delete control.json (consume-once pattern). + * Returns null if no control signal is pending. + */ + async checkControlSignal(): Promise { + await this.ensureInitialized(); + + try { + const content = await fs.readFile(this.controlFilePath, 'utf-8'); + // Parse before deleting so a corrupted file isn't silently consumed + const signal = JSON.parse(content) as ArenaControlSignal; + await fs.unlink(this.controlFilePath); + return signal; + } catch (error: unknown) { + // File doesn't exist = no signal pending + if (isNodeError(error) && error.code === 'ENOENT') { + return null; + } + // Re-throw permission errors so they surface immediately + if (isNodeError(error) && error.code === 'EACCES') { + throw error; + } + debugLogger.error('Error reading control signal:', error); + return null; + } + } + + /** + * Report that the agent has completed the current task successfully. + * This is the primary signal to the main process that the agent is done working. + */ + async reportCompleted(finalSummary?: string): Promise { + await this.ensureInitialized(); + + const stats = this.getStatsFromTelemetry(); + + const statusFile: ArenaStatusFile = { + agentId: this.agentId, + status: 'completed', + updatedAt: Date.now(), + rounds: stats.rounds, + stats, + finalSummary: finalSummary ?? null, + error: null, + }; + + await this.atomicWrite(this.statusFilePath, statusFile); + } + + /** + * Report that the agent hit an error (API/auth/rate-limit, loop, etc.). + */ + async reportError(errorMessage: string): Promise { + await this.ensureInitialized(); + + const stats = this.getStatsFromTelemetry(); + + const statusFile: ArenaStatusFile = { + agentId: this.agentId, + status: 'error', + updatedAt: Date.now(), + rounds: stats.rounds, + stats, + finalSummary: null, + error: errorMessage, + }; + + await this.atomicWrite(this.statusFilePath, statusFile); + } + + /** + * Report that the agent's current request was cancelled by the user. + */ + async reportCancelled(): Promise { + await this.ensureInitialized(); + + const stats = this.getStatsFromTelemetry(); + + const statusFile: ArenaStatusFile = { + agentId: this.agentId, + status: 'cancelled', + updatedAt: Date.now(), + rounds: stats.rounds, + stats, + finalSummary: null, + error: null, + }; + + await this.atomicWrite(this.statusFilePath, statusFile); + } + + /** + * Build ArenaAgentStats from the current uiTelemetryService metrics. + */ + private getStatsFromTelemetry(): ArenaAgentStats { + return ArenaAgentClient.buildStatsFromMetrics( + uiTelemetryService.getMetrics(), + ); + } + + /** + * Convert SessionMetrics into ArenaAgentStats by aggregating across + * all models. Exposed as a static method for testability. + */ + static buildStatsFromMetrics(metrics: SessionMetrics): ArenaAgentStats { + let rounds = 0; + let totalTokens = 0; + let inputTokens = 0; + let outputTokens = 0; + let durationMs = 0; + + for (const model of Object.values(metrics.models)) { + rounds += model.api.totalRequests; + totalTokens += model.tokens.total; + inputTokens += model.tokens.prompt; + outputTokens += model.tokens.candidates; + durationMs += model.api.totalLatencyMs; + } + + return { + rounds, + totalTokens, + inputTokens, + outputTokens, + durationMs, + toolCalls: metrics.tools.totalCalls, + successfulToolCalls: metrics.tools.totalSuccess, + failedToolCalls: metrics.tools.totalFail, + }; + } + + /** + * Atomically write JSON data to a file (write temp → rename). + */ + private async atomicWrite( + filePath: string, + data: ArenaStatusFile, + ): Promise { + const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`; + try { + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await fs.rename(tmpPath, filePath); + } catch (error) { + // Clean up temp file on failure + try { + await fs.unlink(tmpPath); + } catch { + // Ignore cleanup errors + } + throw error; + } + } + + private async ensureInitialized(): Promise { + if (!this.initialized) { + await this.init(); + } + } +} diff --git a/packages/core/src/agents-collab/arena/ArenaManager.test.ts b/packages/core/src/agents-collab/arena/ArenaManager.test.ts new file mode 100644 index 000000000..88ccce684 --- /dev/null +++ b/packages/core/src/agents-collab/arena/ArenaManager.test.ts @@ -0,0 +1,433 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { ArenaManager } from './ArenaManager.js'; +import { ArenaEventType } from './arena-events.js'; +import { ArenaSessionStatus, ARENA_MAX_AGENTS } from './types.js'; + +const hoistedMockSetupArenaWorktrees = vi.hoisted(() => vi.fn()); +const hoistedMockCleanupArenaSession = vi.hoisted(() => vi.fn()); +const hoistedMockGetWorktreeDiff = vi.hoisted(() => vi.fn()); +const hoistedMockApplyWorktreeChanges = vi.hoisted(() => vi.fn()); +const hoistedMockDetectBackend = vi.hoisted(() => vi.fn()); + +vi.mock('../index.js', () => ({ + detectBackend: hoistedMockDetectBackend, +})); + +// Mock GitWorktreeService to avoid real git operations. +// The class mock includes static methods used by ArenaManager. +vi.mock('../../services/gitWorktreeService.js', () => { + const MockClass = vi.fn().mockImplementation(() => ({ + setupArenaWorktrees: hoistedMockSetupArenaWorktrees, + cleanupArenaSession: hoistedMockCleanupArenaSession, + getWorktreeDiff: hoistedMockGetWorktreeDiff, + applyWorktreeChanges: hoistedMockApplyWorktreeChanges, + })); + // Static methods called by ArenaManager + (MockClass as unknown as Record)['getArenaBaseDir'] = () => + path.join(os.tmpdir(), 'arena-mock'); + (MockClass as unknown as Record)['getArenaSessionDir'] = ( + sessionId: string, + ) => path.join(os.tmpdir(), 'arena-mock', sessionId); + (MockClass as unknown as Record)['getWorktreesDir'] = ( + sessionId: string, + ) => path.join(os.tmpdir(), 'arena-mock', sessionId, 'worktrees'); + return { GitWorktreeService: MockClass }; +}); + +// Mock the Config class +const createMockConfig = (workingDir: string) => ({ + getWorkingDir: () => workingDir, + getModel: () => 'test-model', + getSessionId: () => 'test-session', + getToolRegistry: () => ({ + getFunctionDeclarations: () => [], + getFunctionDeclarationsFiltered: () => [], + getTool: () => undefined, + }), + getAgentsSettings: () => ({}), +}); + +describe('ArenaManager', () => { + let tempDir: string; + let mockConfig: ReturnType; + let mockBackend: ReturnType; + + beforeEach(async () => { + // Create a temp directory - no need for git repo since we mock GitWorktreeService + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-test-')); + mockConfig = createMockConfig(tempDir); + + mockBackend = createMockBackend(); + hoistedMockDetectBackend.mockResolvedValue({ backend: mockBackend }); + + hoistedMockSetupArenaWorktrees.mockImplementation( + async ({ + arenaSessionId, + sourceRepoPath, + worktreeNames, + }: { + arenaSessionId: string; + sourceRepoPath: string; + worktreeNames: string[]; + }) => { + const worktrees = worktreeNames.map((name) => ({ + id: `${arenaSessionId}/${name}`, + name, + path: path.join(sourceRepoPath, `.arena-${arenaSessionId}`, name), + branch: `arena/${arenaSessionId}/${name}`, + isActive: true, + createdAt: Date.now(), + })); + + return { + success: true, + arenaSessionId, + worktrees, + worktreesByName: Object.fromEntries( + worktrees.map((worktree) => [worktree.name, worktree]), + ), + errors: [], + wasRepoInitialized: false, + }; + }, + ); + hoistedMockCleanupArenaSession.mockResolvedValue({ + success: true, + removedWorktrees: [], + removedBranches: [], + errors: [], + }); + hoistedMockGetWorktreeDiff.mockResolvedValue(''); + hoistedMockApplyWorktreeChanges.mockResolvedValue({ success: true }); + }); + + afterEach(async () => { + try { + await fs.rm(tempDir, { recursive: true, force: true }); + } catch { + // Ignore cleanup errors + } + }); + + describe('constructor', () => { + it('should create an ArenaManager instance', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager).toBeDefined(); + expect(manager.getSessionId()).toBeUndefined(); + expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.INITIALIZING); + }); + + it('should not have a backend before start', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager.getBackend()).toBeNull(); + }); + }); + + describe('start validation', () => { + it('should reject start with less than 2 models', async () => { + const manager = new ArenaManager(mockConfig as never); + + await expect( + manager.start({ + models: [{ modelId: 'model-1', authType: 'openai' }], + task: 'Test task', + }), + ).rejects.toThrow('Arena requires at least 2 models'); + }); + + it('should reject start with more than max models', async () => { + const manager = new ArenaManager(mockConfig as never); + + const models = Array.from({ length: ARENA_MAX_AGENTS + 1 }, (_, i) => ({ + modelId: `model-${i}`, + authType: 'openai', + })); + + await expect( + manager.start({ + models, + task: 'Test task', + }), + ).rejects.toThrow( + `Arena supports a maximum of ${ARENA_MAX_AGENTS} models`, + ); + }); + + it('should reject start with empty task', async () => { + const manager = new ArenaManager(mockConfig as never); + + await expect( + manager.start({ + models: [ + { modelId: 'model-1', authType: 'openai' }, + { modelId: 'model-2', authType: 'openai' }, + ], + task: '', + }), + ).rejects.toThrow('Arena requires a task/prompt'); + }); + + it('should reject start with duplicate model IDs', async () => { + const manager = new ArenaManager(mockConfig as never); + + await expect( + manager.start({ + models: [ + { modelId: 'model-1', authType: 'openai' }, + { modelId: 'model-1', authType: 'openai' }, + ], + task: 'Test task', + }), + ).rejects.toThrow('Arena models must have unique identifiers'); + }); + }); + + describe('event emitter', () => { + it('should return the event emitter', () => { + const manager = new ArenaManager(mockConfig as never); + const emitter = manager.getEventEmitter(); + expect(emitter).toBeDefined(); + expect(typeof emitter.on).toBe('function'); + expect(typeof emitter.off).toBe('function'); + expect(typeof emitter.emit).toBe('function'); + }); + }); + + describe('PTY interaction methods', () => { + it('should expose PTY interaction methods', () => { + const manager = new ArenaManager(mockConfig as never); + expect(typeof manager.switchToAgent).toBe('function'); + expect(typeof manager.switchToNextAgent).toBe('function'); + expect(typeof manager.switchToPreviousAgent).toBe('function'); + expect(typeof manager.getActiveAgentId).toBe('function'); + expect(typeof manager.getActiveSnapshot).toBe('function'); + expect(typeof manager.getAgentSnapshot).toBe('function'); + expect(typeof manager.forwardInput).toBe('function'); + expect(typeof manager.resizeAgents).toBe('function'); + }); + + it('should return null for active agent ID when no session', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager.getActiveAgentId()).toBeNull(); + }); + + it('should return null for active snapshot when no session', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager.getActiveSnapshot()).toBeNull(); + }); + }); + + describe('cancel', () => { + it('should handle cancel when no session is active', async () => { + const manager = new ArenaManager(mockConfig as never); + await expect(manager.cancel()).resolves.not.toThrow(); + }); + }); + + describe('cleanup', () => { + it('should handle cleanup when no session is active', async () => { + const manager = new ArenaManager(mockConfig as never); + await expect(manager.cleanup()).resolves.not.toThrow(); + }); + }); + + describe('getAgentStates', () => { + it('should return empty array when no agents', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager.getAgentStates()).toEqual([]); + }); + }); + + describe('getAgentState', () => { + it('should return undefined for non-existent agent', () => { + const manager = new ArenaManager(mockConfig as never); + expect(manager.getAgentState('non-existent')).toBeUndefined(); + }); + }); + + describe('applyAgentResult', () => { + it('should return error for non-existent agent', async () => { + const manager = new ArenaManager(mockConfig as never); + const result = await manager.applyAgentResult('non-existent'); + expect(result.success).toBe(false); + expect(result.error).toContain('not found'); + }); + }); + + describe('getAgentDiff', () => { + it('should return error message for non-existent agent', async () => { + const manager = new ArenaManager(mockConfig as never); + const diff = await manager.getAgentDiff('non-existent'); + expect(diff).toContain('not found'); + }); + }); + + describe('backend initialization', () => { + it('should emit SESSION_WARNING when backend detection returns warning', async () => { + const manager = new ArenaManager(mockConfig as never); + const warnings: Array<{ message: string; sessionId: string }> = []; + manager.getEventEmitter().on(ArenaEventType.SESSION_WARNING, (event) => { + warnings.push({ message: event.message, sessionId: event.sessionId }); + }); + + hoistedMockDetectBackend.mockResolvedValueOnce({ + backend: mockBackend, + warning: 'fallback to tmux backend', + }); + + await manager.start(createValidStartOptions()); + + expect(hoistedMockDetectBackend).toHaveBeenCalledWith(undefined); + expect(warnings).toHaveLength(1); + expect(warnings[0]?.message).toContain('fallback to tmux backend'); + expect(warnings[0]?.sessionId).toMatch(/^arena-/); + }); + + it('should emit SESSION_ERROR and mark FAILED when backend init fails', async () => { + const manager = new ArenaManager(mockConfig as never); + const sessionErrors: string[] = []; + manager.getEventEmitter().on(ArenaEventType.SESSION_ERROR, (event) => { + sessionErrors.push(event.error); + }); + + mockBackend.init.mockRejectedValueOnce(new Error('init failed')); + + await expect(manager.start(createValidStartOptions())).rejects.toThrow( + 'init failed', + ); + expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.FAILED); + expect(sessionErrors).toEqual(['init failed']); + }); + }); + + describe('active session lifecycle', () => { + it('cancel should stop backend and move session to CANCELLED', async () => { + const manager = new ArenaManager(mockConfig as never); + + // Disable auto-exit so agents stay running until we cancel. + mockBackend.setAutoExit(false); + + const startPromise = manager.start({ + ...createValidStartOptions(), + timeoutSeconds: 30, + }); + + // Wait until the backend has spawned at least one agent. + await waitForCondition( + () => mockBackend.spawnAgent.mock.calls.length > 0, + ); + + await manager.cancel(); + expect(mockBackend.stopAll).toHaveBeenCalledTimes(1); + expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.CANCELLED); + + await startPromise; + expect(manager.getSessionStatus()).toBe(ArenaSessionStatus.CANCELLED); + }); + + it('cleanup should release backend and worktree resources after start', async () => { + const manager = new ArenaManager(mockConfig as never); + + // auto-exit is on by default, so agents terminate quickly. + await manager.start(createValidStartOptions()); + const sessionIdBeforeCleanup = manager.getSessionId(); + + await manager.cleanup(); + + expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); + expect(hoistedMockCleanupArenaSession).toHaveBeenCalledWith( + sessionIdBeforeCleanup, + ); + expect(manager.getBackend()).toBeNull(); + expect(manager.getSessionId()).toBeUndefined(); + }); + }); +}); + +describe('ARENA_MAX_AGENTS', () => { + it('should be 5', () => { + expect(ARENA_MAX_AGENTS).toBe(5); + }); +}); + +function createMockBackend() { + type ExitCb = ( + agentId: string, + exitCode: number | null, + signal: number | null, + ) => void; + let onAgentExit: ExitCb | null = null; + let autoExit = true; + + const backend = { + type: 'tmux' as const, + init: vi.fn().mockResolvedValue(undefined), + spawnAgent: vi.fn(async (config: { agentId: string }) => { + // By default, simulate immediate agent termination so tests + // don't hang in waitForAllAgentsSettled. + if (autoExit) { + setTimeout(() => onAgentExit?.(config.agentId, 0, null), 5); + } + }), + stopAgent: vi.fn(), + stopAll: vi.fn(), + cleanup: vi.fn().mockResolvedValue(undefined), + setOnAgentExit: vi.fn((cb: ExitCb) => { + onAgentExit = cb; + }), + waitForAll: vi.fn().mockResolvedValue(true), + switchTo: vi.fn(), + switchToNext: vi.fn(), + switchToPrevious: vi.fn(), + getActiveAgentId: vi.fn().mockReturnValue(null), + getActiveSnapshot: vi.fn().mockReturnValue(null), + getAgentSnapshot: vi.fn().mockReturnValue(null), + getAgentScrollbackLength: vi.fn().mockReturnValue(0), + forwardInput: vi.fn().mockReturnValue(false), + writeToAgent: vi.fn().mockReturnValue(false), + resizeAll: vi.fn(), + getAttachHint: vi.fn().mockReturnValue(null), + /** Disable automatic agent exit for tests that need to control timing. */ + setAutoExit(value: boolean) { + autoExit = value; + }, + }; + return backend; +} + +function createValidStartOptions() { + return { + models: [ + { modelId: 'model-1', authType: 'openai' }, + { modelId: 'model-2', authType: 'openai' }, + ], + task: 'Implement feature X', + }; +} + +async function waitForMicrotask(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +async function waitForCondition( + predicate: () => boolean, + timeoutMs = 1000, +): Promise { + const startedAt = Date.now(); + while (!predicate()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out while waiting for condition'); + } + await waitForMicrotask(); + } +} diff --git a/packages/core/src/agents-collab/arena/ArenaManager.ts b/packages/core/src/agents-collab/arena/ArenaManager.ts new file mode 100644 index 000000000..11a178160 --- /dev/null +++ b/packages/core/src/agents-collab/arena/ArenaManager.ts @@ -0,0 +1,1215 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { GitWorktreeService } from '../../services/gitWorktreeService.js'; +import type { Config } from '../../config/config.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { isNodeError } from '../../utils/errors.js'; +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { ArenaEventEmitter, ArenaEventType } from './arena-events.js'; +import type { AgentSpawnConfig, Backend, DisplayMode } from '../index.js'; +import { detectBackend } from '../index.js'; +import { + type ArenaConfig, + type ArenaConfigFile, + type ArenaControlSignal, + type ArenaStartOptions, + type ArenaAgentResult, + type ArenaSessionResult, + type ArenaAgentState, + type ArenaCallbacks, + type ArenaStatusFile, + ArenaAgentStatus, + ArenaSessionStatus, + ARENA_MAX_AGENTS, + safeAgentId, +} from './types.js'; + +const debugLogger = createDebugLogger('ARENA'); + +const ARENA_POLL_INTERVAL_MS = 500; + +/** + * Generates a unique Arena session ID. + */ +function generateArenaSessionId(): string { + const timestamp = Date.now().toString(36); + const random = crypto.randomBytes(4).toString('hex'); + return `arena-${timestamp}-${random}`; +} + +/** + * ArenaManager orchestrates multi-model competitive execution. + * + * It manages: + * - Git worktree creation for isolated environments + * - Parallel agent execution via PTY subprocesses (through Backend) + * - Event emission for UI updates + * - Result collection and comparison + * - Active agent switching, input routing, and screen capture + */ +export class ArenaManager { + private readonly config: Config; + private readonly eventEmitter: ArenaEventEmitter; + private readonly worktreeService: GitWorktreeService; + private readonly callbacks: ArenaCallbacks; + private backend: Backend | null = null; + private cachedResult: ArenaSessionResult | null = null; + + private sessionId: string | undefined; + private sessionStatus: ArenaSessionStatus = ArenaSessionStatus.INITIALIZING; + private agents: Map = new Map(); + private arenaConfig: ArenaConfig | undefined; + private wasRepoInitialized = false; + private startedAt: number | undefined; + private masterAbortController: AbortController | undefined; + private terminalCols: number; + private terminalRows: number; + private pollingInterval: ReturnType | null = null; + private lifecyclePromise: Promise | null = null; + + constructor(config: Config, callbacks: ArenaCallbacks = {}) { + this.config = config; + this.callbacks = callbacks; + this.eventEmitter = new ArenaEventEmitter(); + const arenaSettings = config.getAgentsSettings().arena; + this.worktreeService = new GitWorktreeService( + config.getWorkingDir(), + arenaSettings?.worktreeBaseDir, + ); + this.terminalCols = process.stdout.columns || 120; + this.terminalRows = process.stdout.rows || 40; + } + + // ─── Public API ──────────────────────────────────────────────── + + /** + * Get the event emitter for subscribing to Arena events. + */ + getEventEmitter(): ArenaEventEmitter { + return this.eventEmitter; + } + + /** + * Get the current session ID. + */ + getSessionId(): string | undefined { + return this.sessionId; + } + + /** + * Get the current session status. + */ + getSessionStatus(): ArenaSessionStatus { + return this.sessionStatus; + } + + /** + * Get the current task description (available while session is active). + */ + getTask(): string | undefined { + return this.arenaConfig?.task; + } + + /** + * Get all agent states. + */ + getAgentStates(): ArenaAgentState[] { + return Array.from(this.agents.values()); + } + + /** + * Get a specific agent state. + */ + getAgentState(agentId: string): ArenaAgentState | undefined { + return this.agents.get(agentId); + } + + /** + * Get the cached session result (available after session completes). + */ + getResult(): ArenaSessionResult | null { + return this.cachedResult; + } + + /** + * Get the underlying backend for direct access. + * Returns null before the session initializes a backend. + */ + getBackend(): Backend | null { + return this.backend; + } + + /** + * Store the outer lifecycle promise so cancel/stop can wait for start() + * to fully unwind before proceeding with cleanup. + */ + setLifecyclePromise(p: Promise): void { + this.lifecyclePromise = p; + } + + /** + * Wait for the start lifecycle to fully settle (including error handling + * and listener teardown). Resolves immediately if no lifecycle is active. + */ + async waitForSettled(): Promise { + if (this.lifecyclePromise) { + await this.lifecyclePromise; + } + } + + // ─── PTY Interaction ─────────────────────────────────────────── + + /** + * Switch the active agent for screen display and input routing. + */ + switchToAgent(agentId: string): void { + this.backend?.switchTo(agentId); + } + + /** + * Switch to the next agent in order. + */ + switchToNextAgent(): void { + this.backend?.switchToNext(); + } + + /** + * Switch to the previous agent in order. + */ + switchToPreviousAgent(): void { + this.backend?.switchToPrevious(); + } + + /** + * Get the ID of the currently active agent. + */ + getActiveAgentId(): string | null { + return this.backend?.getActiveAgentId() ?? null; + } + + /** + * Get the screen snapshot for the currently active agent. + */ + getActiveSnapshot(): AnsiOutput | null { + return this.backend?.getActiveSnapshot() ?? null; + } + + /** + * Get the screen snapshot for a specific agent. + */ + getAgentSnapshot( + agentId: string, + scrollOffset: number = 0, + ): AnsiOutput | null { + return this.backend?.getAgentSnapshot(agentId, scrollOffset) ?? null; + } + + /** + * Get the maximum scrollback length for an agent's terminal buffer. + */ + getAgentScrollbackLength(agentId: string): number { + return this.backend?.getAgentScrollbackLength(agentId) ?? 0; + } + + /** + * Forward keyboard input to the currently active agent. + */ + forwardInput(data: string): boolean { + return this.backend?.forwardInput(data) ?? false; + } + + /** + * Resize all agent terminals. + */ + resizeAgents(cols: number, rows: number): void { + this.terminalCols = cols; + this.terminalRows = rows; + this.backend?.resizeAll(cols, rows); + } + + // ─── Session Lifecycle ───────────────────────────────────────── + + /** + * Start an Arena session. + * + * @param options - Arena start options + * @returns Promise resolving to the session result + */ + async start(options: ArenaStartOptions): Promise { + // Validate options + this.validateStartOptions(options); + + // Use caller-provided terminal size if available + if (options.cols && options.cols > 0) { + this.terminalCols = options.cols; + } + if (options.rows && options.rows > 0) { + this.terminalRows = options.rows; + } + + this.sessionId = generateArenaSessionId(); + this.startedAt = Date.now(); + this.sessionStatus = ArenaSessionStatus.INITIALIZING; + this.masterAbortController = new AbortController(); + + const sourceRepoPath = this.config.getWorkingDir(); + + this.arenaConfig = { + sessionId: this.sessionId, + task: options.task, + models: options.models, + maxRoundsPerAgent: options.maxRoundsPerAgent ?? 50, + timeoutSeconds: options.timeoutSeconds ?? 600, + approvalMode: options.approvalMode, + sourceRepoPath, + }; + + debugLogger.info(`Starting Arena session: ${this.sessionId}`); + debugLogger.info(`Task: ${options.task}`); + debugLogger.info( + `Models: ${options.models.map((m) => m.modelId).join(', ')}`, + ); + + // Emit session start event + this.eventEmitter.emit(ArenaEventType.SESSION_START, { + sessionId: this.sessionId, + task: options.task, + models: options.models, + timestamp: Date.now(), + }); + + try { + // Detect and initialize the backend. + // Priority: explicit option > agents.displayMode setting > auto-detect + const displayMode = + options.displayMode ?? + (this.config.getAgentsSettings().displayMode as + | DisplayMode + | undefined); + await this.initializeBackend(displayMode); + + // If cancelled during backend init, bail out early + if (this.masterAbortController?.signal.aborted) { + this.sessionStatus = ArenaSessionStatus.CANCELLED; + return this.collectResults(); + } + + // Set up worktrees for all agents + await this.setupWorktrees(); + + // If cancelled during worktree setup, bail out early + if (this.masterAbortController?.signal.aborted) { + this.sessionStatus = ArenaSessionStatus.CANCELLED; + return this.collectResults(); + } + + // Start all agents in parallel via PTY + this.sessionStatus = ArenaSessionStatus.RUNNING; + await this.runAgents(); + + // Only mark as completed if not already cancelled/timed out + if (this.sessionStatus === ArenaSessionStatus.RUNNING) { + this.sessionStatus = ArenaSessionStatus.COMPLETED; + } + + // Collect results (uses this.sessionStatus for result status) + const result = await this.collectResults(); + this.cachedResult = result; + + // Emit session complete event + this.eventEmitter.emit(ArenaEventType.SESSION_COMPLETE, { + sessionId: this.sessionId, + result, + timestamp: Date.now(), + }); + + this.callbacks.onArenaComplete?.(result); + + return result; + } catch (error) { + this.sessionStatus = ArenaSessionStatus.FAILED; + + const errorMessage = + error instanceof Error ? error.message : String(error); + + // Emit session error event + this.eventEmitter.emit(ArenaEventType.SESSION_ERROR, { + sessionId: this.sessionId, + error: errorMessage, + timestamp: Date.now(), + }); + + this.callbacks.onArenaError?.( + error instanceof Error ? error : new Error(errorMessage), + ); + + throw error; + } + } + + /** + * Cancel the current Arena session. + */ + async cancel(): Promise { + if (!this.sessionId) { + return; + } + + debugLogger.info(`Cancelling Arena session: ${this.sessionId}`); + + // Stop polling + this.stopPolling(); + + // Abort the master controller + this.masterAbortController?.abort(); + + const isTerminal = (s: ArenaAgentStatus) => + s === ArenaAgentStatus.TERMINATED || s === ArenaAgentStatus.CANCELLED; + + // Force stop all PTY processes (sends Ctrl-C) + this.backend?.stopAll(); + + // Update agent statuses + for (const agent of this.agents.values()) { + if (!isTerminal(agent.status)) { + agent.abortController.abort(); + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); + } + } + + this.sessionStatus = ArenaSessionStatus.CANCELLED; + } + + /** + * Clean up the Arena session (remove worktrees, kill processes, etc.). + */ + async cleanup(): Promise { + if (!this.sessionId) { + return; + } + + debugLogger.info(`Cleaning up Arena session: ${this.sessionId}`); + + // Stop polling in case cleanup is called without cancel + this.stopPolling(); + + // Clean up backend resources + if (this.backend) { + await this.backend.cleanup(); + } + + // Clean up worktrees + await this.worktreeService.cleanupArenaSession(this.sessionId); + + this.agents.clear(); + this.cachedResult = null; + this.sessionId = undefined; + this.arenaConfig = undefined; + this.backend = null; + } + + /** + * Clean up runtime resources (processes, backend, memory) without removing + * worktrees or session files on disk. Used when preserveArtifacts is enabled. + */ + async cleanupRuntime(): Promise { + if (!this.sessionId) { + return; + } + + debugLogger.info( + `Cleaning up Arena runtime (preserving artifacts): ${this.sessionId}`, + ); + + this.stopPolling(); + + if (this.backend) { + await this.backend.cleanup(); + } + + this.agents.clear(); + this.cachedResult = null; + this.sessionId = undefined; + this.arenaConfig = undefined; + this.backend = null; + } + + /** + * Apply the result from a specific agent to the main working directory. + */ + async applyAgentResult( + agentId: string, + ): Promise<{ success: boolean; error?: string }> { + const agent = this.agents.get(agentId); + if (!agent) { + return { success: false, error: `Agent ${agentId} not found` }; + } + + if (agent.status !== ArenaAgentStatus.COMPLETED) { + return { + success: false, + error: `Agent ${agentId} has not completed (current status: ${agent.status})`, + }; + } + + return this.worktreeService.applyWorktreeChanges(agent.worktree.path); + } + + /** + * Get the diff for a specific agent's changes. + */ + async getAgentDiff(agentId: string): Promise { + const agent = this.agents.get(agentId); + if (!agent) { + return `Agent ${agentId} not found`; + } + + return this.worktreeService.getWorktreeDiff(agent.worktree.path); + } + + // ─── Private: Validation ─────────────────────────────────────── + + private validateStartOptions(options: ArenaStartOptions): void { + if (!options.models || options.models.length < 2) { + throw new Error('Arena requires at least 2 models to compare'); + } + + if (options.models.length > ARENA_MAX_AGENTS) { + throw new Error(`Arena supports a maximum of ${ARENA_MAX_AGENTS} models`); + } + + if (!options.task || options.task.trim().length === 0) { + throw new Error('Arena requires a task/prompt'); + } + + // Check for duplicate model IDs + const modelIds = options.models.map((m) => m.modelId); + const uniqueIds = new Set(modelIds); + if (uniqueIds.size !== modelIds.length) { + throw new Error('Arena models must have unique identifiers'); + } + + // Check for collisions after filesystem-safe normalization. + // safeAgentId replaces characters like / \ : to '--', so distinct + // model IDs (e.g. "org/model" and "org--model") can map to the same + // status/control file path and corrupt each other's state. + const safeIds = modelIds.map((id) => safeAgentId(id)); + const uniqueSafeIds = new Set(safeIds); + if (uniqueSafeIds.size !== safeIds.length) { + const collisions = modelIds.filter( + (id, i) => safeIds.indexOf(safeIds[i]!) !== i, + ); + throw new Error( + `Arena model IDs collide after path normalization: ${collisions.join(', ')}. ` + + 'Choose model IDs that remain unique when special characters (/ \\ : etc.) are replaced.', + ); + } + } + + // ─── Private: Backend Initialization ─────────────────────────── + + /** + * Initialize the backend. + */ + private async initializeBackend(displayMode?: DisplayMode): Promise { + const { backend, warning } = await detectBackend(displayMode); + await backend.init(); + this.backend = backend; + + if (warning && this.sessionId) { + this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + sessionId: this.sessionId, + message: warning, + timestamp: Date.now(), + }); + } + + // Surface attach hint for external tmux sessions + const attachHint = backend.getAttachHint(); + if (attachHint && this.sessionId) { + this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + sessionId: this.sessionId, + message: `To view agent panes, run: ${attachHint}`, + timestamp: Date.now(), + }); + } + } + + // ─── Private: Worktree Setup ─────────────────────────────────── + + private async setupWorktrees(): Promise { + if (!this.arenaConfig) { + throw new Error('Arena config not initialized'); + } + + debugLogger.info('Setting up worktrees for Arena agents'); + + const worktreeNames = this.arenaConfig.models.map( + (m) => m.displayName || m.modelId, + ); + + const result = await this.worktreeService.setupArenaWorktrees({ + arenaSessionId: this.arenaConfig.sessionId, + sourceRepoPath: this.arenaConfig.sourceRepoPath, + worktreeNames, + }); + + this.wasRepoInitialized = result.wasRepoInitialized; + + if (!result.success) { + const errorMessages = result.errors + .map((e) => `${e.name}: ${e.error}`) + .join('; '); + throw new Error(`Failed to set up worktrees: ${errorMessages}`); + } + + // Create agent states + for (let i = 0; i < this.arenaConfig.models.length; i++) { + const model = this.arenaConfig.models[i]!; + const worktreeName = worktreeNames[i]!; + const worktree = result.worktreesByName[worktreeName]; + + if (!worktree) { + throw new Error( + `No worktree created for model ${model.modelId} (name: ${worktreeName})`, + ); + } + + const agentId = model.modelId; + + const agentState: ArenaAgentState = { + agentId, + model, + status: ArenaAgentStatus.INITIALIZING, + worktree, + abortController: new AbortController(), + stats: { + rounds: 0, + totalTokens: 0, + inputTokens: 0, + outputTokens: 0, + durationMs: 0, + toolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + }, + startedAt: 0, + accumulatedText: '', + }; + + this.agents.set(agentId, agentState); + } + + debugLogger.info(`Created ${this.agents.size} agent worktrees`); + } + + // ─── Private: Agent Execution ────────────────────────────────── + + private async runAgents(): Promise { + if (!this.arenaConfig) { + throw new Error('Arena config not initialized'); + } + + debugLogger.info('Starting Arena agents sequentially via backend'); + + const backend = this.requireBackend(); + + // Wire up exit handler on the backend + backend.setOnAgentExit((agentId, exitCode, signal) => { + this.handleAgentExit(agentId, exitCode, signal); + }); + + // Spawn agents sequentially — each spawn completes before starting the next. + // This creates a visual effect where panes appear one by one. + for (const agent of this.agents.values()) { + await this.spawnAgentPty(agent); + } + + // Start polling agent status files + this.startPolling(); + + // Set up timeout + const timeoutMs = (this.arenaConfig.timeoutSeconds ?? 600) * 1000; + + // Wait for all agents to reach IDLE or TERMINATED, or timeout. + // Unlike waitForAll (which waits for PTY exit), this resolves as soon + // as every agent has finished its first task in interactive mode. + const allSettled = await this.waitForAllAgentsSettled(timeoutMs); + + // Stop polling when all agents are done + this.stopPolling(); + + if (!allSettled) { + debugLogger.info('Arena session timed out, stopping remaining agents'); + this.sessionStatus = ArenaSessionStatus.CANCELLED; + + // Terminate remaining active agents + for (const agent of this.agents.values()) { + if ( + agent.status !== ArenaAgentStatus.COMPLETED && + agent.status !== ArenaAgentStatus.CANCELLED && + agent.status !== ArenaAgentStatus.TERMINATED + ) { + backend.stopAgent(agent.agentId); + agent.abortController.abort(); + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); + } + } + } + + debugLogger.info('All Arena agents settled or timed out'); + } + + private async spawnAgentPty(agent: ArenaAgentState): Promise { + if (!this.arenaConfig) { + return; + } + + const backend = this.requireBackend(); + + const { agentId, model, worktree } = agent; + + debugLogger.info(`Spawning agent PTY: ${agentId}`); + + agent.startedAt = Date.now(); + this.updateAgentStatus(agentId, ArenaAgentStatus.RUNNING); + + // Emit agent start event + this.eventEmitter.emit(ArenaEventType.AGENT_START, { + sessionId: this.arenaConfig.sessionId, + agentId, + model, + worktreePath: worktree.path, + timestamp: Date.now(), + }); + + this.callbacks.onAgentStart?.(agentId, model); + + // Build the CLI command to spawn the agent as a full interactive instance + const spawnConfig = this.buildAgentSpawnConfig(agent); + + try { + await backend.spawnAgent(spawnConfig); + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + agent.error = errorMessage; + this.updateAgentStatus(agentId, ArenaAgentStatus.TERMINATED); + + this.eventEmitter.emit(ArenaEventType.AGENT_ERROR, { + sessionId: this.requireConfig().sessionId, + agentId, + error: errorMessage, + timestamp: Date.now(), + }); + + debugLogger.error(`Failed to spawn agent: ${agentId}`, error); + } + } + + private requireBackend(): Backend { + if (!this.backend) { + throw new Error('Arena backend not initialized.'); + } + return this.backend; + } + + private requireConfig(): ArenaConfig { + if (!this.arenaConfig) { + throw new Error('Arena config not initialized'); + } + return this.arenaConfig; + } + + private handleAgentExit( + agentId: string, + exitCode: number | null, + _signal: number | null, + ): void { + const agent = this.agents.get(agentId); + if (!agent) { + return; + } + + // Already terminated (e.g. via cancel) + if (agent.status === ArenaAgentStatus.TERMINATED) { + return; + } + + agent.stats.durationMs = Date.now() - agent.startedAt; + + if ( + exitCode !== 0 && + exitCode !== null && + !agent.abortController.signal.aborted + ) { + agent.error = `Process exited with code ${exitCode}`; + this.eventEmitter.emit(ArenaEventType.AGENT_ERROR, { + sessionId: this.requireConfig().sessionId, + agentId, + error: agent.error, + timestamp: Date.now(), + }); + } + + this.updateAgentStatus(agentId, ArenaAgentStatus.TERMINATED); + debugLogger.info(`Agent terminated: ${agentId} (exit code: ${exitCode})`); + } + + /** + * Build the spawn configuration for an agent subprocess. + * + * The agent is launched as a full interactive CLI instance, running in + * its own worktree with the specified model. The task is passed via + * the --prompt argument so the CLI enters interactive mode and + * immediately starts working on the task. + */ + private buildAgentSpawnConfig(agent: ArenaAgentState): AgentSpawnConfig { + const { agentId, model, worktree } = agent; + + // Build CLI args for spawning an interactive agent. + // Note: --cwd is NOT a valid CLI flag; the working directory is set + // via AgentSpawnConfig.cwd which becomes the PTY's cwd. + const args: string[] = []; + + // Set the model and auth type + args.push('--model', model.modelId); + args.push('--auth-type', model.authType); + + // Pass the task via --prompt-interactive (-i) so the CLI enters + // interactive mode AND immediately starts working on the task. + // (--prompt runs non-interactively and would exit after completion.) + if (this.arenaConfig?.task) { + args.push('--prompt-interactive', this.arenaConfig.task); + } + + // Set approval mode if specified + if (this.arenaConfig?.approvalMode) { + args.push('--approval-mode', this.arenaConfig.approvalMode); + } + + // Construct env vars for the agent + const arenaSessionDir = this.getArenaSessionDir(); + const env: Record = { + QWEN_CODE: '1', + ARENA_AGENT_ID: agentId, + ARENA_SESSION_ID: this.arenaConfig?.sessionId ?? '', + ARENA_SESSION_DIR: arenaSessionDir, + }; + + // If the model has auth overrides, pass them via env + if (model.apiKey) { + env['QWEN_API_KEY'] = model.apiKey; + } + if (model.baseUrl) { + env['QWEN_BASE_URL'] = model.baseUrl; + } + + const spawnConfig = { + agentId, + command: process.execPath, // Use the same Node.js binary + args: [path.resolve(process.argv[1]!), ...args], // Re-launch the CLI entry point (must be absolute path since cwd changes) + cwd: worktree.path, + env, + cols: this.terminalCols, + rows: this.terminalRows, + }; + + debugLogger.info( + `[buildAgentSpawnConfig] agentId=${agentId}, command=${spawnConfig.command}, cliEntry=${process.argv[1]}, resolvedEntry=${path.resolve(process.argv[1]!)}`, + ); + debugLogger.info( + `[buildAgentSpawnConfig] args=${JSON.stringify(spawnConfig.args)}`, + ); + debugLogger.info( + `[buildAgentSpawnConfig] cwd=${spawnConfig.cwd}, env keys=${Object.keys(env).join(',')}`, + ); + + return spawnConfig; + } + + // ─── Private: Status & Results ───────────────────────────────── + + private updateAgentStatus( + agentId: string, + newStatus: ArenaAgentStatus, + ): void { + const agent = this.agents.get(agentId); + if (!agent) { + return; + } + + const previousStatus = agent.status; + agent.status = newStatus; + + this.eventEmitter.emit(ArenaEventType.AGENT_STATUS_CHANGE, { + sessionId: this.requireConfig().sessionId, + agentId, + previousStatus, + newStatus, + timestamp: Date.now(), + }); + + // Emit AGENT_COMPLETE when agent reaches COMPLETED, CANCELLED, or TERMINATED + if ( + newStatus === ArenaAgentStatus.COMPLETED || + newStatus === ArenaAgentStatus.CANCELLED || + newStatus === ArenaAgentStatus.TERMINATED + ) { + const result = this.buildAgentResult(agent); + + this.eventEmitter.emit(ArenaEventType.AGENT_COMPLETE, { + sessionId: this.requireConfig().sessionId, + agentId, + result, + timestamp: Date.now(), + }); + + this.callbacks.onAgentComplete?.(result); + } + } + + private buildAgentResult(agent: ArenaAgentState): ArenaAgentResult { + return { + agentId: agent.agentId, + model: agent.model, + status: agent.status, + worktree: agent.worktree, + finalText: agent.accumulatedText || undefined, + error: agent.error, + stats: { ...agent.stats }, + startedAt: agent.startedAt, + endedAt: Date.now(), + }; + } + + // ─── Private: Arena Session Directory ───────────────────────── + + /** + * Get the arena session directory for the current session. + * All status and control files are stored here. + */ + private getArenaSessionDir(): string { + if (!this.arenaConfig) { + throw new Error('Arena config not initialized'); + } + return GitWorktreeService.getArenaSessionDir( + this.arenaConfig.sessionId, + this.config.getAgentsSettings().arena?.worktreeBaseDir, + ); + } + + // ─── Private: Polling & Control Signals ────────────────────── + + /** + * Wait for all agents to reach IDLE or TERMINATED state. + * Returns true if all agents settled, false if timeout was reached. + */ + private waitForAllAgentsSettled(timeoutMs: number): Promise { + return new Promise((resolve) => { + const checkSettled = () => { + for (const agent of this.agents.values()) { + if ( + agent.status !== ArenaAgentStatus.COMPLETED && + agent.status !== ArenaAgentStatus.CANCELLED && + agent.status !== ArenaAgentStatus.TERMINATED + ) { + return false; + } + } + return true; + }; + + if (checkSettled()) { + resolve(true); + return; + } + + const timeoutHandle = setTimeout(() => { + clearInterval(pollHandle); + resolve(false); + }, timeoutMs); + + // Re-check periodically (piggybacks on the same polling interval) + const pollHandle = setInterval(() => { + if (checkSettled()) { + clearInterval(pollHandle); + clearTimeout(timeoutHandle); + resolve(true); + } + }, ARENA_POLL_INTERVAL_MS); + }); + } + + /** + * Start polling agent status files at a fixed interval. + */ + private startPolling(): void { + if (this.pollingInterval) { + return; + } + + this.pollingInterval = setInterval(() => { + this.pollAgentStatuses().catch((error) => { + debugLogger.error('Error polling agent statuses:', error); + }); + }, ARENA_POLL_INTERVAL_MS); + } + + /** + * Stop the polling interval. + */ + private stopPolling(): void { + if (this.pollingInterval) { + clearInterval(this.pollingInterval); + this.pollingInterval = null; + } + } + + /** + * Read per-agent status files from `/agents/` directory. + * Updates agent stats, emits AGENT_STATS_UPDATE events, and writes a + * consolidated `status.json` at the arena session root. + */ + private async pollAgentStatuses(): Promise { + const sessionDir = this.getArenaSessionDir(); + const agentsDir = path.join(sessionDir, 'agents'); + const consolidatedAgents: Record = {}; + + for (const agent of this.agents.values()) { + // Only poll agents that are still alive (RUNNING or IDLE) + if ( + agent.status === ArenaAgentStatus.TERMINATED || + agent.status === ArenaAgentStatus.CANCELLED || + agent.status === ArenaAgentStatus.INITIALIZING + ) { + continue; + } + + try { + const statusPath = path.join( + agentsDir, + `${safeAgentId(agent.agentId)}.json`, + ); + const content = await fs.readFile(statusPath, 'utf-8'); + const statusFile = JSON.parse(content) as ArenaStatusFile; + + // Collect for consolidated file + consolidatedAgents[agent.agentId] = statusFile; + + // Update agent stats from the status file, but preserve locally + // calculated durationMs (the child process doesn't track it). + const { durationMs: _childDuration, ...fileStats } = statusFile.stats; + agent.stats = { + ...agent.stats, + ...fileStats, + }; + + // Detect state transitions from the sideband status file + if ( + statusFile.status === 'completed' && + agent.status === ArenaAgentStatus.RUNNING + ) { + // Agent finished its task successfully + agent.stats.durationMs = Date.now() - agent.startedAt; + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.COMPLETED); + } else if ( + statusFile.status === 'cancelled' && + agent.status === ArenaAgentStatus.RUNNING + ) { + // Agent was cancelled by user + agent.stats.durationMs = Date.now() - agent.startedAt; + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.CANCELLED); + } else if ( + statusFile.status === 'error' && + agent.status === ArenaAgentStatus.RUNNING + ) { + // Agent hit an error + agent.stats.durationMs = Date.now() - agent.startedAt; + if (statusFile.error) { + agent.error = statusFile.error; + } + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); + } else if ( + statusFile.status === 'running' && + agent.status === ArenaAgentStatus.COMPLETED + ) { + // Agent received new input and is working again + this.updateAgentStatus(agent.agentId, ArenaAgentStatus.RUNNING); + } + + // Emit stats update event + this.eventEmitter.emit(ArenaEventType.AGENT_STATS_UPDATE, { + sessionId: this.requireConfig().sessionId, + agentId: agent.agentId, + stats: statusFile.stats, + timestamp: Date.now(), + }); + + this.callbacks.onAgentStatsUpdate?.(agent.agentId, statusFile.stats); + } catch (error: unknown) { + // File may not exist yet (agent hasn't written first status) + if (isNodeError(error) && error.code === 'ENOENT') { + continue; + } + debugLogger.error( + `Error reading status for agent ${agent.agentId}:`, + error, + ); + } + } + + // Write consolidated status.json at the arena session root + if (Object.keys(consolidatedAgents).length > 0) { + await this.writeConsolidatedStatus(consolidatedAgents); + } + } + + /** + * Merge agent status data into the arena session's config.json. + * Reads the existing config, adds/updates `updatedAt` and `agents`, + * then writes back atomically (temp file → rename). + */ + private async writeConsolidatedStatus( + agents: Record, + ): Promise { + const sessionDir = this.getArenaSessionDir(); + const configPath = path.join(sessionDir, 'config.json'); + + try { + // Read existing config.json written by GitWorktreeService + let config: ArenaConfigFile; + try { + const content = await fs.readFile(configPath, 'utf-8'); + config = JSON.parse(content) as ArenaConfigFile; + } catch { + // If config.json doesn't exist yet, create a minimal one + const arenaConfig = this.requireConfig(); + config = { + arenaSessionId: arenaConfig.sessionId, + sourceRepoPath: arenaConfig.sourceRepoPath, + worktreeNames: arenaConfig.models.map( + (m) => m.displayName || m.modelId, + ), + createdAt: this.startedAt!, + }; + } + + // Merge in the agent status data + config.updatedAt = Date.now(); + config.agents = agents; + + // Atomic write + const tmpPath = `${configPath}.${crypto.randomBytes(4).toString('hex')}.tmp`; + try { + await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8'); + await fs.rename(tmpPath, configPath); + } catch (writeError) { + try { + await fs.unlink(tmpPath); + } catch { + // Ignore cleanup errors + } + throw writeError; + } + } catch (error) { + debugLogger.error( + 'Failed to write consolidated status to config.json:', + error, + ); + } + } + + /** + * Write a control signal to the arena session's control/ directory. + * The child agent consumes (reads + deletes) this file. + */ + async sendControlSignal( + agentId: string, + type: ArenaControlSignal['type'], + reason: string, + ): Promise { + const agent = this.agents.get(agentId); + if (!agent) { + debugLogger.error( + `Cannot send control signal: agent ${agentId} not found`, + ); + return; + } + + const controlSignal: ArenaControlSignal = { + type, + reason, + timestamp: Date.now(), + }; + + const sessionDir = this.getArenaSessionDir(); + const controlDir = path.join(sessionDir, 'control'); + const controlPath = path.join(controlDir, `${safeAgentId(agentId)}.json`); + + try { + await fs.mkdir(controlDir, { recursive: true }); + await fs.writeFile( + controlPath, + JSON.stringify(controlSignal, null, 2), + 'utf-8', + ); + debugLogger.info( + `Sent ${type} control signal to agent ${agentId}: ${reason}`, + ); + } catch (error) { + debugLogger.error( + `Failed to send control signal to agent ${agentId}:`, + error, + ); + } + } + + private async collectResults(): Promise { + if (!this.arenaConfig) { + throw new Error('Arena config not initialized'); + } + + const agents: ArenaAgentResult[] = []; + + for (const agent of this.agents.values()) { + const result = this.buildAgentResult(agent); + + // Get diff for completed agents (they finished their task) + if (agent.status === ArenaAgentStatus.COMPLETED) { + try { + result.diff = await this.worktreeService.getWorktreeDiff( + agent.worktree.path, + ); + } catch (error) { + debugLogger.error( + `Failed to get diff for agent ${agent.agentId}:`, + error, + ); + } + } + + agents.push(result); + } + + const endedAt = Date.now(); + + return { + sessionId: this.arenaConfig.sessionId, + task: this.arenaConfig.task, + status: this.sessionStatus, + agents, + startedAt: this.startedAt!, + endedAt, + totalDurationMs: endedAt - this.startedAt!, + wasRepoInitialized: this.wasRepoInitialized, + }; + } +} diff --git a/packages/core/src/agents-collab/arena/arena-events.ts b/packages/core/src/agents-collab/arena/arena-events.ts new file mode 100644 index 000000000..b7a46e258 --- /dev/null +++ b/packages/core/src/agents-collab/arena/arena-events.ts @@ -0,0 +1,246 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EventEmitter } from 'events'; +import type { + ArenaAgentStatus, + ArenaModelConfig, + ArenaAgentStats, + ArenaAgentResult, + ArenaSessionResult, +} from './types.js'; + +/** + * Arena event types. + */ +export enum ArenaEventType { + /** Arena session started */ + SESSION_START = 'session_start', + /** Arena session completed */ + SESSION_COMPLETE = 'session_complete', + /** Arena session failed */ + SESSION_ERROR = 'session_error', + /** Agent started */ + AGENT_START = 'agent_start', + /** Agent status changed */ + AGENT_STATUS_CHANGE = 'agent_status_change', + /** Agent streamed text */ + AGENT_STREAM_TEXT = 'agent_stream_text', + /** Agent called a tool */ + AGENT_TOOL_CALL = 'agent_tool_call', + /** Agent tool call completed */ + AGENT_TOOL_RESULT = 'agent_tool_result', + /** Agent stats updated */ + AGENT_STATS_UPDATE = 'agent_stats_update', + /** Agent completed */ + AGENT_COMPLETE = 'agent_complete', + /** Agent error */ + AGENT_ERROR = 'agent_error', + /** Non-fatal warning (e.g., backend fallback) */ + SESSION_WARNING = 'session_warning', +} + +export type ArenaEvent = + | 'session_start' + | 'session_complete' + | 'session_error' + | 'agent_start' + | 'agent_status_change' + | 'agent_stream_text' + | 'agent_tool_call' + | 'agent_tool_result' + | 'agent_stats_update' + | 'agent_complete' + | 'agent_error' + | 'session_warning'; + +/** + * Event payload for session start. + */ +export interface ArenaSessionStartEvent { + sessionId: string; + task: string; + models: ArenaModelConfig[]; + timestamp: number; +} + +/** + * Event payload for session complete. + */ +export interface ArenaSessionCompleteEvent { + sessionId: string; + result: ArenaSessionResult; + timestamp: number; +} + +/** + * Event payload for session error. + */ +export interface ArenaSessionErrorEvent { + sessionId: string; + error: string; + timestamp: number; +} + +/** + * Event payload for agent start. + */ +export interface ArenaAgentStartEvent { + sessionId: string; + agentId: string; + model: ArenaModelConfig; + worktreePath: string; + timestamp: number; +} + +/** + * Event payload for agent status change. + */ +export interface ArenaAgentStatusChangeEvent { + sessionId: string; + agentId: string; + previousStatus: ArenaAgentStatus; + newStatus: ArenaAgentStatus; + timestamp: number; +} + +/** + * Event payload for agent stream text. + */ +export interface ArenaAgentStreamTextEvent { + sessionId: string; + agentId: string; + text: string; + isThought?: boolean; + timestamp: number; +} + +/** + * Event payload for agent tool call. + */ +export interface ArenaAgentToolCallEvent { + sessionId: string; + agentId: string; + callId: string; + toolName: string; + args: Record; + description?: string; + timestamp: number; +} + +/** + * Event payload for agent tool result. + */ +export interface ArenaAgentToolResultEvent { + sessionId: string; + agentId: string; + callId: string; + toolName: string; + success: boolean; + error?: string; + durationMs: number; + timestamp: number; +} + +/** + * Event payload for agent stats update. + */ +export interface ArenaAgentStatsUpdateEvent { + sessionId: string; + agentId: string; + stats: Partial; + timestamp: number; +} + +/** + * Event payload for agent complete. + */ +export interface ArenaAgentCompleteEvent { + sessionId: string; + agentId: string; + result: ArenaAgentResult; + timestamp: number; +} + +/** + * Event payload for agent error. + */ +export interface ArenaAgentErrorEvent { + sessionId: string; + agentId: string; + error: string; + timestamp: number; +} + +/** + * Event payload for session warning (non-fatal). + */ +export interface ArenaSessionWarningEvent { + sessionId: string; + message: string; + timestamp: number; +} + +/** + * Type map for arena events. + */ +export interface ArenaEventMap { + [ArenaEventType.SESSION_START]: ArenaSessionStartEvent; + [ArenaEventType.SESSION_COMPLETE]: ArenaSessionCompleteEvent; + [ArenaEventType.SESSION_ERROR]: ArenaSessionErrorEvent; + [ArenaEventType.AGENT_START]: ArenaAgentStartEvent; + [ArenaEventType.AGENT_STATUS_CHANGE]: ArenaAgentStatusChangeEvent; + [ArenaEventType.AGENT_STREAM_TEXT]: ArenaAgentStreamTextEvent; + [ArenaEventType.AGENT_TOOL_CALL]: ArenaAgentToolCallEvent; + [ArenaEventType.AGENT_TOOL_RESULT]: ArenaAgentToolResultEvent; + [ArenaEventType.AGENT_STATS_UPDATE]: ArenaAgentStatsUpdateEvent; + [ArenaEventType.AGENT_COMPLETE]: ArenaAgentCompleteEvent; + [ArenaEventType.AGENT_ERROR]: ArenaAgentErrorEvent; + [ArenaEventType.SESSION_WARNING]: ArenaSessionWarningEvent; +} + +/** + * Event emitter for Arena events. + */ +export class ArenaEventEmitter { + private ee = new EventEmitter(); + + on( + event: E, + listener: (payload: ArenaEventMap[E]) => void, + ): void { + this.ee.on(event, listener as (...args: unknown[]) => void); + } + + off( + event: E, + listener: (payload: ArenaEventMap[E]) => void, + ): void { + this.ee.off(event, listener as (...args: unknown[]) => void); + } + + emit( + event: E, + payload: ArenaEventMap[E], + ): void { + this.ee.emit(event, payload); + } + + once( + event: E, + listener: (payload: ArenaEventMap[E]) => void, + ): void { + this.ee.once(event, listener as (...args: unknown[]) => void); + } + + removeAllListeners(event?: ArenaEvent): void { + if (event) { + this.ee.removeAllListeners(event); + } else { + this.ee.removeAllListeners(); + } + } +} diff --git a/packages/core/src/agents-collab/arena/index.ts b/packages/core/src/agents-collab/arena/index.ts new file mode 100644 index 000000000..60d6b91e8 --- /dev/null +++ b/packages/core/src/agents-collab/arena/index.ts @@ -0,0 +1,14 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +// Arena-specific exports +export * from './types.js'; +export * from './arena-events.js'; +export * from './ArenaManager.js'; +export * from './ArenaAgentClient.js'; + +// Re-export shared agent infrastructure for backwards compatibility +export * from '../index.js'; diff --git a/packages/core/src/agents-collab/arena/types.ts b/packages/core/src/agents-collab/arena/types.ts new file mode 100644 index 000000000..0fe6e299c --- /dev/null +++ b/packages/core/src/agents-collab/arena/types.ts @@ -0,0 +1,293 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorktreeInfo } from '../../services/gitWorktreeService.js'; +import type { DisplayMode } from '../backends/types.js'; + +/** + * Maximum number of concurrent agents allowed in an Arena session. + */ +export const ARENA_MAX_AGENTS = 5; + +/** + * Represents the status of an Arena agent in interactive mode. + * + * Agents run as interactive CLI subprocesses (--prompt-interactive), so + * they never truly "complete" or "exit" on their own. Instead: + * + * INITIALIZING → RUNNING ⇄ COMPLETED → TERMINATED + * ↘ CANCELLED + * + * - INITIALIZING: Worktree created, PTY not yet spawned. + * - RUNNING: Agent is actively processing a turn (model thinking / tool execution). + * - COMPLETED: Agent finished the current task successfully. + * This is the "selectable" state for /arena select. + * - CANCELLED: Agent's current request was cancelled by the user. + * - TERMINATED: PTY process has exited (killed, crashed, or shut down). + */ +export enum ArenaAgentStatus { + /** Worktree created, PTY not yet spawned */ + INITIALIZING = 'initializing', + /** Agent is actively processing a turn */ + RUNNING = 'running', + /** Agent finished current task successfully */ + COMPLETED = 'completed', + /** Agent's current request was cancelled by the user */ + CANCELLED = 'cancelled', + /** PTY process has exited */ + TERMINATED = 'terminated', +} + +/** + * Represents the status of an Arena session. + */ +export enum ArenaSessionStatus { + /** Session is being set up */ + INITIALIZING = 'initializing', + /** Session is running */ + RUNNING = 'running', + /** Session completed (all agents finished) */ + COMPLETED = 'completed', + /** Session was cancelled */ + CANCELLED = 'cancelled', + /** Session failed during initialization */ + FAILED = 'failed', +} + +/** + * Configuration for a model participating in the Arena. + */ +export interface ArenaModelConfig { + /** Model identifier (e.g., 'qwen-coder-plus', 'gpt-4') */ + modelId: string; + /** Authentication type for this model */ + authType: string; + /** Display name for UI */ + displayName?: string; + /** Optional API key override */ + apiKey?: string; + /** Optional base URL override */ + baseUrl?: string; +} + +/** + * Configuration for an Arena session. + */ +export interface ArenaConfig { + /** Unique identifier for this Arena session */ + sessionId: string; + /** The task/prompt to be executed by all agents */ + task: string; + /** Models participating in the Arena */ + models: ArenaModelConfig[]; + /** Maximum number of rounds per agent (default: 50) */ + maxRoundsPerAgent?: number; + /** Total timeout in seconds for the entire Arena session (default: 600) */ + timeoutSeconds?: number; + /** Approval mode inherited from the main process (e.g., 'auto', 'suggest', etc.) */ + approvalMode?: string; + /** Source repository path */ + sourceRepoPath: string; +} + +/** + * Statistics for an individual Arena agent. + */ +export interface ArenaAgentStats { + /** Number of completed rounds */ + rounds: number; + /** Total tokens used */ + totalTokens: number; + /** Input tokens used */ + inputTokens: number; + /** Output tokens used */ + outputTokens: number; + /** Total execution time in milliseconds */ + durationMs: number; + /** Number of tool calls made */ + toolCalls: number; + /** Number of successful tool calls */ + successfulToolCalls: number; + /** Number of failed tool calls */ + failedToolCalls: number; +} + +/** + * Result from a single Arena agent. + */ +export interface ArenaAgentResult { + /** Agent identifier */ + agentId: string; + /** Model configuration used */ + model: ArenaModelConfig; + /** Final status */ + status: ArenaAgentStatus; + /** Worktree information */ + worktree: WorktreeInfo; + /** Final text output from the agent */ + finalText?: string; + /** Error message if failed */ + error?: string; + /** Execution statistics */ + stats: ArenaAgentStats; + /** Git diff of changes made */ + diff?: string; + /** Files modified by this agent */ + modifiedFiles?: string[]; + /** Start timestamp */ + startedAt: number; + /** End timestamp */ + endedAt?: number; +} + +/** + * Result from an Arena session. + */ +export interface ArenaSessionResult { + /** Session identifier */ + sessionId: string; + /** Original task */ + task: string; + /** Session status */ + status: ArenaSessionStatus; + /** Results from all agents */ + agents: ArenaAgentResult[]; + /** Start timestamp */ + startedAt: number; + /** End timestamp */ + endedAt?: number; + /** Total duration in milliseconds */ + totalDurationMs?: number; + /** Whether the repository was auto-initialized */ + wasRepoInitialized: boolean; + /** Selected winner (agent ID) if user has chosen */ + selectedWinner?: string; +} + +/** + * Options for starting an Arena session. + */ +export interface ArenaStartOptions { + /** Models to participate (at least 2, max ARENA_MAX_AGENTS) */ + models: ArenaModelConfig[]; + /** The task/prompt for all agents */ + task: string; + /** Maximum rounds per agent */ + maxRoundsPerAgent?: number; + /** Timeout in seconds */ + timeoutSeconds?: number; + /** Approval mode to use for agents (inherited from main process) */ + approvalMode?: string; + /** Initial terminal columns for agent PTYs (default: process.stdout.columns or 120) */ + cols?: number; + /** Initial terminal rows for agent PTYs (default: process.stdout.rows or 40) */ + rows?: number; + /** Display mode preference */ + displayMode?: DisplayMode; +} + +/** + * Callback functions for Arena events. + */ +export interface ArenaCallbacks { + /** Called when an agent starts */ + onAgentStart?: (agentId: string, model: ArenaModelConfig) => void; + /** Called when an agent completes */ + onAgentComplete?: (result: ArenaAgentResult) => void; + /** Called when agent stats are updated */ + onAgentStatsUpdate?: ( + agentId: string, + stats: Partial, + ) => void; + /** Called when the arena session completes */ + onArenaComplete?: (result: ArenaSessionResult) => void; + /** Called on arena error */ + onArenaError?: (error: Error) => void; +} + +/** + * File format for per-agent status (child → main process). + * Written atomically by ArenaAgentClient to + * `/agents/.json`. + */ +export interface ArenaStatusFile { + agentId: string; + status: 'running' | 'completed' | 'error' | 'cancelled'; + updatedAt: number; + rounds: number; + currentActivity?: string; + stats: ArenaAgentStats; + finalSummary: string | null; + error: string | null; +} + +/** + * File format for the arena session config file (`config.json`). + * + * Initially written by GitWorktreeService with static config fields + * (arenaSessionId, sourceRepoPath, worktreeNames, baseBranch, createdAt). + * Dynamically updated by ArenaManager with agent status data during polling. + */ +export interface ArenaConfigFile { + /** Arena session identifier */ + arenaSessionId: string; + /** Source repository path */ + sourceRepoPath: string; + /** Names of worktrees created */ + worktreeNames: string[]; + /** Base branch used for worktrees */ + baseBranch?: string; + /** Timestamp when the session was created */ + createdAt: number; + /** Timestamp of the last status update (set by ArenaManager polling) */ + updatedAt?: number; + /** Per-agent status data, keyed by agentId (set by ArenaManager polling) */ + agents?: Record; +} + +/** + * Control signal format for control.json (main → child process). + * Written by ArenaManager, consumed (read + deleted) by ArenaAgentClient. + */ +export interface ArenaControlSignal { + type: 'shutdown' | 'cancel'; + reason: string; + timestamp: number; +} + +/** + * Convert an agentId (e.g. "arena-xxx/qwen-coder-plus") to a filename-safe + * string by replacing path-unsafe characters with "--". + */ +export function safeAgentId(agentId: string): string { + return agentId.replace(/[/\\:*?"<>|]/g, '--'); +} + +/** + * Internal state for tracking an Arena agent during execution. + */ +export interface ArenaAgentState { + /** Agent identifier */ + agentId: string; + /** Model configuration */ + model: ArenaModelConfig; + /** Current status */ + status: ArenaAgentStatus; + /** Worktree information */ + worktree: WorktreeInfo; + /** Abort controller for cancellation */ + abortController: AbortController; + /** Current statistics */ + stats: ArenaAgentStats; + /** Start timestamp */ + startedAt: number; + /** Accumulated text output */ + accumulatedText: string; + /** Promise for the agent execution */ + executionPromise?: Promise; + /** Error if failed */ + error?: string; +} diff --git a/packages/core/src/agents-collab/backends/ITermBackend.test.ts b/packages/core/src/agents-collab/backends/ITermBackend.test.ts new file mode 100644 index 000000000..124df85ee --- /dev/null +++ b/packages/core/src/agents-collab/backends/ITermBackend.test.ts @@ -0,0 +1,569 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { AgentSpawnConfig } from './types.js'; + +// ─── Hoisted mocks for iterm-it2 ──────────────────────────────── +const hoistedVerifyITerm = vi.hoisted(() => vi.fn()); +const hoistedItermSplitPane = vi.hoisted(() => vi.fn()); +const hoistedItermRunCommand = vi.hoisted(() => vi.fn()); +const hoistedItermSendText = vi.hoisted(() => vi.fn()); +const hoistedItermFocusSession = vi.hoisted(() => vi.fn()); +const hoistedItermCloseSession = vi.hoisted(() => vi.fn()); + +vi.mock('./iterm-it2.js', () => ({ + verifyITerm: hoistedVerifyITerm, + itermSplitPane: hoistedItermSplitPane, + itermRunCommand: hoistedItermRunCommand, + itermSendText: hoistedItermSendText, + itermFocusSession: hoistedItermFocusSession, + itermCloseSession: hoistedItermCloseSession, +})); + +// ─── Hoisted mocks for node:fs/promises ───────────────────────── +const hoistedFsMkdir = vi.hoisted(() => vi.fn()); +const hoistedFsReadFile = vi.hoisted(() => vi.fn()); +const hoistedFsRm = vi.hoisted(() => vi.fn()); + +vi.mock('node:fs/promises', () => ({ + mkdir: hoistedFsMkdir, + readFile: hoistedFsReadFile, + rm: hoistedFsRm, +})); + +// Mock debug logger +vi.mock('../../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +import { ITermBackend } from './ITermBackend.js'; + +function makeConfig( + agentId: string, + overrides?: Partial, +): AgentSpawnConfig { + return { + agentId, + command: '/usr/bin/node', + args: ['agent.js'], + cwd: '/tmp/test', + ...overrides, + }; +} + +function setupDefaultMocks(): void { + hoistedVerifyITerm.mockResolvedValue(undefined); + hoistedItermSplitPane.mockResolvedValue('sess-new-1'); + hoistedItermRunCommand.mockResolvedValue(undefined); + hoistedItermSendText.mockResolvedValue(undefined); + hoistedItermFocusSession.mockResolvedValue(undefined); + hoistedItermCloseSession.mockResolvedValue(undefined); + hoistedFsMkdir.mockResolvedValue(undefined); + // Default: marker file doesn't exist yet (agent still running) + hoistedFsReadFile.mockRejectedValue(new Error('ENOENT')); + hoistedFsRm.mockResolvedValue(undefined); +} + +describe('ITermBackend', () => { + let backend: ITermBackend; + let savedItermSessionId: string | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + savedItermSessionId = process.env['ITERM_SESSION_ID']; + delete process.env['ITERM_SESSION_ID']; + setupDefaultMocks(); + backend = new ITermBackend(); + }); + + afterEach(async () => { + await backend.cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + if (savedItermSessionId !== undefined) { + process.env['ITERM_SESSION_ID'] = savedItermSessionId; + } else { + delete process.env['ITERM_SESSION_ID']; + } + }); + + // ─── Initialization ───────────────────────────────────────── + + it('throws if spawnAgent is called before init', async () => { + await expect(backend.spawnAgent(makeConfig('a1'))).rejects.toThrow( + 'not initialized', + ); + }); + + it('init verifies iTerm availability', async () => { + await backend.init(); + expect(hoistedVerifyITerm).toHaveBeenCalled(); + }); + + it('init creates exit marker directory', async () => { + await backend.init(); + expect(hoistedFsMkdir).toHaveBeenCalledWith( + expect.stringContaining('agent-iterm-exit-'), + { recursive: true }, + ); + }); + + it('init is idempotent', async () => { + await backend.init(); + await backend.init(); + expect(hoistedVerifyITerm).toHaveBeenCalledTimes(1); + }); + + // ─── Spawning ───────────────────────────────────────────── + + it('spawns first agent using ITERM_SESSION_ID when set', async () => { + process.env['ITERM_SESSION_ID'] = 'leader-sess'; + backend = new ITermBackend(); + await backend.init(); + + await backend.spawnAgent(makeConfig('agent-1')); + + expect(hoistedItermSplitPane).toHaveBeenCalledWith('leader-sess'); + expect(hoistedItermRunCommand).toHaveBeenCalledWith( + 'sess-new-1', + expect.any(String), + ); + expect(backend.getActiveAgentId()).toBe('agent-1'); + }); + + it('spawns first agent without ITERM_SESSION_ID', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('agent-1')); + + expect(hoistedItermSplitPane).toHaveBeenCalledWith(undefined); + expect(backend.getActiveAgentId()).toBe('agent-1'); + }); + + it('spawns subsequent agent from last session', async () => { + await backend.init(); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('agent-1')); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-2'); + await backend.spawnAgent(makeConfig('agent-2')); + + // Second split should use the first agent's session as source + expect(hoistedItermSplitPane).toHaveBeenLastCalledWith('sess-1'); + }); + + it('rejects duplicate agent IDs', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('dup')); + + await expect(backend.spawnAgent(makeConfig('dup'))).rejects.toThrow( + 'already exists', + ); + }); + + it('registers failed agent and fires exit callback on spawn error', async () => { + await backend.init(); + hoistedItermSplitPane.mockRejectedValueOnce(new Error('split failed')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + await backend.spawnAgent(makeConfig('fail')); + + expect(exitCallback).toHaveBeenCalledWith('fail', 1, null); + }); + + // ─── buildShellCommand (env key validation) ──────────────── + + it('rejects invalid environment variable names', async () => { + await backend.init(); + + await expect( + backend.spawnAgent(makeConfig('bad-env', { env: { 'FOO BAR': 'baz' } })), + ).rejects.toThrow('Invalid environment variable name'); + }); + + it('rejects env key starting with a digit', async () => { + await backend.init(); + + await expect( + backend.spawnAgent(makeConfig('bad-env', { env: { '1VAR': 'baz' } })), + ).rejects.toThrow('Invalid environment variable name'); + }); + + it('accepts valid environment variable names', async () => { + await backend.init(); + + await expect( + backend.spawnAgent( + makeConfig('good-env', { + env: { MY_VAR_123: 'hello', _PRIVATE: 'world' }, + }), + ), + ).resolves.toBeUndefined(); + }); + + // ─── buildShellCommand (atomic marker write) ────────────── + + it('builds command with atomic exit marker write', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string; + // Should contain write-then-rename pattern + expect(cmdArg).toMatch(/echo \$\? > .+\.tmp.+ && mv .+\.tmp/); + }); + + it('builds command with cd and quoted args', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string; + expect(cmdArg).toContain("cd '/tmp/test'"); + expect(cmdArg).toContain("'/usr/bin/node'"); + expect(cmdArg).toContain("'agent.js'"); + }); + + it('includes env vars in command when provided', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a', { env: { NODE_ENV: 'test' } })); + + const cmdArg = hoistedItermRunCommand.mock.calls[0]![1] as string; + expect(cmdArg).toContain("NODE_ENV='test'"); + expect(cmdArg).toContain('env '); + }); + + // ─── Navigation ─────────────────────────────────────────── + + it('switchTo changes active agent and focuses session', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-2'); + await backend.spawnAgent(makeConfig('b')); + + backend.switchTo('b'); + expect(backend.getActiveAgentId()).toBe('b'); + expect(hoistedItermFocusSession).toHaveBeenCalledWith('sess-2'); + }); + + it('switchTo throws for unknown agent', async () => { + await backend.init(); + expect(() => backend.switchTo('ghost')).toThrow('not found'); + }); + + it('switchToNext and switchToPrevious cycle correctly', async () => { + await backend.init(); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-2'); + await backend.spawnAgent(makeConfig('b')); + + expect(backend.getActiveAgentId()).toBe('a'); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('b'); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('a'); + backend.switchToPrevious(); + expect(backend.getActiveAgentId()).toBe('b'); + }); + + it('switchToNext does nothing with a single agent', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('solo')); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('solo'); + }); + + it('switchToPrevious does nothing with a single agent', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('solo')); + backend.switchToPrevious(); + expect(backend.getActiveAgentId()).toBe('solo'); + }); + + // ─── Stop & Cleanup ────────────────────────────────────── + + it('stopAgent closes session and fires exit callback', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + backend.stopAgent('a'); + + expect(hoistedItermCloseSession).toHaveBeenCalledWith('sess-1'); + expect(exitCallback).toHaveBeenCalledWith('a', 1, null); + }); + + it('stopAgent is a no-op for already-stopped agent', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + backend.stopAgent('a'); + hoistedItermCloseSession.mockClear(); + + backend.stopAgent('a'); + expect(hoistedItermCloseSession).not.toHaveBeenCalled(); + }); + + it('stopAgent is a no-op for unknown agent', async () => { + await backend.init(); + backend.stopAgent('ghost'); + expect(hoistedItermCloseSession).not.toHaveBeenCalled(); + }); + + it('stopAll closes all sessions and resets activeAgentId', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + hoistedItermSplitPane.mockResolvedValueOnce('sess-2'); + await backend.spawnAgent(makeConfig('b')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + backend.stopAll(); + + expect(hoistedItermCloseSession).toHaveBeenCalledTimes(2); + expect(exitCallback).toHaveBeenCalledTimes(2); + expect(backend.getActiveAgentId()).toBeNull(); + }); + + it('cleanup closes sessions and removes exit marker directory', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + await backend.cleanup(); + + expect(hoistedItermCloseSession).toHaveBeenCalledWith('sess-1'); + expect(hoistedFsRm).toHaveBeenCalledWith( + expect.stringContaining('agent-iterm-exit-'), + { recursive: true, force: true }, + ); + expect(backend.getActiveAgentId()).toBeNull(); + }); + + it('cleanup tolerates session close errors', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + hoistedItermCloseSession.mockRejectedValueOnce(new Error('session gone')); + + // Should not throw + await expect(backend.cleanup()).resolves.toBeUndefined(); + }); + + it('cleanup tolerates exit marker removal errors', async () => { + await backend.init(); + hoistedFsRm.mockRejectedValueOnce(new Error('ENOENT')); + + // Should not throw + await expect(backend.cleanup()).resolves.toBeUndefined(); + }); + + // ─── Exit Detection ───────────────────────────────────────── + + it('marks agent as exited when marker file appears', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + // Simulate marker file appearing with exit code 0 + hoistedFsReadFile.mockResolvedValue('0\n'); + + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledWith('a', 0, null); + }); + + it('preserves non-zero exit codes from marker', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + hoistedFsReadFile.mockResolvedValue('42\n'); + + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledWith('a', 42, null); + }); + + it('defaults to exit code 1 when marker contains NaN', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + hoistedFsReadFile.mockResolvedValue('garbage\n'); + + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledWith('a', 1, null); + }); + + it('does not fire callback twice for the same agent', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + hoistedFsReadFile.mockResolvedValue('0\n'); + + await vi.advanceTimersByTimeAsync(600); + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledTimes(1); + }); + + it('stops polling once all agents have exited', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + hoistedFsReadFile.mockResolvedValue('0\n'); + + await vi.advanceTimersByTimeAsync(600); + + // Reset to track future reads + hoistedFsReadFile.mockClear(); + + // Advance more — should not poll anymore + await vi.advanceTimersByTimeAsync(2000); + expect(hoistedFsReadFile).not.toHaveBeenCalled(); + }); + + // ─── waitForAll ───────────────────────────────────────────── + + it('waitForAll resolves immediately when no agents exist', async () => { + await backend.init(); + const result = await backend.waitForAll(); + expect(result).toBe(true); + }); + + it('waitForAll resolves when all agents exit', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + hoistedFsReadFile.mockResolvedValue('0\n'); + + const waitPromise = backend.waitForAll(); + await vi.advanceTimersByTimeAsync(600); + + const result = await waitPromise; + expect(result).toBe(true); + }); + + it('waitForAll returns false on timeout', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + + // Marker never appears (readFile keeps throwing) + const waitPromise = backend.waitForAll(1000); + await vi.advanceTimersByTimeAsync(1100); + + const result = await waitPromise; + expect(result).toBe(false); + }); + + // ─── Input ───────────────────────────────────────────────── + + it('writeToAgent sends text via itermSendText', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + const result = backend.writeToAgent('a', 'hello'); + expect(result).toBe(true); + expect(hoistedItermSendText).toHaveBeenCalledWith('sess-1', 'hello'); + }); + + it('writeToAgent returns false for unknown agent', async () => { + await backend.init(); + expect(backend.writeToAgent('ghost', 'hello')).toBe(false); + }); + + it('writeToAgent returns false for stopped agent', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + backend.stopAgent('a'); + + expect(backend.writeToAgent('a', 'hello')).toBe(false); + }); + + it('forwardInput delegates to active agent', async () => { + await backend.init(); + hoistedItermSplitPane.mockResolvedValueOnce('sess-1'); + await backend.spawnAgent(makeConfig('a')); + + const result = backend.forwardInput('hello'); + expect(result).toBe(true); + expect(hoistedItermSendText).toHaveBeenCalledWith('sess-1', 'hello'); + }); + + it('forwardInput returns false with no active agent', async () => { + await backend.init(); + expect(backend.forwardInput('hello')).toBe(false); + }); + + // ─── Snapshots ────────────────────────────────────────────── + + it('getActiveSnapshot returns null', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + expect(backend.getActiveSnapshot()).toBeNull(); + }); + + it('getAgentSnapshot returns null', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + expect(backend.getAgentSnapshot('a')).toBeNull(); + }); + + it('getAgentScrollbackLength returns 0', async () => { + await backend.init(); + await backend.spawnAgent(makeConfig('a')); + expect(backend.getAgentScrollbackLength('a')).toBe(0); + }); + + // ─── getAttachHint ────────────────────────────────────────── + + it('getAttachHint returns null', async () => { + await backend.init(); + expect(backend.getAttachHint()).toBeNull(); + }); + + // ─── resizeAll ────────────────────────────────────────────── + + it('resizeAll is a no-op', async () => { + await backend.init(); + // Should not throw + backend.resizeAll(80, 24); + }); + + // ─── type ─────────────────────────────────────────────────── + + it('has type "iterm2"', () => { + expect(backend.type).toBe('iterm2'); + }); +}); diff --git a/packages/core/src/agents-collab/backends/ITermBackend.ts b/packages/core/src/agents-collab/backends/ITermBackend.ts new file mode 100644 index 000000000..7ff24c44b --- /dev/null +++ b/packages/core/src/agents-collab/backends/ITermBackend.ts @@ -0,0 +1,431 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview ITermBackend implements Backend using the it2 CLI + * (iTerm2 Python API). + * + * Each agent runs in its own iTerm2 split pane. The backend manages pane + * creation, exit detection (via exit marker file polling), and cleanup. + * + * Exit detection uses a file-based marker approach: each agent's command is + * wrapped to write its exit code to a temp file on completion, which the backend + * polls to detect exits. + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { DISPLAY_MODE } from './types.js'; +import type { AgentSpawnConfig, AgentExitCallback, Backend } from './types.js'; +import { + verifyITerm, + itermSplitPane, + itermRunCommand, + itermSendText, + itermFocusSession, + itermCloseSession, +} from './iterm-it2.js'; + +const debugLogger = createDebugLogger('ITERM_BACKEND'); + +/** Polling interval for exit detection (ms) */ +const EXIT_POLL_INTERVAL_MS = 500; + +interface ITermAgentSession { + agentId: string; + sessionId: string; + exitMarkerPath: string; + status: 'running' | 'exited'; + exitCode: number; +} + +export class ITermBackend implements Backend { + readonly type = DISPLAY_MODE.ITERM2; + + /** Directory for exit marker files */ + private exitMarkerDir: string; + /** Session ID of the last agent pane (split source) */ + private lastSplitSessionId: string | null = null; + + private sessions: Map = new Map(); + private agentOrder: string[] = []; + private activeAgentId: string | null = null; + private onExitCallback: AgentExitCallback | null = null; + private exitPollTimer: NodeJS.Timeout | null = null; + private initialized = false; + /** Number of agents currently being spawned asynchronously */ + private pendingSpawns = 0; + /** Queue to serialize spawn operations (prevents split race conditions) */ + private spawnQueue: Promise = Promise.resolve(); + + constructor() { + this.exitMarkerDir = path.join( + os.tmpdir(), + `agent-iterm-exit-${Date.now().toString(36)}`, + ); + } + + async init(): Promise { + if (this.initialized) return; + + await verifyITerm(); + + // Create the exit marker directory + await fs.mkdir(this.exitMarkerDir, { recursive: true }); + + this.initialized = true; + debugLogger.info('ITermBackend initialized'); + } + + // ─── Agent Lifecycle ──────────────────────────────────────── + + async spawnAgent(config: AgentSpawnConfig): Promise { + if (!this.initialized) { + throw new Error('ITermBackend not initialized. Call init() first.'); + } + if (this.sessions.has(config.agentId)) { + throw new Error(`Agent "${config.agentId}" already exists.`); + } + + const exitMarkerPath = path.join(this.exitMarkerDir, config.agentId); + await fs.mkdir(path.dirname(exitMarkerPath), { recursive: true }); + const cmd = this.buildShellCommand(config, exitMarkerPath); + + this.pendingSpawns++; + const spawnPromise = this.spawnQueue.then(() => + this.spawnAgentAsync(config.agentId, cmd, exitMarkerPath), + ); + this.spawnQueue = spawnPromise; + await spawnPromise; + } + + private async spawnAgentAsync( + agentId: string, + cmd: string, + exitMarkerPath: string, + ): Promise { + try { + let sessionId: string; + + if (this.sessions.size === 0) { + // First agent: split from ITERM_SESSION_ID if present, else active session + const leaderSessionId = process.env['ITERM_SESSION_ID'] || undefined; + sessionId = await itermSplitPane(leaderSessionId); + await itermRunCommand(sessionId, cmd); + } else { + // Subsequent agents: split from last agent session, else active session + sessionId = await itermSplitPane(this.lastSplitSessionId || undefined); + await itermRunCommand(sessionId, cmd); + } + + const agentSession: ITermAgentSession = { + agentId, + sessionId, + exitMarkerPath, + status: 'running', + exitCode: 0, + }; + + this.sessions.set(agentId, agentSession); + this.agentOrder.push(agentId); + this.lastSplitSessionId = sessionId; + + if (this.activeAgentId === null) { + this.activeAgentId = agentId; + } + + this.startExitPolling(); + + debugLogger.info(`Spawned agent "${agentId}" in session ${sessionId}`); + } catch (error) { + debugLogger.error(`Failed to spawn agent "${agentId}":`, error); + this.sessions.set(agentId, { + agentId, + sessionId: '', + exitMarkerPath, + status: 'exited', + exitCode: 1, + }); + this.agentOrder.push(agentId); + this.onExitCallback?.(agentId, 1, null); + } finally { + this.pendingSpawns--; + } + } + + stopAgent(agentId: string): void { + const session = this.sessions.get(agentId); + if (!session || session.status !== 'running') return; + itermCloseSession(session.sessionId).catch((e) => + debugLogger.error(`Failed to close session for agent "${agentId}": ${e}`), + ); + session.status = 'exited'; + session.exitCode = 1; + this.onExitCallback?.(agentId, 1, null); + debugLogger.info(`Closed iTerm2 session for agent "${agentId}"`); + } + + stopAll(): void { + for (const session of this.sessions.values()) { + if (session.status === 'running') { + itermCloseSession(session.sessionId).catch((e) => + debugLogger.error( + `Failed to close session for agent "${session.agentId}": ${e}`, + ), + ); + session.status = 'exited'; + session.exitCode = 1; + this.onExitCallback?.(session.agentId, 1, null); + } + } + this.activeAgentId = null; + } + + async cleanup(): Promise { + this.stopExitPolling(); + + // Close all iTerm2 sessions we created + for (const session of this.sessions.values()) { + if (!session.sessionId) continue; + try { + await itermCloseSession(session.sessionId); + } catch (error) { + debugLogger.error('Session cleanup error (ignored):', error); + } + } + + // Clean up exit marker files + try { + await fs.rm(this.exitMarkerDir, { + recursive: true, + force: true, + }); + } catch (error) { + debugLogger.error('Exit marker cleanup error (ignored):', error); + } + + this.sessions.clear(); + this.agentOrder = []; + this.activeAgentId = null; + this.lastSplitSessionId = null; + } + + setOnAgentExit(callback: AgentExitCallback): void { + this.onExitCallback = callback; + } + + async waitForAll(timeoutMs?: number): Promise { + if (this.allExited()) return true; + + return new Promise((resolve) => { + let timeoutHandle: NodeJS.Timeout | undefined; + + const checkInterval = setInterval(() => { + if (this.allExited()) { + clearInterval(checkInterval); + if (timeoutHandle) clearTimeout(timeoutHandle); + resolve(true); + } + }, EXIT_POLL_INTERVAL_MS); + + if (timeoutMs !== undefined) { + timeoutHandle = setTimeout(() => { + clearInterval(checkInterval); + resolve(false); + }, timeoutMs); + } + }); + } + + // ─── Active Agent & Navigation ────────────────────────────── + + switchTo(agentId: string): void { + if (!this.sessions.has(agentId)) { + throw new Error(`Agent "${agentId}" not found.`); + } + const session = this.sessions.get(agentId)!; + this.activeAgentId = agentId; + itermFocusSession(session.sessionId).catch((e) => + debugLogger.error(`Failed to focus session for agent "${agentId}": ${e}`), + ); + } + + switchToNext(): void { + if (this.agentOrder.length <= 1) return; + const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? ''); + const nextIndex = (currentIndex + 1) % this.agentOrder.length; + this.switchTo(this.agentOrder[nextIndex]!); + } + + switchToPrevious(): void { + if (this.agentOrder.length <= 1) return; + const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? ''); + const prevIndex = + (currentIndex - 1 + this.agentOrder.length) % this.agentOrder.length; + this.switchTo(this.agentOrder[prevIndex]!); + } + + getActiveAgentId(): string | null { + return this.activeAgentId; + } + + // ─── Screen Capture ───────────────────────────────────────── + + getActiveSnapshot(): AnsiOutput | null { + // iTerm2 manages rendering — snapshots not supported + return null; + } + + getAgentSnapshot( + _agentId: string, + _scrollOffset: number = 0, + ): AnsiOutput | null { + return null; + } + + getAgentScrollbackLength(_agentId: string): number { + return 0; + } + + // ─── Input ────────────────────────────────────────────────── + + forwardInput(data: string): boolean { + if (!this.activeAgentId) return false; + return this.writeToAgent(this.activeAgentId, data); + } + + writeToAgent(agentId: string, data: string): boolean { + const session = this.sessions.get(agentId); + if (!session || session.status !== 'running') return false; + itermSendText(session.sessionId, data).catch((e) => + debugLogger.error(`Failed to send text to agent "${agentId}": ${e}`), + ); + return true; + } + + // ─── Resize ───────────────────────────────────────────────── + + resizeAll(_cols: number, _rows: number): void { + // iTerm2 manages pane sizes automatically + } + + getAttachHint(): string | null { + // iTerm2 panes are visible directly, no attach needed + return null; + } + + // ─── Private ──────────────────────────────────────────────── + + /** + * Build the shell command with exit marker wrapping. + * + * The command is wrapped so that its exit code is written to a temp file + * when it completes. This allows the backend to detect agent exit via + * file polling, since iTerm2 `write text` runs commands inside a shell + * (the shell stays alive after the command exits). + */ + private buildShellCommand( + config: AgentSpawnConfig, + exitMarkerPath: string, + ): string { + const envParts: string[] = []; + if (config.env) { + for (const [key, value] of Object.entries(config.env)) { + if (!VALID_ENV_KEY.test(key)) { + throw new Error( + `Invalid environment variable name: "${key}". Names must match /^[A-Za-z_][A-Za-z0-9_]*$/.`, + ); + } + envParts.push(`${key}=${shellQuote(value)}`); + } + } + + const cmdParts = [ + shellQuote(config.command), + ...config.args.map(shellQuote), + ]; + + // Build: cd && [env K=V] command args; echo $? > + const parts = [`cd ${shellQuote(config.cwd)}`]; + if (envParts.length > 0) { + parts.push(`env ${envParts.join(' ')} ${cmdParts.join(' ')}`); + } else { + parts.push(cmdParts.join(' ')); + } + + const mainCmd = parts.join(' && '); + // Write exit code to a temp file first, then atomically rename it + // to the marker path. This prevents the polling loop from reading + // a partially-written file. + const tmpMarker = shellQuote(exitMarkerPath + '.tmp'); + const finalMarker = shellQuote(exitMarkerPath); + return `${mainCmd}; echo $? > ${tmpMarker} && mv ${tmpMarker} ${finalMarker}`; + } + + private allExited(): boolean { + if (this.pendingSpawns > 0) return false; + if (this.sessions.size === 0) return true; + for (const session of this.sessions.values()) { + if (session.status === 'running') return false; + } + return true; + } + + private startExitPolling(): void { + if (this.exitPollTimer) return; + + this.exitPollTimer = setInterval(() => { + void this.pollExitStatus(); + }, EXIT_POLL_INTERVAL_MS); + this.exitPollTimer.unref(); + } + + private stopExitPolling(): void { + if (this.exitPollTimer) { + clearInterval(this.exitPollTimer); + this.exitPollTimer = null; + } + } + + private async pollExitStatus(): Promise { + for (const agent of this.sessions.values()) { + if (agent.status !== 'running') continue; + + try { + const content = await fs.readFile(agent.exitMarkerPath, 'utf8'); + const exitCode = parseInt(content.trim(), 10); + agent.status = 'exited'; + agent.exitCode = isNaN(exitCode) ? 1 : exitCode; + + debugLogger.info( + `Agent "${agent.agentId}" exited with code ${agent.exitCode}`, + ); + + this.onExitCallback?.(agent.agentId, agent.exitCode, null); + } catch { + // File doesn't exist yet — command still running + } + } + + if (this.allExited()) { + this.stopExitPolling(); + } + } +} + +/** Regex for valid POSIX environment variable names */ +const VALID_ENV_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/; + +/** + * Simple shell quoting for building command strings. + * Wraps value in single quotes, escaping any internal single quotes. + */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/core/src/agents-collab/backends/TmuxBackend.test.ts b/packages/core/src/agents-collab/backends/TmuxBackend.test.ts new file mode 100644 index 000000000..39a96785d --- /dev/null +++ b/packages/core/src/agents-collab/backends/TmuxBackend.test.ts @@ -0,0 +1,482 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import type { AgentSpawnConfig } from './types.js'; + +// ─── Hoisted mocks for tmux-commands ──────────────────────────── +const hoistedVerifyTmux = vi.hoisted(() => vi.fn()); +const hoistedTmuxCurrentPaneId = vi.hoisted(() => vi.fn()); +const hoistedTmuxCurrentWindowTarget = vi.hoisted(() => vi.fn()); +const hoistedTmuxHasSession = vi.hoisted(() => vi.fn()); +const hoistedTmuxHasWindow = vi.hoisted(() => vi.fn()); +const hoistedTmuxNewSession = vi.hoisted(() => vi.fn()); +const hoistedTmuxNewWindow = vi.hoisted(() => vi.fn()); +const hoistedTmuxSplitWindow = vi.hoisted(() => vi.fn()); +const hoistedTmuxSendKeys = vi.hoisted(() => vi.fn()); +const hoistedTmuxSelectPane = vi.hoisted(() => vi.fn()); +const hoistedTmuxSelectPaneTitle = vi.hoisted(() => vi.fn()); +const hoistedTmuxSelectPaneStyle = vi.hoisted(() => vi.fn()); +const hoistedTmuxSelectLayout = vi.hoisted(() => vi.fn()); +const hoistedTmuxListPanes = vi.hoisted(() => vi.fn()); +const hoistedTmuxSetOption = vi.hoisted(() => vi.fn()); +const hoistedTmuxRespawnPane = vi.hoisted(() => vi.fn()); +const hoistedTmuxKillPane = vi.hoisted(() => vi.fn()); +const hoistedTmuxKillSession = vi.hoisted(() => vi.fn()); +const hoistedTmuxResizePane = vi.hoisted(() => vi.fn()); +const hoistedTmuxGetFirstPaneId = vi.hoisted(() => vi.fn()); + +vi.mock('./tmux-commands.js', () => ({ + verifyTmux: hoistedVerifyTmux, + tmuxCurrentPaneId: hoistedTmuxCurrentPaneId, + tmuxCurrentWindowTarget: hoistedTmuxCurrentWindowTarget, + tmuxHasSession: hoistedTmuxHasSession, + tmuxHasWindow: hoistedTmuxHasWindow, + tmuxNewSession: hoistedTmuxNewSession, + tmuxNewWindow: hoistedTmuxNewWindow, + tmuxSplitWindow: hoistedTmuxSplitWindow, + tmuxSendKeys: hoistedTmuxSendKeys, + tmuxSelectPane: hoistedTmuxSelectPane, + tmuxSelectPaneTitle: hoistedTmuxSelectPaneTitle, + tmuxSelectPaneStyle: hoistedTmuxSelectPaneStyle, + tmuxSelectLayout: hoistedTmuxSelectLayout, + tmuxListPanes: hoistedTmuxListPanes, + tmuxSetOption: hoistedTmuxSetOption, + tmuxRespawnPane: hoistedTmuxRespawnPane, + tmuxKillPane: hoistedTmuxKillPane, + tmuxKillSession: hoistedTmuxKillSession, + tmuxResizePane: hoistedTmuxResizePane, + tmuxGetFirstPaneId: hoistedTmuxGetFirstPaneId, +})); + +// Mock the debug logger +vi.mock('../../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +import { TmuxBackend } from './TmuxBackend.js'; + +function makeConfig( + agentId: string, + overrides?: Partial, +): AgentSpawnConfig { + return { + agentId, + command: '/usr/bin/node', + args: ['agent.js'], + cwd: '/tmp/test', + ...overrides, + }; +} + +/** + * Spawn an agent with fake timers active. The `sleep()` inside + * `spawnAgentAsync` uses `setTimeout`, so we must advance fake timers + * while the spawn promise is pending. + */ +async function spawnWithTimers( + backend: TmuxBackend, + config: AgentSpawnConfig, +): Promise { + const promise = backend.spawnAgent(config); + // Advance past INTERNAL_LAYOUT_SETTLE_MS (200) / EXTERNAL_LAYOUT_SETTLE_MS (120) + // and the 100ms triggerMainProcessRedraw timeout + await vi.advanceTimersByTimeAsync(300); + await promise; +} + +function setupDefaultMocks(): void { + hoistedVerifyTmux.mockResolvedValue(undefined); + hoistedTmuxHasSession.mockResolvedValue(false); + hoistedTmuxHasWindow.mockResolvedValue(false); + hoistedTmuxNewSession.mockResolvedValue(undefined); + hoistedTmuxNewWindow.mockResolvedValue(undefined); + hoistedTmuxGetFirstPaneId.mockResolvedValue('%0'); + hoistedTmuxRespawnPane.mockResolvedValue(undefined); + hoistedTmuxSplitWindow.mockResolvedValue('%1'); + hoistedTmuxSetOption.mockResolvedValue(undefined); + hoistedTmuxSelectPaneTitle.mockResolvedValue(undefined); + hoistedTmuxSelectPaneStyle.mockResolvedValue(undefined); + hoistedTmuxSelectLayout.mockResolvedValue(undefined); + hoistedTmuxSelectPane.mockResolvedValue(undefined); + hoistedTmuxResizePane.mockResolvedValue(undefined); + hoistedTmuxListPanes.mockResolvedValue([]); + hoistedTmuxSendKeys.mockResolvedValue(undefined); + hoistedTmuxKillPane.mockResolvedValue(undefined); + hoistedTmuxKillSession.mockResolvedValue(undefined); + hoistedTmuxCurrentPaneId.mockResolvedValue('%0'); + hoistedTmuxCurrentWindowTarget.mockResolvedValue('main:0'); +} + +describe('TmuxBackend', () => { + let backend: TmuxBackend; + let savedTmuxEnv: string | undefined; + + beforeEach(() => { + vi.useFakeTimers(); + savedTmuxEnv = process.env['TMUX']; + // Default: running outside tmux + delete process.env['TMUX']; + setupDefaultMocks(); + backend = new TmuxBackend(); + }); + + afterEach(async () => { + await backend.cleanup(); + vi.restoreAllMocks(); + vi.useRealTimers(); + if (savedTmuxEnv !== undefined) { + process.env['TMUX'] = savedTmuxEnv; + } else { + delete process.env['TMUX']; + } + }); + + // ─── Initialization ───────────────────────────────────────── + + it('throws if spawnAgent is called before init', async () => { + await expect(backend.spawnAgent(makeConfig('a1'))).rejects.toThrow( + 'not initialized', + ); + }); + + it('init verifies tmux availability', async () => { + await backend.init(); + expect(hoistedVerifyTmux).toHaveBeenCalled(); + }); + + it('init is idempotent', async () => { + await backend.init(); + await backend.init(); + expect(hoistedVerifyTmux).toHaveBeenCalledTimes(1); + }); + + // ─── Spawning (outside tmux) ────────────────────────────── + + it('spawns first agent outside tmux by respawning the initial pane', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('agent-1')); + + expect(hoistedTmuxNewSession).toHaveBeenCalled(); + expect(hoistedTmuxRespawnPane).toHaveBeenCalledWith( + '%0', + expect.any(String), + expect.any(String), + ); + expect(backend.getActiveAgentId()).toBe('agent-1'); + }); + + it('spawns second agent outside tmux by splitting', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('agent-1')); + + // For second agent, list-panes returns the first agent pane + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%2'); + + await spawnWithTimers(backend, makeConfig('agent-2')); + + expect(hoistedTmuxSplitWindow).toHaveBeenCalled(); + }); + + it('rejects duplicate agent IDs', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('dup')); + + await expect(backend.spawnAgent(makeConfig('dup'))).rejects.toThrow( + 'already exists', + ); + }); + + // ─── Spawning (inside tmux) ─────────────────────────────── + + it('spawns first agent inside tmux by splitting from main pane', async () => { + process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0'; + backend = new TmuxBackend(); + await backend.init(); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%1'); + + await spawnWithTimers(backend, makeConfig('agent-1')); + + // Should have split horizontally with firstSplitPercent + expect(hoistedTmuxSplitWindow).toHaveBeenCalledWith( + '%0', + expect.objectContaining({ horizontal: true, percent: 70 }), + ); + // Should refocus on main pane (inside tmux, no server name arg) + expect(hoistedTmuxSelectPane).toHaveBeenCalledWith('%0'); + }); + + // ─── Navigation ─────────────────────────────────────────── + + it('switchTo changes active agent', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%2'); + await spawnWithTimers(backend, makeConfig('b')); + + backend.switchTo('b'); + expect(backend.getActiveAgentId()).toBe('b'); + }); + + it('switchTo throws for unknown agent', async () => { + await backend.init(); + expect(() => backend.switchTo('ghost')).toThrow('not found'); + }); + + it('switchToNext and switchToPrevious cycle correctly', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%2'); + await spawnWithTimers(backend, makeConfig('b')); + + expect(backend.getActiveAgentId()).toBe('a'); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('b'); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('a'); + backend.switchToPrevious(); + expect(backend.getActiveAgentId()).toBe('b'); + }); + + it('switchToNext does nothing with a single agent', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('solo')); + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('solo'); + }); + + // ─── Stop & Cleanup ────────────────────────────────────── + + it('stopAgent kills the pane', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + backend.stopAgent('a'); + expect(hoistedTmuxKillPane).toHaveBeenCalledWith('%0', expect.any(String)); + }); + + it('stopAll kills all running panes', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%2'); + await spawnWithTimers(backend, makeConfig('b')); + + backend.stopAll(); + // Should have killed both panes + expect(hoistedTmuxKillPane).toHaveBeenCalledTimes(2); + }); + + it('cleanup kills panes and the external session', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + await backend.cleanup(); + + expect(hoistedTmuxKillPane).toHaveBeenCalledWith('%0', expect.any(String)); + expect(hoistedTmuxKillSession).toHaveBeenCalled(); + expect(backend.getActiveAgentId()).toBeNull(); + }); + + it('cleanup does not kill session when running inside tmux', async () => { + process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0'; + backend = new TmuxBackend(); + await backend.init(); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + hoistedTmuxSplitWindow.mockResolvedValue('%1'); + await spawnWithTimers(backend, makeConfig('a')); + + hoistedTmuxKillSession.mockClear(); + await backend.cleanup(); + + expect(hoistedTmuxKillSession).not.toHaveBeenCalled(); + }); + + // ─── Exit Detection (Bug #1: missing pane → exited) ────── + + it('marks agent as exited when pane disappears from tmux', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + // Polling returns no panes → agent's pane is gone + hoistedTmuxListPanes.mockResolvedValue([]); + + // Advance timer to trigger poll + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledWith('a', 1, null); + }); + + it('marks agent as exited when pane reports dead', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + // Polling returns the pane as dead with exit code 42 + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: true, deadStatus: 42 }, + ]); + + await vi.advanceTimersByTimeAsync(600); + + expect(exitCallback).toHaveBeenCalledWith('a', 42, null); + }); + + // ─── waitForAll (Bug #3: cleanup resolves waiters) ──────── + + it('waitForAll resolves when all agents exit', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: true, deadStatus: 0 }, + ]); + + const waitPromise = backend.waitForAll(); + + await vi.advanceTimersByTimeAsync(600); + + const result = await waitPromise; + expect(result).toBe(true); + }); + + it('waitForAll resolves after cleanup is called', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + // Pane stays alive — without cleanup, waitForAll would hang + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + + const waitPromise = backend.waitForAll(); + + // Advance a bit (poll runs but agent still alive) + await vi.advanceTimersByTimeAsync(600); + + // Now cleanup + await backend.cleanup(); + + // Advance again so the waitForAll interval fires + await vi.advanceTimersByTimeAsync(600); + + const result = await waitPromise; + // The key thing is the promise resolves instead of hanging forever. + // allExited() returns true since panes were cleared in cleanup. + expect(result).toBe(true); + }); + + it('waitForAll returns false on timeout', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + // Pane stays alive + hoistedTmuxListPanes.mockResolvedValue([ + { paneId: '%0', dead: false, deadStatus: 0 }, + ]); + + const waitPromise = backend.waitForAll(1000); + + await vi.advanceTimersByTimeAsync(1100); + + const result = await waitPromise; + expect(result).toBe(false); + }); + + // ─── Input ──────────────────────────────────────────────── + + it('forwardInput sends literal keys to active agent pane', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + + const result = backend.forwardInput('hello'); + expect(result).toBe(true); + expect(hoistedTmuxSendKeys).toHaveBeenCalledWith( + '%0', + 'hello', + { literal: true }, + expect.any(String), + ); + }); + + it('forwardInput returns false with no active agent', async () => { + await backend.init(); + expect(backend.forwardInput('hello')).toBe(false); + }); + + // ─── Snapshots ──────────────────────────────────────────── + + it('getActiveSnapshot returns null (tmux handles rendering)', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + expect(backend.getActiveSnapshot()).toBeNull(); + }); + + it('getAgentScrollbackLength returns 0', async () => { + await backend.init(); + await spawnWithTimers(backend, makeConfig('a')); + expect(backend.getAgentScrollbackLength('a')).toBe(0); + }); + + // ─── getAttachHint ──────────────────────────────────────── + + it('returns attach command when outside tmux', async () => { + await backend.init(); + const hint = backend.getAttachHint(); + expect(hint).toMatch(/^tmux -L arena-server-\d+ a$/); + }); + + it('returns null when inside tmux', async () => { + process.env['TMUX'] = '/tmp/tmux-1000/default,12345,0'; + backend = new TmuxBackend(); + await backend.init(); + expect(backend.getAttachHint()).toBeNull(); + }); + + // ─── Spawn failure handling ─────────────────────────────── + + it('registers failed agent and fires exit callback on spawn error', async () => { + await backend.init(); + + // Make the external session setup fail + hoistedTmuxHasSession.mockRejectedValueOnce(new Error('tmux exploded')); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + await spawnWithTimers(backend, makeConfig('fail')); + + expect(exitCallback).toHaveBeenCalledWith('fail', 1, null); + }); +}); diff --git a/packages/core/src/agents-collab/backends/TmuxBackend.ts b/packages/core/src/agents-collab/backends/TmuxBackend.ts new file mode 100644 index 000000000..adc75593f --- /dev/null +++ b/packages/core/src/agents-collab/backends/TmuxBackend.ts @@ -0,0 +1,813 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview TmuxBackend implements Backend using tmux split-pane. + * + * Layout (inside tmux): main process on the left (leader pane ~30%), + * agent panes on the right, arranged via `main-vertical`. + * + * ┌────────────┬──────────────────────────────────┐ + * │ │ Agent 1 │ + * │ Leader ├──────────────────────────────────┤ + * │ (30%) │ Agent 2 │ + * │ ├──────────────────────────────────┤ + * │ │ Agent 3 │ + * └────────────┴──────────────────────────────────┘ + * + * Outside tmux: a dedicated tmux server is created and panes are arranged + * using `tiled` layout in a separate session/window. + */ + +import { createDebugLogger } from '../../utils/debugLogger.js'; +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { DISPLAY_MODE } from './types.js'; +import type { AgentSpawnConfig, AgentExitCallback, Backend } from './types.js'; +import { + verifyTmux, + tmuxCurrentWindowTarget, + tmuxCurrentPaneId, + tmuxHasSession, + tmuxHasWindow, + tmuxNewSession, + tmuxNewWindow, + tmuxSplitWindow, + tmuxSendKeys, + tmuxSelectPane, + tmuxSelectPaneTitle, + tmuxSelectPaneStyle, + tmuxSelectLayout, + tmuxListPanes, + tmuxSetOption, + tmuxRespawnPane, + tmuxKillPane, + tmuxKillSession, + tmuxResizePane, + tmuxGetFirstPaneId, + type TmuxPaneInfo, +} from './tmux-commands.js'; + +const debugLogger = createDebugLogger('TMUX_BACKEND'); + +/** Polling interval for exit detection (ms) */ +const EXIT_POLL_INTERVAL_MS = 500; + +/** Default tmux server name prefix (for -L) when running outside tmux. + * Actual name is `${prefix}-${process.pid}` so each leader process is isolated. */ +const TMUX_SERVER_PREFIX = 'arena-server'; +/** Default tmux session name when running outside tmux */ +const DEFAULT_TMUX_SESSION = 'arena-view'; +/** Default tmux window name when running outside tmux */ +const DEFAULT_TMUX_WINDOW = 'arena-view'; +/** Default leader pane width percent (main pane) */ +const DEFAULT_LEADER_WIDTH_PERCENT = 30; +/** Default first split percent (right side) */ +const DEFAULT_FIRST_SPLIT_PERCENT = 70; +/** Default pane border format */ +const DEFAULT_PANE_BORDER_FORMAT = '#{pane_title}'; +/** Layout settle delays */ +const INTERNAL_LAYOUT_SETTLE_MS = 200; +const EXTERNAL_LAYOUT_SETTLE_MS = 120; + +interface TmuxAgentPane { + agentId: string; + paneId: string; + status: 'running' | 'exited'; + exitCode: number; +} + +interface ResolvedTmuxOptions { + serverName: string; + sessionName: string; + windowName: string; + paneTitle: string; + paneBorderStyle?: string; + paneActiveBorderStyle?: string; + paneBorderFormat: string; + paneBorderStatus?: 'top' | 'bottom' | 'off'; + leaderPaneWidthPercent: number; + firstSplitPercent: number; +} + +export class TmuxBackend implements Backend { + readonly type = DISPLAY_MODE.TMUX; + + /** The pane ID where the main process runs (left side) */ + private mainPaneId = ''; + /** Window target (session:window) */ + private windowTarget = ''; + /** Whether we are running inside tmux */ + private insideTmux = false; + /** External tmux server name (when outside tmux) */ + private serverName: string | null = null; + /** External tmux session name (when outside tmux) */ + private sessionName: string | null = null; + /** External tmux window name (when outside tmux) */ + private windowName: string | null = null; + + private panes: Map = new Map(); + private agentOrder: string[] = []; + private activeAgentId: string | null = null; + private onExitCallback: AgentExitCallback | null = null; + private exitPollTimer: NodeJS.Timeout | null = null; + private initialized = false; + /** Whether cleanup() has been called */ + private cleanedUp = false; + /** Number of agents currently being spawned asynchronously */ + private pendingSpawns = 0; + /** Queue to serialize spawn operations (prevents race conditions) */ + private spawnQueue: Promise = Promise.resolve(); + async init(): Promise { + if (this.initialized) return; + + // Verify tmux is available and version is sufficient + await verifyTmux(); + + this.insideTmux = Boolean(process.env['TMUX']); + + if (this.insideTmux) { + // Get the current pane ID (this is where the main process runs) + this.mainPaneId = await tmuxCurrentPaneId(); + this.windowTarget = await tmuxCurrentWindowTarget(); + debugLogger.info( + `Initialized inside tmux: pane ${this.mainPaneId}, window ${this.windowTarget}`, + ); + } else { + debugLogger.info( + 'Initialized outside tmux; will use external tmux server', + ); + } + + this.initialized = true; + } + + // ─── Agent Lifecycle ──────────────────────────────────────── + + async spawnAgent(config: AgentSpawnConfig): Promise { + if (!this.initialized) { + throw new Error('TmuxBackend not initialized. Call init() first.'); + } + if (this.panes.has(config.agentId)) { + throw new Error(`Agent "${config.agentId}" already exists.`); + } + + // Build the shell command string for the agent + const cmd = this.buildShellCommand(config); + + // Track pending spawn so waitForAll/allExited don't return + // prematurely before the pane is registered. + this.pendingSpawns++; + + // Chain spawn operations to ensure they run sequentially. + // This prevents race conditions where multiple agents all see + // panes.size === 0 and try to split from mainPaneId. + const spawnPromise = this.spawnQueue.then(() => + this.spawnAgentAsync(config, cmd), + ); + this.spawnQueue = spawnPromise; + + // Wait for this specific spawn to complete + await spawnPromise; + } + + private async spawnAgentAsync( + config: AgentSpawnConfig, + cmd: string, + ): Promise { + const { agentId } = config; + const options = this.resolveTmuxOptions(config); + + debugLogger.info( + `[spawnAgentAsync] Starting spawn for agent "${agentId}", mainPane="${this.mainPaneId}", currentPanesCount=${this.panes.size}`, + ); + try { + let paneId = ''; + if (this.insideTmux) { + paneId = await this.spawnInsideTmux(cmd, options); + } else { + paneId = await this.spawnOutsideTmux(config, cmd, options); + } + + const serverName = this.getServerName(); + + // Set remain-on-exit so we can detect when the process exits + await tmuxSetOption(paneId, 'remain-on-exit', 'on', serverName); + + // Apply pane title/border styling + await this.applyPaneDecorations(paneId, options, serverName); + + if (this.insideTmux) { + await this.applyInsideLayout(options); + await this.sleep(INTERNAL_LAYOUT_SETTLE_MS); + // Keep focus on the main pane + await tmuxSelectPane(this.mainPaneId); + this.triggerMainProcessRedraw(); + } else { + await this.applyExternalLayout(serverName); + await this.sleep(EXTERNAL_LAYOUT_SETTLE_MS); + } + + const agentPane: TmuxAgentPane = { + agentId, + paneId, + status: 'running', + exitCode: 0, + }; + + this.panes.set(agentId, agentPane); + this.agentOrder.push(agentId); + + // First agent becomes active + if (this.activeAgentId === null) { + this.activeAgentId = agentId; + } + + // Start exit polling if not already running + this.startExitPolling(); + + debugLogger.info( + `[spawnAgentAsync] Spawned agent "${agentId}" in pane ${paneId} — SUCCESS`, + ); + } catch (error) { + debugLogger.error( + `[spawnAgentAsync] Failed to spawn agent "${agentId}":`, + error, + ); + // Still register the agent as failed so exit callback fires + this.panes.set(agentId, { + agentId, + paneId: '', + status: 'exited', + exitCode: 1, + }); + this.agentOrder.push(agentId); + this.onExitCallback?.(agentId, 1, null); + } finally { + this.pendingSpawns--; + } + } + + /** + * Trigger terminal redraw in main process after pane layout changes. + * Uses multiple methods to ensure Ink picks up the new terminal size. + */ + private triggerMainProcessRedraw(): void { + if (!this.insideTmux) return; + // Small delay to let tmux finish the resize operation + setTimeout(() => { + try { + // Method 1: Emit resize event on stdout (Ink listens to this) + if (process.stdout.isTTY) { + process.stdout.emit('resize'); + debugLogger.info( + '[triggerMainProcessRedraw] Emitted stdout resize event', + ); + } + + // Method 2: Send SIGWINCH signal + process.kill(process.pid, 'SIGWINCH'); + debugLogger.info('[triggerMainProcessRedraw] Sent SIGWINCH'); + } catch (error) { + debugLogger.info(`[triggerMainProcessRedraw] Failed: ${error}`); + } + }, 100); + } + + stopAgent(agentId: string): void { + const pane = this.panes.get(agentId); + if (!pane || pane.status !== 'running') return; + // Kill the pane outright — a single Ctrl-C only cancels the current + // turn in interactive CLI agents and does not reliably exit the process. + if (pane.paneId) { + void tmuxKillPane(pane.paneId, this.getServerName()); + } + pane.status = 'exited'; + debugLogger.info(`Killed pane for agent "${agentId}"`); + } + + stopAll(): void { + for (const [agentId, pane] of this.panes.entries()) { + if (pane.status === 'running') { + if (pane.paneId) { + void tmuxKillPane(pane.paneId, this.getServerName()); + } + pane.status = 'exited'; + debugLogger.info(`Killed pane for agent "${agentId}"`); + } + } + } + + async cleanup(): Promise { + this.cleanedUp = true; + this.stopExitPolling(); + + // Kill all agent panes (but not the main pane) + for (const pane of this.panes.values()) { + if (pane.paneId) { + try { + await tmuxKillPane(pane.paneId, this.getServerName()); + debugLogger.info(`Killed agent pane ${pane.paneId}`); + } catch (_error) { + // Pane may already be gone + debugLogger.info( + `Failed to kill pane ${pane.paneId} (may already be gone)`, + ); + } + } + } + + // Kill the external tmux session/server if we created one + if (!this.insideTmux && this.sessionName && this.serverName) { + try { + await tmuxKillSession(this.sessionName, this.serverName); + debugLogger.info( + `Killed external tmux session "${this.sessionName}" on server "${this.serverName}"`, + ); + } catch (_error) { + debugLogger.info( + `Failed to kill external tmux session (may already be gone)`, + ); + } + } + + this.panes.clear(); + this.agentOrder = []; + this.activeAgentId = null; + this.serverName = null; + this.sessionName = null; + this.windowName = null; + this.windowTarget = ''; + this.mainPaneId = ''; + } + + setOnAgentExit(callback: AgentExitCallback): void { + this.onExitCallback = callback; + } + + async waitForAll(timeoutMs?: number): Promise { + if (this.allExited() || this.cleanedUp) return this.allExited(); + + return new Promise((resolve) => { + let timeoutHandle: NodeJS.Timeout | undefined; + + const checkInterval = setInterval(() => { + if (this.allExited() || this.cleanedUp) { + clearInterval(checkInterval); + if (timeoutHandle) clearTimeout(timeoutHandle); + resolve(this.allExited()); + } + }, EXIT_POLL_INTERVAL_MS); + + if (timeoutMs !== undefined) { + timeoutHandle = setTimeout(() => { + clearInterval(checkInterval); + resolve(false); + }, timeoutMs); + } + }); + } + + // ─── Active Agent & Navigation ────────────────────────────── + + switchTo(agentId: string): void { + if (!this.panes.has(agentId)) { + throw new Error(`Agent "${agentId}" not found.`); + } + const pane = this.panes.get(agentId)!; + this.activeAgentId = agentId; + void tmuxSelectPane(pane.paneId, this.getServerName()); + } + + switchToNext(): void { + if (this.agentOrder.length <= 1) return; + const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? ''); + const nextIndex = (currentIndex + 1) % this.agentOrder.length; + this.switchTo(this.agentOrder[nextIndex]!); + } + + switchToPrevious(): void { + if (this.agentOrder.length <= 1) return; + const currentIndex = this.agentOrder.indexOf(this.activeAgentId ?? ''); + const prevIndex = + (currentIndex - 1 + this.agentOrder.length) % this.agentOrder.length; + this.switchTo(this.agentOrder[prevIndex]!); + } + + getActiveAgentId(): string | null { + return this.activeAgentId; + } + + // ─── Screen Capture ───────────────────────────────────────── + + getActiveSnapshot(): AnsiOutput | null { + if (!this.activeAgentId) return null; + return this.getAgentSnapshot(this.activeAgentId); + } + + getAgentSnapshot( + agentId: string, + _scrollOffset: number = 0, + ): AnsiOutput | null { + // tmux panes are rendered by tmux itself. capture-pane is available + // but returns raw text. For the progress bar we don't need snapshots; + // full rendering is handled by tmux directly. + // Return null — the UI doesn't use snapshots for split-pane backends. + return null; + } + + getAgentScrollbackLength(_agentId: string): number { + // Scrollback is managed by tmux, not by us + return 0; + } + + // ─── Input ────────────────────────────────────────────────── + + forwardInput(data: string): boolean { + if (!this.activeAgentId) return false; + return this.writeToAgent(this.activeAgentId, data); + } + + writeToAgent(agentId: string, data: string): boolean { + const pane = this.panes.get(agentId); + if (!pane || pane.status !== 'running') return false; + void tmuxSendKeys( + pane.paneId, + data, + { literal: true }, + this.getServerName(), + ); + return true; + } + + // ─── Resize ───────────────────────────────────────────────── + + resizeAll(_cols: number, _rows: number): void { + // tmux manages pane sizes automatically based on the terminal window + } + + // ─── External Session Info ───────────────────────────────── + + getAttachHint(): string | null { + if (this.insideTmux) { + return null; + } + // When outside tmux, the server name is determined at init time + // (per-process unique). Return the attach command even before + // ensureExternalSession runs, since the server name is deterministic. + const server = this.serverName ?? `${TMUX_SERVER_PREFIX}-${process.pid}`; + return `tmux -L ${server} a`; + } + + // ─── Private ──────────────────────────────────────────────── + + private resolveTmuxOptions(config: AgentSpawnConfig): ResolvedTmuxOptions { + const opts = config.backend?.tmux ?? {}; + return { + serverName: opts.serverName ?? `${TMUX_SERVER_PREFIX}-${process.pid}`, + sessionName: opts.sessionName ?? DEFAULT_TMUX_SESSION, + windowName: opts.windowName ?? DEFAULT_TMUX_WINDOW, + paneTitle: opts.paneTitle ?? config.agentId, + paneBorderStyle: opts.paneBorderStyle, + paneActiveBorderStyle: opts.paneActiveBorderStyle, + paneBorderFormat: opts.paneBorderFormat ?? DEFAULT_PANE_BORDER_FORMAT, + paneBorderStatus: + opts.paneBorderStatus ?? (this.insideTmux ? undefined : 'top'), + leaderPaneWidthPercent: + opts.leaderPaneWidthPercent ?? DEFAULT_LEADER_WIDTH_PERCENT, + firstSplitPercent: opts.firstSplitPercent ?? DEFAULT_FIRST_SPLIT_PERCENT, + }; + } + + private getServerName(): string | undefined { + return this.insideTmux ? undefined : (this.serverName ?? undefined); + } + + private async ensureExternalSession( + config: AgentSpawnConfig, + options: ResolvedTmuxOptions, + ): Promise { + if ( + this.windowTarget && + this.serverName && + this.sessionName && + this.windowName + ) { + return; + } + + this.serverName = options.serverName; + this.sessionName = options.sessionName; + this.windowName = options.windowName; + + const serverName = this.serverName; + const sessionExists = await tmuxHasSession(this.sessionName, serverName); + + if (!sessionExists) { + await tmuxNewSession( + this.sessionName, + { + cols: config.cols, + rows: config.rows, + windowName: this.windowName, + }, + serverName, + ); + } + + const windowExists = sessionExists + ? await tmuxHasWindow(this.sessionName, this.windowName, serverName) + : true; + + if (!windowExists) { + await tmuxNewWindow(this.sessionName, this.windowName, serverName); + } + + this.windowTarget = `${this.sessionName}:${this.windowName}`; + + if (!this.mainPaneId) { + this.mainPaneId = await tmuxGetFirstPaneId(this.windowTarget, serverName); + } + } + + private async spawnInsideTmux( + cmd: string, + options: ResolvedTmuxOptions, + ): Promise { + if (!this.windowTarget) { + throw new Error('Tmux window target not initialized.'); + } + + const panes = await tmuxListPanes(this.windowTarget); + const paneCount = panes.length; + if (paneCount === 1) { + debugLogger.info( + `[spawnInsideTmux] First agent — split -h -l ${options.firstSplitPercent}% from ${this.mainPaneId}`, + ); + return await tmuxSplitWindow(this.mainPaneId, { + horizontal: true, + percent: options.firstSplitPercent, + command: cmd, + }); + } + + const splitTarget = this.pickMiddlePane(panes).paneId; + const horizontal = this.shouldSplitHorizontally(paneCount); + debugLogger.info( + `[spawnInsideTmux] Split from middle pane ${splitTarget} (${paneCount} panes, ${horizontal ? 'horizontal' : 'vertical'})`, + ); + return await tmuxSplitWindow(splitTarget, { + horizontal, + command: cmd, + }); + } + + private async spawnOutsideTmux( + config: AgentSpawnConfig, + cmd: string, + options: ResolvedTmuxOptions, + ): Promise { + await this.ensureExternalSession(config, options); + if (!this.windowTarget) { + throw new Error('External tmux window target not initialized.'); + } + + const serverName = this.getServerName(); + + if (this.panes.size === 0) { + const firstPaneId = await tmuxGetFirstPaneId( + this.windowTarget, + serverName, + ); + this.mainPaneId = firstPaneId; + debugLogger.info( + `[spawnOutsideTmux] First agent — respawn in pane ${firstPaneId}`, + ); + await tmuxRespawnPane(firstPaneId, cmd, serverName); + return firstPaneId; + } + + const panes = await tmuxListPanes(this.windowTarget, serverName); + const splitTarget = this.pickMiddlePane(panes).paneId; + const horizontal = this.shouldSplitHorizontally(panes.length); + debugLogger.info( + `[spawnOutsideTmux] Split from middle pane ${splitTarget} (${panes.length} panes, ${horizontal ? 'horizontal' : 'vertical'})`, + ); + return await tmuxSplitWindow( + splitTarget, + { horizontal, command: cmd }, + serverName, + ); + } + + private pickMiddlePane(panes: TmuxPaneInfo[]): TmuxPaneInfo { + if (panes.length === 0) { + throw new Error('No panes available to split.'); + } + return panes[Math.floor(panes.length / 2)]!; + } + + private shouldSplitHorizontally(paneCount: number): boolean { + return paneCount % 2 === 1; + } + + private async applyPaneDecorations( + paneId: string, + options: ResolvedTmuxOptions, + serverName?: string, + ): Promise { + if (!this.windowTarget) return; + + if (options.paneBorderStatus) { + await tmuxSetOption( + this.windowTarget, + 'pane-border-status', + options.paneBorderStatus, + serverName, + ); + } + + if (options.paneBorderFormat) { + await tmuxSetOption( + this.windowTarget, + 'pane-border-format', + options.paneBorderFormat, + serverName, + ); + } + + if (options.paneBorderStyle) { + await tmuxSetOption( + this.windowTarget, + 'pane-border-style', + options.paneBorderStyle, + serverName, + ); + await tmuxSelectPaneStyle(paneId, options.paneBorderStyle, serverName); + } + + if (options.paneActiveBorderStyle) { + await tmuxSetOption( + this.windowTarget, + 'pane-active-border-style', + options.paneActiveBorderStyle, + serverName, + ); + } + + await tmuxSelectPaneTitle(paneId, options.paneTitle, serverName); + } + + private async applyInsideLayout(options: ResolvedTmuxOptions): Promise { + if (!this.windowTarget || !this.mainPaneId) return; + await tmuxSelectLayout(this.windowTarget, 'main-vertical'); + await tmuxResizePane(this.mainPaneId, { + width: `${options.leaderPaneWidthPercent}%`, + }); + } + + private async applyExternalLayout(serverName?: string): Promise { + if (!this.windowTarget) return; + await tmuxSelectLayout(this.windowTarget, 'tiled', serverName); + } + + private async sleep(ms: number): Promise { + await new Promise((resolve) => setTimeout(resolve, ms)); + } + + private buildShellCommand(config: AgentSpawnConfig): string { + // Build env prefix + command + args + const envParts: string[] = []; + if (config.env) { + for (const [key, value] of Object.entries(config.env)) { + envParts.push(`${key}=${shellQuote(value)}`); + } + } + + const cmdParts = [ + shellQuote(config.command), + ...config.args.map(shellQuote), + ]; + + // cd to the working directory first + const parts = [`cd ${shellQuote(config.cwd)}`]; + if (envParts.length > 0) { + parts.push(`env ${envParts.join(' ')} ${cmdParts.join(' ')}`); + } else { + parts.push(cmdParts.join(' ')); + } + + const fullCommand = parts.join(' && '); + debugLogger.info( + `[buildShellCommand] agentId=${config.agentId}, command=${config.command}, args=${JSON.stringify(config.args)}, cwd=${config.cwd}`, + ); + debugLogger.info(`[buildShellCommand] full shell command: ${fullCommand}`); + return fullCommand; + } + + private allExited(): boolean { + if (this.pendingSpawns > 0) return false; + if (this.panes.size === 0) return true; + for (const pane of this.panes.values()) { + if (pane.status === 'running') return false; + } + return true; + } + + private startExitPolling(): void { + if (this.exitPollTimer) return; + + this.exitPollTimer = setInterval(() => { + void this.pollPaneStatus(); + }, EXIT_POLL_INTERVAL_MS); + } + + private stopExitPolling(): void { + if (this.exitPollTimer) { + clearInterval(this.exitPollTimer); + this.exitPollTimer = null; + } + } + + private async pollPaneStatus(): Promise { + let paneInfos: TmuxPaneInfo[]; + const serverName = this.getServerName(); + try { + if (!this.windowTarget) return; + // List panes in the active window + paneInfos = await tmuxListPanes(this.windowTarget, serverName); + } catch (err) { + // Window may have been killed externally + debugLogger.info( + `[pollPaneStatus] Failed to list panes for window "${this.windowTarget}": ${err}`, + ); + return; + } + + // Build a lookup: paneId → TmuxPaneInfo + const paneMap = new Map(); + for (const info of paneInfos) { + paneMap.set(info.paneId, info); + } + + // Log all pane statuses for debugging (only when there are agent panes) + if (this.panes.size > 0) { + debugLogger.info( + `[pollPaneStatus] paneCount=${paneInfos.length}, agentPanes=${JSON.stringify( + Array.from(this.panes.values()).map((p) => { + const info = paneMap.get(p.paneId); + return { + agentId: p.agentId, + paneId: p.paneId, + status: p.status, + dead: info?.dead, + deadStatus: info?.deadStatus, + }; + }), + )}`, + ); + } + + for (const agent of this.panes.values()) { + if (agent.status !== 'running') continue; + + const info = paneMap.get(agent.paneId); + if (!info) { + // Pane was killed externally — treat as exited + agent.status = 'exited'; + agent.exitCode = 1; + debugLogger.info( + `[pollPaneStatus] Agent "${agent.agentId}" pane ${agent.paneId} not found in tmux list — marking as exited`, + ); + this.onExitCallback?.(agent.agentId, 1, null); + continue; + } + + if (info.dead) { + agent.status = 'exited'; + agent.exitCode = info.deadStatus; + + debugLogger.info( + `[pollPaneStatus] Agent "${agent.agentId}" (pane ${agent.paneId}) detected as DEAD with exit code ${info.deadStatus}`, + ); + + this.onExitCallback?.(agent.agentId, info.deadStatus, null); + } + } + + // Stop polling if all agents have exited + if (this.allExited()) { + this.stopExitPolling(); + } + } +} + +/** + * Simple shell quoting for building command strings. + * Wraps value in single quotes, escaping any internal single quotes. + */ +function shellQuote(value: string): string { + return `'${value.replace(/'/g, "'\\''")}'`; +} diff --git a/packages/core/src/agents-collab/backends/detect.ts b/packages/core/src/agents-collab/backends/detect.ts new file mode 100644 index 000000000..3c53c5ceb --- /dev/null +++ b/packages/core/src/agents-collab/backends/detect.ts @@ -0,0 +1,74 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { TmuxBackend } from './TmuxBackend.js'; +import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js'; +import { isTmuxAvailable } from './tmux-commands.js'; + +const debugLogger = createDebugLogger('BACKEND_DETECT'); + +export interface DetectBackendResult { + backend: Backend; + warning?: string; +} + +/** + * Detect and create the appropriate Backend. + * + * Design principle for current Arena flow: + * - Keep all display mode values in the API surface + * - Only tmux is runnable for now + * - in-process / iTerm2 preferences fail fast as "not implemented yet" + * + * Detection priority: + * 1. User explicit preference (--display=in-process|tmux|iterm2) + * 2. Auto-detect: + * - inside tmux: TmuxBackend + * - other terminals: tmux external session mode when tmux is available + */ +export async function detectBackend( + preference?: DisplayMode, +): Promise { + // 1. User explicit preference + if (preference === DISPLAY_MODE.IN_PROCESS) { + throw new Error( + `Arena display mode "${DISPLAY_MODE.IN_PROCESS}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}".`, + ); + } + + if (preference === DISPLAY_MODE.ITERM2) { + throw new Error( + `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}".`, + ); + } + + if (preference === DISPLAY_MODE.TMUX) { + debugLogger.info('Using TmuxBackend (user preference)'); + return { backend: new TmuxBackend() }; + } + + // 2. Auto-detect + if (process.env['TMUX']) { + debugLogger.info('Detected $TMUX — attempting TmuxBackend'); + return { backend: new TmuxBackend() }; + } + + // Other terminals (including iTerm2): use tmux external session mode if available. + if (isTmuxAvailable()) { + debugLogger.info( + 'tmux is available — using TmuxBackend external session mode', + ); + return { backend: new TmuxBackend() }; + } + + // No supported backend available. + const tmuxEnv = process.env['TMUX']; + const termProgram = process.env['TERM_PROGRAM']; + throw new Error( + `No supported Arena backend detected. $TMUX=${tmuxEnv ? `"${tmuxEnv}"` : '(unset)'}, $TERM_PROGRAM=${termProgram ? `"${termProgram}"` : '(unset)'}. Install tmux to use Arena split-pane mode.`, + ); +} diff --git a/packages/core/src/agents-collab/backends/index.ts b/packages/core/src/agents-collab/backends/index.ts new file mode 100644 index 000000000..f85fe163e --- /dev/null +++ b/packages/core/src/agents-collab/backends/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { DISPLAY_MODE } from './types.js'; +export type { + Backend, + DisplayMode, + AgentSpawnConfig, + AgentExitCallback, + TmuxBackendOptions, +} from './types.js'; +export { TmuxBackend } from './TmuxBackend.js'; +export { ITermBackend } from './ITermBackend.js'; +export { detectBackend, type DetectBackendResult } from './detect.js'; diff --git a/packages/core/src/agents-collab/backends/iterm-it2.test.ts b/packages/core/src/agents-collab/backends/iterm-it2.test.ts new file mode 100644 index 000000000..723253695 --- /dev/null +++ b/packages/core/src/agents-collab/backends/iterm-it2.test.ts @@ -0,0 +1,318 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, beforeEach, vi } from 'vitest'; + +// ─── Hoisted mocks for shell-utils ────────────────────────────── +const hoistedExecCommand = vi.hoisted(() => vi.fn()); +const hoistedIsCommandAvailable = vi.hoisted(() => vi.fn()); + +vi.mock('../../utils/shell-utils.js', () => ({ + execCommand: hoistedExecCommand, + isCommandAvailable: hoistedIsCommandAvailable, +})); + +vi.mock('../../utils/debugLogger.js', () => ({ + createDebugLogger: () => ({ + info: vi.fn(), + error: vi.fn(), + warn: vi.fn(), + }), +})); + +import { + isIt2Available, + ensureIt2Installed, + verifyITerm, + itermSplitPane, + itermRunCommand, + itermFocusSession, + itermSendText, + itermCloseSession, +} from './iterm-it2.js'; + +describe('iterm-it2', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ─── isIt2Available ───────────────────────────────────────── + + describe('isIt2Available', () => { + it('returns true when it2 is on PATH', () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + expect(isIt2Available()).toBe(true); + expect(hoistedIsCommandAvailable).toHaveBeenCalledWith('it2'); + }); + + it('returns false when it2 is not on PATH', () => { + hoistedIsCommandAvailable.mockReturnValue({ available: false }); + expect(isIt2Available()).toBe(false); + }); + }); + + // ─── ensureIt2Installed ────────────────────────────────────── + + describe('ensureIt2Installed', () => { + it('does nothing if it2 is already available', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + await ensureIt2Installed(); + expect(hoistedExecCommand).not.toHaveBeenCalled(); + }); + + it('installs via uv when uv is available', async () => { + // isIt2Available() → false; uv available; install succeeds; recheck → true + hoistedIsCommandAvailable + .mockReturnValueOnce({ available: false }) // isIt2Available() initial + .mockReturnValueOnce({ available: true }); // uv available + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + // After install, it2 is available + hoistedIsCommandAvailable.mockReturnValueOnce({ available: true }); + + await ensureIt2Installed(); + + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'uv', + ['tool', 'install', 'it2'], + expect.any(Object), + ); + }); + + it('falls back to pipx when uv is unavailable', async () => { + hoistedIsCommandAvailable + .mockReturnValueOnce({ available: false }) // isIt2Available() + .mockReturnValueOnce({ available: false }) // uv not available + .mockReturnValueOnce({ available: true }); // pipx available + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + hoistedIsCommandAvailable.mockReturnValueOnce({ available: true }); // recheck + + await ensureIt2Installed(); + + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'pipx', + ['install', 'it2'], + expect.any(Object), + ); + }); + + it('falls back to pip when uv and pipx are unavailable', async () => { + hoistedIsCommandAvailable + .mockReturnValueOnce({ available: false }) // isIt2Available() + .mockReturnValueOnce({ available: false }) // uv + .mockReturnValueOnce({ available: false }) // pipx + .mockReturnValueOnce({ available: true }); // pip available + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + hoistedIsCommandAvailable.mockReturnValueOnce({ available: true }); // recheck + + await ensureIt2Installed(); + + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'pip', + ['install', '--user', 'it2'], + expect.any(Object), + ); + }); + + it('throws if no installer succeeds', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: false }); + + await expect(ensureIt2Installed()).rejects.toThrow( + 'it2 is not installed', + ); + }); + }); + + // ─── verifyITerm ────────────────────────────────────────────── + + describe('verifyITerm', () => { + it('succeeds when session list returns code 0', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: 'session1\n', + stderr: '', + }); + + await expect(verifyITerm()).resolves.toBeUndefined(); + }); + + it('throws Python API error when stderr mentions "api"', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + hoistedExecCommand.mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'Python API not enabled', + }); + + await expect(verifyITerm()).rejects.toThrow('Python API not enabled'); + }); + + it('throws Python API error when stderr mentions "connection refused"', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + hoistedExecCommand.mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'Connection refused to iTerm2', + }); + + await expect(verifyITerm()).rejects.toThrow('Python API not enabled'); + }); + + it('throws generic error for unrecognized failures', async () => { + hoistedIsCommandAvailable.mockReturnValue({ available: true }); + hoistedExecCommand.mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'some unknown error', + }); + + await expect(verifyITerm()).rejects.toThrow('it2 session list failed'); + }); + }); + + // ─── itermSplitPane ────────────────────────────────────────── + + describe('itermSplitPane', () => { + it('splits vertically without session ID', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: 'Created new pane: w0t1p2\n', + stderr: '', + }); + + const paneId = await itermSplitPane(); + expect(paneId).toBe('w0t1p2'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'split', '-v'], + expect.any(Object), + ); + }); + + it('passes -s flag when session ID is provided', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: 'Created new pane: w0t1p3\n', + stderr: '', + }); + + await itermSplitPane('sess-123'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'split', '-v', '-s', 'sess-123'], + expect.any(Object), + ); + }); + + it('throws if pane ID cannot be parsed from output', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: 'Unexpected output\n', + stderr: '', + }); + + await expect(itermSplitPane()).rejects.toThrow('Unable to parse'); + }); + + it('throws on non-zero exit code', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 1, + stdout: '', + stderr: 'split failed', + }); + + await expect(itermSplitPane()).rejects.toThrow('split failed'); + }); + }); + + // ─── itermRunCommand ────────────────────────────────────────── + + describe('itermRunCommand', () => { + it('calls it2 session run with correct args', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + + await itermRunCommand('sess-1', 'ls -la'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'run', '-s', 'sess-1', 'ls -la'], + expect.any(Object), + ); + }); + }); + + // ─── itermFocusSession ──────────────────────────────────────── + + describe('itermFocusSession', () => { + it('calls it2 session focus with correct args', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + + await itermFocusSession('sess-1'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'focus', 'sess-1'], + expect.any(Object), + ); + }); + }); + + // ─── itermSendText ───────────────────────────────────────────── + + describe('itermSendText', () => { + it('calls it2 session send with correct args', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + + await itermSendText('sess-1', 'hello world'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'send', '-s', 'sess-1', 'hello world'], + expect.any(Object), + ); + }); + }); + + // ─── itermCloseSession ──────────────────────────────────────── + + describe('itermCloseSession', () => { + it('calls it2 session close with correct args', async () => { + hoistedExecCommand.mockResolvedValue({ + code: 0, + stdout: '', + stderr: '', + }); + + await itermCloseSession('sess-1'); + expect(hoistedExecCommand).toHaveBeenCalledWith( + 'it2', + ['session', 'close', '-s', 'sess-1'], + expect.any(Object), + ); + }); + }); +}); diff --git a/packages/core/src/agents-collab/backends/iterm-it2.ts b/packages/core/src/agents-collab/backends/iterm-it2.ts new file mode 100644 index 000000000..cf550b912 --- /dev/null +++ b/packages/core/src/agents-collab/backends/iterm-it2.ts @@ -0,0 +1,141 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Type-safe async wrappers for iTerm2 it2 CLI commands. + * + * The it2 CLI talks to iTerm2's Python API. We use it2 directly and avoid + * AppleScript to match the Team design spec. + */ + +import { execCommand, isCommandAvailable } from '../../utils/shell-utils.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('ITERM_IT2'); + +// ─── Helpers ──────────────────────────────────────────────────── + +async function it2Result( + args: string[], +): Promise<{ stdout: string; stderr: string; code: number }> { + debugLogger.info(`it2 ${args.join(' ')}`); + const result = await execCommand('it2', args, { + preserveOutputOnError: true, + }); + if (result.code !== 0 && result.stderr.trim()) { + debugLogger.error(`it2 error: ${result.stderr.trim()}`); + } + return result; +} + +async function it2(args: string[]): Promise { + const result = await it2Result(args); + if (result.code !== 0) { + const message = result.stderr.trim() || result.stdout.trim(); + throw new Error(message || 'it2 command failed'); + } + return result.stdout; +} + +function parseCreatedPaneId(output: string): string { + const match = output.match(/Created new pane:\s*(\S+)/); + if (!match?.[1]) { + throw new Error(`Unable to parse it2 split output: ${output.trim()}`); + } + return match[1]; +} + +// ─── Installation & Verification ─────────────────────────────── + +export function isIt2Available(): boolean { + return isCommandAvailable('it2').available; +} + +async function tryInstallIt2( + command: string, + args: string[], +): Promise { + if (!isCommandAvailable(command).available) return false; + const result = await execCommand(command, args, { + preserveOutputOnError: true, + }); + return result.code === 0; +} + +export async function ensureIt2Installed(): Promise { + if (isIt2Available()) return; + + const installers: Array<{ cmd: string; args: string[] }> = [ + { cmd: 'uv', args: ['tool', 'install', 'it2'] }, + { cmd: 'pipx', args: ['install', 'it2'] }, + { cmd: 'pip', args: ['install', '--user', 'it2'] }, + ]; + + for (const installer of installers) { + const installed = await tryInstallIt2(installer.cmd, installer.args); + if (installed && isIt2Available()) return; + } + + throw new Error( + 'it2 is not installed. Install it2 via "uv tool install it2", "pipx install it2", or "pip install --user it2".', + ); +} + +export async function verifyITerm(): Promise { + await ensureIt2Installed(); + + const result = await it2Result(['session', 'list']); + if (result.code === 0) return; + + const combined = `${result.stdout}\n${result.stderr}`.toLowerCase(); + if ( + combined.includes('api') || + combined.includes('python') || + combined.includes('connection refused') || + combined.includes('not enabled') + ) { + throw new Error( + 'iTerm2 Python API not enabled. Enable it in iTerm2 → Settings → General → Magic → Enable Python API, then restart iTerm2.', + ); + } + + throw new Error( + `it2 session list failed: ${result.stderr.trim() || result.stdout.trim()}`, + ); +} + +// ─── Public API ───────────────────────────────────────────────── + +export async function itermSplitPane(sessionId?: string): Promise { + const args = ['session', 'split', '-v']; + if (sessionId) { + args.push('-s', sessionId); + } + const output = await it2(args); + return parseCreatedPaneId(output); +} + +export async function itermRunCommand( + sessionId: string, + command: string, +): Promise { + await it2(['session', 'run', '-s', sessionId, command]); +} + +export async function itermFocusSession(sessionId: string): Promise { + await it2(['session', 'focus', sessionId]); +} + +export async function itermSendText( + sessionId: string, + text: string, +): Promise { + await it2(['session', 'send', '-s', sessionId, text]); +} + +export async function itermCloseSession(sessionId: string): Promise { + await it2(['session', 'close', '-s', sessionId]); +} diff --git a/packages/core/src/agents-collab/backends/tmux-commands.test.ts b/packages/core/src/agents-collab/backends/tmux-commands.test.ts new file mode 100644 index 000000000..8e4a790ba --- /dev/null +++ b/packages/core/src/agents-collab/backends/tmux-commands.test.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { parseTmuxListPanes } from './tmux-commands.js'; + +describe('parseTmuxListPanes', () => { + it('parses a single running pane', () => { + const output = '%0 0 0\n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([{ paneId: '%0', dead: false, deadStatus: 0 }]); + }); + + it('parses a single dead pane with exit code', () => { + const output = '%1 1 42\n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([{ paneId: '%1', dead: true, deadStatus: 42 }]); + }); + + it('parses multiple panes with mixed statuses', () => { + const output = '%0 0 0\n%1 1 1\n%2 0 0\n%3 1 137\n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([ + { paneId: '%0', dead: false, deadStatus: 0 }, + { paneId: '%1', dead: true, deadStatus: 1 }, + { paneId: '%2', dead: false, deadStatus: 0 }, + { paneId: '%3', dead: true, deadStatus: 137 }, + ]); + }); + + it('returns empty array for empty output', () => { + expect(parseTmuxListPanes('')).toEqual([]); + }); + + it('returns empty array for whitespace-only output', () => { + expect(parseTmuxListPanes(' \n \n')).toEqual([]); + }); + + it('skips lines with insufficient fields', () => { + const output = '%0\n%1 1 0\n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([{ paneId: '%1', dead: true, deadStatus: 0 }]); + }); + + it('defaults deadStatus to 0 when missing', () => { + // tmux might omit the third field when pane is alive + const output = '%0 0\n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([{ paneId: '%0', dead: false, deadStatus: 0 }]); + }); + + it('handles extra whitespace gracefully', () => { + const output = ' %5 1 99 \n'; + const result = parseTmuxListPanes(output); + expect(result).toEqual([{ paneId: '%5', dead: true, deadStatus: 99 }]); + }); +}); diff --git a/packages/core/src/agents-collab/backends/tmux-commands.ts b/packages/core/src/agents-collab/backends/tmux-commands.ts new file mode 100644 index 000000000..6400a72da --- /dev/null +++ b/packages/core/src/agents-collab/backends/tmux-commands.ts @@ -0,0 +1,503 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Type-safe async wrappers for tmux CLI commands. + * + * All functions use `execCommand('tmux', [...args])` from shell-utils, + * avoiding shell injection by passing arguments as arrays (execFile). + */ + +import { execCommand, isCommandAvailable } from '../../utils/shell-utils.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; + +const debugLogger = createDebugLogger('TMUX_CMD'); + +/** + * Information about a tmux pane, parsed from `list-panes`. + */ +export interface TmuxPaneInfo { + /** Pane ID (e.g., '%0', '%1') */ + paneId: string; + /** Whether the pane's process has exited */ + dead: boolean; + /** Exit status of the pane's process (only valid when dead=true) */ + deadStatus: number; +} + +/** + * Information about a tmux window. + */ +export interface TmuxWindowInfo { + /** Window name */ + name: string; + /** Window ID (e.g., '@1') */ + id: string; +} + +/** + * Minimum tmux version required for split-pane support. + */ +const MIN_TMUX_VERSION = '3.0'; + +// ─── Helpers ──────────────────────────────────────────────────── + +async function tmuxResult( + args: string[], + serverName?: string, +): Promise<{ stdout: string; stderr: string; code: number }> { + const fullArgs = serverName ? ['-L', serverName, ...args] : args; + debugLogger.info(`tmux ${fullArgs.join(' ')}`); + const result = await execCommand('tmux', fullArgs, { + preserveOutputOnError: true, + }); + if (result.code !== 0 && result.stderr.trim()) { + debugLogger.error(`tmux error: ${result.stderr.trim()}`); + } + return result; +} + +async function tmux(args: string[], serverName?: string): Promise { + const result = await tmuxResult(args, serverName); + if (result.code !== 0) { + throw new Error( + `tmux ${args[0]} failed (exit ${result.code}): ${result.stderr.trim() || result.stdout.trim()}`, + ); + } + return result.stdout; +} + +function parseVersion(versionStr: string): number[] { + // "tmux 3.4" → [3, 4] + const match = versionStr.match(/(\d+)\.(\d+)/); + if (!match) return [0, 0]; + return [parseInt(match[1]!, 10), parseInt(match[2]!, 10)]; +} + +function isVersionAtLeast(current: string, minimum: string): boolean { + const [curMajor = 0, curMinor = 0] = parseVersion(current); + const [minMajor = 0, minMinor = 0] = parseVersion(minimum); + if (curMajor !== minMajor) return curMajor > minMajor; + return curMinor >= minMinor; +} + +// ─── Public API ───────────────────────────────────────────────── + +/** + * Check if tmux is available on the system. + */ +export function isTmuxAvailable(): boolean { + return isCommandAvailable('tmux').available; +} + +/** + * Get tmux version string (e.g., "tmux 3.4"). + */ +export async function tmuxVersion(): Promise { + const output = await tmux(['-V']); + return output.trim(); +} + +/** + * Verify tmux is available and meets minimum version requirement. + * + * @throws Error if tmux is not available or version is too old. + */ +export async function verifyTmux(): Promise { + if (!isTmuxAvailable()) { + throw new Error( + 'tmux is not installed. Install tmux (version 3.0+) for split-pane mode.', + ); + } + + const version = await tmuxVersion(); + if (!isVersionAtLeast(version, MIN_TMUX_VERSION)) { + throw new Error( + `tmux version ${MIN_TMUX_VERSION}+ required for split-pane mode (found: ${version}).`, + ); + } +} + +/** + * Get the current tmux session name (when running inside tmux). + */ +export async function tmuxCurrentSession(): Promise { + const output = await tmux(['display-message', '-p', '#{session_name}']); + return output.trim(); +} + +/** + * Get the current tmux pane ID (when running inside tmux). + */ +export async function tmuxCurrentPaneId(): Promise { + const output = await tmux(['display-message', '-p', '#{pane_id}']); + return output.trim(); +} + +/** + * Get the current tmux window target (session:window_index). + */ +export async function tmuxCurrentWindowTarget(): Promise { + const output = await tmux([ + 'display-message', + '-p', + '#{session_name}:#{window_index}', + ]); + return output.trim(); +} + +/** + * Check if a tmux session exists. + */ +export async function tmuxHasSession( + name: string, + serverName?: string, +): Promise { + const result = await tmuxResult(['has-session', '-t', name], serverName); + return result.code === 0; +} + +/** + * List windows in a session. + */ +export async function tmuxListWindows( + sessionName: string, + serverName?: string, +): Promise { + const output = await tmux( + ['list-windows', '-t', sessionName, '-F', '#{window_name} #{window_id}'], + serverName, + ); + const windows: TmuxWindowInfo[] = []; + for (const line of output.trim().split('\n')) { + if (!line.trim()) continue; + const [name, id] = line.trim().split(/\s+/, 2); + if (!name || !id) continue; + windows.push({ name, id }); + } + return windows; +} + +/** + * Check if a tmux window exists within a session. + */ +export async function tmuxHasWindow( + sessionName: string, + windowName: string, + serverName?: string, +): Promise { + const windows = await tmuxListWindows(sessionName, serverName); + return windows.some((w) => w.name === windowName); +} + +/** + * Create a new detached tmux session. + */ +export async function tmuxNewSession( + name: string, + opts?: { cols?: number; rows?: number; windowName?: string }, + serverName?: string, +): Promise { + const args = ['new-session', '-d', '-s', name]; + if (opts?.windowName) args.push('-n', opts.windowName); + if (opts?.cols) args.push('-x', String(opts.cols)); + if (opts?.rows) args.push('-y', String(opts.rows)); + await tmux(args, serverName); +} + +/** + * Create a new window in an existing session. + */ +export async function tmuxNewWindow( + targetSession: string, + windowName: string, + serverName?: string, +): Promise { + // -t session: (with trailing colon) means "create window in this session" + // -t session (without colon) means "create at window index = session", which fails if index exists + await tmux( + ['new-window', '-t', `${targetSession}:`, '-n', windowName], + serverName, + ); +} + +/** + * Split a window/pane and return the new pane ID. + * + * @param target - Target pane/window (e.g., session:window or pane ID) + * @param opts.horizontal - Split horizontally (left/right) if true, vertically (top/bottom) if false + * @param opts.percent - Size of the new pane as a percentage (e.g., 70 for 70%) + * @param opts.command - Shell command to execute directly in the new pane. + * When provided, the command becomes the pane's process (not a shell), + * so `#{pane_dead}` is set when the command exits. + * @returns The pane ID of the newly created pane (e.g., '%5') + */ +export async function tmuxSplitWindow( + target: string, + opts?: { horizontal?: boolean; percent?: number; command?: string }, + serverName?: string, +): Promise { + const args = ['split-window', '-t', target]; + if (opts?.horizontal) { + args.push('-h'); + } + if (opts?.percent !== undefined) { + args.push('-l', `${opts.percent}%`); + } + // -P -F: print new pane info in the specified format + args.push('-P', '-F', '#{pane_id}'); + if (opts?.command) { + args.push(opts.command); + } + const output = await tmux(args, serverName); + return output.trim(); +} + +/** + * Send keys to a tmux pane. + * + * @param paneId - Target pane ID + * @param keys - Keys to send + * @param opts.literal - If true, use -l flag (send keys literally, don't interpret) + */ +export async function tmuxSendKeys( + paneId: string, + keys: string, + opts?: { literal?: boolean; enter?: boolean }, + serverName?: string, +): Promise { + const args = ['send-keys', '-t', paneId]; + if (opts?.literal) { + args.push('-l'); + } + args.push(keys); + if (opts?.enter) { + args.push('Enter'); + } + await tmux(args, serverName); +} + +/** + * Select (focus) a tmux pane. + */ +export async function tmuxSelectPane( + paneId: string, + serverName?: string, +): Promise { + await tmux(['select-pane', '-t', paneId], serverName); +} + +/** + * Set a pane title. + */ +export async function tmuxSelectPaneTitle( + paneId: string, + title: string, + serverName?: string, +): Promise { + await tmux(['select-pane', '-t', paneId, '-T', title], serverName); +} + +/** + * Set a pane border style via select-pane -P. + */ +export async function tmuxSelectPaneStyle( + paneId: string, + style: string, + serverName?: string, +): Promise { + await tmux(['select-pane', '-t', paneId, '-P', style], serverName); +} + +/** + * Set the layout for a target window. + * + * @param target - Target window (e.g., session:window) + * @param layout - Layout name: 'tiled', 'even-horizontal', 'even-vertical', etc. + */ +export async function tmuxSelectLayout( + target: string, + layout: string, + serverName?: string, +): Promise { + await tmux(['select-layout', '-t', target, layout], serverName); +} + +/** + * Capture the content of a pane (including ANSI escape codes). + * + * @returns The captured pane content as a string. + */ +export async function tmuxCapturePaneContent( + paneId: string, + serverName?: string, +): Promise { + // -p: output to stdout, -e: include escape sequences + return await tmux(['capture-pane', '-t', paneId, '-p', '-e'], serverName); +} + +/** + * List panes in a target window/session and return parsed info. + * + * @param target - Target window (e.g., session:window) + * @returns Array of pane information. + */ +export async function tmuxListPanes( + target: string, + serverName?: string, +): Promise { + const output = await tmux( + [ + 'list-panes', + '-t', + target, + '-F', + '#{pane_id} #{pane_dead} #{pane_dead_status}', + ], + serverName, + ); + return parseTmuxListPanes(output); +} + +/** + * Parse the output of `tmux list-panes -F '#{pane_id} #{pane_dead} #{pane_dead_status}'`. + */ +export function parseTmuxListPanes(output: string): TmuxPaneInfo[] { + const panes: TmuxPaneInfo[] = []; + for (const line of output.trim().split('\n')) { + if (!line.trim()) continue; + const parts = line.trim().split(/\s+/); + if (parts.length < 2) continue; + panes.push({ + paneId: parts[0]!, + dead: parts[1] === '1', + deadStatus: parts[2] ? parseInt(parts[2], 10) : 0, + }); + } + return panes; +} + +/** + * Set a tmux option on a target pane/window. + */ +export async function tmuxSetOption( + target: string, + option: string, + value: string, + serverName?: string, +): Promise { + await tmux(['set-option', '-t', target, option, value], serverName); +} + +/** + * Respawn a pane with a new command. + * + * Kills the current process in the pane and starts a new one. + * The command becomes the pane's direct process, so `#{pane_dead}` + * is set when the command exits. + * + * @param paneId - Target pane ID + * @param command - Shell command to execute + */ +export async function tmuxRespawnPane( + paneId: string, + command: string, + serverName?: string, +): Promise { + await tmux(['respawn-pane', '-k', '-t', paneId, command], serverName); +} + +/** + * Break a pane into a target session (detaches from current window). + */ +export async function tmuxBreakPane( + paneId: string, + targetSession: string, + serverName?: string, +): Promise { + await tmux(['break-pane', '-s', paneId, '-t', targetSession], serverName); +} + +/** + * Join a pane into a target window. + */ +export async function tmuxJoinPane( + paneId: string, + target: string, + serverName?: string, +): Promise { + await tmux(['join-pane', '-s', paneId, '-t', target], serverName); +} + +/** + * Kill a tmux pane. + */ +export async function tmuxKillPane( + paneId: string, + serverName?: string, +): Promise { + await tmux(['kill-pane', '-t', paneId], serverName); +} + +/** + * Resize a tmux pane. + * + * @param paneId - Target pane ID + * @param opts.height - Height (number for lines, or string like '50%') + * @param opts.width - Width (number for columns, or string like '50%') + */ +export async function tmuxResizePane( + paneId: string, + opts: { height?: number | string; width?: number | string }, + serverName?: string, +): Promise { + const args = ['resize-pane', '-t', paneId]; + if (opts.height !== undefined) { + args.push('-y', String(opts.height)); + } + if (opts.width !== undefined) { + args.push('-x', String(opts.width)); + } + await tmux(args, serverName); +} + +/** + * Kill a tmux session. + */ +export async function tmuxKillSession( + name: string, + serverName?: string, +): Promise { + await tmux(['kill-session', '-t', name], serverName); +} + +/** + * Kill a tmux window. + */ +export async function tmuxKillWindow( + target: string, + serverName?: string, +): Promise { + await tmux(['kill-window', '-t', target], serverName); +} + +/** + * Get the first pane ID of a target window. + */ +export async function tmuxGetFirstPaneId( + target: string, + serverName?: string, +): Promise { + const output = await tmux( + ['list-panes', '-t', target, '-F', '#{pane_id}'], + serverName, + ); + const firstLine = output.trim().split('\n')[0]; + if (!firstLine) { + throw new Error(`No panes found in target: ${target}`); + } + return firstLine.trim(); +} diff --git a/packages/core/src/agents-collab/backends/types.ts b/packages/core/src/agents-collab/backends/types.ts new file mode 100644 index 000000000..577096639 --- /dev/null +++ b/packages/core/src/agents-collab/backends/types.ts @@ -0,0 +1,228 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Shared types for multi-agent systems (Arena, Team, Swarm) + * and the Backend abstraction layer. + * + * These types are used across different agent orchestration modes. + */ + +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; + +/** + * Canonical display mode values shared across core and CLI. + */ +export const DISPLAY_MODE = { + IN_PROCESS: 'in-process', + TMUX: 'tmux', + ITERM2: 'iterm2', +} as const; + +/** + * Supported display mode values. + */ +export type DisplayMode = (typeof DISPLAY_MODE)[keyof typeof DISPLAY_MODE]; + +/** + * Configuration for spawning an agent subprocess. + */ +export interface AgentSpawnConfig { + /** Unique identifier for this agent */ + agentId: string; + /** Command to execute (e.g., the CLI binary path) */ + command: string; + /** Arguments to pass to the command */ + args: string[]; + /** Working directory for the subprocess */ + cwd: string; + /** Additional environment variables (merged with process.env) */ + env?: Record; + /** Terminal columns (default: 120) */ + cols?: number; + /** Terminal rows (default: 40) */ + rows?: number; + /** + * Backend-specific options (optional). + * These are ignored by backends that do not support them. + */ + backend?: { + tmux?: TmuxBackendOptions; + }; +} + +/** + * Callback for agent exit events. + */ +export type AgentExitCallback = ( + agentId: string, + exitCode: number | null, + signal: number | null, +) => void; + +/** + * Backend abstracts the display/pane management layer for multi-agent systems. + * + * Each display mode (in-process / tmux / iTerm2) implements this interface. The orchestration + * layer (Arena, Team, etc.) delegates all pane operations through the backend, + * making the display mode transparent. + */ +export interface Backend { + /** Backend type identifier. */ + readonly type: DisplayMode; + + /** + * Initialize the backend. + * - in-process: runs in the current process (not yet implemented) + * - tmux: verifies tmux availability, creates session + * - iTerm2: verifies iTerm2 is running + */ + init(): Promise; + + // ─── Agent Lifecycle ──────────────────────────────────────── + + /** + * Spawn a new agent subprocess. + * + * @param config - Agent spawn configuration (command, args, cwd, env, etc.) + * @returns Promise that resolves when the agent's pane/PTY is created and ready. + */ + spawnAgent(config: AgentSpawnConfig): Promise; + + /** + * Stop a specific agent. + */ + stopAgent(agentId: string): void; + + /** + * Stop all running agents. + */ + stopAll(): void; + + /** + * Clean up all resources (kill processes, destroy panes/sessions). + */ + cleanup(): Promise; + + /** + * Register a callback for agent exit events. + */ + setOnAgentExit(callback: AgentExitCallback): void; + + /** + * Wait for all agents to exit, with an optional timeout. + * + * @returns true if all agents exited, false if timeout was reached. + */ + waitForAll(timeoutMs?: number): Promise; + + // ─── Active Agent & Navigation ────────────────────────────── + + /** + * Switch the active agent for screen capture and input routing. + */ + switchTo(agentId: string): void; + + /** + * Switch to the next agent in order. + */ + switchToNext(): void; + + /** + * Switch to the previous agent in order. + */ + switchToPrevious(): void; + + /** + * Get the ID of the currently active agent. + */ + getActiveAgentId(): string | null; + + // ─── Screen Capture ───────────────────────────────────────── + + /** + * Get the screen snapshot for the currently active agent. + * + * @returns AnsiOutput or null if no active agent or not supported. + */ + getActiveSnapshot(): AnsiOutput | null; + + /** + * Get the screen snapshot for a specific agent. + * + * @param agentId - Agent to capture + * @param scrollOffset - Lines to scroll back from viewport (default: 0) + * @returns AnsiOutput or null if not found or not supported. + */ + getAgentSnapshot(agentId: string, scrollOffset?: number): AnsiOutput | null; + + /** + * Get the maximum scrollback length for an agent's terminal buffer. + * + * @returns Number of scrollable lines, or 0 if not supported. + */ + getAgentScrollbackLength(agentId: string): number; + + // ─── Input ────────────────────────────────────────────────── + + /** + * Forward input to the currently active agent's PTY stdin. + * + * @returns true if input was forwarded, false otherwise. + */ + forwardInput(data: string): boolean; + + /** + * Write input to a specific agent's PTY stdin. + * + * @returns true if input was written, false otherwise. + */ + writeToAgent(agentId: string, data: string): boolean; + + // ─── Resize ───────────────────────────────────────────────── + + /** + * Resize all agent terminals/panes. + */ + resizeAll(cols: number, rows: number): void; + + // ─── External Session Info ───────────────────────────────── + + /** + * Get a user-facing hint for how to attach to the external display session. + * + * When the backend runs in external mode (e.g., a detached tmux server), + * this returns a shell command the user can run to view the agent panes. + * Returns null if not applicable (e.g., running inside tmux or iTerm2). + */ + getAttachHint(): string | null; +} + +/** + * Optional tmux backend configuration. + */ +export interface TmuxBackendOptions { + /** tmux server name for -L (when running outside tmux) */ + serverName?: string; + /** tmux session name to use/create (when running outside tmux) */ + sessionName?: string; + /** tmux window name to use/create (when running outside tmux) */ + windowName?: string; + /** Pane title for this agent */ + paneTitle?: string; + /** Border style for inactive panes (tmux style string, e.g. "fg=blue") */ + paneBorderStyle?: string; + /** Border style for active pane (tmux style string, e.g. "fg=green,bold") */ + paneActiveBorderStyle?: string; + /** Pane border format (default: "#{pane_title}") */ + paneBorderFormat?: string; + /** Pane border status location */ + paneBorderStatus?: 'top' | 'bottom' | 'off'; + /** Leader pane width percentage (default: 30) */ + leaderPaneWidthPercent?: number; + /** First split percent when inside tmux (default: 70) */ + firstSplitPercent?: number; +} diff --git a/packages/core/src/agents-collab/index.ts b/packages/core/src/agents-collab/index.ts new file mode 100644 index 000000000..b811dbde3 --- /dev/null +++ b/packages/core/src/agents-collab/index.ts @@ -0,0 +1,17 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Multi-agent infrastructure shared across Arena, Team, and Swarm modes. + * + * This module provides the common building blocks for managing multiple concurrent + * agent subprocesses: + * - Backend: Display abstraction (tmux, iTerm2) + * - Shared types for agent spawning and lifecycle + */ + +export * from './backends/index.js'; +export * from './arena/index.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index e1598a641..964880b4e 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -21,6 +21,8 @@ import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js' import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { AnyToolInvocation } from '../tools/tools.js'; +import type { ArenaManager } from '../agents-collab/arena/ArenaManager.js'; +import { ArenaAgentClient } from '../agents-collab/arena/ArenaAgentClient.js'; // Core import { BaseLlmClient } from '../core/baseLlmClient.js'; @@ -280,6 +282,22 @@ export interface SandboxConfig { image: string; } +/** + * Settings shared across multi-agent collaboration features + * (Arena, Team, Swarm). + */ +export interface AgentsCollabSettings { + /** Display mode for multi-agent sessions ('in-process' | 'tmux' | 'iterm2') */ + displayMode?: string; + /** Arena-specific settings */ + arena?: { + /** Custom base directory for Arena worktrees (default: ~/.qwen/arena) */ + worktreeBaseDir?: string; + /** Preserve worktrees and state files after session ends */ + preserveArtifacts?: boolean; + }; +} + export interface ConfigParameters { sessionId?: string; sessionData?: ResumedSessionData; @@ -378,6 +396,8 @@ export interface ConfigParameters { channel?: string; /** Model providers configuration grouped by authType */ modelProvidersConfig?: ModelProvidersConfig; + /** Multi-agent collaboration settings (Arena, Team, Swarm) */ + agents?: AgentsCollabSettings; } function normalizeConfigOutputFormat( @@ -506,6 +526,9 @@ export class Config { private readonly shouldUseNodePtyShell: boolean; private readonly skipNextSpeakerCheck: boolean; private shellExecutionConfig: ShellExecutionConfig; + private arenaManager: ArenaManager | null = null; + private readonly arenaAgentClient: ArenaAgentClient | null; + private readonly agentsSettings: AgentsCollabSettings; private readonly skipLoopDetection: boolean; private readonly skipStartupContext: boolean; private readonly vlmSwitchMode: string | undefined; @@ -636,6 +659,8 @@ export class Config { this.inputFormat = params.inputFormat ?? InputFormat.TEXT; this.fileExclusions = new FileExclusions(this); this.eventEmitter = params.eventEmitter; + this.arenaAgentClient = ArenaAgentClient.create(); + this.agentsSettings = params.agents ?? {}; if (params.contextFileName) { setGeminiMdFilename(params.contextFileName); } @@ -1087,6 +1112,8 @@ export class Config { if (this.toolRegistry) { await this.toolRegistry.stop(); } + + await this.cleanupArenaRuntime(); } catch (error) { // Log but don't throw - cleanup should be best-effort this.debugLogger.error('Error during Config shutdown:', error); @@ -1223,6 +1250,39 @@ export class Config { this.geminiMdFileCount = count; } + getArenaManager(): ArenaManager | null { + return this.arenaManager; + } + + setArenaManager(manager: ArenaManager | null): void { + this.arenaManager = manager; + } + + getArenaAgentClient(): ArenaAgentClient | null { + return this.arenaAgentClient; + } + + getAgentsSettings(): AgentsCollabSettings { + return this.agentsSettings; + } + + /** + * Clean up Arena runtime. When `force` is true (e.g., /arena select --discard), + * always removes worktrees regardless of preserveArtifacts. + */ + async cleanupArenaRuntime(force?: boolean): Promise { + const manager = this.arenaManager; + if (!manager) { + return; + } + if (!force && this.agentsSettings.arena?.preserveArtifacts) { + await manager.cleanupRuntime(); + } else { + await manager.cleanup(); + } + this.arenaManager = null; + } + getApprovalMode(): ApprovalMode { return this.approvalMode; } diff --git a/packages/core/src/core/client.test.ts b/packages/core/src/core/client.test.ts index b5234045e..26f1cad2b 100644 --- a/packages/core/src/core/client.test.ts +++ b/packages/core/src/core/client.test.ts @@ -356,6 +356,7 @@ describe('Gemini Client (client.ts)', () => { getSkipLoopDetection: vi.fn().mockReturnValue(false), getChatRecordingService: vi.fn().mockReturnValue(undefined), getResumedSessionData: vi.fn().mockReturnValue(undefined), + getArenaAgentClient: vi.fn().mockReturnValue(null), } as unknown as Config; client = new GeminiClient(mockConfig); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 9f3625c38..751d15221 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -484,6 +484,21 @@ export class GeminiClient { this.forceFullIdeContext = false; } + // Check for arena control signal before starting a new turn + const arenaAgentClient = this.config.getArenaAgentClient(); + if (arenaAgentClient) { + const controlSignal = await arenaAgentClient.checkControlSignal(); + if (controlSignal) { + debugLogger.info( + `Arena control signal received: ${controlSignal.type} - ${controlSignal.reason}`, + ); + await arenaAgentClient.reportCompleted( + `Stopped by control signal: ${controlSignal.reason}`, + ); + return new Turn(this.getChat(), prompt_id); + } + } + const turn = new Turn(this.getChat(), prompt_id); if (!this.config.getSkipLoopDetection()) { @@ -528,16 +543,37 @@ export class GeminiClient { if (!this.config.getSkipLoopDetection()) { if (this.loopDetector.addAndCheck(event)) { yield { type: GeminiEventType.LoopDetected }; + if (arenaAgentClient) { + await arenaAgentClient.reportError('Loop detected'); + } return turn; } } + // Update arena status on Finished events — stats are derived + // automatically from uiTelemetryService by the reporter. + if (arenaAgentClient && event.type === GeminiEventType.Finished) { + await arenaAgentClient.updateStatus(); + } + yield event; if (event.type === GeminiEventType.Error) { + if (arenaAgentClient) { + const errorMsg = + event.value instanceof Error + ? event.value.message + : 'Unknown error'; + await arenaAgentClient.reportError(errorMsg); + } return turn; } } + if (!turn.pendingToolCalls.length && signal && !signal.aborted) { if (this.config.getSkipNextSpeakerCheck()) { + // Report completed before returning — agent has no more work to do + if (arenaAgentClient) { + await arenaAgentClient.reportCompleted(); + } return turn; } @@ -566,8 +602,16 @@ export class GeminiClient { options, boundedTurns - 1, ); + } else if (arenaAgentClient) { + // No continuation needed — agent completed its task + await arenaAgentClient.reportCompleted(); } } + + // Report cancelled to arena when user cancelled mid-stream + if (signal?.aborted && arenaAgentClient) { + await arenaAgentClient.reportCancelled(); + } return turn; } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index c76fd2f8d..4c34412c2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -130,6 +130,9 @@ export * from './tools/tool-registry.js'; // Export subagents (Phase 1) export * from './subagents/index.js'; +// Export shared multi-agent infrastructure +export * from './agents-collab/index.js'; + // Export skills export * from './skills/index.js'; @@ -177,6 +180,7 @@ export * from './services/chatRecordingService.js'; export * from './services/fileDiscoveryService.js'; export * from './services/fileSystemService.js'; export * from './services/gitService.js'; +export * from './services/gitWorktreeService.js'; export * from './services/sessionService.js'; export * from './services/shellExecutionService.js'; diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts new file mode 100644 index 000000000..b5b4e3de2 --- /dev/null +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -0,0 +1,491 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import type { Mock } from 'vitest'; +import type * as fs from 'node:fs/promises'; +import { GitWorktreeService } from './gitWorktreeService.js'; +import { isCommandAvailable } from '../utils/shell-utils.js'; + +const hoistedMockSimpleGit = vi.hoisted(() => vi.fn()); +const hoistedMockCheckIsRepo = vi.hoisted(() => vi.fn()); +const hoistedMockInit = vi.hoisted(() => vi.fn()); +const hoistedMockAdd = vi.hoisted(() => vi.fn()); +const hoistedMockCommit = vi.hoisted(() => vi.fn()); +const hoistedMockRevparse = vi.hoisted(() => vi.fn()); +const hoistedMockRaw = vi.hoisted(() => vi.fn()); +const hoistedMockBranch = vi.hoisted(() => vi.fn()); +const hoistedMockDiff = vi.hoisted(() => vi.fn()); +const hoistedMockMerge = vi.hoisted(() => vi.fn()); +const hoistedMockStash = vi.hoisted(() => vi.fn()); + +vi.mock('simple-git', () => ({ + simpleGit: hoistedMockSimpleGit, + CheckRepoActions: { IS_REPO_ROOT: 'is-repo-root' }, +})); + +vi.mock('../utils/shell-utils.js', () => ({ + isCommandAvailable: vi.fn(), +})); + +const hoistedMockGetGlobalQwenDir = vi.hoisted(() => vi.fn()); +vi.mock('../config/storage.js', () => ({ + Storage: { + getGlobalQwenDir: hoistedMockGetGlobalQwenDir, + }, +})); + +const hoistedMockFsMkdir = vi.hoisted(() => vi.fn()); +const hoistedMockFsAccess = vi.hoisted(() => vi.fn()); +const hoistedMockFsWriteFile = vi.hoisted(() => vi.fn()); +const hoistedMockFsReaddir = vi.hoisted(() => vi.fn()); +const hoistedMockFsStat = vi.hoisted(() => vi.fn()); +const hoistedMockFsRm = vi.hoisted(() => vi.fn()); +const hoistedMockFsReadFile = vi.hoisted(() => vi.fn()); + +vi.mock('node:fs/promises', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + mkdir: hoistedMockFsMkdir, + access: hoistedMockFsAccess, + writeFile: hoistedMockFsWriteFile, + readdir: hoistedMockFsReaddir, + stat: hoistedMockFsStat, + rm: hoistedMockFsRm, + readFile: hoistedMockFsReadFile, + }; +}); + +describe('GitWorktreeService', () => { + beforeEach(() => { + vi.clearAllMocks(); + + hoistedMockGetGlobalQwenDir.mockReturnValue('/mock-qwen'); + (isCommandAvailable as Mock).mockReturnValue({ available: true }); + + hoistedMockSimpleGit.mockImplementation(() => ({ + checkIsRepo: hoistedMockCheckIsRepo, + init: hoistedMockInit, + add: hoistedMockAdd, + commit: hoistedMockCommit, + revparse: hoistedMockRevparse, + raw: hoistedMockRaw, + branch: hoistedMockBranch, + diff: hoistedMockDiff, + merge: hoistedMockMerge, + stash: hoistedMockStash, + })); + + hoistedMockCheckIsRepo.mockResolvedValue(true); + hoistedMockInit.mockResolvedValue(undefined); + hoistedMockAdd.mockResolvedValue(undefined); + hoistedMockCommit.mockResolvedValue(undefined); + hoistedMockRevparse.mockResolvedValue('main\n'); + hoistedMockRaw.mockResolvedValue(''); + hoistedMockBranch.mockResolvedValue({ branches: {} }); + hoistedMockDiff.mockResolvedValue(''); + hoistedMockMerge.mockResolvedValue(undefined); + hoistedMockStash.mockResolvedValue(''); + + hoistedMockFsMkdir.mockResolvedValue(undefined); + hoistedMockFsAccess.mockRejectedValue({ code: 'ENOENT' }); + hoistedMockFsWriteFile.mockResolvedValue(undefined); + hoistedMockFsReaddir.mockResolvedValue([]); + hoistedMockFsStat.mockResolvedValue({ birthtimeMs: 123 }); + hoistedMockFsRm.mockResolvedValue(undefined); + hoistedMockFsReadFile.mockResolvedValue('{}'); + }); + + it('checkGitAvailable should return an error when git is unavailable', async () => { + (isCommandAvailable as Mock).mockReturnValue({ available: false }); + const service = new GitWorktreeService('/repo'); + + await expect(service.checkGitAvailable()).resolves.toEqual({ + available: false, + error: 'Git is not installed. Please install Git to use Arena feature.', + }); + }); + + it('isGitRepository should fallback to checkIsRepo() when root check throws', async () => { + hoistedMockCheckIsRepo + .mockRejectedValueOnce(new Error('root check failed')) + .mockResolvedValueOnce(true); + const service = new GitWorktreeService('/repo'); + + await expect(service.isGitRepository()).resolves.toBe(true); + expect(hoistedMockCheckIsRepo).toHaveBeenNthCalledWith(1, 'is-repo-root'); + expect(hoistedMockCheckIsRepo).toHaveBeenNthCalledWith(2); + }); + + it('isGitRepository should detect subdirectory inside an existing repo', async () => { + // IS_REPO_ROOT returns false for a subdirectory, but checkIsRepo() + // (without params) returns true because we're inside a repo. + hoistedMockCheckIsRepo + .mockResolvedValueOnce(false) + .mockResolvedValueOnce(true); + const service = new GitWorktreeService('/repo/subdir'); + + await expect(service.isGitRepository()).resolves.toBe(true); + expect(hoistedMockCheckIsRepo).toHaveBeenNthCalledWith(1, 'is-repo-root'); + expect(hoistedMockCheckIsRepo).toHaveBeenNthCalledWith(2); + }); + + it('createWorktree should create a sanitized branch and worktree path', async () => { + const service = new GitWorktreeService('/repo'); + + const result = await service.createWorktree('s1', 'Model A'); + + expect(result.success).toBe(true); + expect(result.worktree?.branch).toBe('arena/s1/model-a'); + expect(result.worktree?.path).toBe('/mock-qwen/arena/s1/worktrees/model-a'); + expect(hoistedMockRaw).toHaveBeenCalledWith([ + 'worktree', + 'add', + '-b', + 'arena/s1/model-a', + '/mock-qwen/arena/s1/worktrees/model-a', + 'main', + ]); + }); + + it('setupArenaWorktrees should fail early for colliding sanitized names', async () => { + const service = new GitWorktreeService('/repo'); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['Model A', 'model_a'], + }); + + expect(result.success).toBe(false); + expect(result.errors).toHaveLength(1); + expect(result.errors[0]?.error).toContain('collides'); + expect(isCommandAvailable).not.toHaveBeenCalled(); + }); + + it('setupArenaWorktrees should return system error when git is unavailable', async () => { + (isCommandAvailable as Mock).mockReturnValue({ available: false }); + const service = new GitWorktreeService('/repo'); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['model-a'], + }); + + expect(result.success).toBe(false); + expect(result.errors).toEqual([ + { + name: 'system', + error: 'Git is not installed. Please install Git to use Arena feature.', + }, + ]); + }); + + it('setupArenaWorktrees should cleanup session after partial creation failure', async () => { + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); + vi.spyOn(service, 'createWorktree') + .mockResolvedValueOnce({ + success: true, + worktree: { + id: 's1/a', + name: 'a', + path: '/w/a', + branch: 'arena/s1/a', + isActive: true, + createdAt: 1, + }, + }) + .mockResolvedValueOnce({ + success: false, + error: 'boom', + }); + const cleanupSpy = vi + .spyOn(service, 'cleanupArenaSession') + .mockResolvedValue({ + success: true, + removedWorktrees: [], + removedBranches: [], + errors: [], + }); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['a', 'b'], + }); + + expect(result.success).toBe(false); + expect(result.errors).toContainEqual({ name: 'b', error: 'boom' }); + expect(cleanupSpy).toHaveBeenCalledWith('s1'); + }); + + it('listArenaWorktrees should return empty array when session dir does not exist', async () => { + const err = new Error('missing') as NodeJS.ErrnoException; + err.code = 'ENOENT'; + hoistedMockFsReaddir.mockRejectedValue(err); + const service = new GitWorktreeService('/repo'); + + await expect(service.listArenaWorktrees('missing')).resolves.toEqual([]); + }); + + it('removeWorktree should fallback to fs.rm + worktree prune when git remove fails', async () => { + hoistedMockRaw + .mockRejectedValueOnce(new Error('remove failed')) + .mockResolvedValueOnce(''); + const service = new GitWorktreeService('/repo'); + + const result = await service.removeWorktree('/w/a'); + + expect(result.success).toBe(true); + expect(hoistedMockFsRm).toHaveBeenCalledWith('/w/a', { + recursive: true, + force: true, + }); + expect(hoistedMockRaw).toHaveBeenNthCalledWith(2, ['worktree', 'prune']); + }); + + it('cleanupArenaSession should remove arena-prefixed branches only', async () => { + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'listArenaWorktrees').mockResolvedValue([]); + hoistedMockBranch.mockImplementation((args?: string[]) => { + if (args?.[0] === '-a') { + return Promise.resolve({ + branches: { + main: {}, + 'arena/s1/a': {}, + 'arena/s1/b': {}, + }, + }); + } + return Promise.resolve({ branches: {} }); + }); + + const result = await service.cleanupArenaSession('s1'); + + expect(result.success).toBe(true); + expect(result.removedBranches).toEqual(['arena/s1/a', 'arena/s1/b']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'arena/s1/a']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'arena/s1/b']); + expect(hoistedMockRaw).toHaveBeenCalledWith(['worktree', 'prune']); + }); + + it('getWorktreeDiff should return staged raw diff without creating commits', async () => { + const service = new GitWorktreeService('/repo'); + hoistedMockDiff.mockResolvedValue('diff --git a/a.ts b/a.ts'); + + const diff = await service.getWorktreeDiff('/w/a', 'main'); + + expect(diff).toBe('diff --git a/a.ts b/a.ts'); + expect(hoistedMockAdd).toHaveBeenCalledWith(['--all']); + expect(hoistedMockDiff).toHaveBeenCalledWith([ + '--binary', + '--cached', + 'main', + ]); + expect(hoistedMockCommit).not.toHaveBeenCalled(); + }); + + it('applyWorktreeChanges should apply raw patch via git apply', async () => { + const service = new GitWorktreeService('/repo'); + // resolveBaseline returns the baseline commit SHA + hoistedMockRaw + .mockResolvedValueOnce('baseline-sha\n') // resolveBaseline log --grep + .mockResolvedValueOnce('') // reset (from withStagedChanges) + .mockResolvedValueOnce(''); // git apply + hoistedMockDiff.mockResolvedValueOnce('diff --git a/a.ts b/a.ts'); + + const result = await service.applyWorktreeChanges('/w/a', '/repo'); + + expect(result.success).toBe(true); + expect(hoistedMockAdd).toHaveBeenCalledWith(['--all']); + // Should diff against the baseline commit, not merge-base + expect(hoistedMockDiff).toHaveBeenCalledWith([ + '--binary', + '--cached', + 'baseline-sha', + ]); + + const applyCall = hoistedMockRaw.mock.calls.find( + (call) => Array.isArray(call[0]) && call[0][0] === 'apply', + ); + expect(applyCall).toBeDefined(); + // When baseline is used, --3way is omitted (target working tree + // matches the pre-image, so plain apply works cleanly). + expect(applyCall?.[0]?.slice(0, 2)).toEqual([ + 'apply', + '--whitespace=nowarn', + ]); + expect(hoistedMockFsWriteFile).toHaveBeenCalled(); + expect(hoistedMockFsRm).toHaveBeenCalledWith( + expect.stringContaining('.arena-apply-'), + { force: true }, + ); + }); + + it('applyWorktreeChanges should skip apply when patch is empty', async () => { + const service = new GitWorktreeService('/repo'); + // resolveBaseline returns baseline commit + hoistedMockRaw.mockResolvedValueOnce('baseline-sha\n'); + hoistedMockDiff.mockResolvedValueOnce(' \n'); + + const result = await service.applyWorktreeChanges('/w/a', '/repo'); + + expect(result.success).toBe(true); + const applyCall = hoistedMockRaw.mock.calls.find( + (call) => Array.isArray(call[0]) && call[0][0] === 'apply', + ); + expect(applyCall).toBeUndefined(); + expect(hoistedMockFsWriteFile).not.toHaveBeenCalled(); + }); + + it('applyWorktreeChanges should return error when git apply fails', async () => { + const service = new GitWorktreeService('/repo'); + // resolveBaseline returns baseline commit + hoistedMockRaw + .mockResolvedValueOnce('baseline-sha\n') // resolveBaseline + .mockResolvedValueOnce('') // reset from withStagedChanges + .mockRejectedValueOnce(new Error('apply failed')); + hoistedMockDiff.mockResolvedValueOnce('diff --git a/a.ts b/a.ts'); + + const result = await service.applyWorktreeChanges('/w/a', '/repo'); + + expect(result.success).toBe(false); + expect(result.error).toContain('apply failed'); + expect(hoistedMockFsRm).toHaveBeenCalledWith( + expect.stringContaining('.arena-apply-'), + { force: true }, + ); + }); + + describe('dirty state propagation', () => { + function makeWorktreeInfo( + name: string, + sessionId: string, + ): { + id: string; + name: string; + path: string; + branch: string; + isActive: boolean; + createdAt: number; + } { + return { + id: `${sessionId}/${name}`, + name, + path: `/mock-qwen/arena/${sessionId}/worktrees/${name}`, + branch: `arena/${sessionId}/${name}`, + isActive: true, + createdAt: 1, + }; + } + + it('setupArenaWorktrees should apply dirty state snapshot to each worktree', async () => { + hoistedMockStash.mockResolvedValue('snapshot-sha\n'); + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); + vi.spyOn(service, 'createWorktree') + .mockResolvedValueOnce({ + success: true, + worktree: makeWorktreeInfo('a', 's1'), + }) + .mockResolvedValueOnce({ + success: true, + worktree: makeWorktreeInfo('b', 's1'), + }); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['a', 'b'], + }); + + expect(result.success).toBe(true); + expect(hoistedMockStash).toHaveBeenCalledWith(['create']); + // stash apply should be called once per worktree + const stashApplyCalls = hoistedMockRaw.mock.calls.filter( + (call: unknown[]) => + Array.isArray(call[0]) && + call[0][0] === 'stash' && + call[0][1] === 'apply', + ); + expect(stashApplyCalls).toHaveLength(2); + expect(stashApplyCalls[0]![0]).toEqual([ + 'stash', + 'apply', + 'snapshot-sha', + ]); + }); + + it('setupArenaWorktrees should skip stash apply when working tree is clean', async () => { + hoistedMockStash.mockResolvedValue('\n'); + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); + vi.spyOn(service, 'createWorktree').mockResolvedValue({ + success: true, + worktree: makeWorktreeInfo('a', 's1'), + }); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['a'], + }); + + expect(result.success).toBe(true); + const stashApplyCalls = hoistedMockRaw.mock.calls.filter( + (call: unknown[]) => + Array.isArray(call[0]) && + call[0][0] === 'stash' && + call[0][1] === 'apply', + ); + expect(stashApplyCalls).toHaveLength(0); + }); + + it('setupArenaWorktrees should still succeed when stash apply fails', async () => { + hoistedMockStash.mockResolvedValue('snapshot-sha\n'); + hoistedMockRaw.mockRejectedValue(new Error('stash apply conflict')); + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); + vi.spyOn(service, 'createWorktree').mockResolvedValue({ + success: true, + worktree: makeWorktreeInfo('a', 's1'), + }); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['a'], + }); + + // Setup should still succeed — dirty state failure is non-fatal + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + + it('setupArenaWorktrees should still succeed when stash create fails', async () => { + hoistedMockStash.mockRejectedValue(new Error('stash create failed')); + const service = new GitWorktreeService('/repo'); + vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); + vi.spyOn(service, 'createWorktree').mockResolvedValue({ + success: true, + worktree: makeWorktreeInfo('a', 's1'), + }); + + const result = await service.setupArenaWorktrees({ + arenaSessionId: 's1', + sourceRepoPath: '/repo', + worktreeNames: ['a'], + }); + + // Setup should still succeed — stash create failure is non-fatal + expect(result.success).toBe(true); + expect(result.errors).toHaveLength(0); + }); + }); +}); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts new file mode 100644 index 000000000..5f0b8bd1b --- /dev/null +++ b/packages/core/src/services/gitWorktreeService.ts @@ -0,0 +1,803 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { simpleGit, CheckRepoActions } from 'simple-git'; +import type { SimpleGit } from 'simple-git'; +import { Storage } from '../config/storage.js'; +import { isCommandAvailable } from '../utils/shell-utils.js'; +import { isNodeError } from '../utils/errors.js'; +import type { ArenaConfigFile } from '../agents-collab/arena/types.js'; + +/** + * Commit message used for the baseline snapshot in arena worktrees. + * After overlaying the user's dirty state (tracked changes + untracked files), + * a commit with this message is created so that later diffs only capture the + * agent's changes — not the pre-existing local edits. + */ +export const ARENA_BASELINE_MESSAGE = 'arena: baseline (dirty state overlay)'; + +export interface WorktreeInfo { + /** Unique identifier for this worktree */ + id: string; + /** Display name (e.g., model name) */ + name: string; + /** Absolute path to the worktree directory */ + path: string; + /** Git branch name for this worktree */ + branch: string; + /** Whether the worktree is currently active */ + isActive: boolean; + /** Creation timestamp */ + createdAt: number; +} + +export interface ArenaWorktreeConfig { + /** Arena session identifier */ + arenaSessionId: string; + /** Source repository path (project root) */ + sourceRepoPath: string; + /** Names/identifiers for each worktree to create */ + worktreeNames: string[]; + /** Base branch to create worktrees from (defaults to current branch) */ + baseBranch?: string; +} + +export interface CreateWorktreeResult { + success: boolean; + worktree?: WorktreeInfo; + error?: string; +} + +export interface ArenaWorktreeSetupResult { + success: boolean; + arenaSessionId: string; + worktrees: WorktreeInfo[]; + worktreesByName: Record; + errors: Array<{ name: string; error: string }>; + wasRepoInitialized: boolean; +} + +/** + * Service for managing git worktrees for Arena multi-agent execution. + * + * Git worktrees allow multiple working directories to share a single repository, + * enabling isolated environments for each Arena agent without copying the entire repo. + */ +export class GitWorktreeService { + private sourceRepoPath: string; + private git: SimpleGit; + private readonly customArenaBaseDir?: string; + + constructor(sourceRepoPath: string, customArenaBaseDir?: string) { + this.sourceRepoPath = path.resolve(sourceRepoPath); + this.git = simpleGit(this.sourceRepoPath); + this.customArenaBaseDir = customArenaBaseDir; + } + + /** + * Gets the directory where Arena worktrees are stored. + * @param customDir - Optional custom base directory override + */ + static getArenaBaseDir(customDir?: string): string { + if (customDir) { + return path.resolve(customDir); + } + return path.join(Storage.getGlobalQwenDir(), 'arena'); + } + + /** + * Gets the directory for a specific Arena session. + * @param customBaseDir - Optional custom base directory override + */ + static getArenaSessionDir( + arenaSessionId: string, + customBaseDir?: string, + ): string { + return path.join( + GitWorktreeService.getArenaBaseDir(customBaseDir), + arenaSessionId, + ); + } + + /** + * Gets the worktrees directory for a specific Arena session. + * @param customBaseDir - Optional custom base directory override + */ + static getWorktreesDir( + arenaSessionId: string, + customBaseDir?: string, + ): string { + return path.join( + GitWorktreeService.getArenaSessionDir(arenaSessionId, customBaseDir), + 'worktrees', + ); + } + + /** + * Instance-level arena base dir, using the custom dir if provided at construction. + */ + getArenaBaseDirForInstance(): string { + return GitWorktreeService.getArenaBaseDir(this.customArenaBaseDir); + } + + /** + * Checks if git is available on the system. + */ + async checkGitAvailable(): Promise<{ available: boolean; error?: string }> { + const { available } = isCommandAvailable('git'); + if (!available) { + return { + available: false, + error: 'Git is not installed. Please install Git to use Arena feature.', + }; + } + return { available: true }; + } + + /** + * Checks if the source path is a git repository. + */ + async isGitRepository(): Promise { + try { + const isRoot = await this.git.checkIsRepo(CheckRepoActions.IS_REPO_ROOT); + if (isRoot) { + return true; + } + } catch { + // IS_REPO_ROOT check failed — fall through to the general check + } + // Not the root (or root check threw) — check if we're inside a git repo + try { + return await this.git.checkIsRepo(); + } catch { + return false; + } + } + + /** + * Initializes the source directory as a git repository. + * Returns true if initialization was performed, false if already a repo. + */ + async initializeRepository(): Promise<{ + initialized: boolean; + error?: string; + }> { + const isRepo = await this.isGitRepository(); + if (isRepo) { + return { initialized: false }; + } + + try { + await this.git.init(false, { '--initial-branch': 'main' }); + + // Create initial commit so we can create worktrees + await this.git.add('.'); + await this.git.commit('Initial commit for Arena', { + '--allow-empty': null, + }); + + return { initialized: true }; + } catch (error) { + return { + initialized: false, + error: `Failed to initialize git repository: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Gets the current branch name. + */ + async getCurrentBranch(): Promise { + const branch = await this.git.revparse(['--abbrev-ref', 'HEAD']); + return branch.trim(); + } + + /** + * Gets the current commit hash. + */ + async getCurrentCommitHash(): Promise { + const hash = await this.git.revparse(['HEAD']); + return hash.trim(); + } + + /** + * Creates a single worktree for an Arena agent. + */ + async createWorktree( + arenaSessionId: string, + name: string, + baseBranch?: string, + ): Promise { + try { + const worktreesDir = GitWorktreeService.getWorktreesDir( + arenaSessionId, + this.customArenaBaseDir, + ); + await fs.mkdir(worktreesDir, { recursive: true }); + + // Sanitize name for use as branch and directory name + const sanitizedName = this.sanitizeName(name); + const worktreePath = path.join(worktreesDir, sanitizedName); + const branchName = `arena/${arenaSessionId}/${sanitizedName}`; + + // Check if worktree already exists + const exists = await this.pathExists(worktreePath); + if (exists) { + return { + success: false, + error: `Worktree already exists at ${worktreePath}`, + }; + } + + // Determine base branch + const base = baseBranch || (await this.getCurrentBranch()); + + // Create the worktree with a new branch + await this.git.raw([ + 'worktree', + 'add', + '-b', + branchName, + worktreePath, + base, + ]); + + const worktree: WorktreeInfo = { + id: `${arenaSessionId}/${sanitizedName}`, + name, + path: worktreePath, + branch: branchName, + isActive: true, + createdAt: Date.now(), + }; + + return { success: true, worktree }; + } catch (error) { + return { + success: false, + error: `Failed to create worktree for "${name}": ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Sets up all worktrees for an Arena session. + * This is the main entry point for Arena worktree creation. + */ + async setupArenaWorktrees( + config: ArenaWorktreeConfig, + ): Promise { + const result: ArenaWorktreeSetupResult = { + success: false, + arenaSessionId: config.arenaSessionId, + worktrees: [], + worktreesByName: {}, + errors: [], + wasRepoInitialized: false, + }; + + // Validate worktree names early (before touching git) + const sanitizedNames = new Map(); + for (const name of config.worktreeNames) { + const sanitized = this.sanitizeName(name); + if (!sanitized) { + result.errors.push({ + name, + error: 'Worktree name becomes empty after sanitization', + }); + continue; + } + const existing = sanitizedNames.get(sanitized); + if (existing) { + result.errors.push({ + name, + error: `Worktree name collides with "${existing}" after sanitization`, + }); + continue; + } + sanitizedNames.set(sanitized, name); + } + if (result.errors.length > 0) { + return result; + } + + // Check git availability + const gitCheck = await this.checkGitAvailable(); + if (!gitCheck.available) { + result.errors.push({ name: 'system', error: gitCheck.error! }); + return result; + } + + // Ensure source is a git repository + const isRepo = await this.isGitRepository(); + if (!isRepo) { + const initResult = await this.initializeRepository(); + if (initResult.error) { + result.errors.push({ name: 'initialization', error: initResult.error }); + return result; + } + result.wasRepoInitialized = initResult.initialized; + } + + // Create arena session directory + const sessionDir = GitWorktreeService.getArenaSessionDir( + config.arenaSessionId, + this.customArenaBaseDir, + ); + await fs.mkdir(sessionDir, { recursive: true }); + + // Save arena config for later reference + const arenaConfigPath = path.join(sessionDir, 'config.json'); + const configFile: ArenaConfigFile = { + arenaSessionId: config.arenaSessionId, + sourceRepoPath: config.sourceRepoPath, + worktreeNames: config.worktreeNames, + baseBranch: config.baseBranch, + createdAt: Date.now(), + }; + await fs.writeFile(arenaConfigPath, JSON.stringify(configFile, null, 2)); + + // Capture the current dirty state (tracked: staged + unstaged changes) + // without modifying the source working tree or index. + // NOTE: `git stash create` does NOT support --include-untracked; + // untracked files are handled separately below via file copy. + let dirtyStateSnapshot = ''; + try { + dirtyStateSnapshot = (await this.git.stash(['create'])).trim(); + } catch { + // Ignore — proceed without dirty state if stash create fails + } + + // Discover untracked files so they can be copied into each worktree. + // `git ls-files --others --exclude-standard` is read-only and safe. + let untrackedFiles: string[] = []; + try { + const raw = await this.git.raw([ + 'ls-files', + '--others', + '--exclude-standard', + ]); + untrackedFiles = raw.trim().split('\n').filter(Boolean); + } catch { + // Non-fatal: proceed without untracked files + } + + // Create worktrees for each agent + for (const name of config.worktreeNames) { + const createResult = await this.createWorktree( + config.arenaSessionId, + name, + config.baseBranch, + ); + + if (createResult.success && createResult.worktree) { + result.worktrees.push(createResult.worktree); + result.worktreesByName[name] = createResult.worktree; + } else { + result.errors.push({ + name, + error: createResult.error || 'Unknown error', + }); + } + } + + // If any worktree failed, clean up all created resources and fail + if (result.errors.length > 0) { + try { + await this.cleanupArenaSession(config.arenaSessionId); + } catch (error) { + result.errors.push({ + name: 'cleanup', + error: `Failed to cleanup after partial worktree creation: ${error instanceof Error ? error.message : 'Unknown error'}`, + }); + } + result.success = false; + return result; + } + + // Success only if all worktrees were created + result.success = result.worktrees.length === config.worktreeNames.length; + + // Overlay the source repo's dirty state onto each worktree so agents + // see the same files the user currently has on disk. + if (result.success) { + for (const worktree of result.worktrees) { + const wtGit = simpleGit(worktree.path); + + // 1. Apply tracked dirty changes (staged + unstaged) + if (dirtyStateSnapshot) { + try { + await wtGit.raw(['stash', 'apply', dirtyStateSnapshot]); + } catch { + // Non-fatal: worktree still usable with committed state only + } + } + + // 2. Copy untracked files into the worktree + for (const relPath of untrackedFiles) { + try { + const src = path.join(this.sourceRepoPath, relPath); + const dst = path.join(worktree.path, relPath); + await fs.mkdir(path.dirname(dst), { recursive: true }); + await fs.copyFile(src, dst); + } catch { + // Non-fatal: skip files that can't be copied + } + } + + // 3. Create a baseline commit capturing the full starting state + // (committed + dirty + untracked). This allows us to later diff + // only the agent's changes, excluding the pre-existing dirty state. + try { + await wtGit.add(['--all']); + await wtGit.commit(ARENA_BASELINE_MESSAGE, { + '--allow-empty': null, + '--no-verify': null, + }); + } catch { + // Non-fatal: diff will fall back to merge-base if baseline is missing + } + } + } + + return result; + } + + /** + * Lists all worktrees for an Arena session. + */ + async listArenaWorktrees(arenaSessionId: string): Promise { + const worktreesDir = GitWorktreeService.getWorktreesDir( + arenaSessionId, + this.customArenaBaseDir, + ); + + try { + const entries = await fs.readdir(worktreesDir, { withFileTypes: true }); + const worktrees: WorktreeInfo[] = []; + + for (const entry of entries) { + if (entry.isDirectory()) { + const worktreePath = path.join(worktreesDir, entry.name); + const branchName = `arena/${arenaSessionId}/${entry.name}`; + + // Try to get stats for creation time + let createdAt = Date.now(); + try { + const stats = await fs.stat(worktreePath); + createdAt = stats.birthtimeMs; + } catch { + // Ignore stat errors + } + + worktrees.push({ + id: `${arenaSessionId}/${entry.name}`, + name: entry.name, + path: worktreePath, + branch: branchName, + isActive: true, + createdAt, + }); + } + } + + return worktrees; + } catch (error) { + if (isNodeError(error) && error.code === 'ENOENT') { + return []; + } + throw error; + } + } + + /** + * Removes a single worktree. + */ + async removeWorktree( + worktreePath: string, + ): Promise<{ success: boolean; error?: string }> { + try { + // Remove the worktree from git + await this.git.raw(['worktree', 'remove', worktreePath, '--force']); + return { success: true }; + } catch (error) { + // Try to remove the directory manually if git worktree remove fails + try { + await fs.rm(worktreePath, { recursive: true, force: true }); + // Prune worktree references + await this.git.raw(['worktree', 'prune']); + return { success: true }; + } catch (_rmError) { + return { + success: false, + error: `Failed to remove worktree: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + } + + /** + * Cleans up all worktrees and branches for an Arena session. + */ + async cleanupArenaSession(arenaSessionId: string): Promise<{ + success: boolean; + removedWorktrees: string[]; + removedBranches: string[]; + errors: string[]; + }> { + const result = { + success: true, + removedWorktrees: [] as string[], + removedBranches: [] as string[], + errors: [] as string[], + }; + + const worktrees = await this.listArenaWorktrees(arenaSessionId); + + // Remove all worktrees + for (const worktree of worktrees) { + const removeResult = await this.removeWorktree(worktree.path); + if (removeResult.success) { + result.removedWorktrees.push(worktree.name); + } else { + result.errors.push( + removeResult.error || `Failed to remove ${worktree.name}`, + ); + result.success = false; + } + } + + // Remove arena session directory + const sessionDir = GitWorktreeService.getArenaSessionDir( + arenaSessionId, + this.customArenaBaseDir, + ); + try { + await fs.rm(sessionDir, { recursive: true, force: true }); + } catch (error) { + result.errors.push( + `Failed to remove session directory: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + + // Clean up arena branches + const branchPrefix = `arena/${arenaSessionId}/`; + try { + const branches = await this.git.branch(['-a']); + for (const branchName of Object.keys(branches.branches)) { + if (branchName.startsWith(branchPrefix)) { + try { + await this.git.branch(['-D', branchName]); + result.removedBranches.push(branchName); + } catch { + // Branch might already be deleted, ignore + } + } + } + } catch { + // Ignore branch listing/deletion errors + } + + // Prune worktree references + try { + await this.git.raw(['worktree', 'prune']); + } catch { + // Ignore prune errors + } + + return result; + } + + /** + * Gets the diff between a worktree and its baseline state. + * Prefers the arena baseline commit (which includes the dirty state overlay) + * so the diff only shows the agent's changes. Falls back to the base branch + * when no baseline commit exists. + */ + async getWorktreeDiff( + worktreePath: string, + baseBranch?: string, + ): Promise { + const worktreeGit = simpleGit(worktreePath); + + const base = + (await this.resolveBaseline(worktreeGit)) ?? + baseBranch ?? + (await this.getCurrentBranch()); + + try { + return await this.withStagedChanges(worktreeGit, () => + worktreeGit.diff(['--binary', '--cached', base]), + ); + } catch (error) { + return `Error getting diff: ${error instanceof Error ? error.message : 'Unknown error'}`; + } + } + + /** + * Applies raw changes from a worktree back to the target working directory. + * + * Diffs from the arena baseline commit (which already includes the user's + * dirty state) so the patch only contains the agent's new changes. + * Falls back to merge-base when no baseline commit exists. + */ + async applyWorktreeChanges( + worktreePath: string, + targetPath?: string, + ): Promise<{ success: boolean; error?: string }> { + const target = targetPath || this.sourceRepoPath; + const worktreeGit = simpleGit(worktreePath); + const targetGit = simpleGit(target); + + try { + // Prefer the baseline commit (created during worktree setup after + // overlaying dirty state) so the patch excludes pre-existing edits. + let base = await this.resolveBaseline(worktreeGit); + const hasBaseline = !!base; + + if (!base) { + // Fallback: diff from merge-base (legacy / non-arena worktrees) + const targetHead = (await targetGit.revparse(['HEAD'])).trim(); + base = ( + await worktreeGit.raw(['merge-base', 'HEAD', targetHead]) + ).trim(); + } + + const patch = await this.withStagedChanges(worktreeGit, () => + worktreeGit.diff(['--binary', '--cached', base]), + ); + + if (!patch.trim()) { + return { success: true }; + } + + const patchFile = path.join( + this.getArenaBaseDirForInstance(), + `.arena-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`, + ); + await fs.mkdir(path.dirname(patchFile), { recursive: true }); + await fs.writeFile(patchFile, patch, 'utf-8'); + + try { + // When using the baseline, the target working tree already matches the + // patch pre-image (both have the dirty state), so a plain apply works. + // --3way is only needed for the merge-base fallback path where the + // pre-image may not match the working tree; it falls back to index + // blob lookup which would fail on baseline-relative patches. + const applyArgs = hasBaseline + ? ['apply', '--whitespace=nowarn', patchFile] + : ['apply', '--3way', '--whitespace=nowarn', patchFile]; + await targetGit.raw(applyArgs); + } finally { + await fs.rm(patchFile, { force: true }); + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: `Failed to apply worktree changes: ${error instanceof Error ? error.message : 'Unknown error'}`, + }; + } + } + + /** + * Lists all Arena sessions. + */ + static async listArenaSessions(customBaseDir?: string): Promise< + Array<{ + arenaSessionId: string; + createdAt: number; + sourceRepoPath: string; + worktreeCount: number; + }> + > { + const arenaDir = GitWorktreeService.getArenaBaseDir(customBaseDir); + const sessions: Array<{ + arenaSessionId: string; + createdAt: number; + sourceRepoPath: string; + worktreeCount: number; + }> = []; + + try { + const entries = await fs.readdir(arenaDir, { withFileTypes: true }); + + for (const entry of entries) { + if (entry.isDirectory()) { + const configPath = path.join(arenaDir, entry.name, 'config.json'); + try { + const configContent = await fs.readFile(configPath, 'utf-8'); + const config = JSON.parse(configContent) as ArenaConfigFile; + + const worktreesDir = path.join(arenaDir, entry.name, 'worktrees'); + let worktreeCount = 0; + try { + const worktreeEntries = await fs.readdir(worktreesDir); + worktreeCount = worktreeEntries.length; + } catch { + // Ignore if worktrees dir doesn't exist + } + + sessions.push({ + arenaSessionId: entry.name, + createdAt: config.createdAt || Date.now(), + sourceRepoPath: config.sourceRepoPath || '', + worktreeCount, + }); + } catch { + // Ignore sessions without valid config + } + } + } + + return sessions.sort((a, b) => b.createdAt - a.createdAt); + } catch { + return []; + } + } + + /** + * Finds the arena baseline commit in a worktree, if one exists. + * Returns the commit SHA, or null if not found. + */ + private async resolveBaseline( + worktreeGit: SimpleGit, + ): Promise { + try { + const sha = ( + await worktreeGit.raw([ + 'log', + '--grep', + ARENA_BASELINE_MESSAGE, + '--format=%H', + '-1', + ]) + ).trim(); + return sha || null; + } catch { + return null; + } + } + + /** Stages all changes, runs a callback, then resets the index. */ + private async withStagedChanges( + git: SimpleGit, + fn: () => Promise, + ): Promise { + await git.add(['--all']); + try { + return await fn(); + } finally { + try { + await git.raw(['reset']); + } catch { + // Best-effort: ignore reset failures + } + } + } + + private sanitizeName(name: string): string { + // Replace invalid characters with hyphens + return name + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, ''); + } + + private async pathExists(p: string): Promise { + try { + await fs.access(p); + return true; + } catch { + return false; + } + } +} diff --git a/packages/core/src/utils/terminalSerializer.ts b/packages/core/src/utils/terminalSerializer.ts index 7bcd2a4ce..e12fe25aa 100644 --- a/packages/core/src/utils/terminalSerializer.ts +++ b/packages/core/src/utils/terminalSerializer.ts @@ -131,17 +131,26 @@ class Cell { } } -export function serializeTerminalToObject(terminal: Terminal): AnsiOutput { +export function serializeTerminalToObject( + terminal: Terminal, + scrollOffset: number = 0, +): AnsiOutput { const buffer = terminal.buffer.active; - const cursorX = buffer.cursorX; - const cursorY = buffer.cursorY; const defaultFg = ''; const defaultBg = ''; + // Clamp scrollOffset to valid range [0, viewportY] + const clampedOffset = Math.max(0, Math.min(scrollOffset, buffer.viewportY)); + const startRow = buffer.viewportY - clampedOffset; + + // Only show cursor when viewing the live viewport (no scroll) + const cursorX = clampedOffset === 0 ? buffer.cursorX : -1; + const cursorY = clampedOffset === 0 ? buffer.cursorY : -1; + const result: AnsiOutput = []; for (let y = 0; y < terminal.rows; y++) { - const line = buffer.getLine(buffer.viewportY + y); + const line = buffer.getLine(startRow + y); const currentLine: AnsiLine = []; if (!line) { result.push(currentLine); From 193bc438bdfd09f48a40ee603bb32c41a6f71245 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 18 Feb 2026 14:33:37 +0800 Subject: [PATCH 03/82] feat(arena): Persist arena events to chat history and add progress updates - Replace SESSION_WARNING with SESSION_UPDATE supporting info/warning types - Emit setup progress messages from ArenaManager during agent initialization - Record all arena UI events to session JSONL for chat history replay - Clean up unused agent event types (stream, tool calls, stats) - Update arena select/stop dialogs to record their output Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/arenaCommand.test.ts | 2 +- packages/cli/src/ui/commands/arenaCommand.ts | 96 +++++++++++++------ .../src/ui/components/ArenaSelectDialog.tsx | 31 +++--- .../cli/src/ui/components/ArenaStopDialog.tsx | 31 +++--- .../src/ui/components/messages/ArenaCards.tsx | 2 +- .../agents-collab/arena/ArenaManager.test.ts | 23 +++-- .../src/agents-collab/arena/ArenaManager.ts | 32 +++++-- .../src/agents-collab/arena/arena-events.ts | 96 ++++--------------- 8 files changed, 163 insertions(+), 150 deletions(-) diff --git a/packages/cli/src/ui/commands/arenaCommand.test.ts b/packages/cli/src/ui/commands/arenaCommand.test.ts index 12def97bb..04f3f5597 100644 --- a/packages/cli/src/ui/commands/arenaCommand.test.ts +++ b/packages/cli/src/ui/commands/arenaCommand.test.ts @@ -257,7 +257,7 @@ describe('arenaCommand select subcommand', () => { messageType: 'error', content: 'No successful agent results to select from. All agents failed or were cancelled.\n' + - 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + 'Use /arena stop to end the session.', }); }); diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index b71b81596..5339f94ca 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -28,7 +28,7 @@ import { type ArenaSessionCompleteEvent, type ArenaSessionErrorEvent, type ArenaSessionStartEvent, - type ArenaSessionWarningEvent, + type ArenaSessionUpdateEvent, } from '@qwen-code/qwen-code-core'; import { MessageType, @@ -147,6 +147,26 @@ function buildArenaExecutionInput( }; } +/** + * Persists a single arena history item to the session JSONL file. + * + * Arena events fire asynchronously (after the slash command's recording + * window has closed), so each item must be recorded individually. + */ +function recordArenaItem(config: Config, item: HistoryItemWithoutId): void { + try { + const chatRecorder = config.getChatRecordingService(); + if (!chatRecorder) return; + chatRecorder.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + debugLogger.error('Failed to record arena history item'); + } +} + function executeArenaCommand( config: Config, ui: CommandContext['ui'], @@ -164,6 +184,15 @@ function executeArenaCommand( ui.addItem({ type, text }, Date.now()); }; + const addAndRecordArenaMessage = ( + type: 'info' | 'warning' | 'error' | 'success', + text: string, + ) => { + const item: HistoryItemWithoutId = { type, text }; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); + }; + const handleSessionStart = (event: ArenaSessionStartEvent) => { const modelList = event.models .map( @@ -171,6 +200,9 @@ function executeArenaCommand( ` ${index + 1}. ${model.displayName || model.modelId}`, ) .join('\n'); + // SESSION_START fires synchronously before the first await in + // ArenaManager.start(), so the slash command processor's finally + // block already captures this item — no extra recording needed. addArenaMessage( MessageType.INFO, `Arena started with ${event.models.length} agents on task: "${event.task}"\nModels:\n${modelList}`, @@ -183,22 +215,33 @@ function executeArenaCommand( debugLogger.debug(`Arena agent started: ${label} (${event.agentId})`); }; - const handleSessionWarning = (event: ArenaSessionWarningEvent) => { + const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => { const attachHintPrefix = 'To view agent panes, run: '; if (event.message.startsWith(attachHintPrefix)) { const command = event.message.slice(attachHintPrefix.length).trim(); - addArenaMessage( + addAndRecordArenaMessage( MessageType.INFO, `Arena panes are running in tmux. Attach with: \`${command}\``, ); return; } - addArenaMessage(MessageType.WARNING, `Arena warning: ${event.message}`); + + if (event.type === 'info') { + addAndRecordArenaMessage(MessageType.INFO, event.message); + } else { + addAndRecordArenaMessage( + MessageType.WARNING, + `Arena warning: ${event.message}`, + ); + } }; const handleAgentError = (event: ArenaAgentErrorEvent) => { const label = agentLabels.get(event.agentId) || event.agentId; - addArenaMessage(MessageType.ERROR, `[${label}] failed: ${event.error}`); + addAndRecordArenaMessage( + MessageType.ERROR, + `[${label}] failed: ${event.error}`, + ); }; const buildAgentCardData = ( @@ -233,7 +276,6 @@ function executeArenaCommand( }; const handleAgentComplete = (event: ArenaAgentCompleteEvent) => { - // Show message for completed (success), cancelled, and terminated (error) agents if ( event.result.status !== ArenaAgentStatus.COMPLETED && event.result.status !== ArenaAgentStatus.CANCELLED && @@ -243,30 +285,28 @@ function executeArenaCommand( } const agent = buildAgentCardData(event.result); - ui.addItem( - { - type: 'arena_agent_complete', - agent, - } as HistoryItemWithoutId, - Date.now(), - ); + const item = { + type: 'arena_agent_complete', + agent, + } as HistoryItemWithoutId; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); }; const handleSessionError = (event: ArenaSessionErrorEvent) => { - addArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); + addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); }; const handleSessionComplete = (event: ArenaSessionCompleteEvent) => { - ui.addItem( - { - type: 'arena_session_complete', - sessionStatus: event.result.status, - task: event.result.task, - totalDurationMs: event.result.totalDurationMs ?? 0, - agents: event.result.agents.map(buildAgentCardData), - } as HistoryItemWithoutId, - Date.now(), - ); + const item = { + type: 'arena_session_complete', + sessionStatus: event.result.status, + task: event.result.task, + totalDurationMs: event.result.totalDurationMs ?? 0, + agents: event.result.agents.map(buildAgentCardData), + } as HistoryItemWithoutId; + ui.addItem(item, Date.now()); + recordArenaItem(config, item); }; emitter.on(ArenaEventType.SESSION_START, handleSessionStart); @@ -277,9 +317,9 @@ function executeArenaCommand( detachListeners.push(() => emitter.off(ArenaEventType.AGENT_START, handleAgentStart), ); - emitter.on(ArenaEventType.SESSION_WARNING, handleSessionWarning); + emitter.on(ArenaEventType.SESSION_UPDATE, handleSessionUpdate); detachListeners.push(() => - emitter.off(ArenaEventType.SESSION_WARNING, handleSessionWarning), + emitter.off(ArenaEventType.SESSION_UPDATE, handleSessionUpdate), ); emitter.on(ArenaEventType.AGENT_ERROR, handleAgentError); detachListeners.push(() => @@ -317,7 +357,7 @@ function executeArenaCommand( }, (error) => { const message = error instanceof Error ? error.message : String(error); - addArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); + addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); debugLogger.error('Arena session failed:', error); // Clear the stored manager so subsequent /arena start calls @@ -567,7 +607,7 @@ export const arenaCommand: SlashCommand = { messageType: 'error', content: 'No successful agent results to select from. All agents failed or were cancelled.\n' + - 'Use /arena select --discard to clean up worktrees, or /arena stop to end the session.', + 'Use /arena stop to end the session.', }; } diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/ArenaSelectDialog.tsx index 222d884e5..b42d8e8d1 100644 --- a/packages/cli/src/ui/components/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/ArenaSelectDialog.tsx @@ -14,7 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemWithoutId } from '../types.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { formatDuration } from '../utils/formatters.js'; import { getArenaStatusLabel } from '../utils/displayUtils.js'; @@ -36,18 +36,25 @@ export function ArenaSelectDialog({ }: ArenaSelectDialogProps): React.JSX.Element { const pushMessage = useCallback( (result: { messageType: 'info' | 'error'; content: string }) => { - addItem( - { - type: - result.messageType === 'info' - ? MessageType.INFO - : MessageType.ERROR, - text: result.content, - }, - Date.now(), - ); + const item: HistoryItemWithoutId = { + type: + result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR, + text: result.content, + }; + addItem(item, Date.now()); + + try { + const chatRecorder = config.getChatRecordingService(); + chatRecorder?.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena select', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + // Best-effort recording + } }, - [addItem], + [addItem, config], ); const onSelect = useCallback( diff --git a/packages/cli/src/ui/components/ArenaStopDialog.tsx b/packages/cli/src/ui/components/ArenaStopDialog.tsx index 24ad2eeb7..da0022aa7 100644 --- a/packages/cli/src/ui/components/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/ArenaStopDialog.tsx @@ -14,7 +14,7 @@ import { } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType } from '../types.js'; +import { MessageType, type HistoryItemWithoutId } from '../types.js'; import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; @@ -38,18 +38,25 @@ export function ArenaStopDialog({ const pushMessage = useCallback( (result: { messageType: 'info' | 'error'; content: string }) => { - addItem( - { - type: - result.messageType === 'info' - ? MessageType.INFO - : MessageType.ERROR, - text: result.content, - }, - Date.now(), - ); + const item: HistoryItemWithoutId = { + type: + result.messageType === 'info' ? MessageType.INFO : MessageType.ERROR, + text: result.content, + }; + addItem(item, Date.now()); + + try { + const chatRecorder = config.getChatRecordingService(); + chatRecorder?.recordSlashCommand({ + phase: 'result', + rawCommand: '/arena stop', + outputHistoryItems: [{ ...item } as Record], + }); + } catch { + // Best-effort recording + } }, - [addItem], + [addItem, config], ); const onStop = useCallback( diff --git a/packages/cli/src/ui/components/messages/ArenaCards.tsx b/packages/cli/src/ui/components/messages/ArenaCards.tsx index ae4be3c68..fe6db8075 100644 --- a/packages/cli/src/ui/components/messages/ArenaCards.tsx +++ b/packages/cli/src/ui/components/messages/ArenaCards.tsx @@ -35,7 +35,7 @@ export const ArenaAgentCard: React.FC = ({ {/* Line 1: Status icon + text + label + duration */} - {icon} {text}: {agent.label} · {duration} + {icon} {agent.label} · {text} · {duration} diff --git a/packages/core/src/agents-collab/arena/ArenaManager.test.ts b/packages/core/src/agents-collab/arena/ArenaManager.test.ts index 88ccce684..0bf2b60ec 100644 --- a/packages/core/src/agents-collab/arena/ArenaManager.test.ts +++ b/packages/core/src/agents-collab/arena/ArenaManager.test.ts @@ -272,11 +272,19 @@ describe('ArenaManager', () => { }); describe('backend initialization', () => { - it('should emit SESSION_WARNING when backend detection returns warning', async () => { + it('should emit SESSION_UPDATE with type warning when backend detection returns warning', async () => { const manager = new ArenaManager(mockConfig as never); - const warnings: Array<{ message: string; sessionId: string }> = []; - manager.getEventEmitter().on(ArenaEventType.SESSION_WARNING, (event) => { - warnings.push({ message: event.message, sessionId: event.sessionId }); + const updates: Array<{ + type: string; + message: string; + sessionId: string; + }> = []; + manager.getEventEmitter().on(ArenaEventType.SESSION_UPDATE, (event) => { + updates.push({ + type: event.type, + message: event.message, + sessionId: event.sessionId, + }); }); hoistedMockDetectBackend.mockResolvedValueOnce({ @@ -287,9 +295,10 @@ describe('ArenaManager', () => { await manager.start(createValidStartOptions()); expect(hoistedMockDetectBackend).toHaveBeenCalledWith(undefined); - expect(warnings).toHaveLength(1); - expect(warnings[0]?.message).toContain('fallback to tmux backend'); - expect(warnings[0]?.sessionId).toMatch(/^arena-/); + const warningUpdate = updates.find((u) => u.type === 'warning'); + expect(warningUpdate).toBeDefined(); + expect(warningUpdate?.message).toContain('fallback to tmux backend'); + expect(warningUpdate?.sessionId).toMatch(/^arena-/); }); it('should emit SESSION_ERROR and mark FAILED when backend init fails', async () => { diff --git a/packages/core/src/agents-collab/arena/ArenaManager.ts b/packages/core/src/agents-collab/arena/ArenaManager.ts index 11a178160..c1f075f08 100644 --- a/packages/core/src/agents-collab/arena/ArenaManager.ts +++ b/packages/core/src/agents-collab/arena/ArenaManager.ts @@ -302,6 +302,7 @@ export class ArenaManager { } // Set up worktrees for all agents + this.emitProgress(`Setting up environment for agents…`); await this.setupWorktrees(); // If cancelled during worktree setup, bail out early @@ -311,6 +312,7 @@ export class ArenaManager { } // Start all agents in parallel via PTY + this.emitProgress('Environment ready. Launching agents…'); this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); @@ -474,6 +476,22 @@ export class ArenaManager { return this.worktreeService.getWorktreeDiff(agent.worktree.path); } + // ─── Private: Progress ───────────────────────────────────────── + + /** + * Emit a progress message via SESSION_UPDATE so the UI can display + * setup status. + */ + private emitProgress(message: string): void { + if (!this.sessionId) return; + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { + sessionId: this.sessionId, + type: 'info', + message, + timestamp: Date.now(), + }); + } + // ─── Private: Validation ─────────────────────────────────────── private validateStartOptions(options: ArenaStartOptions): void { @@ -524,8 +542,9 @@ export class ArenaManager { this.backend = backend; if (warning && this.sessionId) { - this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { sessionId: this.sessionId, + type: 'warning', message: warning, timestamp: Date.now(), }); @@ -534,8 +553,9 @@ export class ArenaManager { // Surface attach hint for external tmux sessions const attachHint = backend.getAttachHint(); if (attachHint && this.sessionId) { - this.eventEmitter.emit(ArenaEventType.SESSION_WARNING, { + this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { sessionId: this.sessionId, + type: 'info', message: `To view agent panes, run: ${attachHint}`, timestamp: Date.now(), }); @@ -1045,14 +1065,6 @@ export class ArenaManager { this.updateAgentStatus(agent.agentId, ArenaAgentStatus.RUNNING); } - // Emit stats update event - this.eventEmitter.emit(ArenaEventType.AGENT_STATS_UPDATE, { - sessionId: this.requireConfig().sessionId, - agentId: agent.agentId, - stats: statusFile.stats, - timestamp: Date.now(), - }); - this.callbacks.onAgentStatsUpdate?.(agent.agentId, statusFile.stats); } catch (error: unknown) { // File may not exist yet (agent hasn't written first status) diff --git a/packages/core/src/agents-collab/arena/arena-events.ts b/packages/core/src/agents-collab/arena/arena-events.ts index b7a46e258..1098fcafa 100644 --- a/packages/core/src/agents-collab/arena/arena-events.ts +++ b/packages/core/src/agents-collab/arena/arena-events.ts @@ -8,7 +8,6 @@ import { EventEmitter } from 'events'; import type { ArenaAgentStatus, ArenaModelConfig, - ArenaAgentStats, ArenaAgentResult, ArenaSessionResult, } from './types.js'; @@ -19,6 +18,8 @@ import type { export enum ArenaEventType { /** Arena session started */ SESSION_START = 'session_start', + /** Informational or warning update during session lifecycle */ + SESSION_UPDATE = 'session_update', /** Arena session completed */ SESSION_COMPLETE = 'session_complete', /** Arena session failed */ @@ -27,35 +28,21 @@ export enum ArenaEventType { AGENT_START = 'agent_start', /** Agent status changed */ AGENT_STATUS_CHANGE = 'agent_status_change', - /** Agent streamed text */ - AGENT_STREAM_TEXT = 'agent_stream_text', - /** Agent called a tool */ - AGENT_TOOL_CALL = 'agent_tool_call', - /** Agent tool call completed */ - AGENT_TOOL_RESULT = 'agent_tool_result', - /** Agent stats updated */ - AGENT_STATS_UPDATE = 'agent_stats_update', /** Agent completed */ AGENT_COMPLETE = 'agent_complete', /** Agent error */ AGENT_ERROR = 'agent_error', - /** Non-fatal warning (e.g., backend fallback) */ - SESSION_WARNING = 'session_warning', } export type ArenaEvent = | 'session_start' + | 'session_update' | 'session_complete' | 'session_error' | 'agent_start' | 'agent_status_change' - | 'agent_stream_text' - | 'agent_tool_call' - | 'agent_tool_result' - | 'agent_stats_update' | 'agent_complete' - | 'agent_error' - | 'session_warning'; + | 'agent_error'; /** * Event payload for session start. @@ -97,61 +84,12 @@ export interface ArenaAgentStartEvent { } /** - * Event payload for agent status change. + * Event payload for agent error. */ -export interface ArenaAgentStatusChangeEvent { +export interface ArenaAgentErrorEvent { sessionId: string; agentId: string; - previousStatus: ArenaAgentStatus; - newStatus: ArenaAgentStatus; - timestamp: number; -} - -/** - * Event payload for agent stream text. - */ -export interface ArenaAgentStreamTextEvent { - sessionId: string; - agentId: string; - text: string; - isThought?: boolean; - timestamp: number; -} - -/** - * Event payload for agent tool call. - */ -export interface ArenaAgentToolCallEvent { - sessionId: string; - agentId: string; - callId: string; - toolName: string; - args: Record; - description?: string; - timestamp: number; -} - -/** - * Event payload for agent tool result. - */ -export interface ArenaAgentToolResultEvent { - sessionId: string; - agentId: string; - callId: string; - toolName: string; - success: boolean; - error?: string; - durationMs: number; - timestamp: number; -} - -/** - * Event payload for agent stats update. - */ -export interface ArenaAgentStatsUpdateEvent { - sessionId: string; - agentId: string; - stats: Partial; + error: string; timestamp: number; } @@ -166,20 +104,24 @@ export interface ArenaAgentCompleteEvent { } /** - * Event payload for agent error. + * Event payload for agent status change. */ -export interface ArenaAgentErrorEvent { +export interface ArenaAgentStatusChangeEvent { sessionId: string; agentId: string; - error: string; + previousStatus: ArenaAgentStatus; + newStatus: ArenaAgentStatus; timestamp: number; } /** - * Event payload for session warning (non-fatal). + * Event payload for session update (informational or warning). */ -export interface ArenaSessionWarningEvent { +export type ArenaSessionUpdateType = 'info' | 'warning'; + +export interface ArenaSessionUpdateEvent { sessionId: string; + type: ArenaSessionUpdateType; message: string; timestamp: number; } @@ -189,17 +131,13 @@ export interface ArenaSessionWarningEvent { */ export interface ArenaEventMap { [ArenaEventType.SESSION_START]: ArenaSessionStartEvent; + [ArenaEventType.SESSION_UPDATE]: ArenaSessionUpdateEvent; [ArenaEventType.SESSION_COMPLETE]: ArenaSessionCompleteEvent; [ArenaEventType.SESSION_ERROR]: ArenaSessionErrorEvent; [ArenaEventType.AGENT_START]: ArenaAgentStartEvent; [ArenaEventType.AGENT_STATUS_CHANGE]: ArenaAgentStatusChangeEvent; - [ArenaEventType.AGENT_STREAM_TEXT]: ArenaAgentStreamTextEvent; - [ArenaEventType.AGENT_TOOL_CALL]: ArenaAgentToolCallEvent; - [ArenaEventType.AGENT_TOOL_RESULT]: ArenaAgentToolResultEvent; - [ArenaEventType.AGENT_STATS_UPDATE]: ArenaAgentStatsUpdateEvent; [ArenaEventType.AGENT_COMPLETE]: ArenaAgentCompleteEvent; [ArenaEventType.AGENT_ERROR]: ArenaAgentErrorEvent; - [ArenaEventType.SESSION_WARNING]: ArenaSessionWarningEvent; } /** From e968483a8a667fcad3bd721bcdd996aa200b7f16 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 19 Feb 2026 21:37:30 +0800 Subject: [PATCH 04/82] refactor(core,cli)!: rename SubAgentScope to AgentHeadless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename SubAgentScope → AgentHeadless and runNonInteractive → execute - Move agents-collab/ into agents/ with new runtime/ subdirectory - Split subagent.ts into agent-core.ts and agent-headless.ts - Update all event types, emitters, and statistics classes BREAKING CHANGE: SubAgentScope renamed to AgentHeadless; runNonInteractive() renamed to execute() Co-authored-by: Qwen-Coder --- .../src/acp-integration/session/Session.ts | 8 +- .../session/SubAgentTracker.test.ts | 108 +- .../session/SubAgentTracker.ts | 48 +- .../session/emitters/MessageEmitter.ts | 3 +- packages/cli/src/ui/AppContainer.test.tsx | 2 +- .../runtime/AgentExecutionDisplay.tsx | 4 +- .../arena/ArenaAgentClient.test.ts | 0 .../arena/ArenaAgentClient.ts | 0 .../arena/ArenaManager.test.ts | 0 .../arena/ArenaManager.ts | 0 .../arena/arena-events.ts | 0 .../{agents-collab => agents}/arena/index.ts | 2 +- .../{agents-collab => agents}/arena/types.ts | 0 .../backends/ITermBackend.test.ts | 0 .../backends/ITermBackend.ts | 0 .../backends/TmuxBackend.test.ts | 0 .../backends/TmuxBackend.ts | 0 .../backends/detect.ts | 0 .../backends/index.ts | 0 .../backends/iterm-it2.test.ts | 0 .../backends/iterm-it2.ts | 0 .../backends/tmux-commands.test.ts | 0 .../backends/tmux-commands.ts | 0 .../backends/types.ts | 0 .../src/{agents-collab => agents}/index.ts | 1 + .../core/src/agents/runtime/agent-core.ts | 907 +++++++++++++++ .../runtime/agent-events.ts} | 32 +- .../runtime/agent-headless.test.ts} | 166 +-- .../core/src/agents/runtime/agent-headless.ts | 362 ++++++ .../runtime/agent-hooks.ts} | 6 +- .../runtime/agent-statistics.test.ts} | 8 +- .../runtime/agent-statistics.ts} | 8 +- packages/core/src/agents/runtime/index.ts | 15 + packages/core/src/config/config.ts | 4 +- packages/core/src/index.ts | 2 +- .../core/src/services/gitWorktreeService.ts | 2 +- packages/core/src/subagents/index.ts | 35 +- .../core/src/subagents/subagent-manager.ts | 24 +- packages/core/src/subagents/subagent.ts | 1004 ----------------- packages/core/src/subagents/types.ts | 16 +- packages/core/src/tools/task.test.ts | 23 +- packages/core/src/tools/task.ts | 98 +- packages/core/src/tools/tools.ts | 4 +- 43 files changed, 1589 insertions(+), 1303 deletions(-) rename packages/core/src/{agents-collab => agents}/arena/ArenaAgentClient.test.ts (100%) rename packages/core/src/{agents-collab => agents}/arena/ArenaAgentClient.ts (100%) rename packages/core/src/{agents-collab => agents}/arena/ArenaManager.test.ts (100%) rename packages/core/src/{agents-collab => agents}/arena/ArenaManager.ts (100%) rename packages/core/src/{agents-collab => agents}/arena/arena-events.ts (100%) rename packages/core/src/{agents-collab => agents}/arena/index.ts (89%) rename packages/core/src/{agents-collab => agents}/arena/types.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/ITermBackend.test.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/ITermBackend.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/TmuxBackend.test.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/TmuxBackend.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/detect.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/index.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/iterm-it2.test.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/iterm-it2.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/tmux-commands.test.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/tmux-commands.ts (100%) rename packages/core/src/{agents-collab => agents}/backends/types.ts (100%) rename packages/core/src/{agents-collab => agents}/index.ts (92%) create mode 100644 packages/core/src/agents/runtime/agent-core.ts rename packages/core/src/{subagents/subagent-events.ts => agents/runtime/agent-events.ts} (78%) rename packages/core/src/{subagents/subagent.test.ts => agents/runtime/agent-headless.test.ts} (87%) create mode 100644 packages/core/src/agents/runtime/agent-headless.ts rename packages/core/src/{subagents/subagent-hooks.ts => agents/runtime/agent-hooks.ts} (83%) rename packages/core/src/{subagents/subagent-statistics.test.ts => agents/runtime/agent-statistics.test.ts} (98%) rename packages/core/src/{subagents/subagent-statistics.ts => agents/runtime/agent-statistics.ts} (97%) create mode 100644 packages/core/src/agents/runtime/index.ts delete mode 100644 packages/core/src/subagents/subagent.ts diff --git a/packages/cli/src/acp-integration/session/Session.ts b/packages/cli/src/acp-integration/session/Session.ts index d7a5e7395..71dda755d 100644 --- a/packages/cli/src/acp-integration/session/Session.ts +++ b/packages/cli/src/acp-integration/session/Session.ts @@ -16,7 +16,7 @@ import type { ToolCallConfirmationDetails, ToolResult, ChatRecord, - SubAgentEventEmitter, + AgentEventEmitter, } from '@qwen-code/qwen-code-core'; import { AuthType, @@ -488,7 +488,7 @@ export class Session implements SessionContext { // Access eventEmitter from TaskTool invocation const taskEventEmitter = ( invocation as { - eventEmitter: SubAgentEventEmitter; + eventEmitter: AgentEventEmitter; } ).eventEmitter; @@ -497,7 +497,7 @@ export class Session implements SessionContext { const subagentType = (args['subagent_type'] as string) ?? ''; // Create a SubAgentTracker for this tool execution - const subAgentTracker = new SubAgentTracker( + const subSubAgentTracker = new SubAgentTracker( this, this.client, parentToolCallId, @@ -505,7 +505,7 @@ export class Session implements SessionContext { ); // Set up sub-agent tool tracking - subAgentCleanupFunctions = subAgentTracker.setup( + subAgentCleanupFunctions = subSubAgentTracker.setup( taskEventEmitter, abortSignal, ); diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts index 96b8bd998..472a7b9ef 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.test.ts @@ -10,26 +10,26 @@ import type { SessionContext } from './types.js'; import type { Config, ToolRegistry, - SubAgentEventEmitter, - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentApprovalRequestEvent, - SubAgentStreamTextEvent, + AgentEventEmitter, + AgentToolCallEvent, + AgentToolResultEvent, + AgentApprovalRequestEvent, + AgentStreamTextEvent, ToolEditConfirmationDetails, ToolInfoConfirmationDetails, } from '@qwen-code/qwen-code-core'; import { - SubAgentEventType, + AgentEventType, ToolConfirmationOutcome, TodoWriteTool, } from '@qwen-code/qwen-code-core'; import type * as acp from '../acp.js'; import { EventEmitter } from 'node:events'; -// Helper to create a mock SubAgentToolCallEvent with required fields +// Helper to create a mock AgentToolCallEvent with required fields function createToolCallEvent( - overrides: Partial & { name: string; callId: string }, -): SubAgentToolCallEvent { + overrides: Partial & { name: string; callId: string }, +): AgentToolCallEvent { return { subagentId: 'test-subagent', round: 1, @@ -40,14 +40,14 @@ function createToolCallEvent( }; } -// Helper to create a mock SubAgentToolResultEvent with required fields +// Helper to create a mock AgentToolResultEvent with required fields function createToolResultEvent( - overrides: Partial & { + overrides: Partial & { name: string; callId: string; success: boolean; }, -): SubAgentToolResultEvent { +): AgentToolResultEvent { return { subagentId: 'test-subagent', round: 1, @@ -56,15 +56,15 @@ function createToolResultEvent( }; } -// Helper to create a mock SubAgentApprovalRequestEvent with required fields +// Helper to create a mock AgentApprovalRequestEvent with required fields function createApprovalEvent( - overrides: Partial & { + overrides: Partial & { name: string; callId: string; - confirmationDetails: SubAgentApprovalRequestEvent['confirmationDetails']; - respond: SubAgentApprovalRequestEvent['respond']; + confirmationDetails: AgentApprovalRequestEvent['confirmationDetails']; + respond: AgentApprovalRequestEvent['respond']; }, -): SubAgentApprovalRequestEvent { +): AgentApprovalRequestEvent { return { subagentId: 'test-subagent', round: 1, @@ -102,10 +102,10 @@ function createInfoConfirmation( }; } -// Helper to create a mock SubAgentStreamTextEvent with required fields +// Helper to create a mock AgentStreamTextEvent with required fields function createStreamTextEvent( - overrides: Partial & { text: string }, -): SubAgentStreamTextEvent { + overrides: Partial & { text: string }, +): AgentStreamTextEvent { return { subagentId: 'test-subagent', round: 1, @@ -120,7 +120,7 @@ describe('SubAgentTracker', () => { let sendUpdateSpy: ReturnType; let requestPermissionSpy: ReturnType; let tracker: SubAgentTracker; - let eventEmitter: SubAgentEventEmitter; + let eventEmitter: AgentEventEmitter; let abortController: AbortController; beforeEach(() => { @@ -151,7 +151,7 @@ describe('SubAgentTracker', () => { 'parent-call-123', 'test-subagent', ); - eventEmitter = new EventEmitter() as unknown as SubAgentEventEmitter; + eventEmitter = new EventEmitter() as unknown as AgentEventEmitter; abortController = new AbortController(); }); @@ -169,19 +169,19 @@ describe('SubAgentTracker', () => { tracker.setup(eventEmitter, abortController.signal); expect(onSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_CALL, + AgentEventType.TOOL_CALL, expect.any(Function), ); expect(onSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_RESULT, + AgentEventType.TOOL_RESULT, expect.any(Function), ); expect(onSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_WAITING_APPROVAL, + AgentEventType.TOOL_WAITING_APPROVAL, expect.any(Function), ); expect(onSpy).toHaveBeenCalledWith( - SubAgentEventType.STREAM_TEXT, + AgentEventType.STREAM_TEXT, expect.any(Function), ); }); @@ -193,19 +193,19 @@ describe('SubAgentTracker', () => { cleanups[0](); expect(offSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_CALL, + AgentEventType.TOOL_CALL, expect.any(Function), ); expect(offSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_RESULT, + AgentEventType.TOOL_RESULT, expect.any(Function), ); expect(offSpy).toHaveBeenCalledWith( - SubAgentEventType.TOOL_WAITING_APPROVAL, + AgentEventType.TOOL_WAITING_APPROVAL, expect.any(Function), ); expect(offSpy).toHaveBeenCalledWith( - SubAgentEventType.STREAM_TEXT, + AgentEventType.STREAM_TEXT, expect.any(Function), ); }); @@ -222,7 +222,7 @@ describe('SubAgentTracker', () => { description: 'Reading file', }); - eventEmitter.emit(SubAgentEventType.TOOL_CALL, event); + eventEmitter.emit(AgentEventType.TOOL_CALL, event); // Allow async operations to complete await vi.waitFor(() => { @@ -258,7 +258,7 @@ describe('SubAgentTracker', () => { args: { todos: [] }, }); - eventEmitter.emit(SubAgentEventType.TOOL_CALL, event); + eventEmitter.emit(AgentEventType.TOOL_CALL, event); // Give time for any async operation await new Promise((resolve) => setTimeout(resolve, 10)); @@ -276,7 +276,7 @@ describe('SubAgentTracker', () => { args: {}, }); - eventEmitter.emit(SubAgentEventType.TOOL_CALL, event); + eventEmitter.emit(AgentEventType.TOOL_CALL, event); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -290,7 +290,7 @@ describe('SubAgentTracker', () => { // First emit tool call to store state eventEmitter.emit( - SubAgentEventType.TOOL_CALL, + AgentEventType.TOOL_CALL, createToolCallEvent({ name: 'read_file', callId: 'call-123', @@ -306,7 +306,7 @@ describe('SubAgentTracker', () => { resultDisplay: 'File contents', }); - eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent); + eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalledWith( @@ -334,7 +334,7 @@ describe('SubAgentTracker', () => { resultDisplay: undefined, }); - eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent); + eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalledWith( @@ -356,7 +356,7 @@ describe('SubAgentTracker', () => { // Store args via tool call eventEmitter.emit( - SubAgentEventType.TOOL_CALL, + AgentEventType.TOOL_CALL, createToolCallEvent({ name: TodoWriteTool.Name, callId: 'call-todo', @@ -377,7 +377,7 @@ describe('SubAgentTracker', () => { }), }); - eventEmitter.emit(SubAgentEventType.TOOL_RESULT, resultEvent); + eventEmitter.emit(AgentEventType.TOOL_RESULT, resultEvent); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalledWith({ @@ -393,7 +393,7 @@ describe('SubAgentTracker', () => { tracker.setup(eventEmitter, abortController.signal); eventEmitter.emit( - SubAgentEventType.TOOL_CALL, + AgentEventType.TOOL_CALL, createToolCallEvent({ name: 'test_tool', callId: 'call-cleanup', @@ -402,7 +402,7 @@ describe('SubAgentTracker', () => { ); eventEmitter.emit( - SubAgentEventType.TOOL_RESULT, + AgentEventType.TOOL_RESULT, createToolResultEvent({ name: 'test_tool', callId: 'call-cleanup', @@ -413,7 +413,7 @@ describe('SubAgentTracker', () => { // Emit another result for same callId - should not have stored args sendUpdateSpy.mockClear(); eventEmitter.emit( - SubAgentEventType.TOOL_RESULT, + AgentEventType.TOOL_RESULT, createToolResultEvent({ name: 'test_tool', callId: 'call-cleanup', @@ -447,7 +447,7 @@ describe('SubAgentTracker', () => { respond: respondSpy, }); - eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event); + eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event); await vi.waitFor(() => { expect(requestPermissionSpy).toHaveBeenCalled(); @@ -483,7 +483,7 @@ describe('SubAgentTracker', () => { respond: respondSpy, }); - eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event); + eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event); await vi.waitFor(() => { expect(respondSpy).toHaveBeenCalledWith( @@ -504,7 +504,7 @@ describe('SubAgentTracker', () => { respond: respondSpy, }); - eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event); + eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event); await vi.waitFor(() => { expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); @@ -525,7 +525,7 @@ describe('SubAgentTracker', () => { respond: respondSpy, }); - eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event); + eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event); await vi.waitFor(() => { expect(respondSpy).toHaveBeenCalledWith(ToolConfirmationOutcome.Cancel); @@ -548,7 +548,7 @@ describe('SubAgentTracker', () => { respond: vi.fn(), }); - eventEmitter.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, event); + eventEmitter.emit(AgentEventType.TOOL_WAITING_APPROVAL, event); await vi.waitFor(() => { expect(requestPermissionSpy).toHaveBeenCalled(); @@ -572,7 +572,7 @@ describe('SubAgentTracker', () => { text: 'Hello, this is a response from the model.', }); - eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event); + eventEmitter.emit(AgentEventType.STREAM_TEXT, event); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalled(); @@ -593,15 +593,15 @@ describe('SubAgentTracker', () => { tracker.setup(eventEmitter, abortController.signal); eventEmitter.emit( - SubAgentEventType.STREAM_TEXT, + AgentEventType.STREAM_TEXT, createStreamTextEvent({ text: 'First chunk ' }), ); eventEmitter.emit( - SubAgentEventType.STREAM_TEXT, + AgentEventType.STREAM_TEXT, createStreamTextEvent({ text: 'Second chunk ' }), ); eventEmitter.emit( - SubAgentEventType.STREAM_TEXT, + AgentEventType.STREAM_TEXT, createStreamTextEvent({ text: 'Third chunk' }), ); @@ -640,7 +640,7 @@ describe('SubAgentTracker', () => { text: 'This should not be emitted', }); - eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event); + eventEmitter.emit(AgentEventType.STREAM_TEXT, event); await new Promise((resolve) => setTimeout(resolve, 10)); @@ -655,7 +655,7 @@ describe('SubAgentTracker', () => { thought: true, }); - eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event); + eventEmitter.emit(AgentEventType.STREAM_TEXT, event); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalled(); @@ -680,7 +680,7 @@ describe('SubAgentTracker', () => { thought: false, }); - eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event); + eventEmitter.emit(AgentEventType.STREAM_TEXT, event); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalled(); @@ -705,7 +705,7 @@ describe('SubAgentTracker', () => { text: 'Default behavior text.', }); - eventEmitter.emit(SubAgentEventType.STREAM_TEXT, event); + eventEmitter.emit(AgentEventType.STREAM_TEXT, event); await vi.waitFor(() => { expect(sendUpdateSpy).toHaveBeenCalled(); diff --git a/packages/cli/src/acp-integration/session/SubAgentTracker.ts b/packages/cli/src/acp-integration/session/SubAgentTracker.ts index d020f2a06..9f56de198 100644 --- a/packages/cli/src/acp-integration/session/SubAgentTracker.ts +++ b/packages/cli/src/acp-integration/session/SubAgentTracker.ts @@ -5,18 +5,18 @@ */ import type { - SubAgentEventEmitter, - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentApprovalRequestEvent, - SubAgentUsageEvent, - SubAgentStreamTextEvent, + AgentEventEmitter, + AgentToolCallEvent, + AgentToolResultEvent, + AgentApprovalRequestEvent, + AgentUsageEvent, + AgentStreamTextEvent, ToolCallConfirmationDetails, AnyDeclarativeTool, AnyToolInvocation, } from '@qwen-code/qwen-code-core'; import { - SubAgentEventType, + AgentEventType, ToolConfirmationOutcome, createDebugLogger, } from '@qwen-code/qwen-code-core'; @@ -101,12 +101,12 @@ export class SubAgentTracker { /** * Sets up event listeners for a sub-agent's tool events. * - * @param eventEmitter - The SubAgentEventEmitter from TaskTool + * @param eventEmitter - The AgentEventEmitter from TaskTool * @param abortSignal - Signal to abort tracking if parent is cancelled * @returns Array of cleanup functions to remove listeners */ setup( - eventEmitter: SubAgentEventEmitter, + eventEmitter: AgentEventEmitter, abortSignal: AbortSignal, ): Array<() => void> { const onToolCall = this.createToolCallHandler(abortSignal); @@ -115,19 +115,19 @@ export class SubAgentTracker { const onUsageMetadata = this.createUsageMetadataHandler(abortSignal); const onStreamText = this.createStreamTextHandler(abortSignal); - eventEmitter.on(SubAgentEventType.TOOL_CALL, onToolCall); - eventEmitter.on(SubAgentEventType.TOOL_RESULT, onToolResult); - eventEmitter.on(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); - eventEmitter.on(SubAgentEventType.USAGE_METADATA, onUsageMetadata); - eventEmitter.on(SubAgentEventType.STREAM_TEXT, onStreamText); + eventEmitter.on(AgentEventType.TOOL_CALL, onToolCall); + eventEmitter.on(AgentEventType.TOOL_RESULT, onToolResult); + eventEmitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata); + eventEmitter.on(AgentEventType.STREAM_TEXT, onStreamText); return [ () => { - eventEmitter.off(SubAgentEventType.TOOL_CALL, onToolCall); - eventEmitter.off(SubAgentEventType.TOOL_RESULT, onToolResult); - eventEmitter.off(SubAgentEventType.TOOL_WAITING_APPROVAL, onApproval); - eventEmitter.off(SubAgentEventType.USAGE_METADATA, onUsageMetadata); - eventEmitter.off(SubAgentEventType.STREAM_TEXT, onStreamText); + eventEmitter.off(AgentEventType.TOOL_CALL, onToolCall); + eventEmitter.off(AgentEventType.TOOL_RESULT, onToolResult); + eventEmitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + eventEmitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata); + eventEmitter.off(AgentEventType.STREAM_TEXT, onStreamText); // Clean up any remaining states this.toolStates.clear(); }, @@ -141,7 +141,7 @@ export class SubAgentTracker { abortSignal: AbortSignal, ): (...args: unknown[]) => void { return (...args: unknown[]) => { - const event = args[0] as SubAgentToolCallEvent; + const event = args[0] as AgentToolCallEvent; if (abortSignal.aborted) return; // Look up tool and build invocation for metadata @@ -182,7 +182,7 @@ export class SubAgentTracker { abortSignal: AbortSignal, ): (...args: unknown[]) => void { return (...args: unknown[]) => { - const event = args[0] as SubAgentToolResultEvent; + const event = args[0] as AgentToolResultEvent; if (abortSignal.aborted) return; const state = this.toolStates.get(event.callId); @@ -210,7 +210,7 @@ export class SubAgentTracker { abortSignal: AbortSignal, ): (...args: unknown[]) => Promise { return async (...args: unknown[]) => { - const event = args[0] as SubAgentApprovalRequestEvent; + const event = args[0] as AgentApprovalRequestEvent; if (abortSignal.aborted) return; const state = this.toolStates.get(event.callId); @@ -287,7 +287,7 @@ export class SubAgentTracker { abortSignal: AbortSignal, ): (...args: unknown[]) => void { return (...args: unknown[]) => { - const event = args[0] as SubAgentUsageEvent; + const event = args[0] as AgentUsageEvent; if (abortSignal.aborted) return; this.messageEmitter.emitUsageMetadata( @@ -307,7 +307,7 @@ export class SubAgentTracker { abortSignal: AbortSignal, ): (...args: unknown[]) => void { return (...args: unknown[]) => { - const event = args[0] as SubAgentStreamTextEvent; + const event = args[0] as AgentStreamTextEvent; if (abortSignal.aborted) return; // Emit streamed text as agent message or thought based on the flag diff --git a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts index a81520be3..d0f0e2c81 100644 --- a/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts +++ b/packages/cli/src/acp-integration/session/emitters/MessageEmitter.ts @@ -6,6 +6,7 @@ import type { GenerateContentResponseUsageMetadata } from '@google/genai'; import type { Usage } from '../../schema.js'; +import type { SubagentMeta } from '../types.js'; import { BaseEmitter } from './BaseEmitter.js'; /** @@ -77,7 +78,7 @@ export class MessageEmitter extends BaseEmitter { usageMetadata: GenerateContentResponseUsageMetadata, text: string = '', durationMs?: number, - subagentMeta?: import('../types.js').SubagentMeta, + subagentMeta?: SubagentMeta, ): Promise { const usage: Usage = { promptTokens: usageMetadata.promptTokenCount, diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 1edec79f9..57eacc797 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -267,7 +267,7 @@ describe('AppContainer State Management', () => { listSubagents: vi.fn().mockResolvedValue([]), addChangeListener: vi.fn(), loadSubagent: vi.fn(), - createSubagentScope: vi.fn(), + createSubagent: vi.fn(), }; vi.spyOn(mockConfig, 'getSubagentManager').mockReturnValue( mockSubagentManager as SubagentManager, diff --git a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx index 8f9fe2a6a..8da7a3a24 100644 --- a/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx +++ b/packages/cli/src/ui/components/subagents/runtime/AgentExecutionDisplay.tsx @@ -8,7 +8,7 @@ import React, { useMemo } from 'react'; import { Box, Text } from 'ink'; import type { TaskResultDisplay, - SubagentStatsSummary, + AgentStatsSummary, Config, } from '@qwen-code/qwen-code-core'; import { theme } from '../../../semantic-colors.js'; @@ -467,7 +467,7 @@ const ExecutionSummaryDetails: React.FC<{ * Tool usage statistics component */ const ToolUsageStats: React.FC<{ - executionSummary?: SubagentStatsSummary; + executionSummary?: AgentStatsSummary; }> = ({ executionSummary }) => { if (!executionSummary) { return ( diff --git a/packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts b/packages/core/src/agents/arena/ArenaAgentClient.test.ts similarity index 100% rename from packages/core/src/agents-collab/arena/ArenaAgentClient.test.ts rename to packages/core/src/agents/arena/ArenaAgentClient.test.ts diff --git a/packages/core/src/agents-collab/arena/ArenaAgentClient.ts b/packages/core/src/agents/arena/ArenaAgentClient.ts similarity index 100% rename from packages/core/src/agents-collab/arena/ArenaAgentClient.ts rename to packages/core/src/agents/arena/ArenaAgentClient.ts diff --git a/packages/core/src/agents-collab/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts similarity index 100% rename from packages/core/src/agents-collab/arena/ArenaManager.test.ts rename to packages/core/src/agents/arena/ArenaManager.test.ts diff --git a/packages/core/src/agents-collab/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts similarity index 100% rename from packages/core/src/agents-collab/arena/ArenaManager.ts rename to packages/core/src/agents/arena/ArenaManager.ts diff --git a/packages/core/src/agents-collab/arena/arena-events.ts b/packages/core/src/agents/arena/arena-events.ts similarity index 100% rename from packages/core/src/agents-collab/arena/arena-events.ts rename to packages/core/src/agents/arena/arena-events.ts diff --git a/packages/core/src/agents-collab/arena/index.ts b/packages/core/src/agents/arena/index.ts similarity index 89% rename from packages/core/src/agents-collab/arena/index.ts rename to packages/core/src/agents/arena/index.ts index 60d6b91e8..e744250c7 100644 --- a/packages/core/src/agents-collab/arena/index.ts +++ b/packages/core/src/agents/arena/index.ts @@ -11,4 +11,4 @@ export * from './ArenaManager.js'; export * from './ArenaAgentClient.js'; // Re-export shared agent infrastructure for backwards compatibility -export * from '../index.js'; +export * from '../backends/index.js'; diff --git a/packages/core/src/agents-collab/arena/types.ts b/packages/core/src/agents/arena/types.ts similarity index 100% rename from packages/core/src/agents-collab/arena/types.ts rename to packages/core/src/agents/arena/types.ts diff --git a/packages/core/src/agents-collab/backends/ITermBackend.test.ts b/packages/core/src/agents/backends/ITermBackend.test.ts similarity index 100% rename from packages/core/src/agents-collab/backends/ITermBackend.test.ts rename to packages/core/src/agents/backends/ITermBackend.test.ts diff --git a/packages/core/src/agents-collab/backends/ITermBackend.ts b/packages/core/src/agents/backends/ITermBackend.ts similarity index 100% rename from packages/core/src/agents-collab/backends/ITermBackend.ts rename to packages/core/src/agents/backends/ITermBackend.ts diff --git a/packages/core/src/agents-collab/backends/TmuxBackend.test.ts b/packages/core/src/agents/backends/TmuxBackend.test.ts similarity index 100% rename from packages/core/src/agents-collab/backends/TmuxBackend.test.ts rename to packages/core/src/agents/backends/TmuxBackend.test.ts diff --git a/packages/core/src/agents-collab/backends/TmuxBackend.ts b/packages/core/src/agents/backends/TmuxBackend.ts similarity index 100% rename from packages/core/src/agents-collab/backends/TmuxBackend.ts rename to packages/core/src/agents/backends/TmuxBackend.ts diff --git a/packages/core/src/agents-collab/backends/detect.ts b/packages/core/src/agents/backends/detect.ts similarity index 100% rename from packages/core/src/agents-collab/backends/detect.ts rename to packages/core/src/agents/backends/detect.ts diff --git a/packages/core/src/agents-collab/backends/index.ts b/packages/core/src/agents/backends/index.ts similarity index 100% rename from packages/core/src/agents-collab/backends/index.ts rename to packages/core/src/agents/backends/index.ts diff --git a/packages/core/src/agents-collab/backends/iterm-it2.test.ts b/packages/core/src/agents/backends/iterm-it2.test.ts similarity index 100% rename from packages/core/src/agents-collab/backends/iterm-it2.test.ts rename to packages/core/src/agents/backends/iterm-it2.test.ts diff --git a/packages/core/src/agents-collab/backends/iterm-it2.ts b/packages/core/src/agents/backends/iterm-it2.ts similarity index 100% rename from packages/core/src/agents-collab/backends/iterm-it2.ts rename to packages/core/src/agents/backends/iterm-it2.ts diff --git a/packages/core/src/agents-collab/backends/tmux-commands.test.ts b/packages/core/src/agents/backends/tmux-commands.test.ts similarity index 100% rename from packages/core/src/agents-collab/backends/tmux-commands.test.ts rename to packages/core/src/agents/backends/tmux-commands.test.ts diff --git a/packages/core/src/agents-collab/backends/tmux-commands.ts b/packages/core/src/agents/backends/tmux-commands.ts similarity index 100% rename from packages/core/src/agents-collab/backends/tmux-commands.ts rename to packages/core/src/agents/backends/tmux-commands.ts diff --git a/packages/core/src/agents-collab/backends/types.ts b/packages/core/src/agents/backends/types.ts similarity index 100% rename from packages/core/src/agents-collab/backends/types.ts rename to packages/core/src/agents/backends/types.ts diff --git a/packages/core/src/agents-collab/index.ts b/packages/core/src/agents/index.ts similarity index 92% rename from packages/core/src/agents-collab/index.ts rename to packages/core/src/agents/index.ts index b811dbde3..d29d4dc09 100644 --- a/packages/core/src/agents-collab/index.ts +++ b/packages/core/src/agents/index.ts @@ -15,3 +15,4 @@ export * from './backends/index.js'; export * from './arena/index.js'; +export * from './runtime/index.js'; diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts new file mode 100644 index 000000000..8af0f9247 --- /dev/null +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -0,0 +1,907 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentCore — the shared execution engine for subagents. + * + * AgentCore encapsulates the model reasoning loop, tool scheduling, stats, + * and event emission. It is composed by both AgentHeadless (one-shot tasks) + * and AgentInteractive (persistent interactive agents). + * + * AgentCore is stateless per-call: it does not own lifecycle or termination + * logic. The caller (executor/collaborator) controls when to start, stop, + * and how to interpret the results. + */ + +import { reportError } from '../../utils/errorReporting.js'; +import type { Config } from '../../config/config.js'; +import { type ToolCallRequestInfo } from '../../core/turn.js'; +import { + CoreToolScheduler, + type ToolCall, + type WaitingToolCall, +} from '../../core/coreToolScheduler.js'; +import type { + ToolConfirmationOutcome, + ToolCallConfirmationDetails, +} from '../../tools/tools.js'; +import { getInitialChatHistory } from '../../utils/environmentContext.js'; +import type { + Content, + Part, + FunctionCall, + GenerateContentConfig, + FunctionDeclaration, + GenerateContentResponseUsageMetadata, +} from '@google/genai'; +import { GeminiChat } from '../../core/geminiChat.js'; +import type { + PromptConfig, + ModelConfig, + RunConfig, + ToolConfig, +} from '../../subagents/types.js'; +import { SubagentTerminateMode } from '../../subagents/types.js'; +import type { + AgentRoundEvent, + AgentToolCallEvent, + AgentToolResultEvent, + AgentUsageEvent, +} from './agent-events.js'; +import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; +import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; +import type { AgentHooks } from './agent-hooks.js'; +import { TaskTool } from '../../tools/task.js'; +import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; +import { type ContextState, templateString } from './agent-headless.js'; + +/** + * Result of a single reasoning loop invocation. + */ +export interface ReasoningLoopResult { + /** The final model text response (empty if terminated by abort/limits). */ + text: string; + /** Why the loop ended. null = normal text completion (no tool calls). */ + terminateMode: SubagentTerminateMode | null; + /** Number of model round-trips completed. */ + turnsUsed: number; +} + +/** + * Options for configuring a reasoning loop invocation. + */ +export interface ReasoningLoopOptions { + /** Maximum number of turns before stopping. */ + maxTurns?: number; + /** Maximum wall-clock time in minutes before stopping. */ + maxTimeMinutes?: number; + /** Start time in ms (for timeout calculation). Defaults to Date.now(). */ + startTimeMs?: number; +} + +/** + * Options for chat creation. + */ +export interface CreateChatOptions { + /** + * When true, omits the "non-interactive mode" system prompt suffix. + * Used by AgentInteractive for persistent interactive agents. + */ + interactive?: boolean; +} + +/** + * Legacy execution stats maintained for backward compatibility. + */ +export interface ExecutionStats { + startTimeMs: number; + totalDurationMs: number; + rounds: number; + totalToolCalls: number; + successfulToolCalls: number; + failedToolCalls: number; + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + estimatedCost?: number; +} + +/** + * AgentCore — shared execution engine for model reasoning and tool scheduling. + * + * This class encapsulates: + * - Chat/model session creation (`createChat`) + * - Tool list preparation (`prepareTools`) + * - The inner reasoning loop (`runReasoningLoop`) + * - Tool call scheduling and execution (`processFunctionCalls`) + * - Statistics tracking and event emission + * + * It does NOT manage lifecycle (start/stop/terminate), abort signals, + * or final result interpretation — those are the caller's responsibility. + */ +export class AgentCore { + readonly subagentId: string; + readonly name: string; + readonly runtimeContext: Config; + readonly promptConfig: PromptConfig; + readonly modelConfig: ModelConfig; + readonly runConfig: RunConfig; + readonly toolConfig?: ToolConfig; + readonly eventEmitter?: AgentEventEmitter; + readonly hooks?: AgentHooks; + readonly stats = new AgentStatistics(); + + /** + * Legacy execution stats maintained for aggregate tracking. + */ + executionStats: ExecutionStats = { + startTimeMs: 0, + totalDurationMs: 0, + rounds: 0, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + estimatedCost: 0, + }; + private toolUsage = new Map< + string, + { + count: number; + success: number; + failure: number; + lastError?: string; + totalDurationMs?: number; + averageDurationMs?: number; + } + >(); + + constructor( + name: string, + runtimeContext: Config, + promptConfig: PromptConfig, + modelConfig: ModelConfig, + runConfig: RunConfig, + toolConfig?: ToolConfig, + eventEmitter?: AgentEventEmitter, + hooks?: AgentHooks, + ) { + const randomPart = Math.random().toString(36).slice(2, 8); + this.subagentId = `${name}-${randomPart}`; + this.name = name; + this.runtimeContext = runtimeContext; + this.promptConfig = promptConfig; + this.modelConfig = modelConfig; + this.runConfig = runConfig; + this.toolConfig = toolConfig; + this.eventEmitter = eventEmitter; + this.hooks = hooks; + } + + // ─── Chat Creation ──────────────────────────────────────── + + /** + * Creates a GeminiChat instance configured for this agent. + * + * @param context - Context state for template variable substitution. + * @param options - Chat creation options. + * - `interactive`: When true, omits the "non-interactive mode" system prompt suffix. + * @returns A configured GeminiChat, or undefined if initialization fails. + */ + async createChat( + context: ContextState, + options?: CreateChatOptions, + ): Promise { + if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { + throw new Error( + 'PromptConfig must have either `systemPrompt` or `initialMessages` defined.', + ); + } + if (this.promptConfig.systemPrompt && this.promptConfig.initialMessages) { + throw new Error( + 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', + ); + } + + const envHistory = await getInitialChatHistory(this.runtimeContext); + + const startHistory = [ + ...envHistory, + ...(this.promptConfig.initialMessages ?? []), + ]; + + const systemInstruction = this.promptConfig.systemPrompt + ? this.buildChatSystemPrompt(context, options) + : undefined; + + try { + const generationConfig: GenerateContentConfig & { + systemInstruction?: string | Content; + } = { + temperature: this.modelConfig.temp, + topP: this.modelConfig.top_p, + }; + + if (systemInstruction) { + generationConfig.systemInstruction = systemInstruction; + } + + return new GeminiChat( + this.runtimeContext, + generationConfig, + startHistory, + ); + } catch (error) { + await reportError( + error, + 'Error initializing chat session.', + startHistory, + 'startChat', + ); + return undefined; + } + } + + // ─── Tool Preparation ───────────────────────────────────── + + /** + * Prepares the list of tools available to this agent. + * + * If no explicit toolConfig or it contains "*" or is empty, + * inherits all tools (excluding TaskTool to prevent recursion). + */ + prepareTools(): FunctionDeclaration[] { + const toolRegistry = this.runtimeContext.getToolRegistry(); + const toolsList: FunctionDeclaration[] = []; + + if (this.toolConfig) { + const asStrings = this.toolConfig.tools.filter( + (t): t is string => typeof t === 'string', + ); + const hasWildcard = asStrings.includes('*'); + const onlyInlineDecls = this.toolConfig.tools.filter( + (t): t is FunctionDeclaration => typeof t !== 'string', + ); + + if (hasWildcard || asStrings.length === 0) { + toolsList.push( + ...toolRegistry + .getFunctionDeclarations() + .filter((t) => t.name !== TaskTool.Name), + ); + } else { + toolsList.push( + ...toolRegistry.getFunctionDeclarationsFiltered(asStrings), + ); + } + toolsList.push(...onlyInlineDecls); + } else { + // Inherit all available tools by default when not specified. + toolsList.push( + ...toolRegistry + .getFunctionDeclarations() + .filter((t) => t.name !== TaskTool.Name), + ); + } + + return toolsList; + } + + // ─── Reasoning Loop ─────────────────────────────────────── + + /** + * Runs the inner model reasoning loop. + * + * This is the core execution cycle: + * send messages → stream response → collect tool calls → execute tools → repeat. + * + * The loop terminates when: + * - The model produces a text response without tool calls (normal completion) + * - maxTurns is reached + * - maxTimeMinutes is exceeded + * - The abortController signal fires + * + * @param chat - The GeminiChat session to use. + * @param initialMessages - The first messages to send (e.g., user task prompt). + * @param toolsList - Available tool declarations. + * @param abortController - Controls cancellation of the current loop. + * @param options - Optional limits (maxTurns, maxTimeMinutes). + * @returns ReasoningLoopResult with the final text, terminate mode, and turns used. + */ + async runReasoningLoop( + chat: GeminiChat, + initialMessages: Content[], + toolsList: FunctionDeclaration[], + abortController: AbortController, + options?: ReasoningLoopOptions, + ): Promise { + const startTime = options?.startTimeMs ?? Date.now(); + let currentMessages = initialMessages; + let turnCounter = 0; + let finalText = ''; + let terminateMode: SubagentTerminateMode | null = null; + + while (true) { + // Check termination conditions. + if (options?.maxTurns && turnCounter >= options.maxTurns) { + terminateMode = SubagentTerminateMode.MAX_TURNS; + break; + } + + let durationMin = (Date.now() - startTime) / (1000 * 60); + if (options?.maxTimeMinutes && durationMin >= options.maxTimeMinutes) { + terminateMode = SubagentTerminateMode.TIMEOUT; + break; + } + + // Create a new AbortController per round to avoid listener accumulation + // in the model SDK. The parent abortController propagates abort to it. + const roundAbortController = new AbortController(); + const onParentAbort = () => roundAbortController.abort(); + abortController.signal.addEventListener('abort', onParentAbort); + if (abortController.signal.aborted) { + roundAbortController.abort(); + } + + const promptId = `${this.runtimeContext.getSessionId()}#${this.subagentId}#${turnCounter++}`; + + const messageParams = { + message: currentMessages[0]?.parts || [], + config: { + abortSignal: roundAbortController.signal, + tools: [{ functionDeclarations: toolsList }], + }, + }; + + const roundStreamStart = Date.now(); + const responseStream = await chat.sendMessageStream( + this.modelConfig.model || + this.runtimeContext.getModel() || + DEFAULT_QWEN_MODEL, + messageParams, + promptId, + ); + this.eventEmitter?.emit(AgentEventType.ROUND_START, { + subagentId: this.subagentId, + round: turnCounter, + promptId, + timestamp: Date.now(), + } as AgentRoundEvent); + + const functionCalls: FunctionCall[] = []; + let roundText = ''; + let lastUsage: GenerateContentResponseUsageMetadata | undefined = + undefined; + let currentResponseId: string | undefined = undefined; + + for await (const streamEvent of responseStream) { + if (roundAbortController.signal.aborted) { + abortController.signal.removeEventListener('abort', onParentAbort); + return { + text: finalText, + terminateMode: SubagentTerminateMode.CANCELLED, + turnsUsed: turnCounter, + }; + } + + // Handle retry events + if (streamEvent.type === 'retry') { + continue; + } + + // Handle chunk events + if (streamEvent.type === 'chunk') { + const resp = streamEvent.value; + // Track the response ID for tool call correlation + if (resp.responseId) { + currentResponseId = resp.responseId; + } + if (resp.functionCalls) functionCalls.push(...resp.functionCalls); + const content = resp.candidates?.[0]?.content; + const parts = content?.parts || []; + for (const p of parts) { + const txt = p.text; + const isThought = p.thought ?? false; + if (txt && !isThought) roundText += txt; + if (txt) + this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { + subagentId: this.subagentId, + round: turnCounter, + text: txt, + thought: isThought, + timestamp: Date.now(), + }); + } + if (resp.usageMetadata) lastUsage = resp.usageMetadata; + } + } + + this.executionStats.rounds = turnCounter; + this.stats.setRounds(turnCounter); + + durationMin = (Date.now() - startTime) / (1000 * 60); + if (options?.maxTimeMinutes && durationMin >= options.maxTimeMinutes) { + abortController.signal.removeEventListener('abort', onParentAbort); + terminateMode = SubagentTerminateMode.TIMEOUT; + break; + } + + // Update token usage if available + if (lastUsage) { + this.recordTokenUsage(lastUsage, turnCounter, roundStreamStart); + } + + if (functionCalls.length > 0) { + currentMessages = await this.processFunctionCalls( + functionCalls, + roundAbortController, + promptId, + turnCounter, + toolsList, + currentResponseId, + ); + } else { + // No tool calls — treat this as the model's final answer. + if (roundText && roundText.trim().length > 0) { + finalText = roundText.trim(); + // Clean up before breaking + abortController.signal.removeEventListener('abort', onParentAbort); + // null terminateMode = normal text completion + break; + } + // Otherwise, nudge the model to finalize a result. + currentMessages = [ + { + role: 'user', + parts: [ + { + text: 'Please provide the final result now and stop calling tools.', + }, + ], + }, + ]; + } + + this.eventEmitter?.emit(AgentEventType.ROUND_END, { + subagentId: this.subagentId, + round: turnCounter, + promptId, + timestamp: Date.now(), + } as AgentRoundEvent); + + // Clean up the per-round listener before the next iteration + abortController.signal.removeEventListener('abort', onParentAbort); + } + + return { + text: finalText, + terminateMode, + turnsUsed: turnCounter, + }; + } + + // ─── Tool Execution ─────────────────────────────────────── + + /** + * Processes a list of function calls via CoreToolScheduler. + * + * Validates each call against the allowed tools list, schedules authorized + * calls, collects results, and emits events for each call/result. + * + * Validates each call, schedules authorized calls, collects results, and emits events. + */ + async processFunctionCalls( + functionCalls: FunctionCall[], + abortController: AbortController, + promptId: string, + currentRound: number, + toolsList: FunctionDeclaration[], + responseId?: string, + ): Promise { + const toolResponseParts: Part[] = []; + + // Build allowed tool names set for filtering + const allowedToolNames = new Set(toolsList.map((t) => t.name)); + + // Filter unauthorized tool calls before scheduling + const authorizedCalls: FunctionCall[] = []; + for (const fc of functionCalls) { + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + + if (!allowedToolNames.has(fc.name)) { + const toolName = String(fc.name); + const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; + + // Emit TOOL_CALL event for visibility + this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { + subagentId: this.subagentId, + round: currentRound, + callId, + name: toolName, + args: fc.args ?? {}, + description: `Tool "${toolName}" not found`, + timestamp: Date.now(), + } as AgentToolCallEvent); + + // Build function response part (used for both event and LLM) + const functionResponsePart = { + functionResponse: { + id: callId, + name: toolName, + response: { error: errorMessage }, + }, + }; + + // Emit TOOL_RESULT event with error + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId, + name: toolName, + success: false, + error: errorMessage, + responseParts: [functionResponsePart], + resultDisplay: errorMessage, + durationMs: 0, + timestamp: Date.now(), + } as AgentToolResultEvent); + + // Record blocked tool call in stats + this.recordToolCallStats(toolName, false, 0, errorMessage); + + // Add function response for LLM + toolResponseParts.push(functionResponsePart); + continue; + } + authorizedCalls.push(fc); + } + + // Build scheduler + const responded = new Set(); + let resolveBatch: (() => void) | null = null; + const scheduler = new CoreToolScheduler({ + config: this.runtimeContext, + outputUpdateHandler: undefined, + onAllToolCallsComplete: async (completedCalls) => { + for (const call of completedCalls) { + const toolName = call.request.name; + const duration = call.durationMs ?? 0; + const success = call.status === 'success'; + const errorMessage = + call.status === 'error' || call.status === 'cancelled' + ? call.response.error?.message + : undefined; + + // Record stats + this.recordToolCallStats(toolName, success, duration, errorMessage); + + // Emit tool result event + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId: call.request.callId, + name: toolName, + success, + error: errorMessage, + responseParts: call.response.responseParts, + resultDisplay: call.response.resultDisplay + ? typeof call.response.resultDisplay === 'string' + ? call.response.resultDisplay + : JSON.stringify(call.response.resultDisplay) + : undefined, + durationMs: duration, + timestamp: Date.now(), + } as AgentToolResultEvent); + + // post-tool hook + await this.hooks?.postToolUse?.({ + subagentId: this.subagentId, + name: this.name, + toolName, + args: call.request.args, + success, + durationMs: duration, + errorMessage, + timestamp: Date.now(), + }); + + // Append response parts + const respParts = call.response.responseParts; + if (respParts) { + const parts = Array.isArray(respParts) ? respParts : [respParts]; + for (const part of parts) { + if (typeof part === 'string') { + toolResponseParts.push({ text: part }); + } else if (part) { + toolResponseParts.push(part); + } + } + } + } + // Signal that this batch is complete (all tools terminal) + resolveBatch?.(); + }, + onToolCallsUpdate: (calls: ToolCall[]) => { + for (const call of calls) { + if (call.status !== 'awaiting_approval') continue; + const waiting = call as WaitingToolCall; + + // Emit approval request event for UI visibility + try { + const { confirmationDetails } = waiting; + const { onConfirm: _onConfirm, ...rest } = confirmationDetails; + this.eventEmitter?.emit(AgentEventType.TOOL_WAITING_APPROVAL, { + subagentId: this.subagentId, + round: currentRound, + callId: waiting.request.callId, + name: waiting.request.name, + description: this.getToolDescription( + waiting.request.name, + waiting.request.args, + ), + confirmationDetails: rest, + respond: async ( + outcome: ToolConfirmationOutcome, + payload?: Parameters< + ToolCallConfirmationDetails['onConfirm'] + >[1], + ) => { + if (responded.has(waiting.request.callId)) return; + responded.add(waiting.request.callId); + await waiting.confirmationDetails.onConfirm(outcome, payload); + }, + timestamp: Date.now(), + }); + } catch { + // ignore UI event emission failures + } + } + }, + getPreferredEditor: () => undefined, + onEditorClose: () => {}, + }); + + // Prepare requests and emit TOOL_CALL events + const requests: ToolCallRequestInfo[] = authorizedCalls.map((fc) => { + const toolName = String(fc.name || 'unknown'); + const callId = fc.id ?? `${fc.name}-${Date.now()}`; + const args = (fc.args ?? {}) as Record; + const request: ToolCallRequestInfo = { + callId, + name: toolName, + args, + isClientInitiated: true, + prompt_id: promptId, + response_id: responseId, + }; + + const description = this.getToolDescription(toolName, args); + this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { + subagentId: this.subagentId, + round: currentRound, + callId, + name: toolName, + args, + description, + timestamp: Date.now(), + } as AgentToolCallEvent); + + // pre-tool hook + void this.hooks?.preToolUse?.({ + subagentId: this.subagentId, + name: this.name, + toolName, + args, + timestamp: Date.now(), + }); + + return request; + }); + + if (requests.length > 0) { + // Create a per-batch completion promise + const batchDone = new Promise((resolve) => { + resolveBatch = () => { + resolve(); + resolveBatch = null; + }; + }); + await scheduler.schedule(requests, abortController.signal); + await batchDone; + } + + // If all tool calls failed, inform the model so it can re-evaluate. + if (functionCalls.length > 0 && toolResponseParts.length === 0) { + toolResponseParts.push({ + text: 'All tool calls failed. Please analyze the errors and try an alternative approach.', + }); + } + + return [{ role: 'user', parts: toolResponseParts }]; + } + + // ─── Stats & Events ─────────────────────────────────────── + + getEventEmitter(): AgentEventEmitter | undefined { + return this.eventEmitter; + } + + getExecutionSummary(): AgentStatsSummary { + return this.stats.getSummary(); + } + + /** + * Returns legacy execution statistics and per-tool usage. + * Returns legacy execution statistics and per-tool usage. + */ + getStatistics(): { + successRate: number; + toolUsage: Array<{ + name: string; + count: number; + success: number; + failure: number; + lastError?: string; + totalDurationMs?: number; + averageDurationMs?: number; + }>; + } & ExecutionStats { + const total = this.executionStats.totalToolCalls; + const successRate = + total > 0 ? (this.executionStats.successfulToolCalls / total) * 100 : 0; + return { + ...this.executionStats, + successRate, + toolUsage: Array.from(this.toolUsage.entries()).map(([name, v]) => ({ + name, + ...v, + })), + }; + } + + /** + * Safely retrieves the description of a tool by attempting to build it. + * Returns an empty string if any error occurs during the process. + */ + getToolDescription(toolName: string, args: Record): string { + try { + const toolRegistry = this.runtimeContext.getToolRegistry(); + const tool = toolRegistry.getTool(toolName); + if (!tool) { + return ''; + } + + const toolInstance = tool.build(args); + return toolInstance.getDescription() || ''; + } catch { + return ''; + } + } + + /** + * Records tool call statistics for both successful and failed tool calls. + */ + recordToolCallStats( + toolName: string, + success: boolean, + durationMs: number, + errorMessage?: string, + ): void { + // Update aggregate stats + this.executionStats.totalToolCalls += 1; + if (success) { + this.executionStats.successfulToolCalls += 1; + } else { + this.executionStats.failedToolCalls += 1; + } + + // Per-tool usage + const tu = this.toolUsage.get(toolName) || { + count: 0, + success: 0, + failure: 0, + totalDurationMs: 0, + averageDurationMs: 0, + }; + tu.count += 1; + if (success) { + tu.success += 1; + } else { + tu.failure += 1; + tu.lastError = errorMessage || 'Unknown error'; + } + tu.totalDurationMs = (tu.totalDurationMs || 0) + durationMs; + tu.averageDurationMs = tu.count > 0 ? tu.totalDurationMs / tu.count : 0; + this.toolUsage.set(toolName, tu); + + // Update statistics service + this.stats.recordToolCall( + toolName, + success, + durationMs, + this.toolUsage.get(toolName)?.lastError, + ); + } + + // ─── Private Helpers ────────────────────────────────────── + + /** + * Builds the system prompt with template substitution and optional + * non-interactive instructions suffix. + */ + private buildChatSystemPrompt( + context: ContextState, + options?: CreateChatOptions, + ): string { + if (!this.promptConfig.systemPrompt) { + return ''; + } + + let finalPrompt = templateString(this.promptConfig.systemPrompt, context); + + // Only add non-interactive instructions when NOT in interactive mode + if (!options?.interactive) { + finalPrompt += ` + +Important Rules: + - You operate in non-interactive mode: do not ask the user questions; proceed with available context. + - Use tools only when necessary to obtain facts or make changes. + - When the task is complete, return the final result as a normal model response (not a tool call) and stop.`; + } + + return finalPrompt; + } + + /** + * Records token usage from model response metadata. + */ + private recordTokenUsage( + usage: GenerateContentResponseUsageMetadata, + turnCounter: number, + roundStreamStart: number, + ): void { + const inTok = Number(usage.promptTokenCount || 0); + const outTok = Number(usage.candidatesTokenCount || 0); + const thoughtTok = Number(usage.thoughtsTokenCount || 0); + const cachedTok = Number(usage.cachedContentTokenCount || 0); + if ( + isFinite(inTok) || + isFinite(outTok) || + isFinite(thoughtTok) || + isFinite(cachedTok) + ) { + this.stats.recordTokens( + isFinite(inTok) ? inTok : 0, + isFinite(outTok) ? outTok : 0, + isFinite(thoughtTok) ? thoughtTok : 0, + isFinite(cachedTok) ? cachedTok : 0, + ); + // Mirror legacy fields for compatibility + this.executionStats.inputTokens = + (this.executionStats.inputTokens || 0) + (isFinite(inTok) ? inTok : 0); + this.executionStats.outputTokens = + (this.executionStats.outputTokens || 0) + + (isFinite(outTok) ? outTok : 0); + this.executionStats.totalTokens = + (this.executionStats.inputTokens || 0) + + (this.executionStats.outputTokens || 0) + + (isFinite(thoughtTok) ? thoughtTok : 0) + + (isFinite(cachedTok) ? cachedTok : 0); + this.executionStats.estimatedCost = + (this.executionStats.inputTokens || 0) * 3e-5 + + (this.executionStats.outputTokens || 0) * 6e-5; + } + this.eventEmitter?.emit(AgentEventType.USAGE_METADATA, { + subagentId: this.subagentId, + round: turnCounter, + usage, + durationMs: Date.now() - roundStreamStart, + timestamp: Date.now(), + } as AgentUsageEvent); + } +} diff --git a/packages/core/src/subagents/subagent-events.ts b/packages/core/src/agents/runtime/agent-events.ts similarity index 78% rename from packages/core/src/subagents/subagent-events.ts rename to packages/core/src/agents/runtime/agent-events.ts index 5de09a3c2..8f68dd1c3 100644 --- a/packages/core/src/subagents/subagent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -9,10 +9,10 @@ import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolResultDisplay, -} from '../tools/tools.js'; +} from '../../tools/tools.js'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; -export type SubAgentEvent = +export type AgentEvent = | 'start' | 'round_start' | 'round_end' @@ -24,7 +24,7 @@ export type SubAgentEvent = | 'finish' | 'error'; -export enum SubAgentEventType { +export enum AgentEventType { START = 'start', ROUND_START = 'round_start', ROUND_END = 'round_end', @@ -37,7 +37,7 @@ export enum SubAgentEventType { ERROR = 'error', } -export interface SubAgentStartEvent { +export interface AgentStartEvent { subagentId: string; name: string; model?: string; @@ -45,14 +45,14 @@ export interface SubAgentStartEvent { timestamp: number; } -export interface SubAgentRoundEvent { +export interface AgentRoundEvent { subagentId: string; round: number; promptId: string; timestamp: number; } -export interface SubAgentStreamTextEvent { +export interface AgentStreamTextEvent { subagentId: string; round: number; text: string; @@ -61,7 +61,7 @@ export interface SubAgentStreamTextEvent { timestamp: number; } -export interface SubAgentUsageEvent { +export interface AgentUsageEvent { subagentId: string; round: number; usage: GenerateContentResponseUsageMetadata; @@ -69,7 +69,7 @@ export interface SubAgentUsageEvent { timestamp: number; } -export interface SubAgentToolCallEvent { +export interface AgentToolCallEvent { subagentId: string; round: number; callId: string; @@ -79,7 +79,7 @@ export interface SubAgentToolCallEvent { timestamp: number; } -export interface SubAgentToolResultEvent { +export interface AgentToolResultEvent { subagentId: string; round: number; callId: string; @@ -92,7 +92,7 @@ export interface SubAgentToolResultEvent { timestamp: number; } -export interface SubAgentApprovalRequestEvent { +export interface AgentApprovalRequestEvent { subagentId: string; round: number; callId: string; @@ -108,7 +108,7 @@ export interface SubAgentApprovalRequestEvent { timestamp: number; } -export interface SubAgentFinishEvent { +export interface AgentFinishEvent { subagentId: string; terminateReason: string; timestamp: number; @@ -122,24 +122,24 @@ export interface SubAgentFinishEvent { totalTokens?: number; } -export interface SubAgentErrorEvent { +export interface AgentErrorEvent { subagentId: string; error: string; timestamp: number; } -export class SubAgentEventEmitter { +export class AgentEventEmitter { private ee = new EventEmitter(); - on(event: SubAgentEvent, listener: (...args: unknown[]) => void) { + on(event: AgentEvent, listener: (...args: unknown[]) => void) { this.ee.on(event, listener); } - off(event: SubAgentEvent, listener: (...args: unknown[]) => void) { + off(event: AgentEvent, listener: (...args: unknown[]) => void) { this.ee.off(event, listener); } - emit(event: SubAgentEvent, payload: unknown) { + emit(event: AgentEvent, payload: unknown) { this.ee.emit(event, payload); } } diff --git a/packages/core/src/subagents/subagent.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts similarity index 87% rename from packages/core/src/subagents/subagent.test.ts rename to packages/core/src/agents/runtime/agent-headless.test.ts index ce6e64ae4..41b31cddc 100644 --- a/packages/core/src/subagents/subagent.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -21,39 +21,39 @@ import { vi, type Mock, } from 'vitest'; -import { Config, type ConfigParameters } from '../config/config.js'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; +import { Config, type ConfigParameters } from '../../config/config.js'; +import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { createContentGenerator, createContentGeneratorConfig, resolveContentGeneratorConfigWithSources, AuthType, -} from '../core/contentGenerator.js'; -import { GeminiChat } from '../core/geminiChat.js'; -import { executeToolCall } from '../core/nonInteractiveToolExecutor.js'; -import type { ToolRegistry } from '../tools/tool-registry.js'; -import { type AnyDeclarativeTool } from '../tools/tools.js'; -import { ContextState, SubAgentScope } from './subagent.js'; +} from '../../core/contentGenerator.js'; +import { GeminiChat } from '../../core/geminiChat.js'; +import { executeToolCall } from '../../core/nonInteractiveToolExecutor.js'; +import type { ToolRegistry } from '../../tools/tool-registry.js'; +import { type AnyDeclarativeTool } from '../../tools/tools.js'; +import { ContextState, AgentHeadless } from './agent-headless.js'; import { - SubAgentEventEmitter, - SubAgentEventType, - type SubAgentStreamTextEvent, - type SubAgentToolCallEvent, - type SubAgentToolResultEvent, -} from './subagent-events.js'; + AgentEventEmitter, + AgentEventType, + type AgentStreamTextEvent, + type AgentToolCallEvent, + type AgentToolResultEvent, +} from './agent-events.js'; import type { ModelConfig, PromptConfig, RunConfig, ToolConfig, -} from './types.js'; -import { SubagentTerminateMode } from './types.js'; +} from '../../subagents/types.js'; +import { SubagentTerminateMode } from '../../subagents/types.js'; -vi.mock('../core/geminiChat.js'); -vi.mock('../core/contentGenerator.js', async (importOriginal) => { +vi.mock('../../core/geminiChat.js'); +vi.mock('../../core/contentGenerator.js', async (importOriginal) => { const actual = - await importOriginal(); - const { DEFAULT_QWEN_MODEL } = await import('../config/models.js'); + await importOriginal(); + const { DEFAULT_QWEN_MODEL } = await import('../../config/models.js'); return { ...actual, createContentGenerator: vi.fn().mockResolvedValue({ @@ -77,7 +77,7 @@ vi.mock('../core/contentGenerator.js', async (importOriginal) => { }), }; }); -vi.mock('../utils/environmentContext.js', () => ({ +vi.mock('../../utils/environmentContext.js', () => ({ getEnvironmentContext: vi.fn().mockResolvedValue([{ text: 'Env Context' }]), getInitialChatHistory: vi.fn(async (_config, extraHistory) => [ { @@ -91,11 +91,11 @@ vi.mock('../utils/environmentContext.js', () => ({ ...(extraHistory ?? []), ]), })); -vi.mock('../core/nonInteractiveToolExecutor.js'); -vi.mock('../ide/ide-client.js'); -vi.mock('../core/client.js'); +vi.mock('../../core/nonInteractiveToolExecutor.js'); +vi.mock('../../ide/ide-client.js'); +vi.mock('../../core/client.js'); -vi.mock('../skills/skill-manager.js', () => { +vi.mock('../../skills/skill-manager.js', () => { const SkillManagerMock = vi.fn(); SkillManagerMock.prototype.startWatching = vi .fn() @@ -107,7 +107,7 @@ vi.mock('../skills/skill-manager.js', () => { return { SkillManager: SkillManagerMock }; }); -vi.mock('./subagent-manager.js', () => { +vi.mock('../../subagents/subagent-manager.js', () => { const SubagentManagerMock = vi.fn(); SubagentManagerMock.prototype.loadSessionSubagents = vi.fn(); SubagentManagerMock.prototype.addChangeListener = vi @@ -226,7 +226,7 @@ describe('subagent.ts', () => { }); }); - describe('SubAgentScope', () => { + describe('AgentHeadless', () => { let mockSendMessageStream: Mock; const defaultModelConfig: ModelConfig = { @@ -299,16 +299,16 @@ describe('subagent.ts', () => { describe('create (Tool Validation)', () => { const promptConfig: PromptConfig = { systemPrompt: 'Test prompt' }; - it('should create a SubAgentScope successfully with minimal config', async () => { + it('should create a AgentHeadless successfully with minimal config', async () => { const { config } = await createMockConfig(); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, defaultModelConfig, defaultRunConfig, ); - expect(scope).toBeInstanceOf(SubAgentScope); + expect(scope).toBeInstanceOf(AgentHeadless); }); it('should not block creation when a tool may require confirmation', async () => { @@ -331,7 +331,7 @@ describe('subagent.ts', () => { const toolConfig: ToolConfig = { tools: ['risky_tool'] }; - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -339,7 +339,7 @@ describe('subagent.ts', () => { defaultRunConfig, toolConfig, ); - expect(scope).toBeInstanceOf(SubAgentScope); + expect(scope).toBeInstanceOf(AgentHeadless); }); it('should succeed if tools do not require confirmation', async () => { @@ -357,7 +357,7 @@ describe('subagent.ts', () => { const toolConfig: ToolConfig = { tools: ['safe_tool'] }; - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -365,7 +365,7 @@ describe('subagent.ts', () => { defaultRunConfig, toolConfig, ); - expect(scope).toBeInstanceOf(SubAgentScope); + expect(scope).toBeInstanceOf(AgentHeadless); }); it('should allow creation regardless of tool parameter requirements', async () => { @@ -390,7 +390,7 @@ describe('subagent.ts', () => { const toolConfig: ToolConfig = { tools: ['tool_with_params'] }; - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -399,13 +399,13 @@ describe('subagent.ts', () => { toolConfig, ); - expect(scope).toBeInstanceOf(SubAgentScope); + expect(scope).toBeInstanceOf(AgentHeadless); // Ensure build was not called during creation expect(mockToolWithParams.build).not.toHaveBeenCalled(); }); }); - describe('runNonInteractive - Initialization and Prompting', () => { + describe('execute - Initialization and Prompting', () => { it('should correctly template the system prompt and initialize GeminiChat', async () => { const { config } = await createMockConfig(); @@ -421,7 +421,7 @@ describe('subagent.ts', () => { // Model stops immediately mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -429,7 +429,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(context); + await scope.execute(context); // Check if GeminiChat was initialized correctly by the subagent expect(GeminiChat).toHaveBeenCalledTimes(1); @@ -471,7 +471,7 @@ describe('subagent.ts', () => { // Model stops immediately mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -479,7 +479,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(context); + await scope.execute(context); const callArgs = vi.mocked(GeminiChat).mock.calls[0]; const generationConfig = getGenerationConfigFromMock(); @@ -505,7 +505,7 @@ describe('subagent.ts', () => { context.set('name', 'Agent'); // 'missing' is not set - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -513,8 +513,8 @@ describe('subagent.ts', () => { defaultRunConfig, ); - // The error from templating causes the runNonInteractive to reject and the terminate_reason to be ERROR. - await expect(scope.runNonInteractive(context)).rejects.toThrow( + // The error from templating causes the execute to reject and the terminate_reason to be ERROR. + await expect(scope.execute(context)).rejects.toThrow( 'Missing context values for the following keys: missing', ); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); @@ -528,7 +528,7 @@ describe('subagent.ts', () => { }; const context = new ContextState(); - const agent = await SubAgentScope.create( + const agent = await AgentHeadless.create( 'TestAgent', config, promptConfig, @@ -536,14 +536,14 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await expect(agent.runNonInteractive(context)).rejects.toThrow( + await expect(agent.execute(context)).rejects.toThrow( 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', ); expect(agent.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); }); }); - describe('runNonInteractive - Execution and Tool Use', () => { + describe('execute - Execution and Tool Use', () => { const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; it('should terminate with GOAL if no outputs are expected and model stops', async () => { @@ -551,7 +551,7 @@ describe('subagent.ts', () => { // Model stops immediately mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -560,7 +560,7 @@ describe('subagent.ts', () => { // No ToolConfig, No OutputConfig ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); @@ -576,7 +576,7 @@ describe('subagent.ts', () => { // Model stops immediately with text response mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -584,7 +584,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); @@ -647,7 +647,7 @@ describe('subagent.ts', () => { name === 'list_files' ? listFilesTool : undefined, ); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -656,7 +656,7 @@ describe('subagent.ts', () => { toolConfig, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); // Check the response sent back to the model (functionResponse part) const secondCallArgs = mockSendMessageStream.mock.calls[1][1]; @@ -671,7 +671,7 @@ describe('subagent.ts', () => { }); }); - describe('runNonInteractive - Termination and Recovery', () => { + describe('execute - Termination and Recovery', () => { const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; it('should terminate with MAX_TURNS if the limit is reached', async () => { @@ -703,7 +703,7 @@ describe('subagent.ts', () => { ]), ); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -711,7 +711,7 @@ describe('subagent.ts', () => { runConfig, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(mockSendMessageStream).toHaveBeenCalledTimes(2); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.MAX_TURNS); @@ -738,7 +738,7 @@ describe('subagent.ts', () => { // The LLM call will hang until we resolve the promise. mockSendMessageStream.mockReturnValue(streamPromise); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -746,7 +746,7 @@ describe('subagent.ts', () => { runConfig, ); - const runPromise = scope.runNonInteractive(new ContextState()); + const runPromise = scope.execute(new ContextState()); // Advance time beyond the limit (6 minutes) while the agent is awaiting the LLM response. await vi.advanceTimersByTimeAsync(6 * 60 * 1000); @@ -767,7 +767,7 @@ describe('subagent.ts', () => { const { config } = await createMockConfig(); mockSendMessageStream.mockRejectedValue(new Error('API Failure')); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -775,14 +775,14 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await expect( - scope.runNonInteractive(new ContextState()), - ).rejects.toThrow('API Failure'); + await expect(scope.execute(new ContextState())).rejects.toThrow( + 'API Failure', + ); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); }); }); - describe('runNonInteractive - Streaming and Thought Handling', () => { + describe('execute - Streaming and Thought Handling', () => { const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; // Helper to create a mock stream that yields specific parts @@ -816,13 +816,13 @@ describe('subagent.ts', () => { }) as unknown as GeminiChat, ); - const eventEmitter = new SubAgentEventEmitter(); - const events: SubAgentStreamTextEvent[] = []; - eventEmitter.on(SubAgentEventType.STREAM_TEXT, (...args: unknown[]) => { - events.push(args[0] as SubAgentStreamTextEvent); + const eventEmitter = new AgentEventEmitter(); + const events: AgentStreamTextEvent[] = []; + eventEmitter.on(AgentEventType.STREAM_TEXT, (...args: unknown[]) => { + events.push(args[0] as AgentStreamTextEvent); }); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -832,7 +832,7 @@ describe('subagent.ts', () => { eventEmitter, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(events).toHaveLength(2); expect(events[0]!.text).toBe('Let me think...'); @@ -855,7 +855,7 @@ describe('subagent.ts', () => { }) as unknown as GeminiChat, ); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -863,7 +863,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(scope.getFinalText()).toBe('The final answer.'); @@ -919,7 +919,7 @@ describe('subagent.ts', () => { }) as unknown as GeminiChat, ); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -927,7 +927,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); expect(scope.getFinalText()).toBe('Actual output.'); @@ -936,7 +936,7 @@ describe('subagent.ts', () => { }); }); - describe('runNonInteractive - Tool Restriction Enforcement (Issue #1121)', () => { + describe('execute - Tool Restriction Enforcement (Issue #1121)', () => { const promptConfig: PromptConfig = { systemPrompt: 'Execute task.' }; it('should NOT execute tools that are not in the allowed tools list', async () => { @@ -1045,19 +1045,19 @@ describe('subagent.ts', () => { ); // Track emitted events - const toolCallEvents: SubAgentToolCallEvent[] = []; - const toolResultEvents: SubAgentToolResultEvent[] = []; + const toolCallEvents: AgentToolCallEvent[] = []; + const toolResultEvents: AgentToolResultEvent[] = []; // Create event emitter BEFORE the scope and subscribe to events - const eventEmitter = new SubAgentEventEmitter(); - eventEmitter.on(SubAgentEventType.TOOL_CALL, (event: unknown) => { - toolCallEvents.push(event as SubAgentToolCallEvent); + const eventEmitter = new AgentEventEmitter(); + eventEmitter.on(AgentEventType.TOOL_CALL, (event: unknown) => { + toolCallEvents.push(event as AgentToolCallEvent); }); - eventEmitter.on(SubAgentEventType.TOOL_RESULT, (event: unknown) => { - toolResultEvents.push(event as SubAgentToolResultEvent); + eventEmitter.on(AgentEventType.TOOL_RESULT, (event: unknown) => { + toolResultEvents.push(event as AgentToolResultEvent); }); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -1067,7 +1067,7 @@ describe('subagent.ts', () => { eventEmitter, ); - await scope.runNonInteractive(new ContextState()); + await scope.execute(new ContextState()); // 1. Only allowed tool should be executed expect(executedTools).toContain('read_file'); diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts new file mode 100644 index 000000000..ce97d143b --- /dev/null +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -0,0 +1,362 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentHeadless — one-shot task execution wrapper around AgentCore. + * + * AgentHeadless manages + * the lifecycle of a single headless task: start → run → finish. + * It delegates all model reasoning and tool scheduling to AgentCore. + * + * For persistent interactive agents, see AgentInteractive (Phase 2). + */ + +import type { Config } from '../../config/config.js'; +import { createDebugLogger } from '../../utils/debugLogger.js'; +import type { AgentEventEmitter } from './agent-events.js'; +import { AgentEventType } from './agent-events.js'; +import type { + AgentStartEvent, + AgentErrorEvent, + AgentFinishEvent, +} from './agent-events.js'; +import type { AgentStatsSummary } from './agent-statistics.js'; +import type { AgentHooks } from './agent-hooks.js'; +import type { + PromptConfig, + ModelConfig, + RunConfig, + ToolConfig, +} from '../../subagents/types.js'; +import { SubagentTerminateMode } from '../../subagents/types.js'; +import { logSubagentExecution } from '../../telemetry/loggers.js'; +import { SubagentExecutionEvent } from '../../telemetry/types.js'; +import { AgentCore } from './agent-core.js'; +import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; + +const debugLogger = createDebugLogger('SUBAGENT'); + +// ─── Utilities (unchanged, re-exported for consumers) ──────── + +/** + * Manages the runtime context state for the subagent. + * This class provides a mechanism to store and retrieve key-value pairs + * that represent the dynamic state and variables accessible to the subagent + * during its execution. + */ +export class ContextState { + private state: Record = {}; + + /** + * Retrieves a value from the context state. + * + * @param key - The key of the value to retrieve. + * @returns The value associated with the key, or undefined if the key is not found. + */ + get(key: string): unknown { + return this.state[key]; + } + + /** + * Sets a value in the context state. + * + * @param key - The key to set the value under. + * @param value - The value to set. + */ + set(key: string, value: unknown): void { + this.state[key] = value; + } + + /** + * Retrieves all keys in the context state. + * + * @returns An array of all keys in the context state. + */ + get_keys(): string[] { + return Object.keys(this.state); + } +} + +/** + * Replaces `${...}` placeholders in a template string with values from a context. + * + * This function identifies all placeholders in the format `${key}`, validates that + * each key exists in the provided `ContextState`, and then performs the substitution. + * + * @param template The template string containing placeholders. + * @param context The `ContextState` object providing placeholder values. + * @returns The populated string with all placeholders replaced. + * @throws {Error} if any placeholder key is not found in the context. + */ +export function templateString( + template: string, + context: ContextState, +): string { + const placeholderRegex = /\$\{(\w+)\}/g; + + // First, find all unique keys required by the template. + const requiredKeys = new Set( + Array.from(template.matchAll(placeholderRegex), (match) => match[1]), + ); + + // Check if all required keys exist in the context. + const contextKeys = new Set(context.get_keys()); + const missingKeys = Array.from(requiredKeys).filter( + (key) => !contextKeys.has(key), + ); + + if (missingKeys.length > 0) { + throw new Error( + `Missing context values for the following keys: ${missingKeys.join( + ', ', + )}`, + ); + } + + // Perform the replacement using a replacer function. + return template.replace(placeholderRegex, (_match, key) => + String(context.get(key)), + ); +} + +// ─── AgentHeadless ────────────────────────────────────────── + +/** + * AgentHeadless — one-shot task executor. + * + * Takes a task, runs it through AgentCore's reasoning loop, and returns + * the result. + * + * Lifecycle: Born → execute() → die. + */ +export class AgentHeadless { + private readonly core: AgentCore; + private finalText: string = ''; + private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR; + + private constructor(core: AgentCore) { + this.core = core; + } + + /** + * Creates a new AgentHeadless instance. + * + * @param name - The name for the subagent, used for logging and identification. + * @param runtimeContext - The shared runtime configuration and services. + * @param promptConfig - Configuration for the subagent's prompt and behavior. + * @param modelConfig - Configuration for the generative model parameters. + * @param runConfig - Configuration for the subagent's execution environment. + * @param toolConfig - Optional configuration for tools available to the subagent. + * @param eventEmitter - Optional event emitter for streaming events to UI. + * @param hooks - Optional lifecycle hooks. + */ + static async create( + name: string, + runtimeContext: Config, + promptConfig: PromptConfig, + modelConfig: ModelConfig, + runConfig: RunConfig, + toolConfig?: ToolConfig, + eventEmitter?: AgentEventEmitter, + hooks?: AgentHooks, + ): Promise { + const core = new AgentCore( + name, + runtimeContext, + promptConfig, + modelConfig, + runConfig, + toolConfig, + eventEmitter, + hooks, + ); + return new AgentHeadless(core); + } + + /** + * Executes the task in headless mode. + * + * This method orchestrates the subagent's execution lifecycle: + * 1. Creates a chat session + * 2. Prepares tools + * 3. Runs the reasoning loop until completion/termination + * 4. Emits start/finish/error events + * 5. Records telemetry + * + * @param context - The current context state containing variables for prompt templating. + * @param externalSignal - Optional abort signal for external cancellation. + */ + async execute( + context: ContextState, + externalSignal?: AbortSignal, + ): Promise { + const chat = await this.core.createChat(context); + + if (!chat) { + this.terminateMode = SubagentTerminateMode.ERROR; + return; + } + + // Set up abort signal propagation + const abortController = new AbortController(); + const onExternalAbort = () => { + abortController.abort(); + }; + if (externalSignal) { + externalSignal.addEventListener('abort', onExternalAbort); + } + if (externalSignal?.aborted) { + abortController.abort(); + } + + const toolsList = this.core.prepareTools(); + + const initialTaskText = String( + (context.get('task_prompt') as string) ?? 'Get Started!', + ); + const initialMessages = [ + { role: 'user' as const, parts: [{ text: initialTaskText }] }, + ]; + + const startTime = Date.now(); + this.core.executionStats.startTimeMs = startTime; + this.core.stats.start(startTime); + + try { + // Emit start event + this.core.eventEmitter?.emit(AgentEventType.START, { + subagentId: this.core.subagentId, + name: this.core.name, + model: + this.core.modelConfig.model || + this.core.runtimeContext.getModel() || + DEFAULT_QWEN_MODEL, + tools: (this.core.toolConfig?.tools || ['*']).map((t) => + typeof t === 'string' ? t : t.name, + ), + timestamp: Date.now(), + } as AgentStartEvent); + + // Log telemetry for subagent start + const startEvent = new SubagentExecutionEvent(this.core.name, 'started'); + logSubagentExecution(this.core.runtimeContext, startEvent); + + // Delegate to AgentCore's reasoning loop + const result = await this.core.runReasoningLoop( + chat, + initialMessages, + toolsList, + abortController, + { + maxTurns: this.core.runConfig.max_turns, + maxTimeMinutes: this.core.runConfig.max_time_minutes, + startTimeMs: startTime, + }, + ); + + this.finalText = result.text; + this.terminateMode = result.terminateMode ?? SubagentTerminateMode.GOAL; + } catch (error) { + debugLogger.error('Error during subagent execution:', error); + this.terminateMode = SubagentTerminateMode.ERROR; + this.core.eventEmitter?.emit(AgentEventType.ERROR, { + subagentId: this.core.subagentId, + error: error instanceof Error ? error.message : String(error), + timestamp: Date.now(), + } as AgentErrorEvent); + + throw error; + } finally { + if (externalSignal) { + externalSignal.removeEventListener('abort', onExternalAbort); + } + this.core.executionStats.totalDurationMs = Date.now() - startTime; + const summary = this.core.stats.getSummary(Date.now()); + this.core.eventEmitter?.emit(AgentEventType.FINISH, { + subagentId: this.core.subagentId, + terminateReason: this.terminateMode, + timestamp: Date.now(), + rounds: summary.rounds, + totalDurationMs: summary.totalDurationMs, + totalToolCalls: summary.totalToolCalls, + successfulToolCalls: summary.successfulToolCalls, + failedToolCalls: summary.failedToolCalls, + inputTokens: summary.inputTokens, + outputTokens: summary.outputTokens, + totalTokens: summary.totalTokens, + } as AgentFinishEvent); + + const completionEvent = new SubagentExecutionEvent( + this.core.name, + this.terminateMode === SubagentTerminateMode.GOAL + ? 'completed' + : 'failed', + { + terminate_reason: this.terminateMode, + result: this.finalText, + execution_summary: this.core.stats.formatCompact( + 'Subagent execution completed', + ), + }, + ); + logSubagentExecution(this.core.runtimeContext, completionEvent); + + await this.core.hooks?.onStop?.({ + subagentId: this.core.subagentId, + name: this.core.name, + terminateReason: this.terminateMode, + summary: summary as unknown as Record, + timestamp: Date.now(), + }); + } + } + + // ─── Accessors ───────────────────────────────────────────── + + /** + * Provides access to the underlying AgentCore for advanced use cases. + * Used by AgentInteractive and InProcessBackend. + */ + getCore(): AgentCore { + return this.core; + } + + get executionStats() { + return this.core.executionStats; + } + + set executionStats(value) { + this.core.executionStats = value; + } + + getEventEmitter() { + return this.core.getEventEmitter(); + } + + getStatistics() { + return this.core.getStatistics(); + } + + getExecutionSummary(): AgentStatsSummary { + return this.core.getExecutionSummary(); + } + + getFinalText(): string { + return this.finalText; + } + + getTerminateMode(): SubagentTerminateMode { + return this.terminateMode; + } + + get name(): string { + return this.core.name; + } + + get runtimeContext(): Config { + return this.core.runtimeContext; + } +} diff --git a/packages/core/src/subagents/subagent-hooks.ts b/packages/core/src/agents/runtime/agent-hooks.ts similarity index 83% rename from packages/core/src/subagents/subagent-hooks.ts rename to packages/core/src/agents/runtime/agent-hooks.ts index f3bf997bf..76b65f95e 100644 --- a/packages/core/src/subagents/subagent-hooks.ts +++ b/packages/core/src/agents/runtime/agent-hooks.ts @@ -18,7 +18,7 @@ export interface PostToolUsePayload extends PreToolUsePayload { errorMessage?: string; } -export interface SubagentStopPayload { +export interface AgentStopPayload { subagentId: string; name: string; // subagent name terminateReason: string; @@ -26,8 +26,8 @@ export interface SubagentStopPayload { timestamp: number; } -export interface SubagentHooks { +export interface AgentHooks { preToolUse?(payload: PreToolUsePayload): Promise | void; postToolUse?(payload: PostToolUsePayload): Promise | void; - onStop?(payload: SubagentStopPayload): Promise | void; + onStop?(payload: AgentStopPayload): Promise | void; } diff --git a/packages/core/src/subagents/subagent-statistics.test.ts b/packages/core/src/agents/runtime/agent-statistics.test.ts similarity index 98% rename from packages/core/src/subagents/subagent-statistics.test.ts rename to packages/core/src/agents/runtime/agent-statistics.test.ts index 39ba70aa4..5da21c17d 100644 --- a/packages/core/src/subagents/subagent-statistics.test.ts +++ b/packages/core/src/agents/runtime/agent-statistics.test.ts @@ -5,14 +5,14 @@ */ import { describe, it, expect, beforeEach } from 'vitest'; -import { SubagentStatistics } from './subagent-statistics.js'; +import { AgentStatistics } from './agent-statistics.js'; -describe('SubagentStatistics', () => { - let stats: SubagentStatistics; +describe('AgentStatistics', () => { + let stats: AgentStatistics; const baseTime = 1000000000000; // Fixed timestamp for consistent testing beforeEach(() => { - stats = new SubagentStatistics(); + stats = new AgentStatistics(); }); describe('basic statistics tracking', () => { diff --git a/packages/core/src/subagents/subagent-statistics.ts b/packages/core/src/agents/runtime/agent-statistics.ts similarity index 97% rename from packages/core/src/subagents/subagent-statistics.ts rename to packages/core/src/agents/runtime/agent-statistics.ts index 72308c633..8487d5e0b 100644 --- a/packages/core/src/subagents/subagent-statistics.ts +++ b/packages/core/src/agents/runtime/agent-statistics.ts @@ -14,7 +14,7 @@ export interface ToolUsageStats { averageDurationMs: number; } -export interface SubagentStatsSummary { +export interface AgentStatsSummary { rounds: number; totalDurationMs: number; totalToolCalls: number; @@ -30,7 +30,7 @@ export interface SubagentStatsSummary { toolUsage: ToolUsageStats[]; } -export class SubagentStatistics { +export class AgentStatistics { private startTimeMs = 0; private rounds = 0; private totalToolCalls = 0; @@ -90,7 +90,7 @@ export class SubagentStatistics { this.cachedTokens += Math.max(0, cached || 0); } - getSummary(now = Date.now()): SubagentStatsSummary { + getSummary(now = Date.now()): AgentStatsSummary { const totalDurationMs = this.startTimeMs ? now - this.startTimeMs : 0; const totalToolCalls = this.totalToolCalls; const successRate = @@ -217,7 +217,7 @@ export class SubagentStatistics { return `${h}h ${m}m`; } - private generatePerformanceTips(stats: SubagentStatsSummary): string[] { + private generatePerformanceTips(stats: AgentStatsSummary): string[] { const tips: string[] = []; const totalCalls = stats.totalToolCalls; const sr = diff --git a/packages/core/src/agents/runtime/index.ts b/packages/core/src/agents/runtime/index.ts new file mode 100644 index 000000000..025790798 --- /dev/null +++ b/packages/core/src/agents/runtime/index.ts @@ -0,0 +1,15 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Runtime barrel — re-exports agent execution primitives. + */ + +export * from './agent-core.js'; +export * from './agent-headless.js'; +export * from './agent-events.js'; +export * from './agent-statistics.js'; +export * from './agent-hooks.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 964880b4e..0d7fd5a09 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -21,8 +21,8 @@ import type { ContentGeneratorConfigSources } from '../core/contentGenerator.js' import type { MCPOAuthConfig } from '../mcp/oauth-provider.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import type { AnyToolInvocation } from '../tools/tools.js'; -import type { ArenaManager } from '../agents-collab/arena/ArenaManager.js'; -import { ArenaAgentClient } from '../agents-collab/arena/ArenaAgentClient.js'; +import type { ArenaManager } from '../agents/arena/ArenaManager.js'; +import { ArenaAgentClient } from '../agents/arena/ArenaAgentClient.js'; // Core import { BaseLlmClient } from '../core/baseLlmClient.js'; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 4c34412c2..6b6b18351 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -131,7 +131,7 @@ export * from './tools/tool-registry.js'; export * from './subagents/index.js'; // Export shared multi-agent infrastructure -export * from './agents-collab/index.js'; +export * from './agents/index.js'; // Export skills export * from './skills/index.js'; diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 5f0b8bd1b..e1a359873 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -11,7 +11,7 @@ import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; import { isNodeError } from '../utils/errors.js'; -import type { ArenaConfigFile } from '../agents-collab/arena/types.js'; +import type { ArenaConfigFile } from '../agents/arena/types.js'; /** * Commit message used for the baseline snapshot in arena worktrees. diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index 17c62a200..f877d23d8 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -8,7 +8,7 @@ * @fileoverview Subagents Phase 1 implementation - File-based configuration layer * * This module provides the foundation for the subagents feature by implementing - * a file-based configuration system that builds on the existing SubAgentScope + * a file-based configuration system that builds on the AgentHeadless * runtime system. It includes: * * - Type definitions for file-based subagent configurations @@ -50,26 +50,29 @@ export type { SubagentTerminateMode, } from './types.js'; -export { SubAgentScope } from './subagent.js'; +export { AgentHeadless } from '../agents/runtime/agent-headless.js'; // Event system for UI integration export type { - SubAgentEvent, - SubAgentStartEvent, - SubAgentRoundEvent, - SubAgentStreamTextEvent, - SubAgentUsageEvent, - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentFinishEvent, - SubAgentErrorEvent, - SubAgentApprovalRequestEvent, -} from './subagent-events.js'; + AgentEvent, + AgentStartEvent, + AgentRoundEvent, + AgentStreamTextEvent, + AgentUsageEvent, + AgentToolCallEvent, + AgentToolResultEvent, + AgentFinishEvent, + AgentErrorEvent, + AgentApprovalRequestEvent, +} from '../agents/runtime/agent-events.js'; -export { SubAgentEventEmitter, SubAgentEventType } from './subagent-events.js'; +export { + AgentEventEmitter, + AgentEventType, +} from '../agents/runtime/agent-events.js'; // Statistics and formatting export type { - SubagentStatsSummary, + AgentStatsSummary, ToolUsageStats, -} from './subagent-statistics.js'; +} from '../agents/runtime/agent-statistics.js'; diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index fea33040c..b2fa2c47e 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -26,7 +26,9 @@ import type { } from './types.js'; import { SubagentError, SubagentErrorCode } from './types.js'; import { SubagentValidator } from './validation.js'; -import { SubAgentScope } from './subagent.js'; +import { AgentHeadless } from '../agents/runtime/agent-headless.js'; +import type { AgentEventEmitter } from '../agents/runtime/agent-events.js'; +import type { AgentHooks } from '../agents/runtime/agent-hooks.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; @@ -578,24 +580,24 @@ export class SubagentManager { } /** - * Creates a SubAgentScope from a subagent configuration. + * Creates an AgentHeadless from a subagent configuration. * * @param config - Subagent configuration * @param runtimeContext - Runtime context - * @returns Promise resolving to SubAgentScope + * @returns Promise resolving to AgentHeadless */ - async createSubagentScope( + async createAgentHeadless( config: SubagentConfig, runtimeContext: Config, options?: { - eventEmitter?: import('./subagent-events.js').SubAgentEventEmitter; - hooks?: import('./subagent-hooks.js').SubagentHooks; + eventEmitter?: AgentEventEmitter; + hooks?: AgentHooks; }, - ): Promise { + ): Promise { try { const runtimeConfig = this.convertToRuntimeConfig(config); - return await SubAgentScope.create( + return await AgentHeadless.create( config.name, runtimeContext, runtimeConfig.promptConfig, @@ -608,7 +610,7 @@ export class SubagentManager { } catch (error) { if (error instanceof Error) { throw new SubagentError( - `Failed to create SubAgentScope: ${error.message}`, + `Failed to create AgentHeadless: ${error.message}`, SubagentErrorCode.INVALID_CONFIG, config.name, ); @@ -619,10 +621,10 @@ export class SubagentManager { /** * Converts a file-based SubagentConfig to runtime configuration - * compatible with SubAgentScope.create(). + * compatible with AgentHeadless.create(). * * @param config - File-based subagent configuration - * @returns Runtime configuration for SubAgentScope + * @returns Runtime configuration for AgentHeadless */ convertToRuntimeConfig(config: SubagentConfig): SubagentRuntimeConfig { // Build prompt configuration diff --git a/packages/core/src/subagents/subagent.ts b/packages/core/src/subagents/subagent.ts deleted file mode 100644 index c9328e5ad..000000000 --- a/packages/core/src/subagents/subagent.ts +++ /dev/null @@ -1,1004 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -import { reportError } from '../utils/errorReporting.js'; -import type { Config } from '../config/config.js'; -import { createDebugLogger } from '../utils/debugLogger.js'; - -const debugLogger = createDebugLogger('SUBAGENT'); -import { type ToolCallRequestInfo } from '../core/turn.js'; -import { - CoreToolScheduler, - type ToolCall, - type WaitingToolCall, -} from '../core/coreToolScheduler.js'; -import type { - ToolConfirmationOutcome, - ToolCallConfirmationDetails, -} from '../tools/tools.js'; -import { getInitialChatHistory } from '../utils/environmentContext.js'; -import type { - Content, - Part, - FunctionCall, - GenerateContentConfig, - FunctionDeclaration, - GenerateContentResponseUsageMetadata, -} from '@google/genai'; -import { GeminiChat } from '../core/geminiChat.js'; -import type { - PromptConfig, - ModelConfig, - RunConfig, - ToolConfig, -} from './types.js'; -import { SubagentTerminateMode } from './types.js'; -import type { - SubAgentFinishEvent, - SubAgentRoundEvent, - SubAgentStartEvent, - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentErrorEvent, - SubAgentUsageEvent, -} from './subagent-events.js'; -import { - type SubAgentEventEmitter, - SubAgentEventType, -} from './subagent-events.js'; -import { - SubagentStatistics, - type SubagentStatsSummary, -} from './subagent-statistics.js'; -import type { SubagentHooks } from './subagent-hooks.js'; -import { logSubagentExecution } from '../telemetry/loggers.js'; -import { SubagentExecutionEvent } from '../telemetry/types.js'; -import { TaskTool } from '../tools/task.js'; -import { DEFAULT_QWEN_MODEL } from '../config/models.js'; - -/** - * @fileoverview Defines the configuration interfaces for a subagent. - * - * These interfaces specify the structure for defining the subagent's prompt, - * the model parameters, and the execution settings. - */ - -interface ExecutionStats { - startTimeMs: number; - totalDurationMs: number; - rounds: number; - totalToolCalls: number; - successfulToolCalls: number; - failedToolCalls: number; - inputTokens?: number; - outputTokens?: number; - totalTokens?: number; - estimatedCost?: number; -} - -/** - * Manages the runtime context state for the subagent. - * This class provides a mechanism to store and retrieve key-value pairs - * that represent the dynamic state and variables accessible to the subagent - * during its execution. - */ -export class ContextState { - private state: Record = {}; - - /** - * Retrieves a value from the context state. - * - * @param key - The key of the value to retrieve. - * @returns The value associated with the key, or undefined if the key is not found. - */ - get(key: string): unknown { - return this.state[key]; - } - - /** - * Sets a value in the context state. - * - * @param key - The key to set the value under. - * @param value - The value to set. - */ - set(key: string, value: unknown): void { - this.state[key] = value; - } - - /** - * Retrieves all keys in the context state. - * - * @returns An array of all keys in the context state. - */ - get_keys(): string[] { - return Object.keys(this.state); - } -} - -/** - * Replaces `${...}` placeholders in a template string with values from a context. - * - * This function identifies all placeholders in the format `${key}`, validates that - * each key exists in the provided `ContextState`, and then performs the substitution. - * - * @param template The template string containing placeholders. - * @param context The `ContextState` object providing placeholder values. - * @returns The populated string with all placeholders replaced. - * @throws {Error} if any placeholder key is not found in the context. - */ -function templateString(template: string, context: ContextState): string { - const placeholderRegex = /\$\{(\w+)\}/g; - - // First, find all unique keys required by the template. - const requiredKeys = new Set( - Array.from(template.matchAll(placeholderRegex), (match) => match[1]), - ); - - // Check if all required keys exist in the context. - const contextKeys = new Set(context.get_keys()); - const missingKeys = Array.from(requiredKeys).filter( - (key) => !contextKeys.has(key), - ); - - if (missingKeys.length > 0) { - throw new Error( - `Missing context values for the following keys: ${missingKeys.join( - ', ', - )}`, - ); - } - - // Perform the replacement using a replacer function. - return template.replace(placeholderRegex, (_match, key) => - String(context.get(key)), - ); -} - -/** - * Represents the scope and execution environment for a subagent. - * This class orchestrates the subagent's lifecycle, managing its chat interactions, - * runtime context, and the collection of its outputs. - */ -export class SubAgentScope { - executionStats: ExecutionStats = { - startTimeMs: 0, - totalDurationMs: 0, - rounds: 0, - totalToolCalls: 0, - successfulToolCalls: 0, - failedToolCalls: 0, - inputTokens: 0, - outputTokens: 0, - totalTokens: 0, - estimatedCost: 0, - }; - private toolUsage = new Map< - string, - { - count: number; - success: number; - failure: number; - lastError?: string; - totalDurationMs?: number; - averageDurationMs?: number; - } - >(); - private eventEmitter?: SubAgentEventEmitter; - private finalText: string = ''; - private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR; - private readonly stats = new SubagentStatistics(); - private hooks?: SubagentHooks; - private readonly subagentId: string; - - /** - * Constructs a new SubAgentScope instance. - * @param name - The name for the subagent, used for logging and identification. - * @param runtimeContext - The shared runtime configuration and services. - * @param promptConfig - Configuration for the subagent's prompt and behavior. - * @param modelConfig - Configuration for the generative model parameters. - * @param runConfig - Configuration for the subagent's execution environment. - * @param toolConfig - Optional configuration for tools available to the subagent. - */ - private constructor( - readonly name: string, - readonly runtimeContext: Config, - private readonly promptConfig: PromptConfig, - private readonly modelConfig: ModelConfig, - private readonly runConfig: RunConfig, - private readonly toolConfig?: ToolConfig, - eventEmitter?: SubAgentEventEmitter, - hooks?: SubagentHooks, - ) { - const randomPart = Math.random().toString(36).slice(2, 8); - this.subagentId = `${this.name}-${randomPart}`; - this.eventEmitter = eventEmitter; - this.hooks = hooks; - } - - /** - * Creates and validates a new SubAgentScope instance. - * This factory method ensures that all tools provided in the prompt configuration - * are valid for non-interactive use before creating the subagent instance. - * @param {string} name - The name of the subagent. - * @param {Config} runtimeContext - The shared runtime configuration and services. - * @param {PromptConfig} promptConfig - Configuration for the subagent's prompt and behavior. - * @param {ModelConfig} modelConfig - Configuration for the generative model parameters. - * @param {RunConfig} runConfig - Configuration for the subagent's execution environment. - * @param {ToolConfig} [toolConfig] - Optional configuration for tools. - * @returns {Promise} A promise that resolves to a valid SubAgentScope instance. - * @throws {Error} If any tool requires user confirmation. - */ - static async create( - name: string, - runtimeContext: Config, - promptConfig: PromptConfig, - modelConfig: ModelConfig, - runConfig: RunConfig, - toolConfig?: ToolConfig, - eventEmitter?: SubAgentEventEmitter, - hooks?: SubagentHooks, - ): Promise { - return new SubAgentScope( - name, - runtimeContext, - promptConfig, - modelConfig, - runConfig, - toolConfig, - eventEmitter, - hooks, - ); - } - - /** - * Runs the subagent in a non-interactive mode. - * This method orchestrates the subagent's execution loop, including prompt templating, - * tool execution, and termination conditions. - * @param {ContextState} context - The current context state containing variables for prompt templating. - * @returns {Promise} A promise that resolves when the subagent has completed its execution. - */ - async runNonInteractive( - context: ContextState, - externalSignal?: AbortSignal, - ): Promise { - const chat = await this.createChatObject(context); - - if (!chat) { - this.terminateMode = SubagentTerminateMode.ERROR; - return; - } - - // Track the current round's AbortController for external signal propagation - let currentRoundAbortController: AbortController | null = null; - const onExternalAbort = () => { - currentRoundAbortController?.abort(); - }; - if (externalSignal) { - externalSignal.addEventListener('abort', onExternalAbort); - } - - const toolRegistry = this.runtimeContext.getToolRegistry(); - - // Prepare the list of tools available to the subagent. - // If no explicit toolConfig or it contains "*" or is empty, inherit all tools. - const toolsList: FunctionDeclaration[] = []; - if (this.toolConfig) { - const asStrings = this.toolConfig.tools.filter( - (t): t is string => typeof t === 'string', - ); - const hasWildcard = asStrings.includes('*'); - const onlyInlineDecls = this.toolConfig.tools.filter( - (t): t is FunctionDeclaration => typeof t !== 'string', - ); - - if (hasWildcard || asStrings.length === 0) { - toolsList.push( - ...toolRegistry - .getFunctionDeclarations() - .filter((t) => t.name !== TaskTool.Name), - ); - } else { - toolsList.push( - ...toolRegistry.getFunctionDeclarationsFiltered(asStrings), - ); - } - toolsList.push(...onlyInlineDecls); - } else { - // Inherit all available tools by default when not specified. - toolsList.push( - ...toolRegistry - .getFunctionDeclarations() - .filter((t) => t.name !== TaskTool.Name), - ); - } - - const initialTaskText = String( - (context.get('task_prompt') as string) ?? 'Get Started!', - ); - let currentMessages: Content[] = [ - { role: 'user', parts: [{ text: initialTaskText }] }, - ]; - - const startTime = Date.now(); - this.executionStats.startTimeMs = startTime; - this.stats.start(startTime); - let turnCounter = 0; - try { - // Emit start event - this.eventEmitter?.emit(SubAgentEventType.START, { - subagentId: this.subagentId, - name: this.name, - model: - this.modelConfig.model || - this.runtimeContext.getModel() || - DEFAULT_QWEN_MODEL, - tools: (this.toolConfig?.tools || ['*']).map((t) => - typeof t === 'string' ? t : t.name, - ), - timestamp: Date.now(), - } as SubAgentStartEvent); - - // Log telemetry for subagent start - const startEvent = new SubagentExecutionEvent(this.name, 'started'); - logSubagentExecution(this.runtimeContext, startEvent); - while (true) { - // Create a new AbortController for each round to avoid listener accumulation - const roundAbortController = new AbortController(); - currentRoundAbortController = roundAbortController; - - // If external signal already aborted, cancel immediately - if (externalSignal?.aborted) { - roundAbortController.abort(); - } - - // Check termination conditions. - if ( - this.runConfig.max_turns && - turnCounter >= this.runConfig.max_turns - ) { - this.terminateMode = SubagentTerminateMode.MAX_TURNS; - break; - } - let durationMin = (Date.now() - startTime) / (1000 * 60); - if ( - this.runConfig.max_time_minutes && - durationMin >= this.runConfig.max_time_minutes - ) { - this.terminateMode = SubagentTerminateMode.TIMEOUT; - break; - } - - const promptId = `${this.runtimeContext.getSessionId()}#${this.subagentId}#${turnCounter++}`; - - const messageParams = { - message: currentMessages[0]?.parts || [], - config: { - abortSignal: roundAbortController.signal, - tools: [{ functionDeclarations: toolsList }], - }, - }; - - const roundStreamStart = Date.now(); - const responseStream = await chat.sendMessageStream( - this.modelConfig.model || - this.runtimeContext.getModel() || - DEFAULT_QWEN_MODEL, - messageParams, - promptId, - ); - this.eventEmitter?.emit(SubAgentEventType.ROUND_START, { - subagentId: this.subagentId, - round: turnCounter, - promptId, - timestamp: Date.now(), - } as SubAgentRoundEvent); - - const functionCalls: FunctionCall[] = []; - let roundText = ''; - let lastUsage: GenerateContentResponseUsageMetadata | undefined = - undefined; - let currentResponseId: string | undefined = undefined; - for await (const streamEvent of responseStream) { - if (roundAbortController.signal.aborted) { - this.terminateMode = SubagentTerminateMode.CANCELLED; - return; - } - - // Handle retry events - if (streamEvent.type === 'retry') { - continue; - } - - // Handle chunk events - if (streamEvent.type === 'chunk') { - const resp = streamEvent.value; - // Track the response ID for tool call correlation - if (resp.responseId) { - currentResponseId = resp.responseId; - } - if (resp.functionCalls) functionCalls.push(...resp.functionCalls); - const content = resp.candidates?.[0]?.content; - const parts = content?.parts || []; - for (const p of parts) { - const txt = p.text; - const isThought = p.thought ?? false; - if (txt && !isThought) roundText += txt; - if (txt) - this.eventEmitter?.emit(SubAgentEventType.STREAM_TEXT, { - subagentId: this.subagentId, - round: turnCounter, - text: txt, - thought: isThought, - timestamp: Date.now(), - }); - } - if (resp.usageMetadata) lastUsage = resp.usageMetadata; - } - } - this.executionStats.rounds = turnCounter; - this.stats.setRounds(turnCounter); - - durationMin = (Date.now() - startTime) / (1000 * 60); - if ( - this.runConfig.max_time_minutes && - durationMin >= this.runConfig.max_time_minutes - ) { - this.terminateMode = SubagentTerminateMode.TIMEOUT; - break; - } - - // Update token usage if available - if (lastUsage) { - const inTok = Number(lastUsage.promptTokenCount || 0); - const outTok = Number(lastUsage.candidatesTokenCount || 0); - const thoughtTok = Number(lastUsage.thoughtsTokenCount || 0); - const cachedTok = Number(lastUsage.cachedContentTokenCount || 0); - if ( - isFinite(inTok) || - isFinite(outTok) || - isFinite(thoughtTok) || - isFinite(cachedTok) - ) { - this.stats.recordTokens( - isFinite(inTok) ? inTok : 0, - isFinite(outTok) ? outTok : 0, - isFinite(thoughtTok) ? thoughtTok : 0, - isFinite(cachedTok) ? cachedTok : 0, - ); - // mirror legacy fields for compatibility - this.executionStats.inputTokens = - (this.executionStats.inputTokens || 0) + - (isFinite(inTok) ? inTok : 0); - this.executionStats.outputTokens = - (this.executionStats.outputTokens || 0) + - (isFinite(outTok) ? outTok : 0); - this.executionStats.totalTokens = - (this.executionStats.inputTokens || 0) + - (this.executionStats.outputTokens || 0) + - (isFinite(thoughtTok) ? thoughtTok : 0) + - (isFinite(cachedTok) ? cachedTok : 0); - this.executionStats.estimatedCost = - (this.executionStats.inputTokens || 0) * 3e-5 + - (this.executionStats.outputTokens || 0) * 6e-5; - } - this.eventEmitter?.emit(SubAgentEventType.USAGE_METADATA, { - subagentId: this.subagentId, - round: turnCounter, - usage: lastUsage, - durationMs: Date.now() - roundStreamStart, - timestamp: Date.now(), - } as SubAgentUsageEvent); - } - - if (functionCalls.length > 0) { - currentMessages = await this.processFunctionCalls( - functionCalls, - roundAbortController, - promptId, - turnCounter, - toolsList, - currentResponseId, - ); - } else { - // No tool calls — treat this as the model's final answer. - if (roundText && roundText.trim().length > 0) { - this.finalText = roundText.trim(); - this.terminateMode = SubagentTerminateMode.GOAL; - break; - } - // Otherwise, nudge the model to finalize a result. - currentMessages = [ - { - role: 'user', - parts: [ - { - text: 'Please provide the final result now and stop calling tools.', - }, - ], - }, - ]; - } - this.eventEmitter?.emit(SubAgentEventType.ROUND_END, { - subagentId: this.subagentId, - round: turnCounter, - promptId, - timestamp: Date.now(), - } as SubAgentRoundEvent); - } - } catch (error) { - debugLogger.error('Error during subagent execution:', error); - this.terminateMode = SubagentTerminateMode.ERROR; - this.eventEmitter?.emit(SubAgentEventType.ERROR, { - subagentId: this.subagentId, - error: error instanceof Error ? error.message : String(error), - timestamp: Date.now(), - } as SubAgentErrorEvent); - - throw error; - } finally { - if (externalSignal) { - externalSignal.removeEventListener('abort', onExternalAbort); - } - // Clear the reference to allow GC - currentRoundAbortController = null; - this.executionStats.totalDurationMs = Date.now() - startTime; - const summary = this.stats.getSummary(Date.now()); - this.eventEmitter?.emit(SubAgentEventType.FINISH, { - subagentId: this.subagentId, - terminateReason: this.terminateMode, - timestamp: Date.now(), - rounds: summary.rounds, - totalDurationMs: summary.totalDurationMs, - totalToolCalls: summary.totalToolCalls, - successfulToolCalls: summary.successfulToolCalls, - failedToolCalls: summary.failedToolCalls, - inputTokens: summary.inputTokens, - outputTokens: summary.outputTokens, - totalTokens: summary.totalTokens, - } as SubAgentFinishEvent); - - const completionEvent = new SubagentExecutionEvent( - this.name, - this.terminateMode === SubagentTerminateMode.GOAL - ? 'completed' - : 'failed', - { - terminate_reason: this.terminateMode, - result: this.finalText, - execution_summary: this.stats.formatCompact( - 'Subagent execution completed', - ), - }, - ); - logSubagentExecution(this.runtimeContext, completionEvent); - - await this.hooks?.onStop?.({ - subagentId: this.subagentId, - name: this.name, - terminateReason: this.terminateMode, - summary: summary as unknown as Record, - timestamp: Date.now(), - }); - } - } - - /** - * Processes a list of function calls, executing each one and collecting their responses. - * This method iterates through the provided function calls, executes them using the - * `executeToolCall` function (or handles `self.emitvalue` internally), and aggregates - * their results. It also manages error reporting for failed tool executions. - * @param {FunctionCall[]} functionCalls - An array of `FunctionCall` objects to process. - * @param {ToolRegistry} toolRegistry - The tool registry to look up and execute tools. - * @param {AbortController} abortController - An `AbortController` to signal cancellation of tool executions. - * @param {string} responseId - Optional API response ID for correlation with tool calls. - * @returns {Promise} A promise that resolves to an array of `Content` parts representing the tool responses, - * which are then used to update the chat history. - */ - private async processFunctionCalls( - functionCalls: FunctionCall[], - abortController: AbortController, - promptId: string, - currentRound: number, - toolsList: FunctionDeclaration[], - responseId?: string, - ): Promise { - const toolResponseParts: Part[] = []; - - // Build allowed tool names set for filtering - const allowedToolNames = new Set(toolsList.map((t) => t.name)); - - // Filter unauthorized tool calls before scheduling - const authorizedCalls: FunctionCall[] = []; - for (const fc of functionCalls) { - const callId = fc.id ?? `${fc.name}-${Date.now()}`; - - if (!allowedToolNames.has(fc.name)) { - const toolName = String(fc.name); - const errorMessage = `Tool "${toolName}" not found. Tools must use the exact names provided.`; - - // Emit TOOL_CALL event for visibility - this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, { - subagentId: this.subagentId, - round: currentRound, - callId, - name: toolName, - args: fc.args ?? {}, - description: `Tool "${toolName}" not found`, - timestamp: Date.now(), - } as SubAgentToolCallEvent); - - // Build function response part (used for both event and LLM) - const functionResponsePart = { - functionResponse: { - id: callId, - name: toolName, - response: { error: errorMessage }, - }, - }; - - // Emit TOOL_RESULT event with error (include responseParts for UI rendering) - this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, { - subagentId: this.subagentId, - round: currentRound, - callId, - name: toolName, - success: false, - error: errorMessage, - responseParts: [functionResponsePart], - resultDisplay: errorMessage, - durationMs: 0, - timestamp: Date.now(), - } as SubAgentToolResultEvent); - - // Record blocked tool call in stats - this.recordToolCallStats(toolName, false, 0, errorMessage); - - // Add function response for LLM - toolResponseParts.push(functionResponsePart); - continue; - } - authorizedCalls.push(fc); - } - - // Build scheduler - const responded = new Set(); - let resolveBatch: (() => void) | null = null; - const scheduler = new CoreToolScheduler({ - config: this.runtimeContext, - outputUpdateHandler: undefined, - onAllToolCallsComplete: async (completedCalls) => { - for (const call of completedCalls) { - const toolName = call.request.name; - const duration = call.durationMs ?? 0; - const success = call.status === 'success'; - const errorMessage = - call.status === 'error' || call.status === 'cancelled' - ? call.response.error?.message - : undefined; - - // Record stats - this.recordToolCallStats(toolName, success, duration, errorMessage); - - // Emit tool result event - this.eventEmitter?.emit(SubAgentEventType.TOOL_RESULT, { - subagentId: this.subagentId, - round: currentRound, - callId: call.request.callId, - name: toolName, - success, - error: errorMessage, - responseParts: call.response.responseParts, - resultDisplay: call.response.resultDisplay - ? typeof call.response.resultDisplay === 'string' - ? call.response.resultDisplay - : JSON.stringify(call.response.resultDisplay) - : undefined, - durationMs: duration, - timestamp: Date.now(), - } as SubAgentToolResultEvent); - - // post-tool hook - await this.hooks?.postToolUse?.({ - subagentId: this.subagentId, - name: this.name, - toolName, - args: call.request.args, - success, - durationMs: duration, - errorMessage, - timestamp: Date.now(), - }); - - // Append response parts - const respParts = call.response.responseParts; - if (respParts) { - const parts = Array.isArray(respParts) ? respParts : [respParts]; - for (const part of parts) { - if (typeof part === 'string') { - toolResponseParts.push({ text: part }); - } else if (part) { - toolResponseParts.push(part); - } - } - } - } - // Signal that this batch is complete (all tools terminal) - resolveBatch?.(); - }, - onToolCallsUpdate: (calls: ToolCall[]) => { - for (const call of calls) { - if (call.status !== 'awaiting_approval') continue; - const waiting = call as WaitingToolCall; - - // Emit approval request event for UI visibility - try { - const { confirmationDetails } = waiting; - const { onConfirm: _onConfirm, ...rest } = confirmationDetails; - this.eventEmitter?.emit(SubAgentEventType.TOOL_WAITING_APPROVAL, { - subagentId: this.subagentId, - round: currentRound, - callId: waiting.request.callId, - name: waiting.request.name, - description: this.getToolDescription( - waiting.request.name, - waiting.request.args, - ), - confirmationDetails: rest, - respond: async ( - outcome: ToolConfirmationOutcome, - payload?: Parameters< - ToolCallConfirmationDetails['onConfirm'] - >[1], - ) => { - if (responded.has(waiting.request.callId)) return; - responded.add(waiting.request.callId); - await waiting.confirmationDetails.onConfirm(outcome, payload); - }, - timestamp: Date.now(), - }); - } catch { - // ignore UI event emission failures - } - - // UI now renders inline confirmation via task tool live output. - } - }, - getPreferredEditor: () => undefined, - onEditorClose: () => {}, - }); - - // Prepare requests and emit TOOL_CALL events - const requests: ToolCallRequestInfo[] = authorizedCalls.map((fc) => { - const toolName = String(fc.name || 'unknown'); - const callId = fc.id ?? `${fc.name}-${Date.now()}`; - const args = (fc.args ?? {}) as Record; - const request: ToolCallRequestInfo = { - callId, - name: toolName, - args, - isClientInitiated: true, - prompt_id: promptId, - response_id: responseId, - }; - - const description = this.getToolDescription(toolName, args); - this.eventEmitter?.emit(SubAgentEventType.TOOL_CALL, { - subagentId: this.subagentId, - round: currentRound, - callId, - name: toolName, - args, - description, - timestamp: Date.now(), - } as SubAgentToolCallEvent); - - // pre-tool hook - void this.hooks?.preToolUse?.({ - subagentId: this.subagentId, - name: this.name, - toolName, - args, - timestamp: Date.now(), - }); - - return request; - }); - - if (requests.length > 0) { - // Create a per-batch completion promise, resolve when onAllToolCallsComplete fires - const batchDone = new Promise((resolve) => { - resolveBatch = () => { - resolve(); - resolveBatch = null; - }; - }); - await scheduler.schedule(requests, abortController.signal); - await batchDone; // Wait for approvals + execution to finish - } - // If all tool calls failed, inform the model so it can re-evaluate. - if (functionCalls.length > 0 && toolResponseParts.length === 0) { - toolResponseParts.push({ - text: 'All tool calls failed. Please analyze the errors and try an alternative approach.', - }); - } - - return [{ role: 'user', parts: toolResponseParts }]; - } - - getEventEmitter() { - return this.eventEmitter; - } - - getStatistics() { - const total = this.executionStats.totalToolCalls; - const successRate = - total > 0 ? (this.executionStats.successfulToolCalls / total) * 100 : 0; - return { - ...this.executionStats, - successRate, - toolUsage: Array.from(this.toolUsage.entries()).map(([name, v]) => ({ - name, - ...v, - })), - }; - } - - getExecutionSummary(): SubagentStatsSummary { - return this.stats.getSummary(); - } - - getFinalText(): string { - return this.finalText; - } - - getTerminateMode(): SubagentTerminateMode { - return this.terminateMode; - } - - private async createChatObject(context: ContextState) { - if (!this.promptConfig.systemPrompt && !this.promptConfig.initialMessages) { - throw new Error( - 'PromptConfig must have either `systemPrompt` or `initialMessages` defined.', - ); - } - if (this.promptConfig.systemPrompt && this.promptConfig.initialMessages) { - throw new Error( - 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', - ); - } - - const envHistory = await getInitialChatHistory(this.runtimeContext); - - const start_history = [ - ...envHistory, - ...(this.promptConfig.initialMessages ?? []), - ]; - - const systemInstruction = this.promptConfig.systemPrompt - ? this.buildChatSystemPrompt(context) - : undefined; - - try { - const generationConfig: GenerateContentConfig & { - systemInstruction?: string | Content; - } = { - temperature: this.modelConfig.temp, - topP: this.modelConfig.top_p, - }; - - if (systemInstruction) { - generationConfig.systemInstruction = systemInstruction; - } - - return new GeminiChat( - this.runtimeContext, - generationConfig, - start_history, - ); - } catch (error) { - await reportError( - error, - 'Error initializing chat session.', - start_history, - 'startChat', - ); - // The calling function will handle the undefined return. - return undefined; - } - } - - /** - * Safely retrieves the description of a tool by attempting to build it. - * Returns an empty string if any error occurs during the process. - * - * @param toolName The name of the tool to get description for. - * @param args The arguments that would be passed to the tool. - * @returns The tool description or empty string if error occurs. - */ - private getToolDescription( - toolName: string, - args: Record, - ): string { - try { - const toolRegistry = this.runtimeContext.getToolRegistry(); - const tool = toolRegistry.getTool(toolName); - if (!tool) { - return ''; - } - - const toolInstance = tool.build(args); - return toolInstance.getDescription() || ''; - } catch { - // Safely ignore all runtime errors and return empty string - return ''; - } - } - - /** - * Records tool call statistics for both successful and failed tool calls. - * This includes updating aggregate stats, per-tool usage, and the statistics service. - */ - private recordToolCallStats( - toolName: string, - success: boolean, - durationMs: number, - errorMessage?: string, - ): void { - // Update aggregate stats - this.executionStats.totalToolCalls += 1; - if (success) { - this.executionStats.successfulToolCalls += 1; - } else { - this.executionStats.failedToolCalls += 1; - } - - // Per-tool usage - const tu = this.toolUsage.get(toolName) || { - count: 0, - success: 0, - failure: 0, - totalDurationMs: 0, - averageDurationMs: 0, - }; - tu.count += 1; - if (success) { - tu.success += 1; - } else { - tu.failure += 1; - tu.lastError = errorMessage || 'Unknown error'; - } - tu.totalDurationMs = (tu.totalDurationMs || 0) + durationMs; - tu.averageDurationMs = tu.count > 0 ? tu.totalDurationMs / tu.count : 0; - this.toolUsage.set(toolName, tu); - - // Update statistics service - this.stats.recordToolCall( - toolName, - success, - durationMs, - this.toolUsage.get(toolName)?.lastError, - ); - } - - private buildChatSystemPrompt(context: ContextState): string { - if (!this.promptConfig.systemPrompt) { - // This should ideally be caught in createChatObject, but serves as a safeguard. - return ''; - } - - let finalPrompt = templateString(this.promptConfig.systemPrompt, context); - - // Add general non-interactive instructions. - finalPrompt += ` - -Important Rules: - - You operate in non-interactive mode: do not ask the user questions; proceed with available context. - - Use tools only when necessary to obtain facts or make changes. - - When the task is complete, return the final result as a normal model response (not a tool call) and stop.`; - - return finalPrompt; - } -} diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index efa73a7e4..e41fe620b 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -24,7 +24,7 @@ export type SubagentLevel = /** * Core configuration for a subagent as stored in Markdown files. * This interface represents the file-based configuration that gets - * converted to runtime configuration for SubAgentScope. + * converted to runtime configuration for AgentHeadless. */ export interface SubagentConfig { /** Unique name identifier for the subagent */ @@ -82,20 +82,20 @@ export interface SubagentConfig { } /** - * Runtime configuration that converts file-based config to existing SubAgentScope. + * Runtime configuration that converts file-based config to AgentHeadless. * This interface maps SubagentConfig to the existing runtime interfaces. */ export interface SubagentRuntimeConfig { - /** Prompt configuration for SubAgentScope */ + /** Prompt configuration for AgentHeadless */ promptConfig: PromptConfig; - /** Model configuration for SubAgentScope */ + /** Model configuration for AgentHeadless */ modelConfig: ModelConfig; - /** Runtime execution configuration for SubAgentScope */ + /** Runtime execution configuration for AgentHeadless */ runConfig: RunConfig; - /** Optional tool configuration for SubAgentScope */ + /** Optional tool configuration for AgentHeadless */ toolConfig?: ToolConfig; } @@ -202,6 +202,10 @@ export enum SubagentTerminateMode { * Indicates that the subagent's execution was cancelled via an abort signal. */ CANCELLED = 'CANCELLED', + /** + * Indicates that the subagent was gracefully shut down (e.g., arena/team session ended). + */ + SHUTDOWN = 'SHUTDOWN', } /** diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts index 458b026b6..a8323f71e 100644 --- a/packages/core/src/tools/task.test.ts +++ b/packages/core/src/tools/task.test.ts @@ -14,7 +14,10 @@ import { type SubagentConfig, SubagentTerminateMode, } from '../subagents/types.js'; -import { type SubAgentScope, ContextState } from '../subagents/subagent.js'; +import { + type AgentHeadless, + ContextState, +} from '../agents/runtime/agent-headless.js'; import { partToString } from '../utils/partUtils.js'; // Type for accessing protected methods in tests @@ -34,7 +37,7 @@ type TaskToolWithProtectedMethods = TaskTool & { // Mock dependencies vi.mock('../subagents/subagent-manager.js'); -vi.mock('../subagents/subagent.js'); +vi.mock('../agents/runtime/agent-headless.js'); const MockedSubagentManager = vi.mocked(SubagentManager); const MockedContextState = vi.mocked(ContextState); @@ -80,7 +83,7 @@ describe('TaskTool', () => { mockSubagentManager = { listSubagents: vi.fn().mockResolvedValue(mockSubagents), loadSubagent: vi.fn(), - createSubagentScope: vi.fn(), + createAgentHeadless: vi.fn(), addChangeListener: vi.fn((listener: () => void) => { changeListeners.push(listener); return () => { @@ -293,12 +296,12 @@ describe('TaskTool', () => { }); describe('TaskToolInvocation', () => { - let mockSubagentScope: SubAgentScope; + let mockSubagentScope: AgentHeadless; let mockContextState: ContextState; beforeEach(() => { mockSubagentScope = { - runNonInteractive: vi.fn().mockResolvedValue(undefined), + execute: vi.fn().mockResolvedValue(undefined), result: 'Task completed successfully', terminateMode: SubagentTerminateMode.GOAL, getFinalText: vi.fn().mockReturnValue('Task completed successfully'), @@ -345,7 +348,7 @@ describe('TaskTool', () => { failedToolCalls: 0, }), getTerminateMode: vi.fn().mockReturnValue(SubagentTerminateMode.GOAL), - } as unknown as SubAgentScope; + } as unknown as AgentHeadless; mockContextState = { set: vi.fn(), @@ -356,7 +359,7 @@ describe('TaskTool', () => { vi.mocked(mockSubagentManager.loadSubagent).mockResolvedValue( mockSubagents[0], ); - vi.mocked(mockSubagentManager.createSubagentScope).mockResolvedValue( + vi.mocked(mockSubagentManager.createAgentHeadless).mockResolvedValue( mockSubagentScope, ); }); @@ -376,12 +379,12 @@ describe('TaskTool', () => { expect(mockSubagentManager.loadSubagent).toHaveBeenCalledWith( 'file-search', ); - expect(mockSubagentManager.createSubagentScope).toHaveBeenCalledWith( + expect(mockSubagentManager.createAgentHeadless).toHaveBeenCalledWith( mockSubagents[0], config, expect.any(Object), // eventEmitter parameter ); - expect(mockSubagentScope.runNonInteractive).toHaveBeenCalledWith( + expect(mockSubagentScope.execute).toHaveBeenCalledWith( mockContextState, undefined, // signal parameter (undefined when not provided) ); @@ -416,7 +419,7 @@ describe('TaskTool', () => { }); it('should handle execution errors gracefully', async () => { - vi.mocked(mockSubagentManager.createSubagentScope).mockRejectedValue( + vi.mocked(mockSubagentManager.createAgentHeadless).mockRejectedValue( new Error('Creation failed'), ); diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index e811dde0d..35aa8af41 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -22,18 +22,18 @@ import { type SubagentConfig, SubagentTerminateMode, } from '../subagents/types.js'; -import { ContextState } from '../subagents/subagent.js'; +import { ContextState } from '../agents/runtime/agent-headless.js'; import { - SubAgentEventEmitter, - SubAgentEventType, -} from '../subagents/subagent-events.js'; + AgentEventEmitter, + AgentEventType, +} from '../agents/runtime/agent-events.js'; import type { - SubAgentToolCallEvent, - SubAgentToolResultEvent, - SubAgentFinishEvent, - SubAgentErrorEvent, - SubAgentApprovalRequestEvent, -} from '../subagents/subagent-events.js'; + AgentToolCallEvent, + AgentToolResultEvent, + AgentFinishEvent, + AgentErrorEvent, + AgentApprovalRequestEvent, +} from '../agents/runtime/agent-events.js'; import { createDebugLogger } from '../utils/debugLogger.js'; export interface TaskParams { @@ -262,7 +262,7 @@ assistant: "I'm going to use the Task tool to launch the with the greeting-respo } class TaskToolInvocation extends BaseToolInvocation { - private readonly _eventEmitter: SubAgentEventEmitter; + readonly eventEmitter: AgentEventEmitter = new AgentEventEmitter(); private currentDisplay: TaskResultDisplay | null = null; private currentToolCalls: TaskResultDisplay['toolCalls'] = []; @@ -272,11 +272,6 @@ class TaskToolInvocation extends BaseToolInvocation { params: TaskParams, ) { super(params); - this._eventEmitter = new SubAgentEventEmitter(); - } - - get eventEmitter(): SubAgentEventEmitter { - return this._eventEmitter; } /** @@ -304,12 +299,12 @@ class TaskToolInvocation extends BaseToolInvocation { private setupEventListeners( updateOutput?: (output: ToolResultDisplay) => void, ): void { - this.eventEmitter.on(SubAgentEventType.START, () => { + this.eventEmitter.on(AgentEventType.START, () => { this.updateDisplay({ status: 'running' }, updateOutput); }); - this.eventEmitter.on(SubAgentEventType.TOOL_CALL, (...args: unknown[]) => { - const event = args[0] as SubAgentToolCallEvent; + this.eventEmitter.on(AgentEventType.TOOL_CALL, (...args: unknown[]) => { + const event = args[0] as AgentToolCallEvent; const newToolCall = { callId: event.callId, name: event.name, @@ -327,33 +322,30 @@ class TaskToolInvocation extends BaseToolInvocation { ); }); - this.eventEmitter.on( - SubAgentEventType.TOOL_RESULT, - (...args: unknown[]) => { - const event = args[0] as SubAgentToolResultEvent; - const toolCallIndex = this.currentToolCalls!.findIndex( - (call) => call.callId === event.callId, + this.eventEmitter.on(AgentEventType.TOOL_RESULT, (...args: unknown[]) => { + const event = args[0] as AgentToolResultEvent; + const toolCallIndex = this.currentToolCalls!.findIndex( + (call) => call.callId === event.callId, + ); + if (toolCallIndex >= 0) { + this.currentToolCalls![toolCallIndex] = { + ...this.currentToolCalls![toolCallIndex], + status: event.success ? 'success' : 'failed', + error: event.error, + responseParts: event.responseParts, + }; + + this.updateDisplay( + { + toolCalls: [...this.currentToolCalls!], + }, + updateOutput, ); - if (toolCallIndex >= 0) { - this.currentToolCalls![toolCallIndex] = { - ...this.currentToolCalls![toolCallIndex], - status: event.success ? 'success' : 'failed', - error: event.error, - responseParts: event.responseParts, - }; + } + }); - this.updateDisplay( - { - toolCalls: [...this.currentToolCalls!], - }, - updateOutput, - ); - } - }, - ); - - this.eventEmitter.on(SubAgentEventType.FINISH, (...args: unknown[]) => { - const event = args[0] as SubAgentFinishEvent; + this.eventEmitter.on(AgentEventType.FINISH, (...args: unknown[]) => { + const event = args[0] as AgentFinishEvent; this.updateDisplay( { status: event.terminateReason === 'GOAL' ? 'completed' : 'failed', @@ -363,8 +355,8 @@ class TaskToolInvocation extends BaseToolInvocation { ); }); - this.eventEmitter.on(SubAgentEventType.ERROR, (...args: unknown[]) => { - const event = args[0] as SubAgentErrorEvent; + this.eventEmitter.on(AgentEventType.ERROR, (...args: unknown[]) => { + const event = args[0] as AgentErrorEvent; this.updateDisplay( { status: 'failed', @@ -376,9 +368,9 @@ class TaskToolInvocation extends BaseToolInvocation { // Indicate when a tool call is waiting for approval this.eventEmitter.on( - SubAgentEventType.TOOL_WAITING_APPROVAL, + AgentEventType.TOOL_WAITING_APPROVAL, (...args: unknown[]) => { - const event = args[0] as SubAgentApprovalRequestEvent; + const event = args[0] as AgentApprovalRequestEvent; const idx = this.currentToolCalls!.findIndex( (c) => c.callId === event.callId, ); @@ -506,7 +498,7 @@ class TaskToolInvocation extends BaseToolInvocation { if (updateOutput) { updateOutput(this.currentDisplay); } - const subagentScope = await this.subagentManager.createSubagentScope( + const subagent = await this.subagentManager.createAgentHeadless( subagentConfig, this.config, { eventEmitter: this.eventEmitter }, @@ -517,13 +509,13 @@ class TaskToolInvocation extends BaseToolInvocation { contextState.set('task_prompt', this.params.prompt); // Execute the subagent (blocking) - await subagentScope.runNonInteractive(contextState, signal); + await subagent.execute(contextState, signal); // Get the results - const finalText = subagentScope.getFinalText(); - const terminateMode = subagentScope.getTerminateMode(); + const finalText = subagent.getFinalText(); + const terminateMode = subagent.getTerminateMode(); const success = terminateMode === SubagentTerminateMode.GOAL; - const executionSummary = subagentScope.getExecutionSummary(); + const executionSummary = subagent.getExecutionSummary(); if (signal?.aborted) { this.updateDisplay( diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 96ae53402..b9e4cf62d 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -9,7 +9,7 @@ import { ToolErrorType } from './tool-error.js'; import type { DiffUpdateResult } from '../ide/ide-client.js'; import type { ShellExecutionConfig } from '../services/shellExecutionService.js'; import { SchemaValidator } from '../utils/schemaValidator.js'; -import { type SubagentStatsSummary } from '../subagents/subagent-statistics.js'; +import { type AgentStatsSummary } from '../agents/runtime/agent-statistics.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; /** @@ -447,7 +447,7 @@ export interface TaskResultDisplay { status: 'running' | 'completed' | 'failed' | 'cancelled'; terminateReason?: string; result?: string; - executionSummary?: SubagentStatsSummary; + executionSummary?: AgentStatsSummary; // If the subagent is awaiting approval for a tool call, // this contains the confirmation details for inline UI rendering. From d4cfb18f79ef228831052601bfa8132e2e925899 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Sat, 21 Feb 2026 21:08:20 +0800 Subject: [PATCH 05/82] feat(core,cli)!: Implement in-process agent backend for arenas Co-authored-by: Qwen-Coder Add InProcessBackend to run subagents in-process rather than via subprocess, enabling faster initialization and better resource management for agent collaboration arenas. Key changes: - Add InProcessBackend with sandboxed in-process agent execution - Refactor agent runtime into headless vs interactive modes - Add AsyncMessageQueue utility for agent message passing - Update ArenaManager with backend selection (in-process vs subprocess) - Refactor subagent types/exports; consolidate in subagents/types - Remove deprecated agent-hooks.ts (functionality merged into runtime) - Update task tool to support new agent lifecycle Breaking: Subagent type exports restructured; import from subagents/types --- packages/cli/src/config/settingsSchema.ts | 20 + .../cli/src/ui/commands/arenaCommand.test.ts | 18 +- packages/cli/src/ui/commands/arenaCommand.ts | 55 +- .../src/ui/components/ArenaSelectDialog.tsx | 6 +- .../src/ui/components/ArenaStatusDialog.tsx | 8 +- packages/cli/src/ui/types.ts | 3 +- packages/cli/src/ui/utils/displayUtils.ts | 23 +- .../src/agents/arena/ArenaAgentClient.test.ts | 70 +- .../core/src/agents/arena/ArenaAgentClient.ts | 32 +- .../src/agents/arena/ArenaManager.test.ts | 16 +- .../core/src/agents/arena/ArenaManager.ts | 292 +++++--- .../core/src/agents/arena/arena-events.ts | 6 +- packages/core/src/agents/arena/types.ts | 36 +- .../agents/backends/InProcessBackend.test.ts | 536 +++++++++++++++ .../src/agents/backends/InProcessBackend.ts | 459 +++++++++++++ packages/core/src/agents/backends/detect.ts | 34 +- packages/core/src/agents/backends/index.ts | 2 + packages/core/src/agents/backends/types.ts | 41 ++ .../core/src/agents/runtime/agent-core.ts | 18 +- .../core/src/agents/runtime/agent-events.ts | 95 ++- .../src/agents/runtime/agent-headless.test.ts | 24 +- .../core/src/agents/runtime/agent-headless.ts | 24 +- .../core/src/agents/runtime/agent-hooks.ts | 33 - .../agents/runtime/agent-interactive.test.ts | 625 ++++++++++++++++++ .../src/agents/runtime/agent-interactive.ts | 425 ++++++++++++ .../core/src/agents/runtime/agent-types.ts | 175 +++++ packages/core/src/agents/runtime/index.ts | 4 +- packages/core/src/config/config.ts | 9 +- packages/core/src/core/client.ts | 4 +- packages/core/src/index.ts | 1 - packages/core/src/subagents/index.ts | 47 +- .../core/src/subagents/subagent-manager.ts | 10 +- packages/core/src/subagents/types.ts | 112 +--- packages/core/src/subagents/validation.ts | 8 +- packages/core/src/tools/task.test.ts | 10 +- packages/core/src/tools/task.ts | 15 +- packages/core/src/tools/tool-registry.ts | 28 +- .../core/src/utils/asyncMessageQueue.test.ts | 75 +++ packages/core/src/utils/asyncMessageQueue.ts | 54 ++ 39 files changed, 2951 insertions(+), 502 deletions(-) create mode 100644 packages/core/src/agents/backends/InProcessBackend.test.ts create mode 100644 packages/core/src/agents/backends/InProcessBackend.ts delete mode 100644 packages/core/src/agents/runtime/agent-hooks.ts create mode 100644 packages/core/src/agents/runtime/agent-interactive.test.ts create mode 100644 packages/core/src/agents/runtime/agent-interactive.ts create mode 100644 packages/core/src/agents/runtime/agent-types.ts create mode 100644 packages/core/src/utils/asyncMessageQueue.test.ts create mode 100644 packages/core/src/utils/asyncMessageQueue.ts diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index ca86ea0a5..c901f5db5 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1231,6 +1231,26 @@ const SETTINGS_SCHEMA = { 'When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.', showInDialog: true, }, + maxRoundsPerAgent: { + type: 'number', + label: 'Max Rounds Per Agent', + category: 'Advanced', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Maximum number of rounds (turns) each agent can execute. No limit if unset.', + showInDialog: false, + }, + timeoutSeconds: { + type: 'number', + label: 'Timeout (seconds)', + category: 'Advanced', + requiresRestart: false, + default: undefined as number | undefined, + description: + 'Total timeout in seconds for the Arena session. No limit if unset.', + showInDialog: false, + }, }, }, team: { diff --git a/packages/cli/src/ui/commands/arenaCommand.test.ts b/packages/cli/src/ui/commands/arenaCommand.test.ts index 04f3f5597..99f902259 100644 --- a/packages/cli/src/ui/commands/arenaCommand.test.ts +++ b/packages/cli/src/ui/commands/arenaCommand.test.ts @@ -7,7 +7,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { type ArenaManager, - ArenaAgentStatus, + AgentStatus, ArenaSessionStatus, } from '@qwen-code/qwen-code-core'; import { arenaCommand } from './arenaCommand.js'; @@ -242,7 +242,7 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.TERMINATED, + status: AgentStatus.FAILED, model: { modelId: 'model-1' }, }, ]), @@ -267,12 +267,12 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'model-1' }, }, { agentId: 'agent-2', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'model-2' }, }, ]), @@ -294,12 +294,12 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'gpt-4o', displayName: 'gpt-4o' }, }, { agentId: 'agent-2', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'claude-sonnet', displayName: 'claude-sonnet' }, }, ]), @@ -327,7 +327,7 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'gpt-4o', displayName: 'gpt-4o' }, }, ]), @@ -350,7 +350,7 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'gpt-4o' }, }, ]), @@ -373,7 +373,7 @@ describe('arenaCommand select subcommand', () => { getAgentStates: vi.fn(() => [ { agentId: 'agent-1', - status: ArenaAgentStatus.COMPLETED, + status: AgentStatus.COMPLETED, model: { modelId: 'gpt-4o' }, }, ]), diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 5339f94ca..cf47f4feb 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -16,7 +16,8 @@ import { CommandKind } from './types.js'; import { ArenaManager, ArenaEventType, - ArenaAgentStatus, + AgentStatus, + isTerminalStatus, ArenaSessionStatus, AuthType, createDebugLogger, @@ -246,41 +247,23 @@ function executeArenaCommand( const buildAgentCardData = ( result: ArenaAgentCompleteEvent['result'], - ): ArenaAgentCardData => { - let status: ArenaAgentCardData['status']; - switch (result.status) { - case ArenaAgentStatus.COMPLETED: - status = 'completed'; - break; - case ArenaAgentStatus.CANCELLED: - status = 'cancelled'; - break; - default: - status = 'terminated'; - break; - } - return { - label: result.model.displayName || result.model.modelId, - status, - durationMs: result.stats.durationMs, - totalTokens: result.stats.totalTokens, - inputTokens: result.stats.inputTokens, - outputTokens: result.stats.outputTokens, - toolCalls: result.stats.toolCalls, - successfulToolCalls: result.stats.successfulToolCalls, - failedToolCalls: result.stats.failedToolCalls, - rounds: result.stats.rounds, - error: result.error, - diff: result.diff, - }; - }; + ): ArenaAgentCardData => ({ + label: result.model.displayName || result.model.modelId, + status: result.status, + durationMs: result.stats.durationMs, + totalTokens: result.stats.totalTokens, + inputTokens: result.stats.inputTokens, + outputTokens: result.stats.outputTokens, + toolCalls: result.stats.toolCalls, + successfulToolCalls: result.stats.successfulToolCalls, + failedToolCalls: result.stats.failedToolCalls, + rounds: result.stats.rounds, + error: result.error, + diff: result.diff, + }); const handleAgentComplete = (event: ArenaAgentCompleteEvent) => { - if ( - event.result.status !== ArenaAgentStatus.COMPLETED && - event.result.status !== ArenaAgentStatus.CANCELLED && - event.result.status !== ArenaAgentStatus.TERMINATED - ) { + if (!isTerminalStatus(event.result.status)) { return; } @@ -598,7 +581,7 @@ export const arenaCommand: SlashCommand = { const agents = manager.getAgentStates(); const hasSuccessful = agents.some( - (a) => a.status === ArenaAgentStatus.COMPLETED, + (a) => a.status === AgentStatus.COMPLETED, ); if (!hasSuccessful) { @@ -616,7 +599,7 @@ export const arenaCommand: SlashCommand = { const matchingAgent = agents.find((a) => { const label = a.model.displayName || a.model.modelId; return ( - a.status === ArenaAgentStatus.COMPLETED && + a.status === AgentStatus.COMPLETED && (label.toLowerCase() === trimmedArgs.toLowerCase() || a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase()) ); diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/ArenaSelectDialog.tsx index b42d8e8d1..9d2f15806 100644 --- a/packages/cli/src/ui/components/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/ArenaSelectDialog.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { type ArenaManager, - ArenaAgentStatus, + AgentStatus, type Config, } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; @@ -138,7 +138,7 @@ export function ArenaSelectDialog({ // Build diff summary from cached result if available let diffAdditions = 0; let diffDeletions = 0; - if (agent.status === ArenaAgentStatus.COMPLETED && result) { + if (agent.status === AgentStatus.COMPLETED && result) { const agentResult = result.agents.find( (a) => a.agentId === agent.agentId, ); @@ -182,7 +182,7 @@ export function ArenaSelectDialog({ value: agent.agentId, title, description, - disabled: agent.status !== ArenaAgentStatus.COMPLETED, + disabled: agent.status !== AgentStatus.COMPLETED, }; }), [agents, result], diff --git a/packages/cli/src/ui/components/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/ArenaStatusDialog.tsx index 221e2f3e6..211a9d9ba 100644 --- a/packages/cli/src/ui/components/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/ArenaStatusDialog.tsx @@ -10,7 +10,7 @@ import { Box, Text } from 'ink'; import { type ArenaManager, type ArenaAgentState, - ArenaAgentStatus, + isTerminalStatus, ArenaSessionStatus, } from '@qwen-code/qwen-code-core'; import { theme } from '../semantic-colors.js'; @@ -42,11 +42,7 @@ function pad( } function getElapsedMs(agent: ArenaAgentState): number { - if ( - agent.status === ArenaAgentStatus.COMPLETED || - agent.status === ArenaAgentStatus.TERMINATED || - agent.status === ArenaAgentStatus.CANCELLED - ) { + if (isTerminalStatus(agent.status)) { return agent.stats.durationMs; } return Date.now() - agent.startedAt; diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index ea3c53ad6..9b07964bf 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -11,6 +11,7 @@ import type { ToolCallConfirmationDetails, ToolConfirmationOutcome, ToolResultDisplay, + AgentStatus, } from '@qwen-code/qwen-code-core'; import type { PartListUnion } from '@google/genai'; import { type ReactNode } from 'react'; @@ -266,7 +267,7 @@ export type HistoryItemMcpStatus = HistoryItemBase & { */ export interface ArenaAgentCardData { label: string; - status: 'completed' | 'cancelled' | 'terminated'; + status: AgentStatus; durationMs: number; totalTokens: number; inputTokens: number; diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts index 2e8f22078..7f422e250 100644 --- a/packages/cli/src/ui/utils/displayUtils.ts +++ b/packages/cli/src/ui/utils/displayUtils.ts @@ -5,7 +5,7 @@ */ import { theme } from '../semantic-colors.js'; -import { ArenaAgentStatus } from '@qwen-code/qwen-code-core'; +import { AgentStatus } from '@qwen-code/qwen-code-core'; // --- Status Labels --- @@ -15,24 +15,17 @@ export interface StatusLabel { color: string; } -export function getArenaStatusLabel( - status: ArenaAgentStatus | string, -): StatusLabel { +export function getArenaStatusLabel(status: AgentStatus): StatusLabel { switch (status) { - case ArenaAgentStatus.COMPLETED: - case 'completed': + case AgentStatus.COMPLETED: return { icon: '✓', text: 'Done', color: theme.status.success }; - case ArenaAgentStatus.CANCELLED: - case 'cancelled': + case AgentStatus.CANCELLED: return { icon: '⊘', text: 'Cancelled', color: theme.status.warning }; - case ArenaAgentStatus.TERMINATED: - case 'terminated': - return { icon: '✗', text: 'Terminated', color: theme.status.error }; - case ArenaAgentStatus.RUNNING: - case 'running': + case AgentStatus.FAILED: + return { icon: '✗', text: 'Failed', color: theme.status.error }; + case AgentStatus.RUNNING: return { icon: '○', text: 'Running', color: theme.text.secondary }; - case ArenaAgentStatus.INITIALIZING: - case 'initializing': + case AgentStatus.INITIALIZING: return { icon: '○', text: 'Initializing', color: theme.text.secondary }; default: return { icon: '○', text: status, color: theme.text.secondary }; diff --git a/packages/core/src/agents/arena/ArenaAgentClient.test.ts b/packages/core/src/agents/arena/ArenaAgentClient.test.ts index d5a5f5f91..6ab61039c 100644 --- a/packages/core/src/agents/arena/ArenaAgentClient.test.ts +++ b/packages/core/src/agents/arena/ArenaAgentClient.test.ts @@ -444,9 +444,9 @@ describe('ArenaAgentClient', () => { }); }); - describe('buildStatsFromMetrics()', () => { - it('should aggregate stats across multiple models', () => { - const metrics: SessionMetrics = { + describe('stats aggregation and wall-clock durationMs', () => { + it('should aggregate multi-model stats and use wall-clock durationMs', async () => { + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue({ models: { 'model-a': { api: { @@ -493,32 +493,58 @@ describe('ArenaAgentClient', () => { byName: {}, }, files: { totalLinesAdded: 0, totalLinesRemoved: 0 }, - }; + }); - const stats = ArenaAgentClient.buildStatsFromMetrics(metrics); + const reporter = new ArenaAgentClient('model-a', tempDir); + await reporter.init(); + await reporter.updateStatus(); - expect(stats.rounds).toBe(5); - expect(stats.totalTokens).toBe(450); - expect(stats.inputTokens).toBe(300); - expect(stats.outputTokens).toBe(150); - expect(stats.durationMs).toBe(1500); - expect(stats.toolCalls).toBe(10); - expect(stats.successfulToolCalls).toBe(8); - expect(stats.failedToolCalls).toBe(2); + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId('model-a')}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.stats.rounds).toBe(5); + expect(content.stats.totalTokens).toBe(450); + expect(content.stats.inputTokens).toBe(300); + expect(content.stats.outputTokens).toBe(150); + expect(content.stats.toolCalls).toBe(10); + expect(content.stats.successfulToolCalls).toBe(8); + expect(content.stats.failedToolCalls).toBe(2); + // durationMs should be wall-clock time, not API latency sum (1500) + expect(content.stats.durationMs).toBeGreaterThanOrEqual(0); + expect(content.stats.durationMs).toBeLessThan(5000); }); - it('should return zeros when no models exist', () => { - const metrics = createMockMetrics(); + it('should return zeros when no models exist', async () => { + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue( + createMockMetrics(), + ); // Override with empty models - metrics.models = {}; + vi.mocked(uiTelemetryService.getMetrics).mockReturnValue({ + ...createMockMetrics(), + models: {}, + }); - const stats = ArenaAgentClient.buildStatsFromMetrics(metrics); + const reporter = new ArenaAgentClient('model-a', tempDir); + await reporter.init(); + await reporter.updateStatus(); - expect(stats.rounds).toBe(0); - expect(stats.totalTokens).toBe(0); - expect(stats.inputTokens).toBe(0); - expect(stats.outputTokens).toBe(0); - expect(stats.durationMs).toBe(0); + const statusPath = path.join( + tempDir, + 'agents', + `${safeAgentId('model-a')}.json`, + ); + const content = JSON.parse(await fs.readFile(statusPath, 'utf-8')); + + expect(content.stats.rounds).toBe(0); + expect(content.stats.totalTokens).toBe(0); + expect(content.stats.inputTokens).toBe(0); + expect(content.stats.outputTokens).toBe(0); + // durationMs is wall-clock, so still non-negative even with no models + expect(content.stats.durationMs).toBeGreaterThanOrEqual(0); }); }); diff --git a/packages/core/src/agents/arena/ArenaAgentClient.ts b/packages/core/src/agents/arena/ArenaAgentClient.ts index 8b1eb8ba1..1099825e4 100644 --- a/packages/core/src/agents/arena/ArenaAgentClient.ts +++ b/packages/core/src/agents/arena/ArenaAgentClient.ts @@ -9,16 +9,14 @@ import * as path from 'node:path'; import * as crypto from 'node:crypto'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { isNodeError } from '../../utils/errors.js'; -import { - uiTelemetryService, - type SessionMetrics, -} from '../../telemetry/uiTelemetry.js'; +import { uiTelemetryService } from '../../telemetry/uiTelemetry.js'; import type { ArenaAgentStats, ArenaControlSignal, ArenaStatusFile, } from './types.js'; import { safeAgentId } from './types.js'; +import { AgentStatus } from '../runtime/agent-types.js'; const debugLogger = createDebugLogger('ARENA_AGENT_CLIENT'); @@ -44,6 +42,7 @@ export class ArenaAgentClient { private readonly controlDir: string; private readonly statusFilePath: string; private readonly controlFilePath: string; + private readonly startTimeMs: number; private initialized = false; /** @@ -71,6 +70,7 @@ export class ArenaAgentClient { this.controlDir = path.join(arenaSessionDir, CONTROL_SUBDIR); this.statusFilePath = path.join(this.agentsDir, `${safe}.json`); this.controlFilePath = path.join(this.controlDir, `${safe}.json`); + this.startTimeMs = Date.now(); } /** @@ -100,7 +100,7 @@ export class ArenaAgentClient { const statusFile: ArenaStatusFile = { agentId: this.agentId, - status: 'running', + status: AgentStatus.RUNNING, updatedAt: Date.now(), rounds: stats.rounds, currentActivity, @@ -150,7 +150,7 @@ export class ArenaAgentClient { const statusFile: ArenaStatusFile = { agentId: this.agentId, - status: 'completed', + status: AgentStatus.COMPLETED, updatedAt: Date.now(), rounds: stats.rounds, stats, @@ -171,7 +171,7 @@ export class ArenaAgentClient { const statusFile: ArenaStatusFile = { agentId: this.agentId, - status: 'error', + status: AgentStatus.FAILED, updatedAt: Date.now(), rounds: stats.rounds, stats, @@ -192,7 +192,7 @@ export class ArenaAgentClient { const statusFile: ArenaStatusFile = { agentId: this.agentId, - status: 'cancelled', + status: AgentStatus.CANCELLED, updatedAt: Date.now(), rounds: stats.rounds, stats, @@ -204,31 +204,21 @@ export class ArenaAgentClient { } /** - * Build ArenaAgentStats from the current uiTelemetryService metrics. + * Build ArenaAgentStats from uiTelemetryService metrics */ private getStatsFromTelemetry(): ArenaAgentStats { - return ArenaAgentClient.buildStatsFromMetrics( - uiTelemetryService.getMetrics(), - ); - } + const metrics = uiTelemetryService.getMetrics(); - /** - * Convert SessionMetrics into ArenaAgentStats by aggregating across - * all models. Exposed as a static method for testability. - */ - static buildStatsFromMetrics(metrics: SessionMetrics): ArenaAgentStats { let rounds = 0; let totalTokens = 0; let inputTokens = 0; let outputTokens = 0; - let durationMs = 0; for (const model of Object.values(metrics.models)) { rounds += model.api.totalRequests; totalTokens += model.tokens.total; inputTokens += model.tokens.prompt; outputTokens += model.tokens.candidates; - durationMs += model.api.totalLatencyMs; } return { @@ -236,7 +226,7 @@ export class ArenaAgentClient { totalTokens, inputTokens, outputTokens, - durationMs, + durationMs: Date.now() - this.startTimeMs, toolCalls: metrics.tools.totalCalls, successfulToolCalls: metrics.tools.totalSuccess, failedToolCalls: metrics.tools.totalFail, diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 0bf2b60ec..3d175be6b 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -18,9 +18,13 @@ const hoistedMockGetWorktreeDiff = vi.hoisted(() => vi.fn()); const hoistedMockApplyWorktreeChanges = vi.hoisted(() => vi.fn()); const hoistedMockDetectBackend = vi.hoisted(() => vi.fn()); -vi.mock('../index.js', () => ({ - detectBackend: hoistedMockDetectBackend, -})); +vi.mock('../index.js', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + detectBackend: hoistedMockDetectBackend, + }; +}); // Mock GitWorktreeService to avoid real git operations. // The class mock includes static methods used by ArenaManager. @@ -48,6 +52,7 @@ const createMockConfig = (workingDir: string) => ({ getWorkingDir: () => workingDir, getModel: () => 'test-model', getSessionId: () => 'test-session', + getUserMemory: () => '', getToolRegistry: () => ({ getFunctionDeclarations: () => [], getFunctionDeclarationsFiltered: () => [], @@ -294,7 +299,10 @@ describe('ArenaManager', () => { await manager.start(createValidStartOptions()); - expect(hoistedMockDetectBackend).toHaveBeenCalledWith(undefined); + expect(hoistedMockDetectBackend).toHaveBeenCalledWith( + undefined, + expect.anything(), + ); const warningUpdate = updates.find((u) => u.type === 'warning'); expect(warningUpdate).toBeDefined(); expect(warningUpdate?.message).toContain('fallback to tmux backend'); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index c1f075f08..f6b098838 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -9,12 +9,18 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { GitWorktreeService } from '../../services/gitWorktreeService.js'; import type { Config } from '../../config/config.js'; +import { getCoreSystemPrompt } from '../../core/prompts.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { isNodeError } from '../../utils/errors.js'; import type { AnsiOutput } from '../../utils/terminalSerializer.js'; import { ArenaEventEmitter, ArenaEventType } from './arena-events.js'; import type { AgentSpawnConfig, Backend, DisplayMode } from '../index.js'; -import { detectBackend } from '../index.js'; +import { detectBackend, DISPLAY_MODE } from '../index.js'; +import type { InProcessBackend } from '../backends/InProcessBackend.js'; +import { + AgentEventType, + type AgentStatusChangeEvent, +} from '../runtime/agent-events.js'; import { type ArenaConfig, type ArenaConfigFile, @@ -25,11 +31,11 @@ import { type ArenaAgentState, type ArenaCallbacks, type ArenaStatusFile, - ArenaAgentStatus, ArenaSessionStatus, ARENA_MAX_AGENTS, safeAgentId, } from './types.js'; +import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; const debugLogger = createDebugLogger('ARENA'); @@ -73,6 +79,8 @@ export class ArenaManager { private terminalRows: number; private pollingInterval: ReturnType | null = null; private lifecyclePromise: Promise | null = null; + /** Cleanup functions for in-process event bridge listeners. */ + private eventBridgeCleanups: Array<() => void> = []; constructor(config: Config, callbacks: ArenaCallbacks = {}) { this.config = config; @@ -260,13 +268,15 @@ export class ArenaManager { this.masterAbortController = new AbortController(); const sourceRepoPath = this.config.getWorkingDir(); + const arenaSettings = this.config.getAgentsSettings().arena; this.arenaConfig = { sessionId: this.sessionId, task: options.task, models: options.models, - maxRoundsPerAgent: options.maxRoundsPerAgent ?? 50, - timeoutSeconds: options.timeoutSeconds ?? 600, + maxRoundsPerAgent: + options.maxRoundsPerAgent ?? arenaSettings?.maxRoundsPerAgent, + timeoutSeconds: options.timeoutSeconds ?? arenaSettings?.timeoutSeconds, approvalMode: options.approvalMode, sourceRepoPath, }; @@ -372,17 +382,15 @@ export class ArenaManager { // Abort the master controller this.masterAbortController?.abort(); - const isTerminal = (s: ArenaAgentStatus) => - s === ArenaAgentStatus.TERMINATED || s === ArenaAgentStatus.CANCELLED; - // Force stop all PTY processes (sends Ctrl-C) this.backend?.stopAll(); - // Update agent statuses + // Update agent statuses — skip agents already in a terminal state + // (COMPLETED, FAILED, CANCELLED) so we don't overwrite a successful result. for (const agent of this.agents.values()) { - if (!isTerminal(agent.status)) { + if (!isTerminalStatus(agent.status)) { agent.abortController.abort(); - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); + this.updateAgentStatus(agent.agentId, AgentStatus.CANCELLED); } } @@ -402,6 +410,9 @@ export class ArenaManager { // Stop polling in case cleanup is called without cancel this.stopPolling(); + // Remove in-process event bridge listeners + this.teardownEventBridge(); + // Clean up backend resources if (this.backend) { await this.backend.cleanup(); @@ -432,6 +443,9 @@ export class ArenaManager { this.stopPolling(); + // Remove in-process event bridge listeners + this.teardownEventBridge(); + if (this.backend) { await this.backend.cleanup(); } @@ -454,7 +468,7 @@ export class ArenaManager { return { success: false, error: `Agent ${agentId} not found` }; } - if (agent.status !== ArenaAgentStatus.COMPLETED) { + if (agent.status !== AgentStatus.COMPLETED) { return { success: false, error: `Agent ${agentId} has not completed (current status: ${agent.status})`, @@ -537,7 +551,7 @@ export class ArenaManager { * Initialize the backend. */ private async initializeBackend(displayMode?: DisplayMode): Promise { - const { backend, warning } = await detectBackend(displayMode); + const { backend, warning } = await detectBackend(displayMode, this.config); await backend.init(); this.backend = backend; @@ -607,7 +621,7 @@ export class ArenaManager { const agentState: ArenaAgentState = { agentId, model, - status: ArenaAgentStatus.INITIALIZING, + status: AgentStatus.INITIALIZING, worktree, abortController: new AbortController(), stats: { @@ -646,25 +660,36 @@ export class ArenaManager { this.handleAgentExit(agentId, exitCode, signal); }); + const isInProcess = backend.type === DISPLAY_MODE.IN_PROCESS; + // Spawn agents sequentially — each spawn completes before starting the next. // This creates a visual effect where panes appear one by one. for (const agent of this.agents.values()) { await this.spawnAgentPty(agent); } - // Start polling agent status files - this.startPolling(); + // For in-process mode, set up event bridges instead of file-based polling. + // For PTY mode, start polling agent status files. + if (isInProcess) { + this.setupInProcessEventBridge(backend as InProcessBackend); + } else { + this.startPolling(); + } // Set up timeout - const timeoutMs = (this.arenaConfig.timeoutSeconds ?? 600) * 1000; + const timeoutSeconds = this.arenaConfig.timeoutSeconds; // Wait for all agents to reach IDLE or TERMINATED, or timeout. // Unlike waitForAll (which waits for PTY exit), this resolves as soon // as every agent has finished its first task in interactive mode. - const allSettled = await this.waitForAllAgentsSettled(timeoutMs); + const allSettled = await this.waitForAllAgentsSettled( + timeoutSeconds ? timeoutSeconds * 1000 : undefined, + ); - // Stop polling when all agents are done - this.stopPolling(); + // Stop polling when all agents are done (no-op for in-process mode) + if (!isInProcess) { + this.stopPolling(); + } if (!allSettled) { debugLogger.info('Arena session timed out, stopping remaining agents'); @@ -672,14 +697,11 @@ export class ArenaManager { // Terminate remaining active agents for (const agent of this.agents.values()) { - if ( - agent.status !== ArenaAgentStatus.COMPLETED && - agent.status !== ArenaAgentStatus.CANCELLED && - agent.status !== ArenaAgentStatus.TERMINATED - ) { + if (!isTerminalStatus(agent.status)) { backend.stopAgent(agent.agentId); agent.abortController.abort(); - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); + agent.stats.durationMs = Date.now() - agent.startedAt; + this.updateAgentStatus(agent.agentId, AgentStatus.CANCELLED); } } } @@ -699,7 +721,7 @@ export class ArenaManager { debugLogger.info(`Spawning agent PTY: ${agentId}`); agent.startedAt = Date.now(); - this.updateAgentStatus(agentId, ArenaAgentStatus.RUNNING); + this.updateAgentStatus(agentId, AgentStatus.RUNNING); // Emit agent start event this.eventEmitter.emit(ArenaEventType.AGENT_START, { @@ -721,7 +743,7 @@ export class ArenaManager { const errorMessage = error instanceof Error ? error.message : String(error); agent.error = errorMessage; - this.updateAgentStatus(agentId, ArenaAgentStatus.TERMINATED); + this.updateAgentStatus(agentId, AgentStatus.FAILED); this.eventEmitter.emit(ArenaEventType.AGENT_ERROR, { sessionId: this.requireConfig().sessionId, @@ -758,8 +780,8 @@ export class ArenaManager { return; } - // Already terminated (e.g. via cancel) - if (agent.status === ArenaAgentStatus.TERMINATED) { + // Already failed/cancelled (e.g. via cancel) + if (isTerminalStatus(agent.status)) { return; } @@ -779,8 +801,13 @@ export class ArenaManager { }); } - this.updateAgentStatus(agentId, ArenaAgentStatus.TERMINATED); - debugLogger.info(`Agent terminated: ${agentId} (exit code: ${exitCode})`); + this.updateAgentStatus( + agentId, + agent.abortController.signal.aborted + ? AgentStatus.CANCELLED + : AgentStatus.FAILED, + ); + debugLogger.info(`Agent exited: ${agentId} (exit code: ${exitCode})`); } /** @@ -832,7 +859,7 @@ export class ArenaManager { env['QWEN_BASE_URL'] = model.baseUrl; } - const spawnConfig = { + const spawnConfig: AgentSpawnConfig = { agentId, command: process.execPath, // Use the same Node.js binary args: [path.resolve(process.argv[1]!), ...args], // Re-launch the CLI entry point (must be absolute path since cwd changes) @@ -840,6 +867,30 @@ export class ArenaManager { env, cols: this.terminalCols, rows: this.terminalRows, + inProcess: { + agentName: model.displayName || model.modelId, + initialTask: this.arenaConfig?.task, + runtimeConfig: { + promptConfig: { + systemPrompt: getCoreSystemPrompt( + this.config.getUserMemory(), + model.modelId, + ), + }, + modelConfig: { model: model.modelId }, + runConfig: { + max_turns: this.arenaConfig?.maxRoundsPerAgent, + max_time_minutes: this.arenaConfig?.timeoutSeconds + ? Math.ceil(this.arenaConfig.timeoutSeconds / 60) + : undefined, + }, + }, + authOverrides: { + authType: model.authType, + apiKey: model.apiKey, + baseUrl: model.baseUrl, + }, + }, }; debugLogger.info( @@ -857,10 +908,26 @@ export class ArenaManager { // ─── Private: Status & Results ───────────────────────────────── - private updateAgentStatus( - agentId: string, - newStatus: ArenaAgentStatus, - ): void { + /** Decide whether a status transition is valid. Returns the new status or null. */ + private resolveTransition( + current: AgentStatus, + incoming: AgentStatus, + ): AgentStatus | null { + if (current === incoming) return null; + if (isTerminalStatus(current)) { + // Allow revival: COMPLETED → RUNNING (agent received new input) + if ( + current === AgentStatus.COMPLETED && + incoming === AgentStatus.RUNNING + ) { + return incoming; + } + return null; + } + return incoming; + } + + private updateAgentStatus(agentId: string, newStatus: AgentStatus): void { const agent = this.agents.get(agentId); if (!agent) { return; @@ -877,12 +944,8 @@ export class ArenaManager { timestamp: Date.now(), }); - // Emit AGENT_COMPLETE when agent reaches COMPLETED, CANCELLED, or TERMINATED - if ( - newStatus === ArenaAgentStatus.COMPLETED || - newStatus === ArenaAgentStatus.CANCELLED || - newStatus === ArenaAgentStatus.TERMINATED - ) { + // Emit AGENT_COMPLETE when agent reaches a terminal status + if (isTerminalStatus(newStatus)) { const result = this.buildAgentResult(agent); this.eventEmitter.emit(ArenaEventType.AGENT_COMPLETE, { @@ -932,15 +995,11 @@ export class ArenaManager { * Wait for all agents to reach IDLE or TERMINATED state. * Returns true if all agents settled, false if timeout was reached. */ - private waitForAllAgentsSettled(timeoutMs: number): Promise { + private waitForAllAgentsSettled(timeoutMs?: number): Promise { return new Promise((resolve) => { const checkSettled = () => { for (const agent of this.agents.values()) { - if ( - agent.status !== ArenaAgentStatus.COMPLETED && - agent.status !== ArenaAgentStatus.CANCELLED && - agent.status !== ArenaAgentStatus.TERMINATED - ) { + if (!isTerminalStatus(agent.status)) { return false; } } @@ -952,16 +1011,19 @@ export class ArenaManager { return; } - const timeoutHandle = setTimeout(() => { - clearInterval(pollHandle); - resolve(false); - }, timeoutMs); + let timeoutHandle: ReturnType | undefined; + if (timeoutMs !== undefined) { + timeoutHandle = setTimeout(() => { + clearInterval(pollHandle); + resolve(false); + }, timeoutMs); + } // Re-check periodically (piggybacks on the same polling interval) const pollHandle = setInterval(() => { if (checkSettled()) { clearInterval(pollHandle); - clearTimeout(timeoutHandle); + if (timeoutHandle) clearTimeout(timeoutHandle); resolve(true); } }, ARENA_POLL_INTERVAL_MS); @@ -993,6 +1055,80 @@ export class ArenaManager { } } + /** + * Set up event bridges for in-process agents. + * Subscribes to each AgentInteractive's events to update ArenaManager state. + * Listeners are tracked in `eventBridgeCleanups` for teardown. + */ + private setupInProcessEventBridge(backend: InProcessBackend): void { + for (const agent of this.agents.values()) { + const interactive = backend.getAgent(agent.agentId); + if (!interactive) continue; + + const emitter = interactive.getEventEmitter(); + if (!emitter) continue; + + // AgentInteractive emits canonical AgentStatus values — no mapping needed. + + const syncStats = () => { + const { totalToolCalls, totalDurationMs, ...rest } = + interactive.getStats(); + Object.assign(agent.stats, rest, { + toolCalls: totalToolCalls, + durationMs: totalDurationMs, + }); + }; + + const applyStatus = (incoming: AgentStatus) => { + const resolved = this.resolveTransition(agent.status, incoming); + if (!resolved) return; + if (resolved === AgentStatus.FAILED) { + agent.error = + interactive.getLastRoundError() || interactive.getError(); + } + if (isTerminalStatus(resolved)) { + agent.stats.durationMs = Date.now() - agent.startedAt; + } + this.updateAgentStatus(agent.agentId, resolved); + }; + + // Sync stats before mapping so counters are up-to-date even when + // the provider omits usage_metadata events. + const onStatusChange = (event: AgentStatusChangeEvent) => { + syncStats(); + applyStatus(event.newStatus); + }; + + const onUsageMetadata = () => syncStats(); + + emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata); + + // Store cleanup functions so listeners can be removed during teardown + this.eventBridgeCleanups.push(() => { + emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.off(AgentEventType.USAGE_METADATA, onUsageMetadata); + }); + + // Reconcile: if the agent already transitioned before the bridge was + // attached (e.g. fast completion or createChat failure during spawn), + // backfill stats and apply its current status now so + // waitForAllAgentsSettled sees it. + syncStats(); + applyStatus(interactive.getStatus()); + } + } + + /** + * Remove all event bridge listeners registered by setupInProcessEventBridge. + */ + private teardownEventBridge(): void { + for (const cleanup of this.eventBridgeCleanups) { + cleanup(); + } + this.eventBridgeCleanups.length = 0; + } + /** * Read per-agent status files from `/agents/` directory. * Updates agent stats, emits AGENT_STATS_UPDATE events, and writes a @@ -1004,11 +1140,10 @@ export class ArenaManager { const consolidatedAgents: Record = {}; for (const agent of this.agents.values()) { - // Only poll agents that are still alive (RUNNING or IDLE) + // Only poll agents that are still alive (RUNNING) if ( - agent.status === ArenaAgentStatus.TERMINATED || - agent.status === ArenaAgentStatus.CANCELLED || - agent.status === ArenaAgentStatus.INITIALIZING + isTerminalStatus(agent.status) || + agent.status === AgentStatus.INITIALIZING ) { continue; } @@ -1024,45 +1159,22 @@ export class ArenaManager { // Collect for consolidated file consolidatedAgents[agent.agentId] = statusFile; - // Update agent stats from the status file, but preserve locally - // calculated durationMs (the child process doesn't track it). - const { durationMs: _childDuration, ...fileStats } = statusFile.stats; + // Update agent stats from the status file. agent.stats = { ...agent.stats, - ...fileStats, + ...statusFile.stats, }; // Detect state transitions from the sideband status file - if ( - statusFile.status === 'completed' && - agent.status === ArenaAgentStatus.RUNNING - ) { - // Agent finished its task successfully - agent.stats.durationMs = Date.now() - agent.startedAt; - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.COMPLETED); - } else if ( - statusFile.status === 'cancelled' && - agent.status === ArenaAgentStatus.RUNNING - ) { - // Agent was cancelled by user - agent.stats.durationMs = Date.now() - agent.startedAt; - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.CANCELLED); - } else if ( - statusFile.status === 'error' && - agent.status === ArenaAgentStatus.RUNNING - ) { - // Agent hit an error - agent.stats.durationMs = Date.now() - agent.startedAt; - if (statusFile.error) { + const resolved = this.resolveTransition( + agent.status, + statusFile.status, + ); + if (resolved) { + if (resolved === AgentStatus.FAILED && statusFile.error) { agent.error = statusFile.error; } - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.TERMINATED); - } else if ( - statusFile.status === 'running' && - agent.status === ArenaAgentStatus.COMPLETED - ) { - // Agent received new input and is working again - this.updateAgentStatus(agent.agentId, ArenaAgentStatus.RUNNING); + this.updateAgentStatus(agent.agentId, resolved); } this.callbacks.onAgentStatsUpdate?.(agent.agentId, statusFile.stats); @@ -1195,7 +1307,7 @@ export class ArenaManager { const result = this.buildAgentResult(agent); // Get diff for completed agents (they finished their task) - if (agent.status === ArenaAgentStatus.COMPLETED) { + if (agent.status === AgentStatus.COMPLETED) { try { result.diff = await this.worktreeService.getWorktreeDiff( agent.worktree.path, diff --git a/packages/core/src/agents/arena/arena-events.ts b/packages/core/src/agents/arena/arena-events.ts index 1098fcafa..20f82d6d5 100644 --- a/packages/core/src/agents/arena/arena-events.ts +++ b/packages/core/src/agents/arena/arena-events.ts @@ -6,11 +6,11 @@ import { EventEmitter } from 'events'; import type { - ArenaAgentStatus, ArenaModelConfig, ArenaAgentResult, ArenaSessionResult, } from './types.js'; +import type { AgentStatus } from '../runtime/agent-types.js'; /** * Arena event types. @@ -109,8 +109,8 @@ export interface ArenaAgentCompleteEvent { export interface ArenaAgentStatusChangeEvent { sessionId: string; agentId: string; - previousStatus: ArenaAgentStatus; - newStatus: ArenaAgentStatus; + previousStatus: AgentStatus; + newStatus: AgentStatus; timestamp: number; } diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index 0fe6e299c..22a002056 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -6,41 +6,13 @@ import type { WorktreeInfo } from '../../services/gitWorktreeService.js'; import type { DisplayMode } from '../backends/types.js'; +import type { AgentStatus } from '../runtime/agent-types.js'; /** * Maximum number of concurrent agents allowed in an Arena session. */ export const ARENA_MAX_AGENTS = 5; -/** - * Represents the status of an Arena agent in interactive mode. - * - * Agents run as interactive CLI subprocesses (--prompt-interactive), so - * they never truly "complete" or "exit" on their own. Instead: - * - * INITIALIZING → RUNNING ⇄ COMPLETED → TERMINATED - * ↘ CANCELLED - * - * - INITIALIZING: Worktree created, PTY not yet spawned. - * - RUNNING: Agent is actively processing a turn (model thinking / tool execution). - * - COMPLETED: Agent finished the current task successfully. - * This is the "selectable" state for /arena select. - * - CANCELLED: Agent's current request was cancelled by the user. - * - TERMINATED: PTY process has exited (killed, crashed, or shut down). - */ -export enum ArenaAgentStatus { - /** Worktree created, PTY not yet spawned */ - INITIALIZING = 'initializing', - /** Agent is actively processing a turn */ - RUNNING = 'running', - /** Agent finished current task successfully */ - COMPLETED = 'completed', - /** Agent's current request was cancelled by the user */ - CANCELLED = 'cancelled', - /** PTY process has exited */ - TERMINATED = 'terminated', -} - /** * Represents the status of an Arena session. */ @@ -124,7 +96,7 @@ export interface ArenaAgentResult { /** Model configuration used */ model: ArenaModelConfig; /** Final status */ - status: ArenaAgentStatus; + status: AgentStatus; /** Worktree information */ worktree: WorktreeInfo; /** Final text output from the agent */ @@ -215,7 +187,7 @@ export interface ArenaCallbacks { */ export interface ArenaStatusFile { agentId: string; - status: 'running' | 'completed' | 'error' | 'cancelled'; + status: AgentStatus; updatedAt: number; rounds: number; currentActivity?: string; @@ -275,7 +247,7 @@ export interface ArenaAgentState { /** Model configuration */ model: ArenaModelConfig; /** Current status */ - status: ArenaAgentStatus; + status: AgentStatus; /** Worktree information */ worktree: WorktreeInfo; /** Abort controller for cancellation */ diff --git a/packages/core/src/agents/backends/InProcessBackend.test.ts b/packages/core/src/agents/backends/InProcessBackend.test.ts new file mode 100644 index 000000000..6c4734f32 --- /dev/null +++ b/packages/core/src/agents/backends/InProcessBackend.test.ts @@ -0,0 +1,536 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { InProcessBackend } from './InProcessBackend.js'; +import { DISPLAY_MODE } from './types.js'; +import type { AgentSpawnConfig } from './types.js'; +import { AgentCore } from '../runtime/agent-core.js'; +import { createContentGenerator } from '../../core/contentGenerator.js'; + +// Mock createContentGenerator to avoid real API client setup +const mockContentGenerator = { + generateContentStream: vi.fn(), +}; +vi.mock('../../core/contentGenerator.js', () => ({ + createContentGenerator: vi.fn().mockResolvedValue({ + generateContentStream: vi.fn(), + }), +})); + +// Mock AgentCore and AgentInteractive to avoid real model calls +vi.mock('../runtime/agent-core.js', () => ({ + AgentCore: vi.fn().mockImplementation(() => ({ + subagentId: 'mock-id', + name: 'mock-agent', + eventEmitter: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, + stats: { + start: vi.fn(), + getSummary: vi.fn().mockReturnValue({}), + }, + createChat: vi.fn().mockResolvedValue({}), + prepareTools: vi.fn().mockReturnValue([]), + runReasoningLoop: vi.fn().mockResolvedValue({ + text: 'Done', + terminateMode: null, + turnsUsed: 1, + }), + getEventEmitter: vi.fn().mockReturnValue({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }), + getExecutionSummary: vi.fn().mockReturnValue({}), + })), +})); + +function createMockToolRegistry() { + return { + getFunctionDeclarations: vi.fn().mockReturnValue([]), + getAllTools: vi.fn().mockReturnValue([]), + getAllToolNames: vi.fn().mockReturnValue([]), + registerTool: vi.fn(), + copyDiscoveredToolsFrom: vi.fn(), + stop: vi.fn().mockResolvedValue(undefined), + }; +} + +function createMockConfig() { + const registry = createMockToolRegistry(); + return { + getModel: vi.fn().mockReturnValue('test-model'), + getToolRegistry: vi.fn().mockReturnValue(registry), + getSessionId: vi.fn().mockReturnValue('test-session'), + getWorkingDir: vi.fn().mockReturnValue('/tmp'), + getTargetDir: vi.fn().mockReturnValue('/tmp'), + createToolRegistry: vi.fn().mockResolvedValue(createMockToolRegistry()), + getContentGenerator: vi.fn().mockReturnValue(mockContentGenerator), + getContentGeneratorConfig: vi.fn().mockReturnValue({ + model: 'test-model', + authType: 'openai', + apiKey: 'parent-key', + baseUrl: 'https://parent.example.com', + }), + getAuthType: vi.fn().mockReturnValue('openai'), + } as never; +} + +function createSpawnConfig(agentId: string): AgentSpawnConfig { + return { + agentId, + command: 'node', + args: [], + cwd: '/tmp', + inProcess: { + agentName: `Agent ${agentId}`, + initialTask: 'Do something', + runtimeConfig: { + promptConfig: { systemPrompt: 'You are a helpful assistant.' }, + modelConfig: { model: 'test-model' }, + runConfig: { max_turns: 10 }, + }, + }, + }; +} + +describe('InProcessBackend', () => { + let backend: InProcessBackend; + + beforeEach(() => { + backend = new InProcessBackend(createMockConfig()); + }); + + it('should have IN_PROCESS type', () => { + expect(backend.type).toBe(DISPLAY_MODE.IN_PROCESS); + }); + + it('should init without error', async () => { + await expect(backend.init()).resolves.toBeUndefined(); + }); + + it('should throw when spawning without inProcess config', async () => { + const config: AgentSpawnConfig = { + agentId: 'test', + command: 'node', + args: [], + cwd: '/tmp', + }; + + await expect(backend.spawnAgent(config)).rejects.toThrow( + 'InProcessBackend requires inProcess config', + ); + }); + + it('should spawn an agent with inProcess config', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + expect(backend.getActiveAgentId()).toBe('agent-1'); + expect(backend.getAgent('agent-1')).toBeDefined(); + }); + + it('should set first spawned agent as active', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + await backend.spawnAgent(createSpawnConfig('agent-2')); + + expect(backend.getActiveAgentId()).toBe('agent-1'); + }); + + it('should navigate between agents', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + await backend.spawnAgent(createSpawnConfig('agent-2')); + await backend.spawnAgent(createSpawnConfig('agent-3')); + + expect(backend.getActiveAgentId()).toBe('agent-1'); + + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('agent-2'); + + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('agent-3'); + + // Wraps around + backend.switchToNext(); + expect(backend.getActiveAgentId()).toBe('agent-1'); + + backend.switchToPrevious(); + expect(backend.getActiveAgentId()).toBe('agent-3'); + }); + + it('should switch to a specific agent', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + await backend.spawnAgent(createSpawnConfig('agent-2')); + + backend.switchTo('agent-2'); + expect(backend.getActiveAgentId()).toBe('agent-2'); + }); + + it('should forward input to active agent', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + const result = backend.forwardInput('hello'); + expect(result).toBe(true); + }); + + it('should return false for forwardInput with no active agent', () => { + expect(backend.forwardInput('hello')).toBe(false); + }); + + it('should write to specific agent', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + expect(backend.writeToAgent('agent-1', 'hello')).toBe(true); + expect(backend.writeToAgent('nonexistent', 'hello')).toBe(false); + }); + + it('should return null for screen capture methods', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + expect(backend.getActiveSnapshot()).toBeNull(); + expect(backend.getAgentSnapshot('agent-1')).toBeNull(); + expect(backend.getAgentScrollbackLength('agent-1')).toBe(0); + }); + + it('should return null for attach hint', () => { + expect(backend.getAttachHint()).toBeNull(); + }); + + it('should stop a specific agent', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + + backend.stopAgent('agent-1'); + // Agent should eventually reach cancelled state + }); + + it('should stop all agents', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + await backend.spawnAgent(createSpawnConfig('agent-2')); + + backend.stopAll(); + // Both agents should be aborted + }); + + it('should cleanup all agents', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + await backend.cleanup(); + + expect(backend.getActiveAgentId()).toBeNull(); + expect(backend.getAgent('agent-1')).toBeUndefined(); + }); + + it('should fire exit callback when agent completes', async () => { + await backend.init(); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + await backend.spawnAgent(createSpawnConfig('agent-1')); + + // The mock agent stays idle after processing initialTask. + // Trigger a graceful shutdown to make it complete. + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + await agent!.shutdown(); + + // Wait for the exit callback to fire + await vi.waitFor(() => { + expect(exitCallback).toHaveBeenCalledWith( + 'agent-1', + expect.any(Number), + null, + ); + }); + }); + + it('should pass per-agent cwd to AgentCore via config proxy', async () => { + const parentConfig = createMockConfig(); + const backendWithParentCwd = new InProcessBackend(parentConfig); + await backendWithParentCwd.init(); + + const agentCwd = '/worktree/agent-1'; + const config = createSpawnConfig('agent-1'); + config.cwd = agentCwd; + + await backendWithParentCwd.spawnAgent(config); + + const MockAgentCore = AgentCore as unknown as ReturnType; + const lastCall = MockAgentCore.mock.calls.at(-1); + expect(lastCall).toBeDefined(); + + // Second arg is the runtime context (Config) + const agentContext = lastCall![1] as { + getWorkingDir: () => string; + getTargetDir: () => string; + getToolRegistry: () => unknown; + }; + expect(agentContext.getWorkingDir()).toBe(agentCwd); + expect(agentContext.getTargetDir()).toBe(agentCwd); + expect(agentContext.getToolRegistry()).toBeDefined(); + }); + + it('should propagate runConfig limits to AgentInteractive', async () => { + await backend.init(); + + const config = createSpawnConfig('agent-1'); + config.inProcess!.runtimeConfig.runConfig = { + max_turns: 5, + max_time_minutes: 10, + }; + + await backend.spawnAgent(config); + + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + expect(agent!.config.maxTurnsPerMessage).toBe(5); + expect(agent!.config.maxTimeMinutesPerMessage).toBe(10); + }); + + it('should default limits to undefined when runConfig omits them', async () => { + await backend.init(); + + const config = createSpawnConfig('agent-1'); + config.inProcess!.runtimeConfig.runConfig = {}; + + await backend.spawnAgent(config); + + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + expect(agent!.config.maxTurnsPerMessage).toBeUndefined(); + expect(agent!.config.maxTimeMinutesPerMessage).toBeUndefined(); + }); + + it('should give each agent its own cwd even when sharing a backend', async () => { + await backend.init(); + + const config1 = createSpawnConfig('agent-1'); + config1.cwd = '/worktree/agent-1'; + const config2 = createSpawnConfig('agent-2'); + config2.cwd = '/worktree/agent-2'; + + await backend.spawnAgent(config1); + await backend.spawnAgent(config2); + + const MockAgentCore = AgentCore as unknown as ReturnType; + const calls = MockAgentCore.mock.calls; + + const ctx1 = calls.at(-2)![1] as { + getWorkingDir: () => string; + getTargetDir: () => string; + }; + const ctx2 = calls.at(-1)![1] as { + getWorkingDir: () => string; + getTargetDir: () => string; + }; + + expect(ctx1.getWorkingDir()).toBe('/worktree/agent-1'); + expect(ctx1.getTargetDir()).toBe('/worktree/agent-1'); + expect(ctx2.getWorkingDir()).toBe('/worktree/agent-2'); + expect(ctx2.getTargetDir()).toBe('/worktree/agent-2'); + }); + + it('should throw when spawning a duplicate agent ID', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + await expect( + backend.spawnAgent(createSpawnConfig('agent-1')), + ).rejects.toThrow('Agent "agent-1" already exists.'); + }); + + it('should fire exit callback with code 1 when start() throws', async () => { + // Make createChat throw for this test + const MockAgentCore = AgentCore as unknown as ReturnType; + MockAgentCore.mockImplementationOnce(() => ({ + subagentId: 'mock-id', + name: 'mock-agent', + eventEmitter: { + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }, + stats: { + start: vi.fn(), + getSummary: vi.fn().mockReturnValue({}), + }, + createChat: vi.fn().mockRejectedValue(new Error('Auth failed')), + prepareTools: vi.fn().mockReturnValue([]), + getEventEmitter: vi.fn().mockReturnValue({ + on: vi.fn(), + off: vi.fn(), + emit: vi.fn(), + }), + getExecutionSummary: vi.fn().mockReturnValue({}), + })); + + await backend.init(); + + const exitCallback = vi.fn(); + backend.setOnAgentExit(exitCallback); + + // spawnAgent should NOT throw — it catches the error internally + await expect( + backend.spawnAgent(createSpawnConfig('agent-fail')), + ).resolves.toBeUndefined(); + + // Exit callback should have been fired with exit code 1 + expect(exitCallback).toHaveBeenCalledWith('agent-fail', 1, null); + }); + + it('should return true immediately from waitForAll after cleanup', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + await backend.cleanup(); + + // waitForAll should return immediately after cleanup + const result = await backend.waitForAll(5000); + expect(result).toBe(true); + }); + + describe('auth isolation', () => { + it('should create per-agent ContentGenerator when authOverrides is provided', async () => { + await backend.init(); + + const config = createSpawnConfig('agent-1'); + config.inProcess!.authOverrides = { + authType: 'anthropic', + apiKey: 'agent-key-123', + baseUrl: 'https://agent.example.com', + }; + + await backend.spawnAgent(config); + + const mockCreate = createContentGenerator as ReturnType; + expect(mockCreate).toHaveBeenCalledWith( + expect.objectContaining({ + authType: 'anthropic', + apiKey: 'agent-key-123', + baseUrl: 'https://agent.example.com', + model: 'test-model', + }), + expect.anything(), + ); + }); + + it('should override getContentGenerator on per-agent config', async () => { + const agentGenerator = { generateContentStream: vi.fn() }; + const mockCreate = createContentGenerator as ReturnType; + mockCreate.mockResolvedValueOnce(agentGenerator); + + await backend.init(); + + const config = createSpawnConfig('agent-1'); + config.inProcess!.authOverrides = { + authType: 'anthropic', + apiKey: 'agent-key', + }; + + await backend.spawnAgent(config); + + const MockAgentCore = AgentCore as unknown as ReturnType; + const lastCall = MockAgentCore.mock.calls.at(-1); + const agentContext = lastCall![1] as { + getContentGenerator: () => unknown; + getAuthType: () => string | undefined; + getModel: () => string; + }; + + expect(agentContext.getContentGenerator()).toBe(agentGenerator); + expect(agentContext.getAuthType()).toBe('anthropic'); + }); + + it('should not create per-agent ContentGenerator without authOverrides', async () => { + const mockCreate = createContentGenerator as ReturnType; + mockCreate.mockClear(); + + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + expect(mockCreate).not.toHaveBeenCalled(); + }); + + it('should fall back to parent ContentGenerator if per-agent creation fails', async () => { + const mockCreate = createContentGenerator as ReturnType; + mockCreate.mockRejectedValueOnce(new Error('Auth failed')); + + await backend.init(); + + const config = createSpawnConfig('agent-1'); + config.inProcess!.authOverrides = { + authType: 'anthropic', + apiKey: 'bad-key', + }; + + // Should not throw — falls back gracefully + await expect(backend.spawnAgent(config)).resolves.toBeUndefined(); + + const MockAgentCore = AgentCore as unknown as ReturnType; + const lastCall = MockAgentCore.mock.calls.at(-1); + const agentContext = lastCall![1] as { + getContentGenerator: () => unknown; + }; + + // Falls back to parent's content generator + expect(agentContext.getContentGenerator()).toBe(mockContentGenerator); + }); + + it('should give different agents different ContentGenerators', async () => { + const gen1 = { generateContentStream: vi.fn() }; + const gen2 = { generateContentStream: vi.fn() }; + const mockCreate = createContentGenerator as ReturnType; + mockCreate.mockResolvedValueOnce(gen1).mockResolvedValueOnce(gen2); + + await backend.init(); + + const config1 = createSpawnConfig('agent-1'); + config1.inProcess!.authOverrides = { + authType: 'openai', + apiKey: 'key-1', + baseUrl: 'https://api1.example.com', + }; + const config2 = createSpawnConfig('agent-2'); + config2.inProcess!.authOverrides = { + authType: 'anthropic', + apiKey: 'key-2', + baseUrl: 'https://api2.example.com', + }; + + await backend.spawnAgent(config1); + await backend.spawnAgent(config2); + + const MockAgentCore = AgentCore as unknown as ReturnType; + const calls = MockAgentCore.mock.calls; + + const ctx1 = calls.at(-2)![1] as { + getContentGenerator: () => unknown; + }; + const ctx2 = calls.at(-1)![1] as { + getContentGenerator: () => unknown; + }; + + expect(ctx1.getContentGenerator()).toBe(gen1); + expect(ctx2.getContentGenerator()).toBe(gen2); + expect(ctx1.getContentGenerator()).not.toBe(ctx2.getContentGenerator()); + }); + }); +}); diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts new file mode 100644 index 000000000..6ea1de34e --- /dev/null +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -0,0 +1,459 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview InProcessBackend — Backend implementation that runs agents + * in the current process using AgentInteractive instead of PTY subprocesses. + * + * This enables Arena to work without tmux or any external terminal multiplexer. + */ + +import { createDebugLogger } from '../../utils/debugLogger.js'; +import type { Config } from '../../config/config.js'; +import { + type AuthType, + type ContentGenerator, + type ContentGeneratorConfig, + createContentGenerator, +} from '../../core/contentGenerator.js'; +import { AUTH_ENV_MAPPINGS } from '../../models/constants.js'; +import { AgentStatus } from '../runtime/agent-types.js'; +import { AgentCore } from '../runtime/agent-core.js'; +import { AgentEventEmitter } from '../runtime/agent-events.js'; +import { ContextState } from '../runtime/agent-headless.js'; +import { AgentInteractive } from '../runtime/agent-interactive.js'; +import type { + Backend, + AgentSpawnConfig, + AgentExitCallback, + InProcessSpawnConfig, +} from './types.js'; +import { DISPLAY_MODE } from './types.js'; +import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import { WorkspaceContext } from '../../utils/workspaceContext.js'; +import { FileDiscoveryService } from '../../services/fileDiscoveryService.js'; +import type { ToolRegistry } from '../../tools/tool-registry.js'; + +const debugLogger = createDebugLogger('IN_PROCESS_BACKEND'); + +/** + * InProcessBackend runs agents in the current Node.js process. + * + * Instead of spawning PTY subprocesses, it creates AgentCore + AgentInteractive + * instances that execute in-process. Screen capture returns null (the UI reads + * messages directly from AgentInteractive). + */ +export class InProcessBackend implements Backend { + readonly type = DISPLAY_MODE.IN_PROCESS; + + private readonly runtimeContext: Config; + private readonly agents = new Map(); + private readonly agentRegistries: ToolRegistry[] = []; + private readonly agentOrder: string[] = []; + private activeAgentId: string | null = null; + private exitCallback: AgentExitCallback | null = null; + /** Whether cleanup() has been called */ + private cleanedUp = false; + + constructor(runtimeContext: Config) { + this.runtimeContext = runtimeContext; + } + + // ─── Backend Interface ───────────────────────────────────── + + async init(): Promise { + debugLogger.info('InProcessBackend initialized'); + } + + async spawnAgent(config: AgentSpawnConfig): Promise { + const inProcessConfig = config.inProcess; + if (!inProcessConfig) { + throw new Error( + `InProcessBackend requires inProcess config for agent ${config.agentId}`, + ); + } + + if (this.agents.has(config.agentId)) { + throw new Error(`Agent "${config.agentId}" already exists.`); + } + + const { promptConfig, modelConfig, runConfig, toolConfig } = + inProcessConfig.runtimeConfig; + + const eventEmitter = new AgentEventEmitter(); + + // Build a per-agent runtime context with isolated working directory, + // target directory, workspace context, tool registry, and (optionally) + // a dedicated ContentGenerator for per-agent auth isolation. + const agentContext = await createPerAgentConfig( + this.runtimeContext, + config.cwd, + inProcessConfig.runtimeConfig.modelConfig.model, + inProcessConfig.authOverrides, + ); + + this.agentRegistries.push(agentContext.getToolRegistry()); + + const core = new AgentCore( + inProcessConfig.agentName, + agentContext, + promptConfig, + modelConfig, + runConfig, + toolConfig, + eventEmitter, + ); + + const interactive = new AgentInteractive( + { + agentId: config.agentId, + agentName: inProcessConfig.agentName, + initialTask: inProcessConfig.initialTask, + maxTurnsPerMessage: runConfig.max_turns, + maxTimeMinutesPerMessage: runConfig.max_time_minutes, + }, + core, + ); + + this.agents.set(config.agentId, interactive); + this.agentOrder.push(config.agentId); + + // Set first agent as active + if (this.activeAgentId === null) { + this.activeAgentId = config.agentId; + } + + try { + const context = new ContextState(); + await interactive.start(context); + + // Watch for completion and fire exit callback + void interactive.waitForCompletion().then(() => { + const status = interactive.getStatus(); + const exitCode = + status === AgentStatus.COMPLETED + ? 0 + : status === AgentStatus.FAILED + ? 1 + : null; + this.exitCallback?.(config.agentId, exitCode, null); + }); + + debugLogger.info(`Spawned in-process agent: ${config.agentId}`); + } catch (error) { + debugLogger.error( + `Failed to start in-process agent "${config.agentId}":`, + error, + ); + this.exitCallback?.(config.agentId, 1, null); + } + } + + stopAgent(agentId: string): void { + const agent = this.agents.get(agentId); + if (agent) { + agent.abort(); + debugLogger.info(`Stopped agent: ${agentId}`); + } + } + + stopAll(): void { + for (const agent of this.agents.values()) { + agent.abort(); + } + debugLogger.info('Stopped all in-process agents'); + } + + async cleanup(): Promise { + this.cleanedUp = true; + + for (const agent of this.agents.values()) { + agent.abort(); + } + // Wait briefly for loops to settle + const promises = Array.from(this.agents.values()).map((a) => + a.waitForCompletion().catch(() => {}), + ); + await Promise.allSettled(promises); + + // Stop per-agent tool registries so tools like TaskTool can release + // listeners registered on shared managers (e.g. SubagentManager). + for (const registry of this.agentRegistries) { + await registry.stop().catch(() => {}); + } + this.agentRegistries.length = 0; + + this.agents.clear(); + this.agentOrder.length = 0; + this.activeAgentId = null; + debugLogger.info('InProcessBackend cleaned up'); + } + + setOnAgentExit(callback: AgentExitCallback): void { + this.exitCallback = callback; + } + + async waitForAll(timeoutMs?: number): Promise { + if (this.cleanedUp) return true; + + const promises = Array.from(this.agents.values()).map((a) => + a.waitForCompletion(), + ); + + if (timeoutMs === undefined) { + await Promise.allSettled(promises); + return true; + } + + let timerId: ReturnType; + const timeout = new Promise<'timeout'>((resolve) => { + timerId = setTimeout(() => resolve('timeout'), timeoutMs); + }); + + const result = await Promise.race([ + Promise.allSettled(promises).then(() => 'done' as const), + timeout, + ]); + + clearTimeout(timerId!); + return result === 'done'; + } + + // ─── Navigation ──────────────────────────────────────────── + + switchTo(agentId: string): void { + if (this.agents.has(agentId)) { + this.activeAgentId = agentId; + } + } + + switchToNext(): void { + this.activeAgentId = this.navigate(1); + } + + switchToPrevious(): void { + this.activeAgentId = this.navigate(-1); + } + + getActiveAgentId(): string | null { + return this.activeAgentId; + } + + // ─── Screen Capture (no-op for in-process) ───────────────── + + getActiveSnapshot(): AnsiOutput | null { + return null; + } + + getAgentSnapshot( + _agentId: string, + _scrollOffset?: number, + ): AnsiOutput | null { + return null; + } + + getAgentScrollbackLength(_agentId: string): number { + return 0; + } + + // ─── Input ───────────────────────────────────────────────── + + forwardInput(data: string): boolean { + if (!this.activeAgentId) return false; + return this.writeToAgent(this.activeAgentId, data); + } + + writeToAgent(agentId: string, data: string): boolean { + const agent = this.agents.get(agentId); + if (!agent) return false; + + agent.enqueueMessage(data); + return true; + } + + // ─── Resize (no-op) ─────────────────────────────────────── + + resizeAll(_cols: number, _rows: number): void { + // No terminals to resize in-process + } + + // ─── External Session ────────────────────────────────────── + + getAttachHint(): string | null { + return null; + } + + // ─── Extra: Direct Access ────────────────────────────────── + + /** + * Get an AgentInteractive instance by agent ID. + * Used by ArenaManager for direct event subscription. + */ + getAgent(agentId: string): AgentInteractive | undefined { + return this.agents.get(agentId); + } + + // ─── Private ─────────────────────────────────────────────── + + private navigate(direction: 1 | -1): string | null { + if (this.agentOrder.length === 0) return null; + if (!this.activeAgentId) return this.agentOrder[0] ?? null; + + const currentIndex = this.agentOrder.indexOf(this.activeAgentId); + if (currentIndex === -1) return this.agentOrder[0] ?? null; + + const nextIndex = + (currentIndex + direction + this.agentOrder.length) % + this.agentOrder.length; + return this.agentOrder[nextIndex] ?? null; + } +} + +/** + * Create a per-agent Config that delegates to the shared base Config but + * overrides key methods to provide per-agent isolation: + * + * - `getWorkingDir()` / `getTargetDir()` → agent's worktree cwd + * - `getWorkspaceContext()` → WorkspaceContext rooted at agent's cwd + * - `getFileService()` → FileDiscoveryService rooted at agent's cwd + * (so .qwenignore checks resolve against the agent's worktree) + * - `getToolRegistry()` → per-agent tool registry with core tools bound to + * the agent Config (so tools resolve paths against the agent's worktree) + * - `getContentGenerator()` / `getContentGeneratorConfig()` / `getAuthType()` + * → per-agent ContentGenerator when `authOverrides` is provided, enabling + * agents to target different model providers in the same Arena session + * + * Uses prototypal delegation so all other Config methods/properties resolve + * against the original instance transparently. + */ +async function createPerAgentConfig( + base: Config, + cwd: string, + modelId?: string, + authOverrides?: InProcessSpawnConfig['authOverrides'], +): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const override = Object.create(base) as any; + + override.getWorkingDir = () => cwd; + override.getTargetDir = () => cwd; + override.getProjectRoot = () => cwd; + + const agentWorkspace = new WorkspaceContext(cwd); + override.getWorkspaceContext = () => agentWorkspace; + + const agentFileService = new FileDiscoveryService(cwd); + override.getFileService = () => agentFileService; + + // Build a per-agent tool registry: core tools are constructed with + // the per-agent Config so they resolve paths against cwd. Discovered + // (MCP/command) tools are copied from the parent registry as-is. + const agentRegistry: ToolRegistry = await override.createToolRegistry( + undefined, + { skipDiscovery: true }, + ); + agentRegistry.copyDiscoveredToolsFrom(base.getToolRegistry()); + override.getToolRegistry = () => agentRegistry; + + // Build a per-agent ContentGenerator when auth overrides are provided. + // This enables Arena agents to use different providers (OpenAI, Anthropic, + // Gemini, etc.) than the parent process. + if (authOverrides?.authType) { + try { + const agentGeneratorConfig = buildAgentContentGeneratorConfig( + base, + modelId, + authOverrides, + ); + const agentGenerator = await createContentGenerator( + agentGeneratorConfig, + override as Config, + ); + override.getContentGenerator = (): ContentGenerator => agentGenerator; + override.getContentGeneratorConfig = (): ContentGeneratorConfig => + agentGeneratorConfig; + override.getAuthType = (): AuthType | undefined => + agentGeneratorConfig.authType; + override.getModel = (): string => agentGeneratorConfig.model; + + debugLogger.info( + `Created per-agent ContentGenerator: authType=${authOverrides.authType}, model=${agentGeneratorConfig.model}`, + ); + } catch (error) { + debugLogger.error( + 'Failed to create per-agent ContentGenerator, falling back to parent:', + error, + ); + } + } + + return override as Config; +} + +/** + * Build a ContentGeneratorConfig for a per-agent ContentGenerator. + * Inherits operational settings (timeout, retries, proxy, sampling, etc.) + * from the parent's config and overlays the agent-specific auth fields. + * + * For cross-provider agents the parent's API key / base URL are invalid, + * so we resolve credentials from the provider-specific environment + * variables (e.g. ANTHROPIC_API_KEY, ANTHROPIC_BASE_URL). This mirrors + * what a PTY subprocess does during its own initialization. + */ +function buildAgentContentGeneratorConfig( + base: Config, + modelId: string | undefined, + authOverrides: NonNullable, +): ContentGeneratorConfig { + const parentConfig = base.getContentGeneratorConfig(); + const sameProvider = authOverrides.authType === parentConfig.authType; + + const resolvedApiKey = resolveCredentialField( + authOverrides.apiKey, + sameProvider ? parentConfig.apiKey : undefined, + authOverrides.authType, + 'apiKey', + ); + + const resolvedBaseUrl = resolveCredentialField( + authOverrides.baseUrl, + sameProvider ? parentConfig.baseUrl : undefined, + authOverrides.authType, + 'baseUrl', + ); + + return { + ...parentConfig, + model: modelId ?? parentConfig.model, + authType: authOverrides.authType as AuthType, + apiKey: resolvedApiKey, + baseUrl: resolvedBaseUrl, + }; +} + +/** + * Resolve a credential field (apiKey or baseUrl) with the following + * priority: explicit override → same-provider parent value → env var. + */ +function resolveCredentialField( + explicitValue: string | undefined, + inheritedValue: string | undefined, + authType: string, + field: 'apiKey' | 'baseUrl', +): string | undefined { + if (explicitValue) return explicitValue; + if (inheritedValue) return inheritedValue; + + const envMapping = + AUTH_ENV_MAPPINGS[authType as keyof typeof AUTH_ENV_MAPPINGS]; + if (!envMapping) return undefined; + + for (const envKey of envMapping[field]) { + const value = process.env[envKey]; + if (value) return value; + } + return undefined; +} diff --git a/packages/core/src/agents/backends/detect.ts b/packages/core/src/agents/backends/detect.ts index 3c53c5ceb..c8c43c2c8 100644 --- a/packages/core/src/agents/backends/detect.ts +++ b/packages/core/src/agents/backends/detect.ts @@ -5,7 +5,9 @@ */ import { createDebugLogger } from '../../utils/debugLogger.js'; +import type { Config } from '../../config/config.js'; import { TmuxBackend } from './TmuxBackend.js'; +import { InProcessBackend } from './InProcessBackend.js'; import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js'; import { isTmuxAvailable } from './tmux-commands.js'; @@ -19,30 +21,29 @@ export interface DetectBackendResult { /** * Detect and create the appropriate Backend. * - * Design principle for current Arena flow: - * - Keep all display mode values in the API surface - * - Only tmux is runnable for now - * - in-process / iTerm2 preferences fail fast as "not implemented yet" - * * Detection priority: * 1. User explicit preference (--display=in-process|tmux|iterm2) * 2. Auto-detect: * - inside tmux: TmuxBackend * - other terminals: tmux external session mode when tmux is available + * - fallback to InProcessBackend + * + * @param preference - Optional display mode preference + * @param runtimeContext - Runtime config for in-process fallback */ export async function detectBackend( - preference?: DisplayMode, + preference: DisplayMode | undefined, + runtimeContext: Config, ): Promise { // 1. User explicit preference if (preference === DISPLAY_MODE.IN_PROCESS) { - throw new Error( - `Arena display mode "${DISPLAY_MODE.IN_PROCESS}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}".`, - ); + debugLogger.info('Using InProcessBackend (user preference)'); + return { backend: new InProcessBackend(runtimeContext) }; } if (preference === DISPLAY_MODE.ITERM2) { throw new Error( - `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}".`, + `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, ); } @@ -65,10 +66,13 @@ export async function detectBackend( return { backend: new TmuxBackend() }; } - // No supported backend available. - const tmuxEnv = process.env['TMUX']; - const termProgram = process.env['TERM_PROGRAM']; - throw new Error( - `No supported Arena backend detected. $TMUX=${tmuxEnv ? `"${tmuxEnv}"` : '(unset)'}, $TERM_PROGRAM=${termProgram ? `"${termProgram}"` : '(unset)'}. Install tmux to use Arena split-pane mode.`, + // Fallback: use InProcessBackend + debugLogger.info( + 'No PTY backend available — falling back to InProcessBackend', ); + return { + backend: new InProcessBackend(runtimeContext), + warning: + 'tmux is not available. Using in-process mode (no split-pane terminal view).', + }; } diff --git a/packages/core/src/agents/backends/index.ts b/packages/core/src/agents/backends/index.ts index f85fe163e..6105fe45c 100644 --- a/packages/core/src/agents/backends/index.ts +++ b/packages/core/src/agents/backends/index.ts @@ -11,7 +11,9 @@ export type { AgentSpawnConfig, AgentExitCallback, TmuxBackendOptions, + InProcessSpawnConfig, } from './types.js'; export { TmuxBackend } from './TmuxBackend.js'; export { ITermBackend } from './ITermBackend.js'; +export { InProcessBackend } from './InProcessBackend.js'; export { detectBackend, type DetectBackendResult } from './detect.js'; diff --git a/packages/core/src/agents/backends/types.ts b/packages/core/src/agents/backends/types.ts index 577096639..0b706b08f 100644 --- a/packages/core/src/agents/backends/types.ts +++ b/packages/core/src/agents/backends/types.ts @@ -12,6 +12,12 @@ */ import type { AnsiOutput } from '../../utils/terminalSerializer.js'; +import type { + PromptConfig, + ModelConfig, + RunConfig, + ToolConfig, +} from '../runtime/agent-types.js'; /** * Canonical display mode values shared across core and CLI. @@ -52,6 +58,41 @@ export interface AgentSpawnConfig { backend?: { tmux?: TmuxBackendOptions; }; + + /** + * In-process spawn configuration (optional). + * When provided, InProcessBackend uses this to create an AgentInteractive + * instead of launching a PTY subprocess. + */ + inProcess?: InProcessSpawnConfig; +} + +/** + * Configuration for spawning an in-process agent (no PTY subprocess). + */ +export interface InProcessSpawnConfig { + /** Human-readable agent name for display. */ + agentName: string; + /** Optional initial task to start working on immediately. */ + initialTask?: string; + /** Runtime configuration for the AgentCore. */ + runtimeConfig: { + promptConfig: PromptConfig; + modelConfig: ModelConfig; + runConfig: RunConfig; + toolConfig?: ToolConfig; + }; + /** + * Per-agent auth/provider overrides. When present, a dedicated + * ContentGenerator is created for this agent instead of inheriting + * the parent process's. This enables Arena agents to target different + * model providers (OpenAI, Anthropic, Gemini, etc.) in the same session. + */ + authOverrides?: { + authType: string; + apiKey?: string; + baseUrl?: string; + }; } /** diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 8af0f9247..4767c258d 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -43,17 +43,17 @@ import type { ModelConfig, RunConfig, ToolConfig, -} from '../../subagents/types.js'; -import { SubagentTerminateMode } from '../../subagents/types.js'; +} from './agent-types.js'; +import { AgentTerminateMode } from './agent-types.js'; import type { AgentRoundEvent, AgentToolCallEvent, AgentToolResultEvent, AgentUsageEvent, + AgentHooks, } from './agent-events.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import { AgentStatistics, type AgentStatsSummary } from './agent-statistics.js'; -import type { AgentHooks } from './agent-hooks.js'; import { TaskTool } from '../../tools/task.js'; import { DEFAULT_QWEN_MODEL } from '../../config/models.js'; import { type ContextState, templateString } from './agent-headless.js'; @@ -65,7 +65,7 @@ export interface ReasoningLoopResult { /** The final model text response (empty if terminated by abort/limits). */ text: string; /** Why the loop ended. null = normal text completion (no tool calls). */ - terminateMode: SubagentTerminateMode | null; + terminateMode: AgentTerminateMode | null; /** Number of model round-trips completed. */ turnsUsed: number; } @@ -324,18 +324,18 @@ export class AgentCore { let currentMessages = initialMessages; let turnCounter = 0; let finalText = ''; - let terminateMode: SubagentTerminateMode | null = null; + let terminateMode: AgentTerminateMode | null = null; while (true) { // Check termination conditions. if (options?.maxTurns && turnCounter >= options.maxTurns) { - terminateMode = SubagentTerminateMode.MAX_TURNS; + terminateMode = AgentTerminateMode.MAX_TURNS; break; } let durationMin = (Date.now() - startTime) / (1000 * 60); if (options?.maxTimeMinutes && durationMin >= options.maxTimeMinutes) { - terminateMode = SubagentTerminateMode.TIMEOUT; + terminateMode = AgentTerminateMode.TIMEOUT; break; } @@ -384,7 +384,7 @@ export class AgentCore { abortController.signal.removeEventListener('abort', onParentAbort); return { text: finalText, - terminateMode: SubagentTerminateMode.CANCELLED, + terminateMode: AgentTerminateMode.CANCELLED, turnsUsed: turnCounter, }; } @@ -427,7 +427,7 @@ export class AgentCore { durationMin = (Date.now() - startTime) / (1000 * 60); if (options?.maxTimeMinutes && durationMin >= options.maxTimeMinutes) { abortController.signal.removeEventListener('abort', onParentAbort); - terminateMode = SubagentTerminateMode.TIMEOUT; + terminateMode = AgentTerminateMode.TIMEOUT; break; } diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index 8f68dd1c3..e02d8b692 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -4,6 +4,15 @@ * SPDX-License-Identifier: Apache-2.0 */ +/** + * @fileoverview Agent event types, emitter, and lifecycle hooks. + * + * Defines the observation/notification contracts for the agent runtime: + * - Event types emitted during agent execution (streaming, tool calls, etc.) + * - AgentEventEmitter — typed wrapper around EventEmitter + * - Lifecycle hooks (pre/post tool use, stop) for synchronous callbacks + */ + import { EventEmitter } from 'events'; import type { ToolCallConfirmationDetails, @@ -11,6 +20,9 @@ import type { ToolResultDisplay, } from '../../tools/tools.js'; import type { Part, GenerateContentResponseUsageMetadata } from '@google/genai'; +import type { AgentStatus } from './agent-types.js'; + +// ─── Event Types ──────────────────────────────────────────── export type AgentEvent = | 'start' @@ -22,7 +34,8 @@ export type AgentEvent = | 'tool_waiting_approval' | 'usage_metadata' | 'finish' - | 'error'; + | 'error' + | 'status_change'; export enum AgentEventType { START = 'start', @@ -35,8 +48,11 @@ export enum AgentEventType { USAGE_METADATA = 'usage_metadata', FINISH = 'finish', ERROR = 'error', + STATUS_CHANGE = 'status_change', } +// ─── Event Payloads ───────────────────────────────────────── + export interface AgentStartEvent { subagentId: string; name: string; @@ -128,18 +144,85 @@ export interface AgentErrorEvent { timestamp: number; } +export interface AgentStatusChangeEvent { + agentId: string; + previousStatus: AgentStatus; + newStatus: AgentStatus; + timestamp: number; +} + +// ─── Event Map ────────────────────────────────────────────── + +/** + * Maps each event type to its payload type for type-safe emit/on. + */ +export interface AgentEventMap { + [AgentEventType.START]: AgentStartEvent; + [AgentEventType.ROUND_START]: AgentRoundEvent; + [AgentEventType.ROUND_END]: AgentRoundEvent; + [AgentEventType.STREAM_TEXT]: AgentStreamTextEvent; + [AgentEventType.TOOL_CALL]: AgentToolCallEvent; + [AgentEventType.TOOL_RESULT]: AgentToolResultEvent; + [AgentEventType.TOOL_WAITING_APPROVAL]: AgentApprovalRequestEvent; + [AgentEventType.USAGE_METADATA]: AgentUsageEvent; + [AgentEventType.FINISH]: AgentFinishEvent; + [AgentEventType.ERROR]: AgentErrorEvent; + [AgentEventType.STATUS_CHANGE]: AgentStatusChangeEvent; +} + +// ─── Event Emitter ────────────────────────────────────────── + export class AgentEventEmitter { private ee = new EventEmitter(); - on(event: AgentEvent, listener: (...args: unknown[]) => void) { - this.ee.on(event, listener); + on( + event: E, + listener: (payload: AgentEventMap[E]) => void, + ): void { + this.ee.on(event, listener as (...args: unknown[]) => void); } - off(event: AgentEvent, listener: (...args: unknown[]) => void) { - this.ee.off(event, listener); + off( + event: E, + listener: (payload: AgentEventMap[E]) => void, + ): void { + this.ee.off(event, listener as (...args: unknown[]) => void); } - emit(event: AgentEvent, payload: unknown) { + emit( + event: E, + payload: AgentEventMap[E], + ): void { this.ee.emit(event, payload); } } + +// ─── Lifecycle Hooks ──────────────────────────────────────── + +export interface PreToolUsePayload { + subagentId: string; + name: string; // subagent name + toolName: string; + args: Record; + timestamp: number; +} + +export interface PostToolUsePayload extends PreToolUsePayload { + success: boolean; + durationMs: number; + errorMessage?: string; +} + +export interface AgentStopPayload { + subagentId: string; + name: string; // subagent name + terminateReason: string; + summary: Record; + timestamp: number; +} + +export interface AgentHooks { + preToolUse?(payload: PreToolUsePayload): Promise | void; + postToolUse?(payload: PostToolUsePayload): Promise | void; + onStop?(payload: AgentStopPayload): Promise | void; +} diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 41b31cddc..82bdc2d70 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -46,8 +46,8 @@ import type { PromptConfig, RunConfig, ToolConfig, -} from '../../subagents/types.js'; -import { SubagentTerminateMode } from '../../subagents/types.js'; +} from './agent-types.js'; +import { AgentTerminateMode } from './agent-types.js'; vi.mock('../../core/geminiChat.js'); vi.mock('../../core/contentGenerator.js', async (importOriginal) => { @@ -517,7 +517,7 @@ describe('subagent.ts', () => { await expect(scope.execute(context)).rejects.toThrow( 'Missing context values for the following keys: missing', ); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.ERROR); }); it('should validate that systemPrompt and initialMessages are mutually exclusive', async () => { @@ -539,7 +539,7 @@ describe('subagent.ts', () => { await expect(agent.execute(context)).rejects.toThrow( 'PromptConfig cannot have both `systemPrompt` and `initialMessages` defined.', ); - expect(agent.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); + expect(agent.getTerminateMode()).toBe(AgentTerminateMode.ERROR); }); }); @@ -562,7 +562,7 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); // Check the initial message expect(mockSendMessageStream.mock.calls[0][1].message).toEqual([ @@ -586,7 +586,7 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); }); @@ -667,7 +667,7 @@ describe('subagent.ts', () => { 'file1.txt\nfile2.ts', ); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); }); }); @@ -714,7 +714,7 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); expect(mockSendMessageStream).toHaveBeenCalledTimes(2); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.MAX_TURNS); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.MAX_TURNS); }); it.skip('should terminate with TIMEOUT if the time limit is reached during an LLM call', async () => { @@ -757,7 +757,7 @@ describe('subagent.ts', () => { await runPromise; - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.TIMEOUT); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.TIMEOUT); expect(mockSendMessageStream).toHaveBeenCalledTimes(1); vi.useRealTimers(); @@ -778,7 +778,7 @@ describe('subagent.ts', () => { await expect(scope.execute(new ContextState())).rejects.toThrow( 'API Failure', ); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.ERROR); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.ERROR); }); }); @@ -865,7 +865,7 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); expect(scope.getFinalText()).toBe('The final answer.'); }); @@ -929,7 +929,7 @@ describe('subagent.ts', () => { await scope.execute(new ContextState()); - expect(scope.getTerminateMode()).toBe(SubagentTerminateMode.GOAL); + expect(scope.getTerminateMode()).toBe(AgentTerminateMode.GOAL); expect(scope.getFinalText()).toBe('Actual output.'); // Should have been called twice: first with thought-only, then nudged expect(mockSendMessageStream).toHaveBeenCalledTimes(2); diff --git a/packages/core/src/agents/runtime/agent-headless.ts b/packages/core/src/agents/runtime/agent-headless.ts index ce97d143b..ac02f80df 100644 --- a/packages/core/src/agents/runtime/agent-headless.ts +++ b/packages/core/src/agents/runtime/agent-headless.ts @@ -16,22 +16,22 @@ import type { Config } from '../../config/config.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; -import type { AgentEventEmitter } from './agent-events.js'; -import { AgentEventType } from './agent-events.js'; import type { + AgentEventEmitter, AgentStartEvent, AgentErrorEvent, AgentFinishEvent, + AgentHooks, } from './agent-events.js'; +import { AgentEventType } from './agent-events.js'; import type { AgentStatsSummary } from './agent-statistics.js'; -import type { AgentHooks } from './agent-hooks.js'; import type { PromptConfig, ModelConfig, RunConfig, ToolConfig, -} from '../../subagents/types.js'; -import { SubagentTerminateMode } from '../../subagents/types.js'; +} from './agent-types.js'; +import { AgentTerminateMode } from './agent-types.js'; import { logSubagentExecution } from '../../telemetry/loggers.js'; import { SubagentExecutionEvent } from '../../telemetry/types.js'; import { AgentCore } from './agent-core.js'; @@ -135,7 +135,7 @@ export function templateString( export class AgentHeadless { private readonly core: AgentCore; private finalText: string = ''; - private terminateMode: SubagentTerminateMode = SubagentTerminateMode.ERROR; + private terminateMode: AgentTerminateMode = AgentTerminateMode.ERROR; private constructor(core: AgentCore) { this.core = core; @@ -196,7 +196,7 @@ export class AgentHeadless { const chat = await this.core.createChat(context); if (!chat) { - this.terminateMode = SubagentTerminateMode.ERROR; + this.terminateMode = AgentTerminateMode.ERROR; return; } @@ -258,10 +258,10 @@ export class AgentHeadless { ); this.finalText = result.text; - this.terminateMode = result.terminateMode ?? SubagentTerminateMode.GOAL; + this.terminateMode = result.terminateMode ?? AgentTerminateMode.GOAL; } catch (error) { debugLogger.error('Error during subagent execution:', error); - this.terminateMode = SubagentTerminateMode.ERROR; + this.terminateMode = AgentTerminateMode.ERROR; this.core.eventEmitter?.emit(AgentEventType.ERROR, { subagentId: this.core.subagentId, error: error instanceof Error ? error.message : String(error), @@ -291,9 +291,7 @@ export class AgentHeadless { const completionEvent = new SubagentExecutionEvent( this.core.name, - this.terminateMode === SubagentTerminateMode.GOAL - ? 'completed' - : 'failed', + this.terminateMode === AgentTerminateMode.GOAL ? 'completed' : 'failed', { terminate_reason: this.terminateMode, result: this.finalText, @@ -348,7 +346,7 @@ export class AgentHeadless { return this.finalText; } - getTerminateMode(): SubagentTerminateMode { + getTerminateMode(): AgentTerminateMode { return this.terminateMode; } diff --git a/packages/core/src/agents/runtime/agent-hooks.ts b/packages/core/src/agents/runtime/agent-hooks.ts deleted file mode 100644 index 76b65f95e..000000000 --- a/packages/core/src/agents/runtime/agent-hooks.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @license - * Copyright 2025 Qwen - * SPDX-License-Identifier: Apache-2.0 - */ - -export interface PreToolUsePayload { - subagentId: string; - name: string; // subagent name - toolName: string; - args: Record; - timestamp: number; -} - -export interface PostToolUsePayload extends PreToolUsePayload { - success: boolean; - durationMs: number; - errorMessage?: string; -} - -export interface AgentStopPayload { - subagentId: string; - name: string; // subagent name - terminateReason: string; - summary: Record; - timestamp: number; -} - -export interface AgentHooks { - preToolUse?(payload: PreToolUsePayload): Promise | void; - postToolUse?(payload: PostToolUsePayload): Promise | void; - onStop?(payload: AgentStopPayload): Promise | void; -} diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts new file mode 100644 index 000000000..633043ba7 --- /dev/null +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -0,0 +1,625 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { AgentInteractive } from './agent-interactive.js'; +import type { AgentCore } from './agent-core.js'; +import { AgentEventEmitter, AgentEventType } from './agent-events.js'; +import { ContextState } from './agent-headless.js'; +import type { AgentInteractiveConfig } from './agent-types.js'; +import { AgentStatus } from './agent-types.js'; + +function createMockChat() { + return { + sendMessageStream: vi.fn(), + }; +} + +function createMockCore( + overrides: { + chatValue?: unknown; + nullChat?: boolean; + loopResult?: { text: string; terminateMode: null; turnsUsed: number }; + } = {}, +) { + const emitter = new AgentEventEmitter(); + const chatReturnValue = overrides.nullChat + ? undefined + : overrides.chatValue !== undefined + ? overrides.chatValue + : createMockChat(); + const core = { + subagentId: 'test-agent-abc123', + name: 'test-agent', + eventEmitter: emitter, + stats: { + start: vi.fn(), + getSummary: vi.fn().mockReturnValue({ + rounds: 1, + totalDurationMs: 100, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }), + setRounds: vi.fn(), + recordToolCall: vi.fn(), + recordTokens: vi.fn(), + }, + createChat: vi.fn().mockResolvedValue(chatReturnValue), + prepareTools: vi.fn().mockReturnValue([]), + runReasoningLoop: vi.fn().mockResolvedValue( + overrides.loopResult ?? { + text: 'Done', + terminateMode: null, + turnsUsed: 1, + }, + ), + getEventEmitter: () => emitter, + getExecutionSummary: vi.fn().mockReturnValue({ + rounds: 1, + totalDurationMs: 100, + totalToolCalls: 0, + successfulToolCalls: 0, + failedToolCalls: 0, + inputTokens: 0, + outputTokens: 0, + totalTokens: 0, + }), + } as unknown as AgentCore; + + return { core, emitter }; +} + +function createConfig( + overrides: Partial = {}, +): AgentInteractiveConfig { + return { + agentId: 'agent-1', + agentName: 'Test Agent', + ...overrides, + }; +} + +describe('AgentInteractive', () => { + let context: ContextState; + + beforeEach(() => { + context = new ContextState(); + }); + + // ─── Lifecycle ────────────────────────────────────────────── + + it('should initialize and complete cleanly without initialTask', async () => { + const { core } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + // No initialTask → agent is waiting on queue, status is still initializing. + // Shutdown drains queue, loop exits normally → completed. + await agent.shutdown(); + expect(agent.getStatus()).toBe('completed'); + }); + + it('should process initialTask immediately on start', async () => { + const { core } = createMockCore(); + const config = createConfig({ initialTask: 'Do something' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + expect(core.runReasoningLoop).toHaveBeenCalledOnce(); + expect(agent.getMessages().length).toBeGreaterThan(0); + expect(agent.getMessages()[0]?.role).toBe('user'); + expect(agent.getMessages()[0]?.content).toBe('Do something'); + + await agent.shutdown(); + }); + + it('should process enqueued messages', async () => { + const { core } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + agent.enqueueMessage('Hello'); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + expect(core.runReasoningLoop).toHaveBeenCalledOnce(); + + await agent.shutdown(); + }); + + it('should set status to failed when chat creation fails', async () => { + const { core } = createMockCore({ nullChat: true }); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + expect(agent.getStatus()).toBe('failed'); + expect(agent.getError()).toBe('Failed to create chat session'); + }); + + // ─── Error Recovery ──────────────────────────────────────── + + it('should survive round errors and recover', async () => { + const { core } = createMockCore(); + + let callCount = 0; + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + callCount++; + if (callCount === 1) { + return Promise.reject(new Error('Model error')); + } + return Promise.resolve({ + text: 'Recovered', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + agent.enqueueMessage('cause error'); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('failed'); + expect(callCount).toBe(1); + }); + + // Error recorded as assistant message with error metadata + const messages = agent.getMessages(); + const errorMsg = messages.find( + (m) => + m.role === 'assistant' && + m.content.includes('Error: Model error') && + m.metadata?.['error'] === true, + ); + expect(errorMsg).toBeDefined(); + + // Second message works fine + agent.enqueueMessage('recover'); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + expect(callCount).toBe(2); + }); + + await agent.shutdown(); + }); + + // ─── Cancellation ────────────────────────────────────────── + + it('should cancel current round without killing the agent', async () => { + const { core } = createMockCore(); + let resolveLoop: () => void; + (core.runReasoningLoop as ReturnType).mockImplementation( + () => + new Promise<{ text: string; terminateMode: string; turnsUsed: number }>( + (resolve) => { + resolveLoop = () => + resolve({ text: '', terminateMode: 'cancelled', turnsUsed: 0 }); + }, + ), + ); + + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + agent.enqueueMessage('long task'); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('running'); + }); + + agent.cancelCurrentRound(); + resolveLoop!(); + + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('failed'); + }); + + await agent.shutdown(); + }); + + it('should abort immediately', async () => { + const { core } = createMockCore(); + (core.runReasoningLoop as ReturnType).mockImplementation( + () => + new Promise((resolve) => { + setTimeout( + () => + resolve({ + text: '', + terminateMode: 'cancelled', + turnsUsed: 0, + }), + 50, + ); + }), + ); + + const config = createConfig({ initialTask: 'long task' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + agent.abort(); + + await agent.waitForCompletion(); + expect(agent.getStatus()).toBe('cancelled'); + }); + + // ─── Accessors ───────────────────────────────────────────── + + it('should provide stats via getStats()', async () => { + const { core } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + const stats = agent.getStats(); + expect(stats).toBeDefined(); + expect(stats.rounds).toBe(1); + }); + + it('should provide core via getCore()', () => { + const { core } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + expect(agent.getCore()).toBe(core); + }); + + // ─── Stream Buffer & Message Recording ───────────────────── + + it('should record assistant text from stream events (not result.text)', async () => { + const { core, emitter } = createMockCore(); + + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'Hello from stream', + timestamp: Date.now(), + }); + return Promise.resolve({ + text: 'Hello from stream', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'test' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + const assistantMsgs = agent + .getMessages() + .filter((m) => m.role === 'assistant' && !m.thought); + // Exactly one — from stream flush, not duplicated by result.text + expect(assistantMsgs).toHaveLength(1); + expect(assistantMsgs[0]?.content).toBe('Hello from stream'); + + await agent.shutdown(); + }); + + it('should not carry stream buffer across messages', async () => { + const { core, emitter } = createMockCore(); + + let runCount = 0; + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + runCount++; + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: `response-${runCount}`, + timestamp: Date.now(), + }); + return Promise.resolve({ + text: `response-${runCount}`, + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'first message' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + agent.enqueueMessage('second message'); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + expect(runCount).toBe(2); + }); + + // No message containing both responses (no cross-contamination) + const messages = agent.getMessages(); + const assistantMessages = messages.filter( + (m) => m.role === 'assistant' && !m.thought, + ); + const corrupted = assistantMessages.find( + (m) => + m.content.includes('response-1') && m.content.includes('response-2'), + ); + expect(corrupted).toBeUndefined(); + + await agent.shutdown(); + }); + + it('should capture thinking text as assistant messages with thought=true', async () => { + const { core, emitter } = createMockCore(); + + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'Let me think...', + thought: true, + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'Here is the answer', + thought: false, + timestamp: Date.now(), + }); + return Promise.resolve({ + text: 'Here is the answer', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'think about this' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + const messages = agent.getMessages(); + const thoughtMsg = messages.find( + (m) => m.role === 'assistant' && m.thought === true, + ); + const textMsg = messages.find((m) => m.role === 'assistant' && !m.thought); + + expect(thoughtMsg).toBeDefined(); + expect(thoughtMsg?.content).toBe('Let me think...'); + expect(textMsg).toBeDefined(); + expect(textMsg?.content).toBe('Here is the answer'); + + await agent.shutdown(); + }); + + it('should record tool_call and tool_result with correct roles', async () => { + const { core, emitter } = createMockCore(); + + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'I will read the file', + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.TOOL_CALL, { + subagentId: 'test', + round: 1, + callId: 'call-1', + name: 'read_file', + args: { path: 'test.ts' }, + description: 'Read test.ts', + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.TOOL_RESULT, { + subagentId: 'test', + round: 1, + callId: 'call-1', + name: 'read_file', + success: true, + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.ROUND_END, { + subagentId: 'test', + round: 1, + promptId: 'p1', + timestamp: Date.now(), + }); + return Promise.resolve({ + text: '', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'read a file' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + const messages = agent.getMessages(); + const toolCall = messages.find((m) => m.role === 'tool_call'); + const toolResult = messages.find((m) => m.role === 'tool_result'); + + expect(toolCall).toBeDefined(); + expect(toolCall?.metadata?.['toolName']).toBe('read_file'); + expect(toolCall?.metadata?.['callId']).toBe('call-1'); + + expect(toolResult).toBeDefined(); + expect(toolResult?.metadata?.['success']).toBe(true); + + await agent.shutdown(); + }); + + it('should flush text before tool_call to preserve temporal ordering', async () => { + const { core, emitter } = createMockCore(); + + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + // Text arrives before tool call in the stream + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'Let me check', + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.TOOL_CALL, { + subagentId: 'test', + round: 1, + callId: 'call-1', + name: 'read_file', + args: {}, + description: '', + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.TOOL_RESULT, { + subagentId: 'test', + round: 1, + callId: 'call-1', + name: 'read_file', + success: true, + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.ROUND_END, { + subagentId: 'test', + round: 1, + promptId: 'p1', + timestamp: Date.now(), + }); + return Promise.resolve({ + text: '', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'task' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + const messages = agent.getMessages(); + // Filter to just the non-user messages for ordering check + const nonUser = messages.filter((m) => m.role !== 'user'); + + // Text should come before tool_call + const textIdx = nonUser.findIndex( + (m) => m.role === 'assistant' && m.content === 'Let me check', + ); + const toolIdx = nonUser.findIndex((m) => m.role === 'tool_call'); + expect(textIdx).toBeLessThan(toolIdx); + + await agent.shutdown(); + }); + + it('should return in-progress stream state during streaming', async () => { + const { core, emitter } = createMockCore(); + + let capturedInProgress: ReturnType< + typeof AgentInteractive.prototype.getInProgressStream + > = null; + + (core.runReasoningLoop as ReturnType).mockImplementation( + () => { + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'thinking...', + thought: true, + timestamp: Date.now(), + }); + emitter.emit(AgentEventType.STREAM_TEXT, { + subagentId: 'test', + round: 1, + text: 'visible text', + timestamp: Date.now(), + }); + // Capture in-progress state before the loop returns + capturedInProgress = agent.getInProgressStream(); + return Promise.resolve({ + text: 'visible text', + terminateMode: null, + turnsUsed: 1, + }); + }, + ); + + const config = createConfig({ initialTask: 'test' }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + await vi.waitFor(() => { + expect(agent.getStatus()).toBe('completed'); + }); + + // During streaming, in-progress state was available + expect(capturedInProgress).toEqual({ + text: 'visible text', + thinking: 'thinking...', + round: 1, + }); + + // After flush, in-progress state is null + expect(agent.getInProgressStream()).toBeNull(); + + await agent.shutdown(); + }); + + // ─── Events ──────────────────────────────────────────────── + + it('should emit status_change events', async () => { + const { core, emitter } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + const statuses: AgentStatus[] = []; + emitter.on(AgentEventType.STATUS_CHANGE, (payload) => { + statuses.push(payload.newStatus); + }); + + await agent.start(context); + await agent.shutdown(); + + expect(statuses).toContain(AgentStatus.COMPLETED); + }); +}); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts new file mode 100644 index 000000000..66fa4faa5 --- /dev/null +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -0,0 +1,425 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentInteractive — persistent interactive agent. + * + * Composes AgentCore with on-demand message processing to provide an agent + * that processes user inputs sequentially and settles between batches. + * Used by InProcessBackend for Arena's in-process mode. + * + * AgentInteractive is the **sole consumer** of AgentCore events. It builds + * conversation state (messages + in-progress stream) that the UI reads. + * The UI never directly subscribes to AgentCore events for data — it reads + * from AgentInteractive and uses notifications to know when to re-render. + * + * Lifecycle: start() → (running ↔ completed/failed)* → shutdown()/abort() + */ + +import { createDebugLogger } from '../../utils/debugLogger.js'; +import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; +import type { + AgentStreamTextEvent, + AgentToolCallEvent, + AgentToolResultEvent, +} from './agent-events.js'; +import type { AgentStatsSummary } from './agent-statistics.js'; +import type { AgentCore } from './agent-core.js'; +import type { ContextState } from './agent-headless.js'; +import type { GeminiChat } from '../../core/geminiChat.js'; +import type { FunctionDeclaration } from '@google/genai'; +import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; +import { + AgentTerminateMode, + AgentStatus, + isTerminalStatus, + type AgentInteractiveConfig, + type AgentMessage, + type InProgressStreamState, +} from './agent-types.js'; + +const debugLogger = createDebugLogger('AGENT_INTERACTIVE'); + +/** + * AgentInteractive — persistent interactive agent that processes + * messages on demand. + * + * Three-level cancellation: + * - `cancelCurrentRound()` — abort the current reasoning loop only + * - `shutdown()` — graceful: stop accepting messages, wait for cycle + * - `abort()` — immediate: master abort, set cancelled + */ +export class AgentInteractive { + readonly config: AgentInteractiveConfig; + private readonly core: AgentCore; + private readonly queue = new AsyncMessageQueue(); + private readonly messages: AgentMessage[] = []; + + private status: AgentStatus = AgentStatus.INITIALIZING; + private error: string | undefined; + private lastRoundError: string | undefined; + private executionPromise: Promise | undefined; + private masterAbortController = new AbortController(); + private roundAbortController: AbortController | undefined; + private chat: GeminiChat | undefined; + private toolsList: FunctionDeclaration[] = []; + private processing = false; + + // Stream accumulator — separate buffers for thought and non-thought text. + // Flushed to messages on ROUND_END (intermediate rounds), before TOOL_CALL + // events (to preserve temporal ordering), and after runReasoningLoop returns + // (final round, since ROUND_END doesn't fire for it). + private thoughtBuffer = ''; + private textBuffer = ''; + private streamRound = -1; + + constructor(config: AgentInteractiveConfig, core: AgentCore) { + this.config = config; + this.core = core; + this.setupEventListeners(); + } + + // ─── Lifecycle ────────────────────────────────────────────── + + /** + * Start the agent. Initializes the chat session, then kicks off + * processing if an initialTask is configured. + */ + async start(context: ContextState): Promise { + this.setStatus(AgentStatus.INITIALIZING); + + this.chat = await this.core.createChat(context, { interactive: true }); + if (!this.chat) { + this.error = 'Failed to create chat session'; + this.setStatus(AgentStatus.FAILED); + return; + } + + this.toolsList = this.core.prepareTools(); + this.core.stats.start(Date.now()); + + if (this.config.initialTask) { + this.queue.enqueue(this.config.initialTask); + this.executionPromise = this.runLoop(); + } + } + + /** + * Run loop: process all pending messages, then settle status. + * Exits when the queue is empty or the agent is aborted. + */ + private async runLoop(): Promise { + this.processing = true; + try { + let message = this.queue.dequeue(); + while (message !== null && !this.masterAbortController.signal.aborted) { + this.addMessage('user', message); + await this.runOneRound(message); + message = this.queue.dequeue(); + } + + if (this.masterAbortController.signal.aborted) { + this.setStatus(AgentStatus.CANCELLED); + } else { + this.settleRoundStatus(); + } + } catch (err) { + this.error = err instanceof Error ? err.message : String(err); + this.setStatus(AgentStatus.FAILED); + debugLogger.error('AgentInteractive processing failed:', err); + } finally { + this.processing = false; + } + } + + /** + * Run a single reasoning round for one message. + * Creates a per-round AbortController so cancellation is scoped. + */ + private async runOneRound(message: string): Promise { + if (!this.chat) return; + + this.setStatus(AgentStatus.RUNNING); + this.lastRoundError = undefined; + this.roundAbortController = new AbortController(); + + // Propagate master abort to round + const onMasterAbort = () => this.roundAbortController?.abort(); + this.masterAbortController.signal.addEventListener('abort', onMasterAbort); + if (this.masterAbortController.signal.aborted) { + this.roundAbortController.abort(); + } + + try { + const initialMessages = [ + { role: 'user' as const, parts: [{ text: message }] }, + ]; + + const result = await this.core.runReasoningLoop( + this.chat, + initialMessages, + this.toolsList, + this.roundAbortController, + { + maxTurns: this.config.maxTurnsPerMessage, + maxTimeMinutes: this.config.maxTimeMinutesPerMessage, + }, + ); + + // Finalize any unflushed stream content from the last round. + // ROUND_END doesn't fire for the final text-producing round + // (AgentCore breaks before emitting it), so we flush here. + this.flushStreamBuffers(); + + // Surface non-normal termination so Arena (and other consumers) + // can distinguish limit-triggered stops from successful completions. + if ( + result.terminateMode && + result.terminateMode !== AgentTerminateMode.GOAL + ) { + this.lastRoundError = `Terminated: ${result.terminateMode}`; + } + } catch (err) { + // Agent survives round errors — log and settle status in runLoop. + // Flush any partial stream content accumulated before the error. + this.flushStreamBuffers(); + const errorMessage = err instanceof Error ? err.message : String(err); + this.lastRoundError = errorMessage; + debugLogger.error('AgentInteractive round error:', err); + this.addMessage('assistant', `Error: ${errorMessage}`, { + metadata: { error: true }, + }); + } finally { + this.masterAbortController.signal.removeEventListener( + 'abort', + onMasterAbort, + ); + this.roundAbortController = undefined; + } + } + + // ─── Cancellation ────────────────────────────────────────── + + /** + * Cancel only the current reasoning round. + */ + cancelCurrentRound(): void { + this.roundAbortController?.abort(); + } + + /** + * Graceful shutdown: stop accepting messages and wait for current + * processing to finish. + */ + async shutdown(): Promise { + this.queue.drain(); + if (this.executionPromise) { + await this.executionPromise; + } + // If no processing cycle ever ran (no initialTask, no messages), + // ensure the agent reaches a terminal status. + if (!isTerminalStatus(this.status)) { + this.setStatus(AgentStatus.COMPLETED); + } + } + + /** + * Immediate abort: cancel everything and set status to cancelled. + */ + abort(): void { + this.masterAbortController.abort(); + this.queue.drain(); + } + + // ─── Message Queue ───────────────────────────────────────── + + /** + * Enqueue a message for the agent to process. + */ + enqueueMessage(message: string): void { + this.queue.enqueue(message); + if (!this.processing) { + this.executionPromise = this.runLoop(); + } + } + + // ─── State Accessors ─────────────────────────────────────── + + getMessages(): readonly AgentMessage[] { + return this.messages; + } + + /** + * Returns the in-progress streaming state for UI mid-switch handoff. + * The UI reads this when attaching to an agent that's currently streaming + * to display content accumulated before the UI subscribed. + */ + getInProgressStream(): InProgressStreamState | null { + if (!this.textBuffer && !this.thoughtBuffer) return null; + return { + text: this.textBuffer, + thinking: this.thoughtBuffer, + round: this.streamRound, + }; + } + + getStatus(): AgentStatus { + return this.status; + } + + getError(): string | undefined { + return this.error; + } + + getLastRoundError(): string | undefined { + return this.lastRoundError; + } + + getStats(): AgentStatsSummary { + return this.core.getExecutionSummary(); + } + + getCore(): AgentCore { + return this.core; + } + + getEventEmitter(): AgentEventEmitter | undefined { + return this.core.getEventEmitter(); + } + + /** + * Wait for the run loop to finish (used by InProcessBackend). + */ + async waitForCompletion(): Promise { + if (this.executionPromise) { + await this.executionPromise; + } + } + + // ─── Private Helpers ─────────────────────────────────────── + + /** Emit terminal status for the just-completed round. */ + private settleRoundStatus(): void { + if (this.lastRoundError) { + this.setStatus(AgentStatus.FAILED); + } else { + this.setStatus(AgentStatus.COMPLETED); + } + } + + private setStatus(newStatus: AgentStatus): void { + const previousStatus = this.status; + if (previousStatus === newStatus) return; + + this.status = newStatus; + + this.core.eventEmitter?.emit(AgentEventType.STATUS_CHANGE, { + agentId: this.config.agentId, + previousStatus, + newStatus, + timestamp: Date.now(), + }); + } + + private addMessage( + role: AgentMessage['role'], + content: string, + options?: { thought?: boolean; metadata?: Record }, + ): void { + const message: AgentMessage = { + role, + content, + timestamp: Date.now(), + }; + if (options?.thought) { + message.thought = true; + } + if (options?.metadata) { + message.metadata = options.metadata; + } + this.messages.push(message); + } + + /** + * Flush accumulated stream buffers to finalized messages. + * + * Thought text → assistant message with thought=true. + * Regular text → assistant message. + * Called on ROUND_END, before TOOL_CALL (ordering), and after + * runReasoningLoop returns (final round). + */ + private flushStreamBuffers(): void { + if (this.thoughtBuffer) { + this.addMessage('assistant', this.thoughtBuffer, { thought: true }); + this.thoughtBuffer = ''; + } + if (this.textBuffer) { + this.addMessage('assistant', this.textBuffer); + this.textBuffer = ''; + } + this.streamRound = -1; + } + + /** + * Set up listeners on AgentCore's event emitter. + * + * AgentInteractive is the sole consumer of these events. It builds + * the conversation state (messages + in-progress stream) that the + * UI reads. Listeners use canonical event types from agent-events.ts. + */ + private setupEventListeners(): void { + const emitter = this.core.eventEmitter; + if (!emitter) return; + + emitter.on(AgentEventType.STREAM_TEXT, (event: AgentStreamTextEvent) => { + // Round boundary: flush previous round's buffers before starting a new one + if (event.round !== this.streamRound && this.streamRound !== -1) { + this.flushStreamBuffers(); + } + this.streamRound = event.round; + + if (event.thought) { + this.thoughtBuffer += event.text; + } else { + this.textBuffer += event.text; + } + }); + + emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { + // Flush text buffers first — in the stream, text arrives before + // tool calls, so flushing preserves temporal ordering in messages. + this.flushStreamBuffers(); + + this.addMessage('tool_call', `Tool call: ${event.name}`, { + metadata: { + callId: event.callId, + toolName: event.name, + args: event.args, + round: event.round, + }, + }); + }); + + emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { + const statusText = event.success ? 'succeeded' : 'failed'; + const summary = event.error + ? `Tool ${event.name} ${statusText}: ${event.error}` + : `Tool ${event.name} ${statusText}`; + this.addMessage('tool_result', summary, { + metadata: { + callId: event.callId, + toolName: event.name, + success: event.success, + round: event.round, + }, + }); + }); + + emitter.on(AgentEventType.ROUND_END, () => { + this.flushStreamBuffers(); + }); + } +} diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts new file mode 100644 index 000000000..df3e5fc9a --- /dev/null +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Agent runtime types. + * + * Contains the canonical definitions for agent configuration (prompt, model, + * run, tool), termination modes, and interactive agent types. + */ + +import type { Content, FunctionDeclaration } from '@google/genai'; + +// ─── Agent Configuration ───────────────────────────────────── + +/** + * Configures the initial prompt for an agent. + */ +export interface PromptConfig { + /** + * A single system prompt string that defines the agent's persona and instructions. + * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + */ + systemPrompt?: string; + + /** + * An array of user/model content pairs to seed the chat history for few-shot prompting. + * Note: You should use either `systemPrompt` or `initialMessages`, but not both. + */ + initialMessages?: Content[]; +} + +/** + * Configures the generative model parameters for an agent. + */ +export interface ModelConfig { + /** + * The name or identifier of the model to be used (e.g., 'qwen3-coder-plus'). + * + * TODO: In the future, this needs to support 'auto' or some other string to support routing use cases. + */ + model?: string; + /** The temperature for the model's sampling process. */ + temp?: number; + /** The top-p value for nucleus sampling. */ + top_p?: number; +} + +/** + * Configures the execution environment and constraints for an agent. + * + * TODO: Consider adding max_tokens as a form of budgeting. + */ +export interface RunConfig { + /** The maximum execution time for the agent in minutes. */ + max_time_minutes?: number; + /** + * The maximum number of conversational turns (a user message + model response) + * before the execution is terminated. Helps prevent infinite loops. + */ + max_turns?: number; +} + +/** + * Configures the tools available to an agent during its execution. + */ +export interface ToolConfig { + /** + * A list of tool names (from the tool registry) or full function declarations + * that the agent is permitted to use. + */ + tools: Array; +} + +/** + * Describes the possible termination modes for an agent. + * This enum provides a clear indication of why an agent's execution ended. + */ +export enum AgentTerminateMode { + /** The agent's execution terminated due to an unrecoverable error. */ + ERROR = 'ERROR', + /** The agent's execution terminated because it exceeded the maximum allowed working time. */ + TIMEOUT = 'TIMEOUT', + /** The agent's execution successfully completed all its defined goals. */ + GOAL = 'GOAL', + /** The agent's execution terminated because it exceeded the maximum number of turns. */ + MAX_TURNS = 'MAX_TURNS', + /** The agent's execution was cancelled via an abort signal. */ + CANCELLED = 'CANCELLED', + /** The agent was gracefully shut down (e.g., arena/team session ended). */ + SHUTDOWN = 'SHUTDOWN', +} + +// ─── Agent Status ──────────────────────────────────────────── + +/** + * Canonical lifecycle status for any agent (headless, interactive, arena). + * + * State machine: + * INITIALIZING → RUNNING ⇄ COMPLETED / FAILED / CANCELLED + * + * - INITIALIZING: Setting up (creating chat, loading tools). + * - RUNNING: Actively processing (model thinking / tool execution). + * - COMPLETED: Finished successfully (may re-enter RUNNING on new input). + * - FAILED: Finished with error (API failure, process crash, etc.). + * - CANCELLED: Cancelled by user or system. + */ +export enum AgentStatus { + INITIALIZING = 'initializing', + RUNNING = 'running', + COMPLETED = 'completed', + FAILED = 'failed', + CANCELLED = 'cancelled', +} + +/** True for COMPLETED, FAILED, CANCELLED — agent is done working. */ +export const isTerminalStatus = (s: AgentStatus): boolean => + s === AgentStatus.COMPLETED || + s === AgentStatus.FAILED || + s === AgentStatus.CANCELLED; + +/** + * Lightweight configuration for an AgentInteractive instance. + * Carries only interactive-specific parameters; the heavy runtime + * configs (prompt, model, run, tools) live on AgentCore. + */ +export interface AgentInteractiveConfig { + /** Unique identifier for this agent. */ + agentId: string; + /** Human-readable name for display. */ + agentName: string; + /** Optional initial task to start working on immediately. */ + initialTask?: string; + /** Max model round-trips per enqueued message (default: unlimited). */ + maxTurnsPerMessage?: number; + /** Max wall-clock minutes per enqueued message (default: unlimited). */ + maxTimeMinutesPerMessage?: number; +} + +/** + * A message exchanged with or produced by an interactive agent. + * + * This is a UI-oriented data model (not the Gemini API Content type). + * AgentInteractive is the sole writer; the UI reads via getMessages(). + */ +export interface AgentMessage { + /** Discriminator for the message kind. */ + role: 'user' | 'assistant' | 'tool_call' | 'tool_result'; + /** The text content of the message. */ + content: string; + /** When the message was created (ms since epoch). */ + timestamp: number; + /** + * Whether this assistant message contains thinking/reasoning content. + * Mirrors AgentStreamTextEvent.thought. Only meaningful when role is 'assistant'. + */ + thought?: boolean; + /** Optional metadata (e.g. tool call info, round number). */ + metadata?: Record; +} + +/** + * Snapshot of in-progress streaming state for UI mid-switch handoff. + * Returned by AgentInteractive.getInProgressStream(). + */ +export interface InProgressStreamState { + /** Accumulated non-thought text so far in the current round. */ + text: string; + /** Accumulated thinking text so far in the current round. */ + thinking: string; + /** The reasoning-loop round number being streamed. */ + round: number; +} diff --git a/packages/core/src/agents/runtime/index.ts b/packages/core/src/agents/runtime/index.ts index 025790798..93ef0e5a3 100644 --- a/packages/core/src/agents/runtime/index.ts +++ b/packages/core/src/agents/runtime/index.ts @@ -8,8 +8,10 @@ * @fileoverview Runtime barrel — re-exports agent execution primitives. */ +export * from './agent-types.js'; export * from './agent-core.js'; export * from './agent-headless.js'; +export * from './agent-interactive.js'; export * from './agent-events.js'; export * from './agent-statistics.js'; -export * from './agent-hooks.js'; +export { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0d7fd5a09..b032c9c02 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -295,6 +295,10 @@ export interface AgentsCollabSettings { worktreeBaseDir?: string; /** Preserve worktrees and state files after session ends */ preserveArtifacts?: boolean; + /** Maximum rounds (turns) per agent. No limit if unset. */ + maxRoundsPerAgent?: number; + /** Total timeout in seconds for the Arena session. No limit if unset. */ + timeoutSeconds?: number; }; } @@ -1698,6 +1702,7 @@ export class Config { async createToolRegistry( sendSdkMcpMessage?: SendSdkMcpMessage, + options?: { skipDiscovery?: boolean }, ): Promise { const registry = new ToolRegistry( this, @@ -1786,7 +1791,9 @@ export class Config { registerCoreTool(LspTool, this); } - await registry.discoverAllTools(); + if (!options?.skipDiscovery) { + await registry.discoverAllTools(); + } this.debugLogger.debug( `ToolRegistry created: ${JSON.stringify(registry.getAllToolNames())} (${registry.getAllToolNames().length} tools)`, ); diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index 751d15221..7b0924840 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -492,9 +492,7 @@ export class GeminiClient { debugLogger.info( `Arena control signal received: ${controlSignal.type} - ${controlSignal.reason}`, ); - await arenaAgentClient.reportCompleted( - `Stopped by control signal: ${controlSignal.reason}`, - ); + await arenaAgentClient.reportCancelled(); return new Turn(this.getChat(), prompt_id); } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 6b6b18351..6345fd054 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -248,7 +248,6 @@ export { export * from './extension/index.js'; export * from './prompts/mcp-prompts.js'; export * from './skills/index.js'; -export * from './subagents/index.js'; // ============================================================================ // Utilities diff --git a/packages/core/src/subagents/index.ts b/packages/core/src/subagents/index.ts index f877d23d8..c05c38697 100644 --- a/packages/core/src/subagents/index.ts +++ b/packages/core/src/subagents/index.ts @@ -5,18 +5,11 @@ */ /** - * @fileoverview Subagents Phase 1 implementation - File-based configuration layer + * @fileoverview Subagents — file-based configuration layer. * * This module provides the foundation for the subagents feature by implementing - * a file-based configuration system that builds on the AgentHeadless - * runtime system. It includes: + * a file-based configuration system that builds on the agent runtime. * - * - Type definitions for file-based subagent configurations - * - Validation system for configuration integrity - * - Runtime conversion functions integrated into the manager - * - Manager class for CRUD operations on subagent files - * - * The implementation follows the Markdown + YAML frontmatter format , with storage at both project and user levels. */ // Core types and interfaces @@ -40,39 +33,3 @@ export { SubagentValidator } from './validation.js'; // Main management class export { SubagentManager } from './subagent-manager.js'; - -// Re-export existing runtime types for convenience -export type { - PromptConfig, - ModelConfig, - RunConfig, - ToolConfig, - SubagentTerminateMode, -} from './types.js'; - -export { AgentHeadless } from '../agents/runtime/agent-headless.js'; - -// Event system for UI integration -export type { - AgentEvent, - AgentStartEvent, - AgentRoundEvent, - AgentStreamTextEvent, - AgentUsageEvent, - AgentToolCallEvent, - AgentToolResultEvent, - AgentFinishEvent, - AgentErrorEvent, - AgentApprovalRequestEvent, -} from '../agents/runtime/agent-events.js'; - -export { - AgentEventEmitter, - AgentEventType, -} from '../agents/runtime/agent-events.js'; - -// Statistics and formatting -export type { - AgentStatsSummary, - ToolUsageStats, -} from '../agents/runtime/agent-statistics.js'; diff --git a/packages/core/src/subagents/subagent-manager.ts b/packages/core/src/subagents/subagent-manager.ts index b2fa2c47e..ca908527d 100644 --- a/packages/core/src/subagents/subagent-manager.ts +++ b/packages/core/src/subagents/subagent-manager.ts @@ -19,16 +19,20 @@ import type { SubagentLevel, ListSubagentsOptions, CreateSubagentOptions, +} from './types.js'; +import type { PromptConfig, ModelConfig, RunConfig, ToolConfig, -} from './types.js'; +} from '../agents/runtime/agent-types.js'; import { SubagentError, SubagentErrorCode } from './types.js'; import { SubagentValidator } from './validation.js'; import { AgentHeadless } from '../agents/runtime/agent-headless.js'; -import type { AgentEventEmitter } from '../agents/runtime/agent-events.js'; -import type { AgentHooks } from '../agents/runtime/agent-hooks.js'; +import type { + AgentEventEmitter, + AgentHooks, +} from '../agents/runtime/agent-events.js'; import type { Config } from '../config/config.js'; import { createDebugLogger } from '../utils/debugLogger.js'; diff --git a/packages/core/src/subagents/types.ts b/packages/core/src/subagents/types.ts index e41fe620b..55e57f61e 100644 --- a/packages/core/src/subagents/types.ts +++ b/packages/core/src/subagents/types.ts @@ -4,7 +4,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Content, FunctionDeclaration } from '@google/genai'; +/** + * @fileoverview Subagent configuration types. + * + * Agent runtime types (PromptConfig, ModelConfig, RunConfig, ToolConfig, + * AgentTerminateMode) are canonically defined in agents/runtime/agent-types.ts. + */ + +import type { + ModelConfig, + RunConfig, + PromptConfig, + ToolConfig, +} from '../agents/runtime/agent-types.js'; /** * Represents the storage level for a subagent configuration. @@ -176,101 +188,3 @@ export const SubagentErrorCode = { export type SubagentErrorCode = (typeof SubagentErrorCode)[keyof typeof SubagentErrorCode]; - -/** - * Describes the possible termination modes for a subagent. - * This enum provides a clear indication of why a subagent's execution might have ended. - */ -export enum SubagentTerminateMode { - /** - * Indicates that the subagent's execution terminated due to an unrecoverable error. - */ - ERROR = 'ERROR', - /** - * Indicates that the subagent's execution terminated because it exceeded the maximum allowed working time. - */ - TIMEOUT = 'TIMEOUT', - /** - * Indicates that the subagent's execution successfully completed all its defined goals. - */ - GOAL = 'GOAL', - /** - * Indicates that the subagent's execution terminated because it exceeded the maximum number of turns. - */ - MAX_TURNS = 'MAX_TURNS', - /** - * Indicates that the subagent's execution was cancelled via an abort signal. - */ - CANCELLED = 'CANCELLED', - /** - * Indicates that the subagent was gracefully shut down (e.g., arena/team session ended). - */ - SHUTDOWN = 'SHUTDOWN', -} - -/** - * Configures the initial prompt for the subagent. - */ -export interface PromptConfig { - /** - * A single system prompt string that defines the subagent's persona and instructions. - * Note: You should use either `systemPrompt` or `initialMessages`, but not both. - */ - systemPrompt?: string; - - /** - * An array of user/model content pairs to seed the chat history for few-shot prompting. - * Note: You should use either `systemPrompt` or `initialMessages`, but not both. - */ - initialMessages?: Content[]; -} - -/** - * Configures the tools available to the subagent during its execution. - */ -export interface ToolConfig { - /** - * A list of tool names (from the tool registry) or full function declarations - * that the subagent is permitted to use. - */ - tools: Array; -} - -/** - * Configures the generative model parameters for the subagent. - * This interface specifies the model to be used and its associated generation settings, - * such as temperature and top-p values, which influence the creativity and diversity of the model's output. - */ -export interface ModelConfig { - /** - * The name or identifier of the model to be used (e.g., 'qwen3-coder-plus'). - * - * TODO: In the future, this needs to support 'auto' or some other string to support routing use cases. - */ - model?: string; - /** - * The temperature for the model's sampling process. - */ - temp?: number; - /** - * The top-p value for nucleus sampling. - */ - top_p?: number; -} - -/** - * Configures the execution environment and constraints for the subagent. - * This interface defines parameters that control the subagent's runtime behavior, - * such as maximum execution time, to prevent infinite loops or excessive resource consumption. - * - * TODO: Consider adding max_tokens as a form of budgeting. - */ -export interface RunConfig { - /** The maximum execution time for the subagent in minutes. */ - max_time_minutes?: number; - /** - * The maximum number of conversational turns (a user message + model response) - * before the execution is terminated. Helps prevent infinite loops. - */ - max_turns?: number; -} diff --git a/packages/core/src/subagents/validation.ts b/packages/core/src/subagents/validation.ts index 5df8cc315..cc38a4a43 100644 --- a/packages/core/src/subagents/validation.ts +++ b/packages/core/src/subagents/validation.ts @@ -5,12 +5,8 @@ */ import { SubagentError, SubagentErrorCode } from './types.js'; -import type { - ModelConfig, - RunConfig, - SubagentConfig, - ValidationResult, -} from './types.js'; +import type { SubagentConfig, ValidationResult } from './types.js'; +import type { ModelConfig, RunConfig } from '../agents/runtime/agent-types.js'; /** * Validates subagent configurations to ensure they are well-formed diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts index a8323f71e..28b6168be 100644 --- a/packages/core/src/tools/task.test.ts +++ b/packages/core/src/tools/task.test.ts @@ -10,10 +10,8 @@ import type { PartListUnion } from '@google/genai'; import type { ToolResultDisplay, TaskResultDisplay } from './tools.js'; import type { Config } from '../config/config.js'; import { SubagentManager } from '../subagents/subagent-manager.js'; -import { - type SubagentConfig, - SubagentTerminateMode, -} from '../subagents/types.js'; +import type { SubagentConfig } from '../subagents/types.js'; +import { AgentTerminateMode } from '../agents/runtime/agent-types.js'; import { type AgentHeadless, ContextState, @@ -303,7 +301,7 @@ describe('TaskTool', () => { mockSubagentScope = { execute: vi.fn().mockResolvedValue(undefined), result: 'Task completed successfully', - terminateMode: SubagentTerminateMode.GOAL, + terminateMode: AgentTerminateMode.GOAL, getFinalText: vi.fn().mockReturnValue('Task completed successfully'), formatCompactResult: vi .fn() @@ -347,7 +345,7 @@ describe('TaskTool', () => { successfulToolCalls: 3, failedToolCalls: 0, }), - getTerminateMode: vi.fn().mockReturnValue(SubagentTerminateMode.GOAL), + getTerminateMode: vi.fn().mockReturnValue(AgentTerminateMode.GOAL), } as unknown as AgentHeadless; mockContextState = { diff --git a/packages/core/src/tools/task.ts b/packages/core/src/tools/task.ts index 35aa8af41..430d25a65 100644 --- a/packages/core/src/tools/task.ts +++ b/packages/core/src/tools/task.ts @@ -18,10 +18,8 @@ import type { } from './tools.js'; import type { Config } from '../config/config.js'; import type { SubagentManager } from '../subagents/subagent-manager.js'; -import { - type SubagentConfig, - SubagentTerminateMode, -} from '../subagents/types.js'; +import type { SubagentConfig } from '../subagents/types.js'; +import { AgentTerminateMode } from '../agents/runtime/agent-types.js'; import { ContextState } from '../agents/runtime/agent-headless.js'; import { AgentEventEmitter, @@ -54,6 +52,7 @@ export class TaskTool extends BaseDeclarativeTool { private subagentManager: SubagentManager; private availableSubagents: SubagentConfig[] = []; + private readonly removeChangeListener: () => void; constructor(private readonly config: Config) { // Initialize with a basic schema first @@ -89,7 +88,7 @@ export class TaskTool extends BaseDeclarativeTool { ); this.subagentManager = config.getSubagentManager(); - this.subagentManager.addChangeListener(() => { + this.removeChangeListener = this.subagentManager.addChangeListener(() => { void this.refreshSubagents(); }); @@ -97,6 +96,10 @@ export class TaskTool extends BaseDeclarativeTool { this.refreshSubagents(); } + dispose(): void { + this.removeChangeListener(); + } + /** * Asynchronously initializes the tool by loading available subagents * and updating the description and schema. @@ -514,7 +517,7 @@ class TaskToolInvocation extends BaseToolInvocation { // Get the results const finalText = subagent.getFinalText(); const terminateMode = subagent.getTerminateMode(); - const success = terminateMode === SubagentTerminateMode.GOAL; + const success = terminateMode === AgentTerminateMode.GOAL; const executionSummary = subagent.getExecutionSummary(); if (signal?.aborted) { diff --git a/packages/core/src/tools/tool-registry.ts b/packages/core/src/tools/tool-registry.ts index 1db7f7e59..3ce247781 100644 --- a/packages/core/src/tools/tool-registry.ts +++ b/packages/core/src/tools/tool-registry.ts @@ -209,6 +209,22 @@ export class ToolRegistry { this.tools.set(tool.name, tool); } + /** + * Copies discovered (non-core) tools from another registry into this one. + * Used to share MCP/command-discovered tools with per-agent registries + * that were built with skipDiscovery. + */ + copyDiscoveredToolsFrom(source: ToolRegistry): void { + for (const tool of source.getAllTools()) { + if ( + (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) && + !this.tools.has(tool.name) + ) { + this.tools.set(tool.name, tool); + } + } + } + private removeDiscoveredTools(): void { for (const tool of this.tools.values()) { if (tool instanceof DiscoveredTool || tool instanceof DiscoveredMCPTool) { @@ -489,10 +505,20 @@ export class ToolRegistry { } /** - * Stops all MCP clients and cleans up resources. + * Stops all MCP clients, disposes tools, and cleans up resources. * This method is idempotent and safe to call multiple times. */ async stop(): Promise { + for (const tool of this.tools.values()) { + if ('dispose' in tool && typeof tool.dispose === 'function') { + try { + tool.dispose(); + } catch (error) { + debugLogger.error(`Error disposing tool ${tool.name}:`, error); + } + } + } + try { await this.mcpClientManager.stop(); } catch (error) { diff --git a/packages/core/src/utils/asyncMessageQueue.test.ts b/packages/core/src/utils/asyncMessageQueue.test.ts new file mode 100644 index 000000000..fe5421033 --- /dev/null +++ b/packages/core/src/utils/asyncMessageQueue.test.ts @@ -0,0 +1,75 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { AsyncMessageQueue } from './asyncMessageQueue.js'; + +describe('AsyncMessageQueue', () => { + it('should dequeue items in FIFO order', () => { + const queue = new AsyncMessageQueue(); + queue.enqueue('a'); + queue.enqueue('b'); + queue.enqueue('c'); + + expect(queue.dequeue()).toBe('a'); + expect(queue.dequeue()).toBe('b'); + expect(queue.dequeue()).toBe('c'); + }); + + it('should return null when empty', () => { + const queue = new AsyncMessageQueue(); + expect(queue.dequeue()).toBeNull(); + }); + + it('should return remaining items then null after drain()', () => { + const queue = new AsyncMessageQueue(); + queue.enqueue('x'); + queue.enqueue('y'); + + queue.drain(); + + expect(queue.dequeue()).toBe('x'); + expect(queue.dequeue()).toBe('y'); + expect(queue.dequeue()).toBeNull(); + }); + + it('should silently drop items enqueued after drain()', () => { + const queue = new AsyncMessageQueue(); + queue.drain(); + queue.enqueue('dropped'); + + expect(queue.size).toBe(0); + }); + + it('should track size accurately', () => { + const queue = new AsyncMessageQueue(); + expect(queue.size).toBe(0); + + queue.enqueue(1); + queue.enqueue(2); + expect(queue.size).toBe(2); + + queue.dequeue(); + expect(queue.size).toBe(1); + }); + + it('should report isDrained correctly', () => { + const queue = new AsyncMessageQueue(); + expect(queue.isDrained).toBe(false); + + queue.drain(); + expect(queue.isDrained).toBe(true); + }); + + it('should handle multiple sequential enqueue-dequeue cycles', () => { + const queue = new AsyncMessageQueue(); + + for (let i = 0; i < 5; i++) { + queue.enqueue(i); + expect(queue.dequeue()).toBe(i); + } + }); +}); diff --git a/packages/core/src/utils/asyncMessageQueue.ts b/packages/core/src/utils/asyncMessageQueue.ts new file mode 100644 index 000000000..3268718ef --- /dev/null +++ b/packages/core/src/utils/asyncMessageQueue.ts @@ -0,0 +1,54 @@ +/** + * @license + * Copyright 2025 Qwen + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Generic non-blocking message queue. + * + * Simple FIFO queue for producer/consumer patterns. Dequeue is + * non-blocking — returns null when empty. The consumer decides + * when and how to process items. + */ + +/** + * A generic non-blocking message queue. + * + * - `enqueue(item)` adds an item. Silently dropped after `drain()`. + * - `dequeue()` returns the next item, or `null` if empty. + * - `drain()` signals that no more items will be enqueued. + */ +export class AsyncMessageQueue { + private items: T[] = []; + private drained = false; + + /** Add an item to the queue. Dropped silently after drain. */ + enqueue(item: T): void { + if (this.drained) return; + this.items.push(item); + } + + /** Remove and return the next item, or null if empty. */ + dequeue(): T | null { + if (this.items.length > 0) { + return this.items.shift()!; + } + return null; + } + + /** Signal that no more items will be enqueued. */ + drain(): void { + this.drained = true; + } + + /** Number of items currently in the queue. */ + get size(): number { + return this.items.length; + } + + /** Whether `drain()` has been called. */ + get isDrained(): boolean { + return this.drained; + } +} From 5d07c495f1c311e911b690c7eb7dcb78eb739a2d Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 23 Feb 2026 13:21:16 +0800 Subject: [PATCH 06/82] feat(cli): Add agent tab navigation and live tool output for in-process arena mode Add AgentViewContext, AgentTabBar, and AgentChatView components for tab-based agent switching. Add useArenaInProcess hook bridging ArenaManager events to React state. Add agentHistoryAdapter converting AgentMessage[] to HistoryItem[]. Core support changes: - Replace stream buffers with ROUND_TEXT events (complete round text) - Add TOOL_OUTPUT_UPDATE events for live tool output streaming - Add pendingApprovals/liveOutputs/shellPids state to AgentInteractive - Fix missing ROUND_END emission for final text rounds Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 17 +- packages/cli/src/ui/App.test.tsx | 66 +-- packages/cli/src/ui/AppContainer.test.tsx | 18 + packages/cli/src/ui/AppContainer.tsx | 28 +- .../cli/src/ui/components/DialogManager.tsx | 8 +- .../src/ui/components/HistoryItemDisplay.tsx | 2 +- .../cli/src/ui/components/InputPrompt.tsx | 4 +- .../components/agent-view/AgentChatView.tsx | 248 ++++++++ .../ui/components/agent-view/AgentTabBar.tsx | 137 +++++ .../agent-view/agentHistoryAdapter.test.ts | 528 ++++++++++++++++++ .../agent-view/agentHistoryAdapter.ts | 194 +++++++ .../cli/src/ui/components/agent-view/index.ts | 9 + .../{messages => arena}/ArenaCards.tsx | 0 .../{ => arena}/ArenaSelectDialog.tsx | 16 +- .../{ => arena}/ArenaStartDialog.tsx | 10 +- .../{ => arena}/ArenaStatusDialog.tsx | 151 +++-- .../{ => arena}/ArenaStopDialog.tsx | 12 +- .../cli/src/ui/contexts/AgentViewContext.tsx | 201 +++++++ .../cli/src/ui/hooks/useArenaInProcess.ts | 175 ++++++ .../cli/src/ui/layouts/DefaultAppLayout.tsx | 38 +- .../core/src/agents/arena/ArenaManager.ts | 2 + .../src/agents/backends/InProcessBackend.ts | 11 +- .../core/src/agents/runtime/agent-core.ts | 134 ++++- .../core/src/agents/runtime/agent-events.ts | 30 + .../agents/runtime/agent-interactive.test.ts | 115 +--- .../src/agents/runtime/agent-interactive.ts | 234 +++++--- .../core/src/agents/runtime/agent-types.ts | 12 +- 27 files changed, 2086 insertions(+), 314 deletions(-) create mode 100644 packages/cli/src/ui/components/agent-view/AgentChatView.tsx create mode 100644 packages/cli/src/ui/components/agent-view/AgentTabBar.tsx create mode 100644 packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts create mode 100644 packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts create mode 100644 packages/cli/src/ui/components/agent-view/index.ts rename packages/cli/src/ui/components/{messages => arena}/ArenaCards.tsx (100%) rename packages/cli/src/ui/components/{ => arena}/ArenaSelectDialog.tsx (92%) rename packages/cli/src/ui/components/{ => arena}/ArenaStartDialog.tsx (93%) rename packages/cli/src/ui/components/{ => arena}/ArenaStatusDialog.tsx (54%) rename packages/cli/src/ui/components/{ => arena}/ArenaStopDialog.tsx (92%) create mode 100644 packages/cli/src/ui/contexts/AgentViewContext.tsx create mode 100644 packages/cli/src/ui/hooks/useArenaInProcess.ts diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 08c0631a8..b4bf51a15 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -35,6 +35,7 @@ import { KeypressProvider } from './ui/contexts/KeypressContext.js'; import { SessionStatsProvider } from './ui/contexts/SessionContext.js'; import { SettingsContext } from './ui/contexts/SettingsContext.js'; import { VimModeProvider } from './ui/contexts/VimModeContext.js'; +import { AgentViewProvider } from './ui/contexts/AgentViewContext.js'; import { useKittyKeyboardProtocol } from './ui/hooks/useKittyKeyboardProtocol.js'; import { themeManager } from './ui/themes/theme-manager.js'; import { detectAndEnableKittyProtocol } from './ui/utils/kittyProtocolDetector.js'; @@ -162,13 +163,15 @@ export async function startInteractiveUI( > - + + + diff --git a/packages/cli/src/ui/App.test.tsx b/packages/cli/src/ui/App.test.tsx index be09fe52f..8df422f4b 100644 --- a/packages/cli/src/ui/App.test.tsx +++ b/packages/cli/src/ui/App.test.tsx @@ -9,6 +9,11 @@ import { render } from 'ink-testing-library'; import { Text, useIsScreenReaderEnabled } from 'ink'; import { App } from './App.js'; import { UIStateContext, type UIState } from './contexts/UIStateContext.js'; +import { + UIActionsContext, + type UIActions, +} from './contexts/UIActionsContext.js'; +import { AgentViewProvider } from './contexts/AgentViewContext.js'; import { StreamingState } from './types.js'; vi.mock('ink', async (importOriginal) => { @@ -43,6 +48,10 @@ vi.mock('./components/Footer.js', () => ({ Footer: () => Footer, })); +vi.mock('./components/agent-view/AgentTabBar.js', () => ({ + AgentTabBar: () => null, +})); + describe('App', () => { const mockUIState: Partial = { streamingState: StreamingState.Idle, @@ -58,13 +67,24 @@ describe('App', () => { }, }; - it('should render main content and composer when not quitting', () => { - const { lastFrame } = render( - - - , + const mockUIActions = { + refreshStatic: vi.fn(), + } as unknown as UIActions; + + const renderWithProviders = (uiState: UIState) => + render( + + + + + + + , ); + it('should render main content and composer when not quitting', () => { + const { lastFrame } = renderWithProviders(mockUIState as UIState); + expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('Composer'); }); @@ -75,11 +95,7 @@ describe('App', () => { quittingMessages: [{ id: 1, type: 'user', text: 'test' }], } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(quittingUIState); expect(lastFrame()).toContain('Quitting...'); }); @@ -90,11 +106,7 @@ describe('App', () => { dialogsVisible: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(dialogUIState); expect(lastFrame()).toContain('MainContent'); expect(lastFrame()).toContain('DialogManager'); @@ -107,11 +119,7 @@ describe('App', () => { ctrlCPressedOnce: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(ctrlCUIState); expect(lastFrame()).toContain('Press Ctrl+C again to exit.'); }); @@ -123,11 +131,7 @@ describe('App', () => { ctrlDPressedOnce: true, } as UIState; - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(ctrlDUIState); expect(lastFrame()).toContain('Press Ctrl+D again to exit.'); }); @@ -135,11 +139,7 @@ describe('App', () => { it('should render ScreenReaderAppLayout when screen reader is enabled', () => { (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(true); - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(mockUIState as UIState); expect(lastFrame()).toContain( 'Notifications\nFooter\nMainContent\nComposer', @@ -149,11 +149,7 @@ describe('App', () => { it('should render DefaultAppLayout when screen reader is not enabled', () => { (useIsScreenReaderEnabled as vi.Mock).mockReturnValue(false); - const { lastFrame } = render( - - - , - ); + const { lastFrame } = renderWithProviders(mockUIState as UIState); expect(lastFrame()).toContain('MainContent\nComposer'); }); diff --git a/packages/cli/src/ui/AppContainer.test.tsx b/packages/cli/src/ui/AppContainer.test.tsx index 57eacc797..d5a427b48 100644 --- a/packages/cli/src/ui/AppContainer.test.tsx +++ b/packages/cli/src/ui/AppContainer.test.tsx @@ -78,6 +78,24 @@ vi.mock('./hooks/useAutoAcceptIndicator.js'); vi.mock('./hooks/useGitBranchName.js'); vi.mock('./contexts/VimModeContext.js'); vi.mock('./contexts/SessionContext.js'); +vi.mock('./contexts/AgentViewContext.js', () => ({ + useAgentViewState: vi.fn(() => ({ + activeView: 'main', + agents: new Map(), + })), + useAgentViewActions: vi.fn(() => ({ + switchToMain: vi.fn(), + switchToAgent: vi.fn(), + switchToNext: vi.fn(), + switchToPrevious: vi.fn(), + registerAgent: vi.fn(), + unregisterAgent: vi.fn(), + unregisterAll: vi.fn(), + })), +})); +vi.mock('./hooks/useArenaInProcess.js', () => ({ + useArenaInProcess: vi.fn(), +})); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 663a0782a..f321c7509 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -97,6 +97,8 @@ import { } from './hooks/useExtensionUpdates.js'; import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; +import { useAgentViewState } from './contexts/AgentViewContext.js'; +import { useArenaInProcess } from './hooks/useArenaInProcess.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -710,6 +712,8 @@ export const AppContainer = (props: AppContainerProps) => { shouldBlockTab: () => hasSuggestionsVisible, }); + const agentViewState = useAgentViewState(); + const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ isConfigInitialized, @@ -720,9 +724,17 @@ export const AppContainer = (props: AppContainerProps) => { // Callback for handling final submit (must be after addMessage from useMessageQueue) const handleFinalSubmit = useCallback( (submittedValue: string) => { + // Route to active in-process agent if viewing a sub-agent tab. + if (agentViewState.activeView !== 'main') { + const agent = agentViewState.agents.get(agentViewState.activeView); + if (agent) { + agent.interactiveAgent.enqueueMessage(submittedValue.trim()); + return; + } + } addMessage(submittedValue); }, - [addMessage], + [addMessage, agentViewState], ); const handleArenaModelsSelected = useCallback( @@ -807,10 +819,17 @@ export const AppContainer = (props: AppContainerProps) => { } }, [buffer, terminalWidth, terminalHeight]); - // Compute available terminal height based on controls measurement + // agentViewState is declared earlier (before handleFinalSubmit) so it + // is available for input routing. Referenced here for layout computation. + + // Compute available terminal height based on controls measurement. + // When in-process agents are present the AgentTabBar renders an extra + // row at the top of the layout; subtract it so downstream consumers + // (shell, transcript, etc.) don't overestimate available space. + const tabBarHeight = agentViewState.agents.size > 0 ? 1 : 0; const availableTerminalHeight = Math.max( 0, - terminalHeight - controlsHeight - staticExtraHeight - 2, + terminalHeight - controlsHeight - staticExtraHeight - 2 - tabBarHeight, ); config.setShellExecutionConfig({ @@ -826,6 +845,9 @@ export const AppContainer = (props: AppContainerProps) => { const isFocused = useFocus(); useBracketedPaste(); + // Bridge arena in-process events to AgentViewContext + useArenaInProcess(config); + // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; diff --git a/packages/cli/src/ui/components/DialogManager.tsx b/packages/cli/src/ui/components/DialogManager.tsx index cb88ba76f..86f365ab2 100644 --- a/packages/cli/src/ui/components/DialogManager.tsx +++ b/packages/cli/src/ui/components/DialogManager.tsx @@ -20,10 +20,10 @@ import { AuthDialog } from '../auth/AuthDialog.js'; import { EditorSettingsDialog } from './EditorSettingsDialog.js'; import { PermissionsModifyTrustDialog } from './PermissionsModifyTrustDialog.js'; import { ModelDialog } from './ModelDialog.js'; -import { ArenaStartDialog } from './ArenaStartDialog.js'; -import { ArenaSelectDialog } from './ArenaSelectDialog.js'; -import { ArenaStopDialog } from './ArenaStopDialog.js'; -import { ArenaStatusDialog } from './ArenaStatusDialog.js'; +import { ArenaStartDialog } from './arena/ArenaStartDialog.js'; +import { ArenaSelectDialog } from './arena/ArenaSelectDialog.js'; +import { ArenaStopDialog } from './arena/ArenaStopDialog.js'; +import { ArenaStatusDialog } from './arena/ArenaStatusDialog.js'; import { ApprovalModeDialog } from './ApprovalModeDialog.js'; import { theme } from '../semantic-colors.js'; import { useUIState } from '../contexts/UIStateContext.js'; diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index 55b678739..5b3aa6055 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -39,7 +39,7 @@ import { getMCPServerStatus } from '@qwen-code/qwen-code-core'; import { SkillsList } from './views/SkillsList.js'; import { ToolsList } from './views/ToolsList.js'; import { McpStatus } from './views/McpStatus.js'; -import { ArenaAgentCard, ArenaSessionCard } from './messages/ArenaCards.js'; +import { ArenaAgentCard, ArenaSessionCard } from './arena/ArenaCards.js'; interface HistoryItemDisplayProps { item: HistoryItem; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 8820e2126..d857f1fad 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -873,7 +873,9 @@ export const InputPrompt: React.FC = ({ ], ); - useKeypress(handleInput, { isActive: !isEmbeddedShellFocused }); + useKeypress(handleInput, { + isActive: !isEmbeddedShellFocused, + }); const linesToRender = buffer.viewportVisualLines; const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx new file mode 100644 index 000000000..20eb0adc0 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -0,0 +1,248 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentChatView — displays a single in-process agent's conversation. + * + * Renders the agent's message history using HistoryItemDisplay — the same + * component used by the main agent view. AgentMessage[] is converted to + * HistoryItem[] by agentMessagesToHistoryItems() so all 27 HistoryItem types + * are available without duplicating rendering logic. + * + * Layout: + * - Static area: finalized messages (efficient Ink ) + * - Live area: tool groups still executing / awaiting confirmation + * - Status line: spinner while the agent is running + * + * Model text output is shown only after each round completes (no live + * streaming), which avoids per-chunk re-renders and keeps the display simple. + */ + +import { Box, Text, Static } from 'ink'; +import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; +import { + AgentStatus, + AgentEventType, + type AgentStatusChangeEvent, +} from '@qwen-code/qwen-code-core'; +import { + useAgentViewState, + useAgentViewActions, +} from '../../contexts/AgentViewContext.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { HistoryItemDisplay } from '../HistoryItemDisplay.js'; +import { ToolCallStatus } from '../../types.js'; +import { theme } from '../../semantic-colors.js'; +import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; + +// ─── Main Component ───────────────────────────────────────── + +interface AgentChatViewProps { + agentId: string; +} + +export const AgentChatView = ({ agentId }: AgentChatViewProps) => { + const { agents } = useAgentViewState(); + const { setAgentShellFocused } = useAgentViewActions(); + const uiState = useUIState(); + const { historyRemountKey, availableTerminalHeight, constrainHeight } = + uiState; + const { columns: terminalWidth } = useTerminalSize(); + const agent = agents.get(agentId); + const contentWidth = terminalWidth - 4; + + // Force re-render on message updates and status changes. + // STREAM_TEXT is deliberately excluded — model text is shown only after + // each round completes (via committed messages), avoiding per-chunk re-renders. + const [, setRenderTick] = useState(0); + const tickRef = useRef(0); + const forceRender = useCallback(() => { + tickRef.current += 1; + setRenderTick(tickRef.current); + }, []); + + useEffect(() => { + if (!agent) return; + + const emitter = agent.interactiveAgent.getEventEmitter(); + if (!emitter) return; + + const onStatusChange = (_event: AgentStatusChangeEvent) => forceRender(); + const onToolCall = () => forceRender(); + const onToolResult = () => forceRender(); + const onRoundEnd = () => forceRender(); + const onApproval = () => forceRender(); + const onOutputUpdate = () => forceRender(); + + emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.on(AgentEventType.TOOL_CALL, onToolCall); + emitter.on(AgentEventType.TOOL_RESULT, onToolResult); + emitter.on(AgentEventType.ROUND_END, onRoundEnd); + emitter.on(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.on(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + + return () => { + emitter.off(AgentEventType.STATUS_CHANGE, onStatusChange); + emitter.off(AgentEventType.TOOL_CALL, onToolCall); + emitter.off(AgentEventType.TOOL_RESULT, onToolResult); + emitter.off(AgentEventType.ROUND_END, onRoundEnd); + emitter.off(AgentEventType.TOOL_WAITING_APPROVAL, onApproval); + emitter.off(AgentEventType.TOOL_OUTPUT_UPDATE, onOutputUpdate); + }; + }, [agent, forceRender]); + + const interactiveAgent = agent?.interactiveAgent; + const messages = interactiveAgent?.getMessages() ?? []; + const pendingApprovals = interactiveAgent?.getPendingApprovals(); + const liveOutputs = interactiveAgent?.getLiveOutputs(); + const shellPids = interactiveAgent?.getShellPids(); + const status = interactiveAgent?.getStatus(); + const isRunning = + status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING; + + // Derive the active PTY PID: first shell PID among currently-executing tools. + // Resets naturally to undefined when the tool finishes (shellPids cleared). + const activePtyId = + shellPids && shellPids.size > 0 + ? shellPids.values().next().value + : undefined; + + // Track whether the user has toggled input focus into the embedded shell. + // Mirrors the main agent's embeddedShellFocused in AppContainer. + const [embeddedShellFocused, setEmbeddedShellFocusedLocal] = useState(false); + + // Sync to AgentViewContext so AgentTabBar can suppress arrow-key navigation + // when an agent's embedded shell is focused. + useEffect(() => { + setAgentShellFocused(embeddedShellFocused); + return () => setAgentShellFocused(false); + }, [embeddedShellFocused, setAgentShellFocused]); + + // Reset focus when the shell exits (activePtyId disappears). + useEffect(() => { + if (!activePtyId) setEmbeddedShellFocusedLocal(false); + }, [activePtyId]); + + // Ctrl+F: toggle shell input focus when a PTY is active. + useKeypress( + (key) => { + if (key.ctrl && key.name === 'f') { + if (activePtyId || embeddedShellFocused) { + setEmbeddedShellFocusedLocal((prev) => !prev); + } + } + }, + { isActive: true }, + ); + + // Convert AgentMessage[] → HistoryItem[] via adapter. + // tickRef.current in deps ensures we rebuild when events fire even if + // messages.length and pendingApprovals.size haven't changed (e.g. a + // tool result updates an existing entry in place). + const allItems = useMemo( + () => + agentMessagesToHistoryItems( + messages, + pendingApprovals ?? new Map(), + liveOutputs, + shellPids, + ), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + messages.length, + pendingApprovals?.size, + liveOutputs?.size, + shellPids?.size, + tickRef.current, + ], + ); + + // Split into committed (Static) and pending (live area). + // Any tool_group with an Executing or Confirming tool — plus everything + // after it — stays in the live area so confirmation dialogs remain + // interactive (Ink's cannot receive input). + const splitIndex = useMemo(() => { + for (let idx = allItems.length - 1; idx >= 0; idx--) { + const item = allItems[idx]!; + if ( + item.type === 'tool_group' && + item.tools.some( + (t) => + t.status === ToolCallStatus.Executing || + t.status === ToolCallStatus.Confirming, + ) + ) { + return idx; + } + } + return allItems.length; // all committed + }, [allItems]); + + const committedItems = allItems.slice(0, splitIndex); + const pendingItems = allItems.slice(splitIndex); + + if (!agent || !interactiveAgent) { + return ( + + + Agent "{agentId}" not found. + + + ); + } + + return ( + + {/* Committed message history. + key includes historyRemountKey: when refreshStatic() clears the + terminal it bumps the key, forcing Static to remount and re-emit + all items on the cleared screen. */} + ( + + ))} + > + {(item) => item} + + + {/* Live area — tool groups awaiting confirmation or still executing. + Must remain outside Static so confirmation dialogs are interactive. + Pass PTY state so ShellInputPrompt is reachable via Ctrl+F. */} + {pendingItems.map((item) => ( + + ))} + + {/* Spinner */} + {isRunning && ( + + + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx new file mode 100644 index 000000000..1d526b9b0 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -0,0 +1,137 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentTabBar — horizontal tab strip for in-process agent views. + * + * Rendered at the top of the terminal whenever in-process agents are registered. + * Left/Right arrow keys cycle through tabs when the input buffer is empty. + * + * Tab indicators: running, idle/completed, failed, cancelled + */ + +import { Box, Text } from 'ink'; +import { useState, useEffect, useCallback } from 'react'; +import { AgentStatus, AgentEventType } from '@qwen-code/qwen-code-core'; +import { + useAgentViewState, + useAgentViewActions, + type RegisteredAgent, +} from '../../contexts/AgentViewContext.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { useUIState } from '../../contexts/UIStateContext.js'; +import { theme } from '../../semantic-colors.js'; + +// ─── Status Indicators ────────────────────────────────────── + +function statusIndicator(agent: RegisteredAgent): { + symbol: string; + color: string; +} { + const status = agent.interactiveAgent.getStatus(); + switch (status) { + case AgentStatus.RUNNING: + case AgentStatus.INITIALIZING: + return { symbol: '\u25CF', color: theme.status.warning }; // ● running + case AgentStatus.COMPLETED: + return { symbol: '\u2713', color: theme.status.success }; // ✓ completed + case AgentStatus.FAILED: + return { symbol: '\u2717', color: theme.status.error }; // ✗ failed + case AgentStatus.CANCELLED: + return { symbol: '\u25CB', color: theme.text.secondary }; // ○ cancelled + default: + return { symbol: '\u25CB', color: theme.text.secondary }; // ○ fallback + } +} + +// ─── Component ────────────────────────────────────────────── + +export const AgentTabBar: React.FC = () => { + const { activeView, agents, agentShellFocused } = useAgentViewState(); + const { switchToNext, switchToPrevious } = useAgentViewActions(); + const { buffer, embeddedShellFocused } = useUIState(); + + // Left/Right arrow keys switch tabs when the input buffer is empty + // and no embedded shell (main or agent tab) has input focus. + useKeypress( + (key) => { + if (buffer.text !== '' || embeddedShellFocused || agentShellFocused) + return; + if (key.name === 'left') { + switchToPrevious(); + } else if (key.name === 'right') { + switchToNext(); + } + }, + { isActive: true }, + ); + + // Subscribe to STATUS_CHANGE events from all agents so the tab bar + // re-renders when an agent's status transitions (e.g. RUNNING → COMPLETED). + // Without this, status indicators would be stale until the next unrelated render. + const [, setTick] = useState(0); + const forceRender = useCallback(() => setTick((t) => t + 1), []); + + useEffect(() => { + const cleanups: Array<() => void> = []; + for (const [, agent] of agents) { + const emitter = agent.interactiveAgent.getEventEmitter(); + if (emitter) { + emitter.on(AgentEventType.STATUS_CHANGE, forceRender); + cleanups.push(() => + emitter.off(AgentEventType.STATUS_CHANGE, forceRender), + ); + } + } + return () => cleanups.forEach((fn) => fn()); + }, [agents, forceRender]); + + return ( + + {/* Main tab */} + + + {' Main '} + + + + {/* Separator */} + {'\u2502'} + + {/* Agent tabs */} + {[...agents.entries()].map(([agentId, agent]) => { + const isActive = activeView === agentId; + const { symbol, color: indicatorColor } = statusIndicator(agent); + + return ( + + + {` ${agent.displayName} `} + + {` ${symbol}`} + + ); + })} + + {/* Navigation hint */} + + ←/→ + + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts new file mode 100644 index 000000000..c63093642 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts @@ -0,0 +1,528 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect } from 'vitest'; +import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; +import type { + AgentMessage, + ToolCallConfirmationDetails, +} from '@qwen-code/qwen-code-core'; +import { ToolCallStatus } from '../../types.js'; + +// ─── Helpers ──────────────────────────────────────────────── + +function msg( + role: AgentMessage['role'], + content: string, + extra?: Partial, +): AgentMessage { + return { role, content, timestamp: 0, ...extra }; +} + +const noApprovals = new Map(); + +function toolCallMsg( + callId: string, + toolName: string, + opts?: { description?: string; renderOutputAsMarkdown?: boolean }, +): AgentMessage { + return msg('tool_call', `Tool call: ${toolName}`, { + metadata: { + callId, + toolName, + description: opts?.description ?? '', + renderOutputAsMarkdown: opts?.renderOutputAsMarkdown, + }, + }); +} + +function toolResultMsg( + callId: string, + toolName: string, + opts?: { + success?: boolean; + resultDisplay?: string; + outputFile?: string; + }, +): AgentMessage { + return msg('tool_result', `Tool ${toolName}`, { + metadata: { + callId, + toolName, + success: opts?.success ?? true, + resultDisplay: opts?.resultDisplay, + outputFile: opts?.outputFile, + }, + }); +} + +// ─── Role mapping ──────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — role mapping', () => { + it('maps user message', () => { + const items = agentMessagesToHistoryItems( + [msg('user', 'hello')], + noApprovals, + ); + expect(items).toHaveLength(1); + expect(items[0]).toMatchObject({ type: 'user', text: 'hello' }); + }); + + it('maps plain assistant message', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'response')], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'gemini', text: 'response' }); + }); + + it('maps thought assistant message', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'thinking...', { thought: true })], + noApprovals, + ); + expect(items[0]).toMatchObject({ + type: 'gemini_thought', + text: 'thinking...', + }); + }); + + it('maps assistant message with error metadata', () => { + const items = agentMessagesToHistoryItems( + [msg('assistant', 'oops', { metadata: { error: true } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'error', text: 'oops' }); + }); + + it('maps info message with no level → type info', () => { + const items = agentMessagesToHistoryItems( + [msg('info', 'note')], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'info', text: 'note' }); + }); + + it.each([ + ['warning', 'warning'], + ['success', 'success'], + ['error', 'error'], + ] as const)('maps info message with level=%s', (level, expectedType) => { + const items = agentMessagesToHistoryItems( + [msg('info', 'text', { metadata: { level } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: expectedType }); + }); + + it('maps unknown info level → type info', () => { + const items = agentMessagesToHistoryItems( + [msg('info', 'x', { metadata: { level: 'verbose' } })], + noApprovals, + ); + expect(items[0]).toMatchObject({ type: 'info' }); + }); + + it('skips unknown roles without crashing', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'before'), + // force an unknown role + { role: 'unknown' as AgentMessage['role'], content: 'x', timestamp: 0 }, + msg('user', 'after'), + ], + noApprovals, + ); + expect(items).toHaveLength(2); + expect(items[0]).toMatchObject({ type: 'user', text: 'before' }); + expect(items[1]).toMatchObject({ type: 'user', text: 'after' }); + }); +}); + +// ─── Tool grouping ─────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool grouping', () => { + it('merges a tool_call + tool_result pair into one tool_group', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'read_file'), toolResultMsg('c1', 'read_file')], + noApprovals, + ); + expect(items).toHaveLength(1); + expect(items[0]!.type).toBe('tool_group'); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools).toHaveLength(1); + expect(group.tools[0]!.name).toBe('read_file'); + }); + + it('merges multiple parallel tool calls into one tool_group', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read_file'), + toolCallMsg('c2', 'write_file'), + toolResultMsg('c1', 'read_file'), + toolResultMsg('c2', 'write_file'), + ], + noApprovals, + ); + expect(items).toHaveLength(1); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools).toHaveLength(2); + expect(group.tools[0]!.name).toBe('read_file'); + expect(group.tools[1]!.name).toBe('write_file'); + }); + + it('preserves tool call order by first appearance', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c2', 'second'), + toolCallMsg('c1', 'first'), + toolResultMsg('c1', 'first'), + toolResultMsg('c2', 'second'), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.name).toBe('second'); + expect(group.tools[1]!.name).toBe('first'); + }); + + it('breaks tool groups at non-tool messages', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'tool_a'), + toolResultMsg('c1', 'tool_a'), + msg('assistant', 'between'), + toolCallMsg('c2', 'tool_b'), + toolResultMsg('c2', 'tool_b'), + ], + noApprovals, + ); + expect(items).toHaveLength(3); + expect(items[0]!.type).toBe('tool_group'); + expect(items[1]!.type).toBe('gemini'); + expect(items[2]!.type).toBe('tool_group'); + }); + + it('handles tool_result arriving without a prior tool_call gracefully', () => { + const items = agentMessagesToHistoryItems( + [ + toolResultMsg('c1', 'orphan', { + success: true, + resultDisplay: 'output', + }), + ], + noApprovals, + ); + expect(items).toHaveLength(1); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.callId).toBe('c1'); + expect(group.tools[0]!.status).toBe(ToolCallStatus.Success); + }); +}); + +// ─── Tool status ───────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool status', () => { + it('Executing: tool_call with no result yet', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Executing); + }); + + it('Success: tool_result with success=true', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read'), + toolResultMsg('c1', 'read', { success: true }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Success); + }); + + it('Error: tool_result with success=false', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'write'), + toolResultMsg('c1', 'write', { success: false }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Error); + }); + + it('Confirming: tool_call present in pendingApprovals', () => { + const fakeApproval = {} as ToolCallConfirmationDetails; + const approvals = new Map([['c1', fakeApproval]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + approvals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming); + expect(group.tools[0]!.confirmationDetails).toBe(fakeApproval); + }); + + it('Confirming takes priority over Executing', () => { + // pending approval AND no result yet → Confirming, not Executing + const approvals = new Map([['c1', {} as ToolCallConfirmationDetails]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + approvals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.status).toBe(ToolCallStatus.Confirming); + }); +}); + +// ─── Tool metadata ─────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — tool metadata', () => { + it('forwards resultDisplay from tool_result', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'read'), + toolResultMsg('c1', 'read', { + success: true, + resultDisplay: 'file contents', + }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('file contents'); + }); + + it('forwards outputFile from tool_result', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { + success: true, + outputFile: '/tmp/output.txt', + }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.outputFile).toBe('/tmp/output.txt'); + }); + + it('forwards renderOutputAsMarkdown from tool_call', () => { + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'web_fetch', { renderOutputAsMarkdown: true }), + toolResultMsg('c1', 'web_fetch', { success: true }), + ], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.renderOutputAsMarkdown).toBe(true); + }); + + it('forwards description from tool_call', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'read', { description: 'reading src/index.ts' })], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.description).toBe('reading src/index.ts'); + }); +}); + +// ─── liveOutputs overlay ───────────────────────────────────── + +describe('agentMessagesToHistoryItems — liveOutputs', () => { + it('uses liveOutput as resultDisplay for Executing tools', () => { + const liveOutputs = new Map([['c1', 'live stdout so far']]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('live stdout so far'); + }); + + it('ignores liveOutput for completed tools', () => { + const liveOutputs = new Map([['c1', 'stale live output']]); + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { + success: true, + resultDisplay: 'final output', + }), + ], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBe('final output'); + }); + + it('falls back to entry resultDisplay when no liveOutput for callId', () => { + const liveOutputs = new Map([['other-id', 'unrelated']]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + liveOutputs, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.resultDisplay).toBeUndefined(); + }); +}); + +// ─── shellPids overlay ─────────────────────────────────────── + +describe('agentMessagesToHistoryItems — shellPids', () => { + it('sets ptyId for Executing tools with a known PID', () => { + const shellPids = new Map([['c1', 12345]]); + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + undefined, + shellPids, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBe(12345); + }); + + it('does not set ptyId for completed tools', () => { + const shellPids = new Map([['c1', 12345]]); + const items = agentMessagesToHistoryItems( + [ + toolCallMsg('c1', 'shell'), + toolResultMsg('c1', 'shell', { success: true }), + ], + noApprovals, + undefined, + shellPids, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBeUndefined(); + }); + + it('does not set ptyId when shellPids is not provided', () => { + const items = agentMessagesToHistoryItems( + [toolCallMsg('c1', 'shell')], + noApprovals, + ); + const group = items[0] as Extract< + (typeof items)[0], + { type: 'tool_group' } + >; + expect(group.tools[0]!.ptyId).toBeUndefined(); + }); +}); + +// ─── ID stability ──────────────────────────────────────────── + +describe('agentMessagesToHistoryItems — ID stability', () => { + it('assigns monotonically increasing IDs', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'u1'), + msg('assistant', 'a1'), + msg('info', 'i1'), + toolCallMsg('c1', 'tool'), + toolResultMsg('c1', 'tool'), + ], + noApprovals, + ); + const ids = items.map((i) => i.id); + expect(ids).toEqual([0, 1, 2, 3]); + }); + + it('tool_group consumes one ID regardless of how many calls it contains', () => { + const items = agentMessagesToHistoryItems( + [ + msg('user', 'go'), + toolCallMsg('c1', 'tool_a'), + toolCallMsg('c2', 'tool_b'), + toolResultMsg('c1', 'tool_a'), + toolResultMsg('c2', 'tool_b'), + msg('assistant', 'done'), + ], + noApprovals, + ); + // user=0, tool_group=1, assistant=2 + expect(items.map((i) => i.id)).toEqual([0, 1, 2]); + }); + + it('IDs from a prefix of messages are stable when more messages are appended', () => { + const base: AgentMessage[] = [msg('user', 'u'), msg('assistant', 'a')]; + + const before = agentMessagesToHistoryItems(base, noApprovals); + const after = agentMessagesToHistoryItems( + [...base, msg('info', 'i')], + noApprovals, + ); + + expect(after[0]!.id).toBe(before[0]!.id); + expect(after[1]!.id).toBe(before[1]!.id); + expect(after[2]!.id).toBe(2); + }); +}); diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts new file mode 100644 index 000000000..951618abf --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.ts @@ -0,0 +1,194 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview agentHistoryAdapter — converts AgentMessage[] to HistoryItem[]. + * + * This adapter bridges the sub-agent data model (AgentMessage[] from + * AgentInteractive) to the shared rendering model (HistoryItem[] consumed by + * HistoryItemDisplay). It lives in the CLI package so that packages/core types + * are never coupled to CLI rendering types. + * + * ID stability: AgentMessage[] is append-only, so the resulting HistoryItem[] + * only ever grows. Index-based IDs are therefore stable — Ink's + * requires items never shift or be removed, which this guarantees. + */ + +import type { + AgentMessage, + ToolCallConfirmationDetails, + ToolResultDisplay, +} from '@qwen-code/qwen-code-core'; +import type { HistoryItem, IndividualToolCallDisplay } from '../../types.js'; +import { ToolCallStatus } from '../../types.js'; + +/** + * Convert AgentMessage[] + pendingApprovals into HistoryItem[]. + * + * Consecutive tool_call / tool_result messages are merged into a single + * tool_group HistoryItem. pendingApprovals overlays confirmation state so + * ToolGroupMessage can render confirmation dialogs. + * + * liveOutputs (optional) provides real-time display data for executing tools. + * shellPids (optional) provides PTY PIDs for interactive shell tools so + * HistoryItemDisplay can render ShellInputPrompt on the active shell. + */ +export function agentMessagesToHistoryItems( + messages: readonly AgentMessage[], + pendingApprovals: ReadonlyMap, + liveOutputs?: ReadonlyMap, + shellPids?: ReadonlyMap, +): HistoryItem[] { + const items: HistoryItem[] = []; + let nextId = 0; + let i = 0; + + while (i < messages.length) { + const msg = messages[i]!; + + // ── user ────────────────────────────────────────────────── + if (msg.role === 'user') { + items.push({ type: 'user', text: msg.content, id: nextId++ }); + i++; + + // ── assistant ───────────────────────────────────────────── + } else if (msg.role === 'assistant') { + if (msg.metadata?.['error']) { + items.push({ type: 'error', text: msg.content, id: nextId++ }); + } else if (msg.thought) { + items.push({ type: 'gemini_thought', text: msg.content, id: nextId++ }); + } else { + items.push({ type: 'gemini', text: msg.content, id: nextId++ }); + } + i++; + + // ── info / warning / success / error ────────────────────── + } else if (msg.role === 'info') { + const level = msg.metadata?.['level'] as string | undefined; + const type = + level === 'warning' || level === 'success' || level === 'error' + ? level + : 'info'; + items.push({ type, text: msg.content, id: nextId++ }); + i++; + + // ── tool_call / tool_result → tool_group ────────────────── + } else if (msg.role === 'tool_call' || msg.role === 'tool_result') { + const groupId = nextId++; + + const callMap = new Map< + string, + { + callId: string; + name: string; + description: string; + resultDisplay: ToolResultDisplay | string | undefined; + outputFile: string | undefined; + renderOutputAsMarkdown: boolean | undefined; + success: boolean | undefined; + } + >(); + const callOrder: string[] = []; + + while ( + i < messages.length && + (messages[i]!.role === 'tool_call' || + messages[i]!.role === 'tool_result') + ) { + const m = messages[i]!; + const callId = (m.metadata?.['callId'] as string) ?? `unknown-${i}`; + + if (m.role === 'tool_call') { + if (!callMap.has(callId)) callOrder.push(callId); + callMap.set(callId, { + callId, + name: (m.metadata?.['toolName'] as string) ?? 'unknown', + description: (m.metadata?.['description'] as string) ?? '', + resultDisplay: undefined, + outputFile: undefined, + renderOutputAsMarkdown: m.metadata?.['renderOutputAsMarkdown'] as + | boolean + | undefined, + success: undefined, + }); + } else { + // tool_result — attach to existing call entry + const entry = callMap.get(callId); + const resultDisplay = m.metadata?.['resultDisplay'] as + | ToolResultDisplay + | string + | undefined; + const outputFile = m.metadata?.['outputFile'] as string | undefined; + const success = m.metadata?.['success'] as boolean; + + if (entry) { + entry.success = success; + entry.resultDisplay = resultDisplay; + entry.outputFile = outputFile; + } else { + // Result arrived without a prior tool_call message (shouldn't + // normally happen, but handle gracefully) + callOrder.push(callId); + callMap.set(callId, { + callId, + name: (m.metadata?.['toolName'] as string) ?? 'unknown', + description: '', + resultDisplay, + outputFile, + renderOutputAsMarkdown: undefined, + success, + }); + } + } + i++; + } + + const tools: IndividualToolCallDisplay[] = callOrder.map((callId) => { + const entry = callMap.get(callId)!; + const approval = pendingApprovals.get(callId); + + let status: ToolCallStatus; + if (approval) { + status = ToolCallStatus.Confirming; + } else if (entry.success === undefined) { + status = ToolCallStatus.Executing; + } else if (entry.success) { + status = ToolCallStatus.Success; + } else { + status = ToolCallStatus.Error; + } + + // For executing tools, use live output if available (Gap 4) + const resultDisplay = + status === ToolCallStatus.Executing && liveOutputs?.has(callId) + ? liveOutputs.get(callId) + : entry.resultDisplay; + + return { + callId: entry.callId, + name: entry.name, + description: entry.description, + resultDisplay, + outputFile: entry.outputFile, + renderOutputAsMarkdown: entry.renderOutputAsMarkdown, + status, + confirmationDetails: approval, + ptyId: + status === ToolCallStatus.Executing + ? shellPids?.get(callId) + : undefined, + }; + }); + + items.push({ type: 'tool_group', tools, id: groupId }); + } else { + // Skip unknown roles + i++; + } + } + + return items; +} diff --git a/packages/cli/src/ui/components/agent-view/index.ts b/packages/cli/src/ui/components/agent-view/index.ts new file mode 100644 index 000000000..30c4ea7b9 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/index.ts @@ -0,0 +1,9 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +export { AgentTabBar } from './AgentTabBar.js'; +export { AgentChatView } from './AgentChatView.js'; +export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; diff --git a/packages/cli/src/ui/components/messages/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx similarity index 100% rename from packages/cli/src/ui/components/messages/ArenaCards.tsx rename to packages/cli/src/ui/components/arena/ArenaCards.tsx diff --git a/packages/cli/src/ui/components/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx similarity index 92% rename from packages/cli/src/ui/components/ArenaSelectDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx index 9d2f15806..19a322ed1 100644 --- a/packages/cli/src/ui/components/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx @@ -12,14 +12,14 @@ import { AgentStatus, type Config, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType, type HistoryItemWithoutId } from '../types.js'; -import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -import { formatDuration } from '../utils/formatters.js'; -import { getArenaStatusLabel } from '../utils/displayUtils.js'; -import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; -import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MessageType, type HistoryItemWithoutId } from '../../types.js'; +import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js'; +import { formatDuration } from '../../utils/formatters.js'; +import { getArenaStatusLabel } from '../../utils/displayUtils.js'; +import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js'; interface ArenaSelectDialogProps { manager: ArenaManager; diff --git a/packages/cli/src/ui/components/ArenaStartDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx similarity index 93% rename from packages/cli/src/ui/components/ArenaStartDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStartDialog.tsx index 2641dcba6..c60e6ddf5 100644 --- a/packages/cli/src/ui/components/ArenaStartDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx @@ -9,11 +9,11 @@ import { useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import Link from 'ink-link'; import { AuthType } from '@qwen-code/qwen-code-core'; -import { useConfig } from '../contexts/ConfigContext.js'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MultiSelect } from './shared/MultiSelect.js'; -import { t } from '../../i18n/index.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MultiSelect } from '../shared/MultiSelect.js'; +import { t } from '../../../i18n/index.js'; interface ArenaStartDialogProps { onClose: () => void; diff --git a/packages/cli/src/ui/components/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx similarity index 54% rename from packages/cli/src/ui/components/ArenaStatusDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 211a9d9ba..cceed019d 100644 --- a/packages/cli/src/ui/components/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -5,20 +5,24 @@ */ import type React from 'react'; -import { useEffect, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { Box, Text } from 'ink'; import { type ArenaManager, type ArenaAgentState, + type InProcessBackend, + type AgentStatsSummary, isTerminalStatus, ArenaSessionStatus, + DISPLAY_MODE, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { formatDuration } from '../utils/formatters.js'; -import { getArenaStatusLabel } from '../utils/displayUtils.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { formatDuration } from '../../utils/formatters.js'; +import { getArenaStatusLabel } from '../../utils/displayUtils.js'; const STATUS_REFRESH_INTERVAL_MS = 2000; +const IN_PROCESS_REFRESH_INTERVAL_MS = 1000; interface ArenaStatusDialogProps { manager: ArenaManager; @@ -77,12 +81,20 @@ export function ArenaStatusDialog({ }: ArenaStatusDialogProps): React.JSX.Element { const [tick, setTick] = useState(0); + // Detect in-process backend for live stats reading + const backend = manager.getBackend(); + const isInProcess = backend?.type === DISPLAY_MODE.IN_PROCESS; + const inProcessBackend = isInProcess ? (backend as InProcessBackend) : null; + useEffect(() => { + const interval = isInProcess + ? IN_PROCESS_REFRESH_INTERVAL_MS + : STATUS_REFRESH_INTERVAL_MS; const timer = setInterval(() => { setTick((prev) => prev + 1); - }, STATUS_REFRESH_INTERVAL_MS); + }, interval); return () => clearInterval(timer); - }, []); + }, [isInProcess]); // Force re-read on every tick void tick; @@ -92,6 +104,20 @@ export function ArenaStatusDialog({ const agents = manager.getAgentStates(); const task = manager.getTask() ?? ''; + // For in-process mode, read live stats directly from AgentInteractive + const liveStats = useMemo(() => { + if (!inProcessBackend) return null; + const statsMap = new Map(); + for (const agent of agents) { + const interactive = inProcessBackend.getAgent(agent.agentId); + if (interactive) { + statsMap.set(agent.agentId, interactive.getStats()); + } + } + return statsMap; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [inProcessBackend, agents, tick]); + const maxTaskLen = 60; const displayTask = task.length > maxTaskLen ? task.slice(0, maxTaskLen - 1) + '…' : task; @@ -130,6 +156,12 @@ export function ArenaStatusDialog({ · {sessionLabel.text} + {isInProcess && ( + <> + · + In-Process + + )} @@ -189,52 +221,73 @@ export function ArenaStatusDialog({ const { text: statusText, color } = getArenaStatusLabel(agent.status); const elapsed = getElapsedMs(agent); + // Use live stats from AgentInteractive when in-process, otherwise + // fall back to the cached ArenaAgentState.stats (file-polled). + const live = liveStats?.get(agent.agentId); + const totalTokens = live?.totalTokens ?? agent.stats.totalTokens; + const rounds = live?.rounds ?? agent.stats.rounds; + const toolCalls = live?.totalToolCalls ?? agent.stats.toolCalls; + const successfulToolCalls = + live?.successfulToolCalls ?? agent.stats.successfulToolCalls; + const failedToolCalls = + live?.failedToolCalls ?? agent.stats.failedToolCalls; + return ( - - - - {truncate(label, MAX_MODEL_NAME_LENGTH)} - - - - {statusText} - - - - {pad(formatDuration(elapsed), colTime - 1, 'right')} - - - - - {pad( - agent.stats.totalTokens.toLocaleString(), - colTokens - 1, - 'right', - )} - - - - - {pad(String(agent.stats.rounds), colRounds - 1, 'right')} - - - - {agent.stats.failedToolCalls > 0 ? ( - - - {agent.stats.successfulToolCalls} - - / - - {agent.stats.failedToolCalls} - - - ) : ( + + + - {pad(String(agent.stats.toolCalls), colTools - 1, 'right')} + {truncate(label, MAX_MODEL_NAME_LENGTH)} - )} + + + {statusText} + + + + {pad(formatDuration(elapsed), colTime - 1, 'right')} + + + + + {pad(totalTokens.toLocaleString(), colTokens - 1, 'right')} + + + + + {pad(String(rounds), colRounds - 1, 'right')} + + + + {failedToolCalls > 0 ? ( + + + {successfulToolCalls} + + / + {failedToolCalls} + + ) : ( + + {pad(String(toolCalls), colTools - 1, 'right')} + + )} + + {/* In-process mode: show extra detail row with cost + thought tokens */} + {live && (live.estimatedCost > 0 || live.thoughtTokens > 0) && ( + + + {live.estimatedCost > 0 && + `Cost: $${live.estimatedCost.toFixed(4)}`} + {live.estimatedCost > 0 && live.thoughtTokens > 0 && ' · '} + {live.thoughtTokens > 0 && + `Thinking: ${live.thoughtTokens.toLocaleString()} tok`} + {live.cachedTokens > 0 && + ` · Cached: ${live.cachedTokens.toLocaleString()} tok`} + + + )} ); })} diff --git a/packages/cli/src/ui/components/ArenaStopDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx similarity index 92% rename from packages/cli/src/ui/components/ArenaStopDialog.tsx rename to packages/cli/src/ui/components/arena/ArenaStopDialog.tsx index da0022aa7..a790e20c2 100644 --- a/packages/cli/src/ui/components/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx @@ -12,12 +12,12 @@ import { createDebugLogger, type Config, } from '@qwen-code/qwen-code-core'; -import { theme } from '../semantic-colors.js'; -import { useKeypress } from '../hooks/useKeypress.js'; -import { MessageType, type HistoryItemWithoutId } from '../types.js'; -import type { UseHistoryManagerReturn } from '../hooks/useHistoryManager.js'; -import { DescriptiveRadioButtonSelect } from './shared/DescriptiveRadioButtonSelect.js'; -import type { DescriptiveRadioSelectItem } from './shared/DescriptiveRadioButtonSelect.js'; +import { theme } from '../../semantic-colors.js'; +import { useKeypress } from '../../hooks/useKeypress.js'; +import { MessageType, type HistoryItemWithoutId } from '../../types.js'; +import type { UseHistoryManagerReturn } from '../../hooks/useHistoryManager.js'; +import { DescriptiveRadioButtonSelect } from '../shared/DescriptiveRadioButtonSelect.js'; +import type { DescriptiveRadioSelectItem } from '../shared/DescriptiveRadioButtonSelect.js'; const debugLogger = createDebugLogger('ARENA_STOP_DIALOG'); diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx new file mode 100644 index 000000000..4a95b5a3e --- /dev/null +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -0,0 +1,201 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentViewContext — React context for in-process agent view switching. + * + * Tracks which view is active (main or an agent tab) and the set of registered + * AgentInteractive instances. Consumed by AgentTabBar, AgentChatView, and + * DefaultAppLayout to implement tab-based agent navigation. + * + * Kept separate from UIStateContext to avoid bloating the main state with + * in-process-only concerns and to make the feature self-contained. + */ + +import { + createContext, + useContext, + useCallback, + useMemo, + useState, +} from 'react'; +import type { AgentInteractive } from '@qwen-code/qwen-code-core'; + +// ─── Types ────────────────────────────────────────────────── + +export interface RegisteredAgent { + interactiveAgent: AgentInteractive; + displayName: string; + color: string; +} + +export interface AgentViewState { + /** 'main' or an agentId */ + activeView: string; + /** Registered in-process agents keyed by agentId */ + agents: ReadonlyMap; + /** Whether any agent tab's embedded shell currently has input focus. */ + agentShellFocused: boolean; +} + +export interface AgentViewActions { + switchToMain(): void; + switchToAgent(agentId: string): void; + switchToNext(): void; + switchToPrevious(): void; + registerAgent( + agentId: string, + interactiveAgent: AgentInteractive, + displayName: string, + color: string, + ): void; + unregisterAgent(agentId: string): void; + unregisterAll(): void; + setAgentShellFocused(focused: boolean): void; +} + +// ─── Context ──────────────────────────────────────────────── + +const AgentViewStateContext = createContext(null); +const AgentViewActionsContext = createContext(null); + +// ─── Hook: useAgentViewState ──────────────────────────────── + +export function useAgentViewState(): AgentViewState { + const ctx = useContext(AgentViewStateContext); + if (!ctx) { + throw new Error( + 'useAgentViewState must be used within an AgentViewProvider', + ); + } + return ctx; +} + +// ─── Hook: useAgentViewActions ────────────────────────────── + +export function useAgentViewActions(): AgentViewActions { + const ctx = useContext(AgentViewActionsContext); + if (!ctx) { + throw new Error( + 'useAgentViewActions must be used within an AgentViewProvider', + ); + } + return ctx; +} + +// ─── Provider ─────────────────────────────────────────────── + +interface AgentViewProviderProps { + children: React.ReactNode; +} + +export function AgentViewProvider({ children }: AgentViewProviderProps) { + const [activeView, setActiveView] = useState('main'); + const [agents, setAgents] = useState>( + () => new Map(), + ); + const [agentShellFocused, setAgentShellFocused] = useState(false); + + // ── Navigation ── + + const switchToMain = useCallback(() => { + setActiveView('main'); + }, []); + + const switchToAgent = useCallback( + (agentId: string) => { + if (agents.has(agentId)) { + setActiveView(agentId); + } + }, + [agents], + ); + + const switchToNext = useCallback(() => { + const ids = ['main', ...agents.keys()]; + const currentIndex = ids.indexOf(activeView); + const nextIndex = (currentIndex + 1) % ids.length; + setActiveView(ids[nextIndex]!); + }, [agents, activeView]); + + const switchToPrevious = useCallback(() => { + const ids = ['main', ...agents.keys()]; + const currentIndex = ids.indexOf(activeView); + const prevIndex = (currentIndex - 1 + ids.length) % ids.length; + setActiveView(ids[prevIndex]!); + }, [agents, activeView]); + + // ── Registration ── + + const registerAgent = useCallback( + ( + agentId: string, + interactiveAgent: AgentInteractive, + displayName: string, + color: string, + ) => { + setAgents((prev) => { + const next = new Map(prev); + next.set(agentId, { interactiveAgent, displayName, color }); + return next; + }); + }, + [], + ); + + const unregisterAgent = useCallback((agentId: string) => { + setAgents((prev) => { + if (!prev.has(agentId)) return prev; + const next = new Map(prev); + next.delete(agentId); + return next; + }); + setActiveView((current) => (current === agentId ? 'main' : current)); + }, []); + + const unregisterAll = useCallback(() => { + setAgents(new Map()); + setActiveView('main'); + }, []); + + // ── Memoized values ── + + const state: AgentViewState = useMemo( + () => ({ activeView, agents, agentShellFocused }), + [activeView, agents, agentShellFocused], + ); + + const actions: AgentViewActions = useMemo( + () => ({ + switchToMain, + switchToAgent, + switchToNext, + switchToPrevious, + registerAgent, + unregisterAgent, + unregisterAll, + setAgentShellFocused, + }), + [ + switchToMain, + switchToAgent, + switchToNext, + switchToPrevious, + registerAgent, + unregisterAgent, + unregisterAll, + setAgentShellFocused, + ], + ); + + return ( + + + {children} + + + ); +} diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts new file mode 100644 index 000000000..7cb29d312 --- /dev/null +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -0,0 +1,175 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview useArenaInProcess — bridges ArenaManager in-process events + * to the AgentViewContext for React-based agent tab navigation. + * + * When an arena session starts with an InProcessBackend, this hook: + * 1. Listens to AGENT_START events from ArenaManager + * 2. Retrieves the AgentInteractive from InProcessBackend + * 3. Registers it with AgentViewContext + * 4. Cleans up on SESSION_COMPLETE / SESSION_ERROR / unmount + */ + +import { useEffect, useRef } from 'react'; +import { + ArenaEventType, + DISPLAY_MODE, + type ArenaManager, + type ArenaAgentStartEvent, + type Config, + type InProcessBackend, +} from '@qwen-code/qwen-code-core'; +import { useAgentViewActions } from '../contexts/AgentViewContext.js'; +import { theme } from '../semantic-colors.js'; + +// Palette of colors for agent tabs (cycles for >N agents) +const getAgentColors = () => [ + theme.text.accent, + theme.text.link, + theme.status.success, + theme.status.warning, + theme.text.code, + theme.status.error, +]; + +export function useArenaInProcess(config: Config): void { + const actions = useAgentViewActions(); + const actionsRef = useRef(actions); + actionsRef.current = actions; + + useEffect(() => { + // Poll for arena manager (it's set asynchronously by the /arena start command) + let checkInterval: ReturnType | null = null; + // Track the manager instance (not just a boolean) so we never + // reattach to the same completed manager after SESSION_COMPLETE. + let attachedManager: ArenaManager | null = null; + let detachListeners: (() => void) | null = null; + // Pending agent-registration retry timeouts (cancelled on session end & unmount). + const retryTimeouts = new Set>(); + + const tryAttach = () => { + const manager: ArenaManager | null = config.getArenaManager(); + // Skip if no manager or if it's the same instance we already handled + if (!manager || manager === attachedManager) return; + + const backend = manager.getBackend(); + if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return; + + attachedManager = manager; + if (checkInterval) { + clearInterval(checkInterval); + checkInterval = null; + } + + const inProcessBackend = backend as InProcessBackend; + const emitter = manager.getEventEmitter(); + const agentColors = getAgentColors(); + let colorIndex = 0; + + // Register agents that already started (race condition if events + // fired before we attached) + const existingAgents = manager.getAgentStates(); + for (const agentState of existingAgents) { + const interactive = inProcessBackend.getAgent(agentState.agentId); + if (interactive) { + const displayName = + agentState.model.displayName || agentState.model.modelId; + const color = agentColors[colorIndex % agentColors.length]!; + colorIndex++; + actionsRef.current.registerAgent( + agentState.agentId, + interactive, + displayName, + color, + ); + } + } + + // Listen for new agent starts. + // AGENT_START is emitted by ArenaManager *before* backend.spawnAgent() + // creates the AgentInteractive, so getAgent() may still return + // undefined. We retry with a short poll to bridge the gap. + const MAX_AGENT_RETRIES = 20; + const AGENT_RETRY_INTERVAL_MS = 50; + + const onAgentStart = (event: ArenaAgentStartEvent) => { + const tryRegister = (retriesLeft: number) => { + const interactive = inProcessBackend.getAgent(event.agentId); + if (interactive) { + const displayName = event.model.displayName || event.model.modelId; + const color = agentColors[colorIndex % agentColors.length]!; + colorIndex++; + actionsRef.current.registerAgent( + event.agentId, + interactive, + displayName, + color, + ); + return; + } + if (retriesLeft > 0) { + const timeout = setTimeout(() => { + retryTimeouts.delete(timeout); + tryRegister(retriesLeft - 1); + }, AGENT_RETRY_INTERVAL_MS); + retryTimeouts.add(timeout); + } + }; + tryRegister(MAX_AGENT_RETRIES); + }; + + // On session end, unregister agents, remove listeners from this + // manager, and resume polling for a genuinely new manager instance. + const onSessionEnd = () => { + actionsRef.current.unregisterAll(); + for (const timeout of retryTimeouts) { + clearTimeout(timeout); + } + retryTimeouts.clear(); + // Remove listeners eagerly so they don't fire again + emitter.off(ArenaEventType.AGENT_START, onAgentStart); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + detachListeners = null; + // Keep attachedManager reference — prevents reattach to this + // same (completed) manager on the next poll tick. + // Polling will pick up a new manager once /arena start creates one. + if (!checkInterval) { + checkInterval = setInterval(tryAttach, 500); + } + }; + + emitter.on(ArenaEventType.AGENT_START, onAgentStart); + emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd); + + detachListeners = () => { + emitter.off(ArenaEventType.AGENT_START, onAgentStart); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); + emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + }; + }; + + // Check immediately, then poll every 500ms + tryAttach(); + if (!attachedManager) { + checkInterval = setInterval(tryAttach, 500); + } + + return () => { + if (checkInterval) { + clearInterval(checkInterval); + } + for (const timeout of retryTimeouts) { + clearTimeout(timeout); + } + retryTimeouts.clear(); + detachListeners?.(); + }; + }, [config]); +} diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 93ad311c6..5faa39a2f 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -5,22 +5,54 @@ */ import type React from 'react'; +import { useEffect, useRef } from 'react'; import { Box } from 'ink'; import { MainContent } from '../components/MainContent.js'; import { DialogManager } from '../components/DialogManager.js'; import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; +import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; +import { AgentChatView } from '../components/agent-view/AgentChatView.js'; import { useUIState } from '../contexts/UIStateContext.js'; +import { useUIActions } from '../contexts/UIActionsContext.js'; +import { useAgentViewState } from '../contexts/AgentViewContext.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; export const DefaultAppLayout: React.FC = () => { const uiState = useUIState(); + const { refreshStatic } = useUIActions(); + const { activeView, agents } = useAgentViewState(); const { columns: terminalWidth } = useTerminalSize(); + const hasAgents = agents.size > 0; + + // Clear terminal on view switch so previous view's output + // is removed. refreshStatic clears the terminal and bumps the + // historyRemountKey so MainContent's re-renders all items + // when switching back. + const prevViewRef = useRef(activeView); + useEffect(() => { + if (prevViewRef.current !== activeView) { + prevViewRef.current = activeView; + refreshStatic(); + } + }, [activeView, refreshStatic]); return ( - + {/* Content area: only the active view is rendered. + Conditional rendering avoids Ink's display="none" bug + where Static items remain visible even when the parent is hidden. + Each mount gets a fresh instance that re-renders items + on the cleared terminal. */} + {activeView !== 'main' && agents.has(activeView) ? ( + + ) : ( + + )} + {/* Shared footer — single instance keeps mainControlsRef attached + regardless of which tab is active so height measurement stays + current. */} {uiState.dialogsVisible ? ( @@ -32,9 +64,11 @@ export const DefaultAppLayout: React.FC = () => { ) : ( )} - + + {/* Tab bar: visible whenever in-process agents exist */} + {hasAgents && } ); }; diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index f6b098838..4eec705a2 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -668,6 +668,8 @@ export class ArenaManager { await this.spawnAgentPty(agent); } + this.emitProgress('All agents are now live and working on the task.'); + // For in-process mode, set up event bridges instead of file-based polling. // For PTY mode, start polling agent status files. if (isInProcess) { diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 6ea1de34e..24b898bb4 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -173,11 +173,18 @@ export class InProcessBackend implements Backend { for (const agent of this.agents.values()) { agent.abort(); } - // Wait briefly for loops to settle + // Wait for loops to settle, but cap at 3s so CLI exit isn't blocked + // if an agent's reasoning loop doesn't terminate promptly after abort. + const CLEANUP_TIMEOUT_MS = 3000; const promises = Array.from(this.agents.values()).map((a) => a.waitForCompletion().catch(() => {}), ); - await Promise.allSettled(promises); + let timerId: ReturnType; + const timeout = new Promise((resolve) => { + timerId = setTimeout(resolve, CLEANUP_TIMEOUT_MS); + }); + await Promise.race([Promise.allSettled(promises), timeout]); + clearTimeout(timerId!); // Stop per-agent tool registries so tools like TaskTool can release // listeners registered on shared managers (e.g. SubagentManager). diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 4767c258d..466c77e3d 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -22,6 +22,7 @@ import { type ToolCallRequestInfo } from '../../core/turn.js'; import { CoreToolScheduler, type ToolCall, + type ExecutingToolCall, type WaitingToolCall, } from '../../core/coreToolScheduler.js'; import type { @@ -47,8 +48,10 @@ import type { import { AgentTerminateMode } from './agent-types.js'; import type { AgentRoundEvent, + AgentRoundTextEvent, AgentToolCallEvent, AgentToolResultEvent, + AgentToolOutputUpdateEvent, AgentUsageEvent, AgentHooks, } from './agent-events.js'; @@ -327,6 +330,13 @@ export class AgentCore { let terminateMode: AgentTerminateMode | null = null; while (true) { + // Check abort before starting a new round — prevents unnecessary API + // calls after processFunctionCalls was unblocked by an abort signal. + if (abortController.signal.aborted) { + terminateMode = AgentTerminateMode.CANCELLED; + break; + } + // Check termination conditions. if (options?.maxTurns && turnCounter >= options.maxTurns) { terminateMode = AgentTerminateMode.MAX_TURNS; @@ -375,6 +385,7 @@ export class AgentCore { const functionCalls: FunctionCall[] = []; let roundText = ''; + let roundThoughtText = ''; let lastUsage: GenerateContentResponseUsageMetadata | undefined = undefined; let currentResponseId: string | undefined = undefined; @@ -407,6 +418,7 @@ export class AgentCore { for (const p of parts) { const txt = p.text; const isThought = p.thought ?? false; + if (txt && isThought) roundThoughtText += txt; if (txt && !isThought) roundText += txt; if (txt) this.eventEmitter?.emit(AgentEventType.STREAM_TEXT, { @@ -421,6 +433,16 @@ export class AgentCore { } } + if (roundText || roundThoughtText) { + this.eventEmitter?.emit(AgentEventType.ROUND_TEXT, { + subagentId: this.subagentId, + round: turnCounter, + text: roundText, + thoughtText: roundThoughtText, + timestamp: Date.now(), + } as AgentRoundTextEvent); + } + this.executionStats.rounds = turnCounter; this.stats.setRounds(turnCounter); @@ -449,6 +471,15 @@ export class AgentCore { // No tool calls — treat this as the model's final answer. if (roundText && roundText.trim().length > 0) { finalText = roundText.trim(); + // Emit ROUND_END for the final round so all consumers see it. + // Previously this was skipped, requiring AgentInteractive to + // compensate with an explicit flushStreamBuffers() call. + this.eventEmitter?.emit(AgentEventType.ROUND_END, { + subagentId: this.subagentId, + round: turnCounter, + promptId, + timestamp: Date.now(), + } as AgentRoundEvent); // Clean up before breaking abortController.signal.removeEventListener('abort', onParentAbort); // null terminateMode = normal text completion @@ -525,6 +556,7 @@ export class AgentCore { name: toolName, args: fc.args ?? {}, description: `Tool "${toolName}" not found`, + isOutputMarkdown: false, timestamp: Date.now(), } as AgentToolCallEvent); @@ -564,11 +596,28 @@ export class AgentCore { // Build scheduler const responded = new Set(); let resolveBatch: (() => void) | null = null; + const emittedCallIds = new Set(); + // pidMap: callId → PTY PID, populated by onToolCallsUpdate when a shell + // tool spawns a PTY. Shared with outputUpdateHandler via closure so the + // PID is included in TOOL_OUTPUT_UPDATE events for interactive shell support. + const pidMap = new Map(); const scheduler = new CoreToolScheduler({ config: this.runtimeContext, - outputUpdateHandler: undefined, + outputUpdateHandler: (callId, outputChunk) => { + this.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.subagentId, + round: currentRound, + callId, + outputChunk, + pid: pidMap.get(callId), + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + }, onAllToolCallsComplete: async (completedCalls) => { for (const call of completedCalls) { + if (emittedCallIds.has(call.request.callId)) continue; + emittedCallIds.add(call.request.callId); + const toolName = call.request.name; const duration = call.durationMs ?? 0; const success = call.status === 'success'; @@ -589,11 +638,8 @@ export class AgentCore { success, error: errorMessage, responseParts: call.response.responseParts, - resultDisplay: call.response.resultDisplay - ? typeof call.response.resultDisplay === 'string' - ? call.response.resultDisplay - : JSON.stringify(call.response.resultDisplay) - : undefined, + resultDisplay: call.response.resultDisplay, + outputFile: call.response.outputFile, durationMs: duration, timestamp: Date.now(), } as AgentToolResultEvent); @@ -628,6 +674,27 @@ export class AgentCore { }, onToolCallsUpdate: (calls: ToolCall[]) => { for (const call of calls) { + // Track PTY PIDs so TOOL_OUTPUT_UPDATE events can carry them. + if (call.status === 'executing') { + const pid = (call as ExecutingToolCall).pid; + if (pid !== undefined) { + const isNewPid = !pidMap.has(call.request.callId); + pidMap.set(call.request.callId, pid); + // Emit immediately so the UI can offer interactive shell + // focus (Ctrl+F) before the tool produces its first output. + if (isNewPid) { + this.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.subagentId, + round: currentRound, + callId: call.request.callId, + outputChunk: (call as ExecutingToolCall).liveOutput ?? '', + pid, + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + } + } + } + if (call.status !== 'awaiting_approval') continue; const waiting = call as WaitingToolCall; @@ -681,6 +748,7 @@ export class AgentCore { }; const description = this.getToolDescription(toolName, args); + const isOutputMarkdown = this.getToolIsOutputMarkdown(toolName); this.eventEmitter?.emit(AgentEventType.TOOL_CALL, { subagentId: this.subagentId, round: currentRound, @@ -688,6 +756,7 @@ export class AgentCore { name: toolName, args, description, + isOutputMarkdown, timestamp: Date.now(), } as AgentToolCallEvent); @@ -711,8 +780,52 @@ export class AgentCore { resolveBatch = null; }; }); + + // Auto-resolve on abort so processFunctionCalls doesn't block forever + // when tools are awaiting approval or executing without abort support. + const onAbort = () => { + resolveBatch?.(); + for (const req of requests) { + if (emittedCallIds.has(req.callId)) continue; + emittedCallIds.add(req.callId); + + const errorMessage = 'Tool call cancelled by user abort.'; + this.recordToolCallStats(req.name, false, 0, errorMessage); + + this.eventEmitter?.emit(AgentEventType.TOOL_RESULT, { + subagentId: this.subagentId, + round: currentRound, + callId: req.callId, + name: req.name, + success: false, + error: errorMessage, + responseParts: [ + { + functionResponse: { + id: req.callId, + name: req.name, + response: { error: errorMessage }, + }, + }, + ], + resultDisplay: errorMessage, + durationMs: 0, + timestamp: Date.now(), + } as AgentToolResultEvent); + } + }; + abortController.signal.addEventListener('abort', onAbort, { once: true }); + + // If already aborted before the listener was registered, resolve + // immediately to avoid blocking forever. + if (abortController.signal.aborted) { + onAbort(); + } + await scheduler.schedule(requests, abortController.signal); await batchDone; + + abortController.signal.removeEventListener('abort', onAbort); } // If all tool calls failed, inform the model so it can re-evaluate. @@ -783,6 +896,15 @@ export class AgentCore { } } + private getToolIsOutputMarkdown(toolName: string): boolean { + try { + const toolRegistry = this.runtimeContext.getToolRegistry(); + return toolRegistry.getTool(toolName)?.isOutputMarkdown ?? false; + } catch { + return false; + } + } + /** * Records tool call statistics for both successful and failed tool calls. */ diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index e02d8b692..643608681 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -28,9 +28,11 @@ export type AgentEvent = | 'start' | 'round_start' | 'round_end' + | 'round_text' | 'stream_text' | 'tool_call' | 'tool_result' + | 'tool_output_update' | 'tool_waiting_approval' | 'usage_metadata' | 'finish' @@ -41,9 +43,12 @@ export enum AgentEventType { START = 'start', ROUND_START = 'round_start', ROUND_END = 'round_end', + /** Complete round text, emitted once after streaming before tool calls. */ + ROUND_TEXT = 'round_text', STREAM_TEXT = 'stream_text', TOOL_CALL = 'tool_call', TOOL_RESULT = 'tool_result', + TOOL_OUTPUT_UPDATE = 'tool_output_update', TOOL_WAITING_APPROVAL = 'tool_waiting_approval', USAGE_METADATA = 'usage_metadata', FINISH = 'finish', @@ -68,6 +73,14 @@ export interface AgentRoundEvent { timestamp: number; } +export interface AgentRoundTextEvent { + subagentId: string; + round: number; + text: string; + thoughtText: string; + timestamp: number; +} + export interface AgentStreamTextEvent { subagentId: string; round: number; @@ -92,6 +105,8 @@ export interface AgentToolCallEvent { name: string; args: Record; description: string; + /** Whether the tool's output should be rendered as markdown. */ + isOutputMarkdown?: boolean; timestamp: number; } @@ -104,10 +119,23 @@ export interface AgentToolResultEvent { error?: string; responseParts?: Part[]; resultDisplay?: ToolResultDisplay; + /** Path to the temp file where oversized output was saved. */ + outputFile?: string; durationMs?: number; timestamp: number; } +export interface AgentToolOutputUpdateEvent { + subagentId: string; + round: number; + callId: string; + /** Latest accumulated output for this tool call (replaces previous). */ + outputChunk: ToolResultDisplay; + /** PTY process PID — present when the tool runs in an interactive shell. */ + pid?: number; + timestamp: number; +} + export interface AgentApprovalRequestEvent { subagentId: string; round: number; @@ -160,9 +188,11 @@ export interface AgentEventMap { [AgentEventType.START]: AgentStartEvent; [AgentEventType.ROUND_START]: AgentRoundEvent; [AgentEventType.ROUND_END]: AgentRoundEvent; + [AgentEventType.ROUND_TEXT]: AgentRoundTextEvent; [AgentEventType.STREAM_TEXT]: AgentStreamTextEvent; [AgentEventType.TOOL_CALL]: AgentToolCallEvent; [AgentEventType.TOOL_RESULT]: AgentToolResultEvent; + [AgentEventType.TOOL_OUTPUT_UPDATE]: AgentToolOutputUpdateEvent; [AgentEventType.TOOL_WAITING_APPROVAL]: AgentApprovalRequestEvent; [AgentEventType.USAGE_METADATA]: AgentUsageEvent; [AgentEventType.FINISH]: AgentFinishEvent; diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 633043ba7..9c3162d22 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -184,13 +184,13 @@ describe('AgentInteractive', () => { expect(callCount).toBe(1); }); - // Error recorded as assistant message with error metadata + // Error recorded as info message with error level const messages = agent.getMessages(); const errorMsg = messages.find( (m) => - m.role === 'assistant' && - m.content.includes('Error: Model error') && - m.metadata?.['error'] === true, + m.role === 'info' && + m.content.includes('Model error') && + m.metadata?.['level'] === 'error', ); expect(errorMsg).toBeDefined(); @@ -286,21 +286,22 @@ describe('AgentInteractive', () => { expect(agent.getCore()).toBe(core); }); - // ─── Stream Buffer & Message Recording ───────────────────── + // ─── Message Recording ───────────────────────────────────── - it('should record assistant text from stream events (not result.text)', async () => { + it('should record assistant text from ROUND_TEXT events', async () => { const { core, emitter } = createMockCore(); (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, - text: 'Hello from stream', + text: 'Hello from round', + thoughtText: '', timestamp: Date.now(), }); return Promise.resolve({ - text: 'Hello from stream', + text: 'Hello from round', terminateMode: null, turnsUsed: 1, }); @@ -318,24 +319,24 @@ describe('AgentInteractive', () => { const assistantMsgs = agent .getMessages() .filter((m) => m.role === 'assistant' && !m.thought); - // Exactly one — from stream flush, not duplicated by result.text expect(assistantMsgs).toHaveLength(1); - expect(assistantMsgs[0]?.content).toBe('Hello from stream'); + expect(assistantMsgs[0]?.content).toBe('Hello from round'); await agent.shutdown(); }); - it('should not carry stream buffer across messages', async () => { + it('should not cross-contaminate text across messages', async () => { const { core, emitter } = createMockCore(); let runCount = 0; (core.runReasoningLoop as ReturnType).mockImplementation( () => { runCount++; - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: `response-${runCount}`, + thoughtText: '', timestamp: Date.now(), }); return Promise.resolve({ @@ -360,7 +361,6 @@ describe('AgentInteractive', () => { expect(runCount).toBe(2); }); - // No message containing both responses (no cross-contamination) const messages = agent.getMessages(); const assistantMessages = messages.filter( (m) => m.role === 'assistant' && !m.thought, @@ -379,18 +379,11 @@ describe('AgentInteractive', () => { (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'Let me think...', - thought: true, - timestamp: Date.now(), - }); - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'Here is the answer', - thought: false, + thoughtText: 'Let me think...', timestamp: Date.now(), }); return Promise.resolve({ @@ -428,10 +421,11 @@ describe('AgentInteractive', () => { (core.runReasoningLoop as ReturnType).mockImplementation( () => { - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'I will read the file', + thoughtText: '', timestamp: Date.now(), }); emitter.emit(AgentEventType.TOOL_CALL, { @@ -451,12 +445,6 @@ describe('AgentInteractive', () => { success: true, timestamp: Date.now(), }); - emitter.emit(AgentEventType.ROUND_END, { - subagentId: 'test', - round: 1, - promptId: 'p1', - timestamp: Date.now(), - }); return Promise.resolve({ text: '', terminateMode: null, @@ -487,16 +475,16 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); - it('should flush text before tool_call to preserve temporal ordering', async () => { + it('should place text before tool_call to preserve temporal ordering', async () => { const { core, emitter } = createMockCore(); (core.runReasoningLoop as ReturnType).mockImplementation( () => { - // Text arrives before tool call in the stream - emitter.emit(AgentEventType.STREAM_TEXT, { + emitter.emit(AgentEventType.ROUND_TEXT, { subagentId: 'test', round: 1, text: 'Let me check', + thoughtText: '', timestamp: Date.now(), }); emitter.emit(AgentEventType.TOOL_CALL, { @@ -516,12 +504,6 @@ describe('AgentInteractive', () => { success: true, timestamp: Date.now(), }); - emitter.emit(AgentEventType.ROUND_END, { - subagentId: 'test', - round: 1, - promptId: 'p1', - timestamp: Date.now(), - }); return Promise.resolve({ text: '', terminateMode: null, @@ -539,10 +521,8 @@ describe('AgentInteractive', () => { }); const messages = agent.getMessages(); - // Filter to just the non-user messages for ordering check const nonUser = messages.filter((m) => m.role !== 'user'); - // Text should come before tool_call const textIdx = nonUser.findIndex( (m) => m.role === 'assistant' && m.content === 'Let me check', ); @@ -552,59 +532,6 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); - it('should return in-progress stream state during streaming', async () => { - const { core, emitter } = createMockCore(); - - let capturedInProgress: ReturnType< - typeof AgentInteractive.prototype.getInProgressStream - > = null; - - (core.runReasoningLoop as ReturnType).mockImplementation( - () => { - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'thinking...', - thought: true, - timestamp: Date.now(), - }); - emitter.emit(AgentEventType.STREAM_TEXT, { - subagentId: 'test', - round: 1, - text: 'visible text', - timestamp: Date.now(), - }); - // Capture in-progress state before the loop returns - capturedInProgress = agent.getInProgressStream(); - return Promise.resolve({ - text: 'visible text', - terminateMode: null, - turnsUsed: 1, - }); - }, - ); - - const config = createConfig({ initialTask: 'test' }); - const agent = new AgentInteractive(config, core); - - await agent.start(context); - await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); - }); - - // During streaming, in-progress state was available - expect(capturedInProgress).toEqual({ - text: 'visible text', - thinking: 'thinking...', - round: 1, - }); - - // After flush, in-progress state is null - expect(agent.getInProgressStream()).toBeNull(); - - await agent.shutdown(); - }); - // ─── Events ──────────────────────────────────────────────── it('should emit status_change events', async () => { diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 66fa4faa5..4970077e0 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -7,30 +7,28 @@ /** * @fileoverview AgentInteractive — persistent interactive agent. * - * Composes AgentCore with on-demand message processing to provide an agent - * that processes user inputs sequentially and settles between batches. - * Used by InProcessBackend for Arena's in-process mode. - * - * AgentInteractive is the **sole consumer** of AgentCore events. It builds - * conversation state (messages + in-progress stream) that the UI reads. - * The UI never directly subscribes to AgentCore events for data — it reads - * from AgentInteractive and uses notifications to know when to re-render. - * - * Lifecycle: start() → (running ↔ completed/failed)* → shutdown()/abort() + * Composes AgentCore with on-demand message processing. Builds conversation + * state (messages, pending approvals, live outputs) that the UI reads. */ import { createDebugLogger } from '../../utils/debugLogger.js'; import { type AgentEventEmitter, AgentEventType } from './agent-events.js'; import type { - AgentStreamTextEvent, + AgentRoundTextEvent, AgentToolCallEvent, AgentToolResultEvent, + AgentToolOutputUpdateEvent, + AgentApprovalRequestEvent, } from './agent-events.js'; import type { AgentStatsSummary } from './agent-statistics.js'; import type { AgentCore } from './agent-core.js'; import type { ContextState } from './agent-headless.js'; import type { GeminiChat } from '../../core/geminiChat.js'; import type { FunctionDeclaration } from '@google/genai'; +import type { + ToolCallConfirmationDetails, + ToolResultDisplay, +} from '../../tools/tools.js'; import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; import { AgentTerminateMode, @@ -38,7 +36,6 @@ import { isTerminalStatus, type AgentInteractiveConfig, type AgentMessage, - type InProgressStreamState, } from './agent-types.js'; const debugLogger = createDebugLogger('AGENT_INTERACTIVE'); @@ -68,13 +65,23 @@ export class AgentInteractive { private toolsList: FunctionDeclaration[] = []; private processing = false; - // Stream accumulator — separate buffers for thought and non-thought text. - // Flushed to messages on ROUND_END (intermediate rounds), before TOOL_CALL - // events (to preserve temporal ordering), and after runReasoningLoop returns - // (final round, since ROUND_END doesn't fire for it). - private thoughtBuffer = ''; - private textBuffer = ''; - private streamRound = -1; + // Pending tool approval requests. Keyed by callId. + // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when + // the user responds. The UI reads this to show confirmation dialogs. + private readonly pendingApprovals = new Map< + string, + ToolCallConfirmationDetails + >(); + + // Live streaming output for currently-executing tools. Keyed by callId. + // Populated by TOOL_OUTPUT_UPDATE (replaces previous), cleared on TOOL_RESULT. + // The UI reads this via getLiveOutputs() to show real-time stdout. + private readonly liveOutputs = new Map(); + + // PTY PIDs for currently-executing shell tools. Keyed by callId. + // Populated by TOOL_OUTPUT_UPDATE when pid is present, cleared on TOOL_RESULT. + // The UI reads this via getShellPids() to enable interactive shell input. + private readonly shellPids = new Map(); constructor(config: AgentInteractiveConfig, core: AgentCore) { this.config = config; @@ -169,29 +176,24 @@ export class AgentInteractive { }, ); - // Finalize any unflushed stream content from the last round. - // ROUND_END doesn't fire for the final text-producing round - // (AgentCore breaks before emitting it), so we flush here. - this.flushStreamBuffers(); - - // Surface non-normal termination so Arena (and other consumers) - // can distinguish limit-triggered stops from successful completions. + // Surface non-normal termination as a visible info message and as + // lastRoundError so Arena can distinguish limit stops from successes. if ( result.terminateMode && result.terminateMode !== AgentTerminateMode.GOAL ) { + const msg = terminateModeMessage(result.terminateMode); + if (msg) { + this.addMessage('info', msg.text, { metadata: { level: msg.level } }); + } this.lastRoundError = `Terminated: ${result.terminateMode}`; } } catch (err) { // Agent survives round errors — log and settle status in runLoop. - // Flush any partial stream content accumulated before the error. - this.flushStreamBuffers(); const errorMessage = err instanceof Error ? err.message : String(err); this.lastRoundError = errorMessage; debugLogger.error('AgentInteractive round error:', err); - this.addMessage('assistant', `Error: ${errorMessage}`, { - metadata: { error: true }, - }); + this.addMessage('info', errorMessage, { metadata: { level: 'error' } }); } finally { this.masterAbortController.signal.removeEventListener( 'abort', @@ -205,9 +207,14 @@ export class AgentInteractive { /** * Cancel only the current reasoning round. + * Adds a visible "cancelled" info message and clears pending approvals. */ cancelCurrentRound(): void { this.roundAbortController?.abort(); + this.pendingApprovals.clear(); + this.addMessage('info', 'Agent round cancelled.', { + metadata: { level: 'warning' }, + }); } /** @@ -232,6 +239,7 @@ export class AgentInteractive { abort(): void { this.masterAbortController.abort(); this.queue.drain(); + this.pendingApprovals.clear(); } // ─── Message Queue ───────────────────────────────────────── @@ -252,20 +260,6 @@ export class AgentInteractive { return this.messages; } - /** - * Returns the in-progress streaming state for UI mid-switch handoff. - * The UI reads this when attaching to an agent that's currently streaming - * to display content accumulated before the UI subscribed. - */ - getInProgressStream(): InProgressStreamState | null { - if (!this.textBuffer && !this.thoughtBuffer) return null; - return { - text: this.textBuffer, - thinking: this.thoughtBuffer, - round: this.streamRound, - }; - } - getStatus(): AgentStatus { return this.status; } @@ -290,6 +284,34 @@ export class AgentInteractive { return this.core.getEventEmitter(); } + /** + * Returns tool calls currently awaiting user approval. + * Keyed by callId → full ToolCallConfirmationDetails (with onConfirm). + * The UI reads this to render confirmation dialogs inside ToolGroupMessage. + */ + getPendingApprovals(): ReadonlyMap { + return this.pendingApprovals; + } + + /** + * Returns live output for currently-executing tools. + * Keyed by callId → latest ToolResultDisplay (replaces on each update). + * Entries are cleared when TOOL_RESULT arrives for the call. + */ + getLiveOutputs(): ReadonlyMap { + return this.liveOutputs; + } + + /** + * Returns PTY PIDs for currently-executing interactive shell tools. + * Keyed by callId → PID. Populated from TOOL_OUTPUT_UPDATE when pid is + * present; cleared when TOOL_RESULT arrives. The UI uses this to enable + * interactive shell input via HistoryItemDisplay's activeShellPtyId prop. + */ + getShellPids(): ReadonlyMap { + return this.shellPids; + } + /** * Wait for the run loop to finish (used by InProcessBackend). */ @@ -343,67 +365,47 @@ export class AgentInteractive { this.messages.push(message); } - /** - * Flush accumulated stream buffers to finalized messages. - * - * Thought text → assistant message with thought=true. - * Regular text → assistant message. - * Called on ROUND_END, before TOOL_CALL (ordering), and after - * runReasoningLoop returns (final round). - */ - private flushStreamBuffers(): void { - if (this.thoughtBuffer) { - this.addMessage('assistant', this.thoughtBuffer, { thought: true }); - this.thoughtBuffer = ''; - } - if (this.textBuffer) { - this.addMessage('assistant', this.textBuffer); - this.textBuffer = ''; - } - this.streamRound = -1; - } - - /** - * Set up listeners on AgentCore's event emitter. - * - * AgentInteractive is the sole consumer of these events. It builds - * the conversation state (messages + in-progress stream) that the - * UI reads. Listeners use canonical event types from agent-events.ts. - */ private setupEventListeners(): void { const emitter = this.core.eventEmitter; if (!emitter) return; - emitter.on(AgentEventType.STREAM_TEXT, (event: AgentStreamTextEvent) => { - // Round boundary: flush previous round's buffers before starting a new one - if (event.round !== this.streamRound && this.streamRound !== -1) { - this.flushStreamBuffers(); + emitter.on(AgentEventType.ROUND_TEXT, (event: AgentRoundTextEvent) => { + if (event.thoughtText) { + this.addMessage('assistant', event.thoughtText, { thought: true }); } - this.streamRound = event.round; - - if (event.thought) { - this.thoughtBuffer += event.text; - } else { - this.textBuffer += event.text; + if (event.text) { + this.addMessage('assistant', event.text); } }); emitter.on(AgentEventType.TOOL_CALL, (event: AgentToolCallEvent) => { - // Flush text buffers first — in the stream, text arrives before - // tool calls, so flushing preserves temporal ordering in messages. - this.flushStreamBuffers(); - this.addMessage('tool_call', `Tool call: ${event.name}`, { metadata: { callId: event.callId, toolName: event.name, args: event.args, + description: event.description, + renderOutputAsMarkdown: event.isOutputMarkdown, round: event.round, }, }); }); + emitter.on( + AgentEventType.TOOL_OUTPUT_UPDATE, + (event: AgentToolOutputUpdateEvent) => { + this.liveOutputs.set(event.callId, event.outputChunk); + if (event.pid !== undefined) { + this.shellPids.set(event.callId, event.pid); + } + }, + ); + emitter.on(AgentEventType.TOOL_RESULT, (event: AgentToolResultEvent) => { + this.liveOutputs.delete(event.callId); + this.shellPids.delete(event.callId); + this.pendingApprovals.delete(event.callId); + const statusText = event.success ? 'succeeded' : 'failed'; const summary = event.error ? `Tool ${event.name} ${statusText}: ${event.error}` @@ -413,13 +415,67 @@ export class AgentInteractive { callId: event.callId, toolName: event.name, success: event.success, + resultDisplay: event.resultDisplay, + outputFile: event.outputFile, round: event.round, }, }); }); - emitter.on(AgentEventType.ROUND_END, () => { - this.flushStreamBuffers(); - }); + emitter.on( + AgentEventType.TOOL_WAITING_APPROVAL, + (event: AgentApprovalRequestEvent) => { + const fullDetails = { + ...event.confirmationDetails, + onConfirm: async ( + outcome: Parameters[0], + payload?: Parameters[1], + ) => { + this.pendingApprovals.delete(event.callId); + // Nudge the UI to re-render so the tool transitions visually + // from Confirming → Executing without waiting for the first + // real TOOL_OUTPUT_UPDATE from the tool's execution. + this.core.eventEmitter?.emit(AgentEventType.TOOL_OUTPUT_UPDATE, { + subagentId: this.core.subagentId, + round: event.round, + callId: event.callId, + outputChunk: '', + timestamp: Date.now(), + } as AgentToolOutputUpdateEvent); + await event.respond(outcome, payload); + }, + } as ToolCallConfirmationDetails; + + this.pendingApprovals.set(event.callId, fullDetails); + }, + ); + } +} + +/** + * Map a non-GOAL terminate mode to a visible status message for the UI, + * or return null to suppress the message entirely. + * + * CANCELLED is suppressed here because cancelCurrentRound() already emits + * its own warning. SHUTDOWN is suppressed as a normal lifecycle end. + */ +function terminateModeMessage( + mode: AgentTerminateMode, +): { text: string; level: 'info' | 'warning' | 'error' } | null { + switch (mode) { + case AgentTerminateMode.MAX_TURNS: + return { + text: 'Agent stopped: maximum turns reached.', + level: 'warning', + }; + case AgentTerminateMode.TIMEOUT: + return { text: 'Agent stopped: time limit reached.', level: 'warning' }; + case AgentTerminateMode.ERROR: + return { text: 'Agent stopped due to an error.', level: 'error' }; + case AgentTerminateMode.CANCELLED: + case AgentTerminateMode.SHUTDOWN: + return null; + default: + return null; } } diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index df3e5fc9a..2684406c1 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -147,7 +147,7 @@ export interface AgentInteractiveConfig { */ export interface AgentMessage { /** Discriminator for the message kind. */ - role: 'user' | 'assistant' | 'tool_call' | 'tool_result'; + role: 'user' | 'assistant' | 'tool_call' | 'tool_result' | 'info'; /** The text content of the message. */ content: string; /** When the message was created (ms since epoch). */ @@ -157,7 +157,15 @@ export interface AgentMessage { * Mirrors AgentStreamTextEvent.thought. Only meaningful when role is 'assistant'. */ thought?: boolean; - /** Optional metadata (e.g. tool call info, round number). */ + /** + * Optional metadata. + * + * For role='info': metadata.level?: 'info' | 'warning' | 'success' | 'error' + * Controls which status message component is rendered. Defaults to 'info'. + * For role='tool_call': callId, toolName, args, description, renderOutputAsMarkdown, round + * For role='tool_result': callId, toolName, success, resultDisplay, outputFile, round + * For role='assistant' with error: error=true + */ metadata?: Record; } From e12e0533a37c589cb5ccb86fbb4ec3212206dc3f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 23 Feb 2026 22:44:45 +0800 Subject: [PATCH 07/82] refactor(core)!: Generalize GitWorktreeService from Arena-specific to reusable service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rename ArenaWorktreeConfig → WorktreeSetupConfig, setupArenaWorktrees → setupWorktrees, cleanupArenaSession → cleanupSession, etc. Change default storage path from ~/.qwen/arena/ to ~/.qwen/worktrees/ and branch prefix from arena/ to worktrees/. Add branchPrefix and metadata options for flexibility. Remove auto-repo-init behavior; fail fast instead. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/arenaCommand.ts | 4 +- .../ui/components/arena/ArenaStatusDialog.tsx | 6 - .../src/agents/arena/ArenaManager.test.ts | 42 +-- .../core/src/agents/arena/ArenaManager.ts | 51 ++-- .../src/services/gitWorktreeService.test.ts | 105 ++++---- .../core/src/services/gitWorktreeService.ts | 240 ++++++++++-------- 6 files changed, 241 insertions(+), 207 deletions(-) diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index cf47f4feb..fde381e53 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -277,7 +277,7 @@ function executeArenaCommand( }; const handleSessionError = (event: ArenaSessionErrorEvent) => { - addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${event.error}`); + addAndRecordArenaMessage(MessageType.ERROR, `${event.error}`); }; const handleSessionComplete = (event: ArenaSessionCompleteEvent) => { @@ -340,7 +340,7 @@ function executeArenaCommand( }, (error) => { const message = error instanceof Error ? error.message : String(error); - addAndRecordArenaMessage(MessageType.ERROR, `Arena failed: ${message}`); + addAndRecordArenaMessage(MessageType.ERROR, `${message}`); debugLogger.error('Arena session failed:', error); // Clear the stored manager so subsequent /arena start calls diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index cceed019d..09325a603 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -156,12 +156,6 @@ export function ArenaStatusDialog({ · {sessionLabel.text} - {isInProcess && ( - <> - · - In-Process - - )} diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 3d175be6b..405af5e5c 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -12,8 +12,8 @@ import { ArenaManager } from './ArenaManager.js'; import { ArenaEventType } from './arena-events.js'; import { ArenaSessionStatus, ARENA_MAX_AGENTS } from './types.js'; -const hoistedMockSetupArenaWorktrees = vi.hoisted(() => vi.fn()); -const hoistedMockCleanupArenaSession = vi.hoisted(() => vi.fn()); +const hoistedMockSetupWorktrees = vi.hoisted(() => vi.fn()); +const hoistedMockCleanupSession = vi.hoisted(() => vi.fn()); const hoistedMockGetWorktreeDiff = vi.hoisted(() => vi.fn()); const hoistedMockApplyWorktreeChanges = vi.hoisted(() => vi.fn()); const hoistedMockDetectBackend = vi.hoisted(() => vi.fn()); @@ -30,15 +30,17 @@ vi.mock('../index.js', async (importOriginal) => { // The class mock includes static methods used by ArenaManager. vi.mock('../../services/gitWorktreeService.js', () => { const MockClass = vi.fn().mockImplementation(() => ({ - setupArenaWorktrees: hoistedMockSetupArenaWorktrees, - cleanupArenaSession: hoistedMockCleanupArenaSession, + checkGitAvailable: vi.fn().mockResolvedValue({ available: true }), + isGitRepository: vi.fn().mockResolvedValue(true), + setupWorktrees: hoistedMockSetupWorktrees, + cleanupSession: hoistedMockCleanupSession, getWorktreeDiff: hoistedMockGetWorktreeDiff, applyWorktreeChanges: hoistedMockApplyWorktreeChanges, })); // Static methods called by ArenaManager - (MockClass as unknown as Record)['getArenaBaseDir'] = () => + (MockClass as unknown as Record)['getBaseDir'] = () => path.join(os.tmpdir(), 'arena-mock'); - (MockClass as unknown as Record)['getArenaSessionDir'] = ( + (MockClass as unknown as Record)['getSessionDir'] = ( sessionId: string, ) => path.join(os.tmpdir(), 'arena-mock', sessionId); (MockClass as unknown as Record)['getWorktreesDir'] = ( @@ -74,38 +76,37 @@ describe('ArenaManager', () => { mockBackend = createMockBackend(); hoistedMockDetectBackend.mockResolvedValue({ backend: mockBackend }); - hoistedMockSetupArenaWorktrees.mockImplementation( + hoistedMockSetupWorktrees.mockImplementation( async ({ - arenaSessionId, + sessionId, sourceRepoPath, worktreeNames, }: { - arenaSessionId: string; + sessionId: string; sourceRepoPath: string; worktreeNames: string[]; }) => { const worktrees = worktreeNames.map((name) => ({ - id: `${arenaSessionId}/${name}`, + id: `${sessionId}/${name}`, name, - path: path.join(sourceRepoPath, `.arena-${arenaSessionId}`, name), - branch: `arena/${arenaSessionId}/${name}`, + path: path.join(sourceRepoPath, `.arena-${sessionId}`, name), + branch: `arena/${sessionId}/${name}`, isActive: true, createdAt: Date.now(), })); return { success: true, - arenaSessionId, + sessionId, worktrees, worktreesByName: Object.fromEntries( worktrees.map((worktree) => [worktree.name, worktree]), ), errors: [], - wasRepoInitialized: false, }; }, ); - hoistedMockCleanupArenaSession.mockResolvedValue({ + hoistedMockCleanupSession.mockResolvedValue({ success: true, removedWorktrees: [], removedBranches: [], @@ -306,7 +307,7 @@ describe('ArenaManager', () => { const warningUpdate = updates.find((u) => u.type === 'warning'); expect(warningUpdate).toBeDefined(); expect(warningUpdate?.message).toContain('fallback to tmux backend'); - expect(warningUpdate?.sessionId).toMatch(/^arena-/); + expect(warningUpdate?.sessionId).toBe('test-session'); }); it('should emit SESSION_ERROR and mark FAILED when backend init fails', async () => { @@ -338,9 +339,11 @@ describe('ArenaManager', () => { timeoutSeconds: 30, }); - // Wait until the backend has spawned at least one agent. + // Wait until the backend has spawned all agents. + // (Agents are spawned sequentially; cancelling between spawns would + // cause spawnAgentPty to overwrite the CANCELLED status back to RUNNING.) await waitForCondition( - () => mockBackend.spawnAgent.mock.calls.length > 0, + () => mockBackend.spawnAgent.mock.calls.length >= 2, ); await manager.cancel(); @@ -361,8 +364,9 @@ describe('ArenaManager', () => { await manager.cleanup(); expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); - expect(hoistedMockCleanupArenaSession).toHaveBeenCalledWith( + expect(hoistedMockCleanupSession).toHaveBeenCalledWith( sessionIdBeforeCleanup, + 'arena', ); expect(manager.getBackend()).toBeNull(); expect(manager.getSessionId()).toBeUndefined(); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 4eec705a2..73e8b0f53 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -8,6 +8,7 @@ import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { GitWorktreeService } from '../../services/gitWorktreeService.js'; +import { Storage } from '../../config/storage.js'; import type { Config } from '../../config/config.js'; import { getCoreSystemPrompt } from '../../core/prompts.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; @@ -41,15 +42,6 @@ const debugLogger = createDebugLogger('ARENA'); const ARENA_POLL_INTERVAL_MS = 500; -/** - * Generates a unique Arena session ID. - */ -function generateArenaSessionId(): string { - const timestamp = Date.now().toString(36); - const random = crypto.randomBytes(4).toString('hex'); - return `arena-${timestamp}-${random}`; -} - /** * ArenaManager orchestrates multi-model competitive execution. * @@ -64,6 +56,7 @@ export class ArenaManager { private readonly config: Config; private readonly eventEmitter: ArenaEventEmitter; private readonly worktreeService: GitWorktreeService; + private readonly arenaBaseDir: string; private readonly callbacks: ArenaCallbacks; private backend: Backend | null = null; private cachedResult: ArenaSessionResult | null = null; @@ -72,7 +65,7 @@ export class ArenaManager { private sessionStatus: ArenaSessionStatus = ArenaSessionStatus.INITIALIZING; private agents: Map = new Map(); private arenaConfig: ArenaConfig | undefined; - private wasRepoInitialized = false; + private startedAt: number | undefined; private masterAbortController: AbortController | undefined; private terminalCols: number; @@ -87,9 +80,13 @@ export class ArenaManager { this.callbacks = callbacks; this.eventEmitter = new ArenaEventEmitter(); const arenaSettings = config.getAgentsSettings().arena; + // Use the user-configured base dir, or default to ~/.qwen/arena. + this.arenaBaseDir = + arenaSettings?.worktreeBaseDir ?? + path.join(Storage.getGlobalQwenDir(), 'arena'); this.worktreeService = new GitWorktreeService( config.getWorkingDir(), - arenaSettings?.worktreeBaseDir, + this.arenaBaseDir, ); this.terminalCols = process.stdout.columns || 120; this.terminalRows = process.stdout.rows || 40; @@ -262,7 +259,7 @@ export class ArenaManager { this.terminalRows = options.rows; } - this.sessionId = generateArenaSessionId(); + this.sessionId = this.config.getSessionId(); this.startedAt = Date.now(); this.sessionStatus = ArenaSessionStatus.INITIALIZING; this.masterAbortController = new AbortController(); @@ -287,6 +284,20 @@ export class ArenaManager { `Models: ${options.models.map((m) => m.modelId).join(', ')}`, ); + // Fail fast on missing git or non-repo directory before any UI output + // so the user gets a clean, single error message without the + // "Arena started…" banner. + const gitCheck = await this.worktreeService.checkGitAvailable(); + if (!gitCheck.available) { + throw new Error(gitCheck.error!); + } + const isRepo = await this.worktreeService.isGitRepository(); + if (!isRepo) { + throw new Error( + 'Failed to start arena: current directory is not a git repository.', + ); + } + // Emit session start event this.eventEmitter.emit(ArenaEventType.SESSION_START, { sessionId: this.sessionId, @@ -419,7 +430,7 @@ export class ArenaManager { } // Clean up worktrees - await this.worktreeService.cleanupArenaSession(this.sessionId); + await this.worktreeService.cleanupSession(this.sessionId, 'arena'); this.agents.clear(); this.cachedResult = null; @@ -589,14 +600,14 @@ export class ArenaManager { (m) => m.displayName || m.modelId, ); - const result = await this.worktreeService.setupArenaWorktrees({ - arenaSessionId: this.arenaConfig.sessionId, + const result = await this.worktreeService.setupWorktrees({ + sessionId: this.arenaConfig.sessionId, sourceRepoPath: this.arenaConfig.sourceRepoPath, worktreeNames, + branchPrefix: 'arena', + metadata: { arenaSessionId: this.arenaConfig.sessionId }, }); - this.wasRepoInitialized = result.wasRepoInitialized; - if (!result.success) { const errorMessages = result.errors .map((e) => `${e.name}: ${e.error}`) @@ -985,9 +996,9 @@ export class ArenaManager { if (!this.arenaConfig) { throw new Error('Arena config not initialized'); } - return GitWorktreeService.getArenaSessionDir( + return GitWorktreeService.getSessionDir( this.arenaConfig.sessionId, - this.config.getAgentsSettings().arena?.worktreeBaseDir, + this.arenaBaseDir, ); } @@ -1335,7 +1346,7 @@ export class ArenaManager { startedAt: this.startedAt!, endedAt, totalDurationMs: endedAt - this.startedAt!, - wasRepoInitialized: this.wasRepoInitialized, + wasRepoInitialized: false, }; } } diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts index b5b4e3de2..f3cd33ed5 100644 --- a/packages/core/src/services/gitWorktreeService.test.ts +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -106,7 +106,7 @@ describe('GitWorktreeService', () => { await expect(service.checkGitAvailable()).resolves.toEqual({ available: false, - error: 'Git is not installed. Please install Git to use Arena feature.', + error: 'Git is not installed. Please install Git.', }); }); @@ -140,23 +140,25 @@ describe('GitWorktreeService', () => { const result = await service.createWorktree('s1', 'Model A'); expect(result.success).toBe(true); - expect(result.worktree?.branch).toBe('arena/s1/model-a'); - expect(result.worktree?.path).toBe('/mock-qwen/arena/s1/worktrees/model-a'); + expect(result.worktree?.branch).toBe('worktrees/s1/model-a'); + expect(result.worktree?.path).toBe( + '/mock-qwen/worktrees/s1/worktrees/model-a', + ); expect(hoistedMockRaw).toHaveBeenCalledWith([ 'worktree', 'add', '-b', - 'arena/s1/model-a', - '/mock-qwen/arena/s1/worktrees/model-a', + 'worktrees/s1/model-a', + '/mock-qwen/worktrees/s1/worktrees/model-a', 'main', ]); }); - it('setupArenaWorktrees should fail early for colliding sanitized names', async () => { + it('setupWorktrees should fail early for colliding sanitized names', async () => { const service = new GitWorktreeService('/repo'); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['Model A', 'model_a'], }); @@ -167,12 +169,12 @@ describe('GitWorktreeService', () => { expect(isCommandAvailable).not.toHaveBeenCalled(); }); - it('setupArenaWorktrees should return system error when git is unavailable', async () => { + it('setupWorktrees should return system error when git is unavailable', async () => { (isCommandAvailable as Mock).mockReturnValue({ available: false }); const service = new GitWorktreeService('/repo'); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['model-a'], }); @@ -181,12 +183,12 @@ describe('GitWorktreeService', () => { expect(result.errors).toEqual([ { name: 'system', - error: 'Git is not installed. Please install Git to use Arena feature.', + error: 'Git is not installed. Please install Git.', }, ]); }); - it('setupArenaWorktrees should cleanup session after partial creation failure', async () => { + it('setupWorktrees should cleanup session after partial creation failure', async () => { const service = new GitWorktreeService('/repo'); vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); vi.spyOn(service, 'createWorktree') @@ -196,7 +198,7 @@ describe('GitWorktreeService', () => { id: 's1/a', name: 'a', path: '/w/a', - branch: 'arena/s1/a', + branch: 'worktrees/s1/a', isActive: true, createdAt: 1, }, @@ -205,33 +207,31 @@ describe('GitWorktreeService', () => { success: false, error: 'boom', }); - const cleanupSpy = vi - .spyOn(service, 'cleanupArenaSession') - .mockResolvedValue({ - success: true, - removedWorktrees: [], - removedBranches: [], - errors: [], - }); + const cleanupSpy = vi.spyOn(service, 'cleanupSession').mockResolvedValue({ + success: true, + removedWorktrees: [], + removedBranches: [], + errors: [], + }); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['a', 'b'], }); expect(result.success).toBe(false); expect(result.errors).toContainEqual({ name: 'b', error: 'boom' }); - expect(cleanupSpy).toHaveBeenCalledWith('s1'); + expect(cleanupSpy).toHaveBeenCalledWith('s1', 'worktrees'); }); - it('listArenaWorktrees should return empty array when session dir does not exist', async () => { + it('listWorktrees should return empty array when session dir does not exist', async () => { const err = new Error('missing') as NodeJS.ErrnoException; err.code = 'ENOENT'; hoistedMockFsReaddir.mockRejectedValue(err); const service = new GitWorktreeService('/repo'); - await expect(service.listArenaWorktrees('missing')).resolves.toEqual([]); + await expect(service.listWorktrees('missing')).resolves.toEqual([]); }); it('removeWorktree should fallback to fs.rm + worktree prune when git remove fails', async () => { @@ -250,28 +250,31 @@ describe('GitWorktreeService', () => { expect(hoistedMockRaw).toHaveBeenNthCalledWith(2, ['worktree', 'prune']); }); - it('cleanupArenaSession should remove arena-prefixed branches only', async () => { + it('cleanupSession should remove prefixed branches only', async () => { const service = new GitWorktreeService('/repo'); - vi.spyOn(service, 'listArenaWorktrees').mockResolvedValue([]); + vi.spyOn(service, 'listWorktrees').mockResolvedValue([]); hoistedMockBranch.mockImplementation((args?: string[]) => { if (args?.[0] === '-a') { return Promise.resolve({ branches: { main: {}, - 'arena/s1/a': {}, - 'arena/s1/b': {}, + 'worktrees/s1/a': {}, + 'worktrees/s1/b': {}, }, }); } return Promise.resolve({ branches: {} }); }); - const result = await service.cleanupArenaSession('s1'); + const result = await service.cleanupSession('s1'); expect(result.success).toBe(true); - expect(result.removedBranches).toEqual(['arena/s1/a', 'arena/s1/b']); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'arena/s1/a']); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'arena/s1/b']); + expect(result.removedBranches).toEqual([ + 'worktrees/s1/a', + 'worktrees/s1/b', + ]); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/a']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/b']); expect(hoistedMockRaw).toHaveBeenCalledWith(['worktree', 'prune']); }); @@ -323,7 +326,7 @@ describe('GitWorktreeService', () => { ]); expect(hoistedMockFsWriteFile).toHaveBeenCalled(); expect(hoistedMockFsRm).toHaveBeenCalledWith( - expect.stringContaining('.arena-apply-'), + expect.stringContaining('.worktree-apply-'), { force: true }, ); }); @@ -358,7 +361,7 @@ describe('GitWorktreeService', () => { expect(result.success).toBe(false); expect(result.error).toContain('apply failed'); expect(hoistedMockFsRm).toHaveBeenCalledWith( - expect.stringContaining('.arena-apply-'), + expect.stringContaining('.worktree-apply-'), { force: true }, ); }); @@ -378,14 +381,14 @@ describe('GitWorktreeService', () => { return { id: `${sessionId}/${name}`, name, - path: `/mock-qwen/arena/${sessionId}/worktrees/${name}`, - branch: `arena/${sessionId}/${name}`, + path: `/mock-qwen/worktrees/${sessionId}/worktrees/${name}`, + branch: `worktrees/${sessionId}/${name}`, isActive: true, createdAt: 1, }; } - it('setupArenaWorktrees should apply dirty state snapshot to each worktree', async () => { + it('setupWorktrees should apply dirty state snapshot to each worktree', async () => { hoistedMockStash.mockResolvedValue('snapshot-sha\n'); const service = new GitWorktreeService('/repo'); vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); @@ -399,8 +402,8 @@ describe('GitWorktreeService', () => { worktree: makeWorktreeInfo('b', 's1'), }); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['a', 'b'], }); @@ -422,7 +425,7 @@ describe('GitWorktreeService', () => { ]); }); - it('setupArenaWorktrees should skip stash apply when working tree is clean', async () => { + it('setupWorktrees should skip stash apply when working tree is clean', async () => { hoistedMockStash.mockResolvedValue('\n'); const service = new GitWorktreeService('/repo'); vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); @@ -431,8 +434,8 @@ describe('GitWorktreeService', () => { worktree: makeWorktreeInfo('a', 's1'), }); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['a'], }); @@ -447,7 +450,7 @@ describe('GitWorktreeService', () => { expect(stashApplyCalls).toHaveLength(0); }); - it('setupArenaWorktrees should still succeed when stash apply fails', async () => { + it('setupWorktrees should still succeed when stash apply fails', async () => { hoistedMockStash.mockResolvedValue('snapshot-sha\n'); hoistedMockRaw.mockRejectedValue(new Error('stash apply conflict')); const service = new GitWorktreeService('/repo'); @@ -457,8 +460,8 @@ describe('GitWorktreeService', () => { worktree: makeWorktreeInfo('a', 's1'), }); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['a'], }); @@ -468,7 +471,7 @@ describe('GitWorktreeService', () => { expect(result.errors).toHaveLength(0); }); - it('setupArenaWorktrees should still succeed when stash create fails', async () => { + it('setupWorktrees should still succeed when stash create fails', async () => { hoistedMockStash.mockRejectedValue(new Error('stash create failed')); const service = new GitWorktreeService('/repo'); vi.spyOn(service, 'isGitRepository').mockResolvedValue(true); @@ -477,8 +480,8 @@ describe('GitWorktreeService', () => { worktree: makeWorktreeInfo('a', 's1'), }); - const result = await service.setupArenaWorktrees({ - arenaSessionId: 's1', + const result = await service.setupWorktrees({ + sessionId: 's1', sourceRepoPath: '/repo', worktreeNames: ['a'], }); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index e1a359873..5683fcdf0 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -11,15 +11,21 @@ import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; import { isNodeError } from '../utils/errors.js'; -import type { ArenaConfigFile } from '../agents/arena/types.js'; /** - * Commit message used for the baseline snapshot in arena worktrees. + * Commit message used for the baseline snapshot in worktrees. * After overlaying the user's dirty state (tracked changes + untracked files), * a commit with this message is created so that later diffs only capture the * agent's changes — not the pre-existing local edits. */ -export const ARENA_BASELINE_MESSAGE = 'arena: baseline (dirty state overlay)'; +export const BASELINE_COMMIT_MESSAGE = 'baseline (dirty state overlay)'; + +/** + * Default directory and branch-prefix name used for worktrees. + * Changing this value affects the on-disk layout (`~/.qwen//`) + * **and** the default git branch prefix (`//…`). + */ +export const WORKTREES_DIR = 'worktrees'; export interface WorktreeInfo { /** Unique identifier for this worktree */ @@ -36,15 +42,19 @@ export interface WorktreeInfo { createdAt: number; } -export interface ArenaWorktreeConfig { - /** Arena session identifier */ - arenaSessionId: string; +export interface WorktreeSetupConfig { + /** Session identifier */ + sessionId: string; /** Source repository path (project root) */ sourceRepoPath: string; /** Names/identifiers for each worktree to create */ worktreeNames: string[]; /** Base branch to create worktrees from (defaults to current branch) */ baseBranch?: string; + /** Branch prefix for worktree branches (default: 'worktrees') */ + branchPrefix?: string; + /** Extra metadata to persist alongside the session config */ + metadata?: Record; } export interface CreateWorktreeResult { @@ -53,76 +63,79 @@ export interface CreateWorktreeResult { error?: string; } -export interface ArenaWorktreeSetupResult { +export interface WorktreeSetupResult { success: boolean; - arenaSessionId: string; + sessionId: string; worktrees: WorktreeInfo[]; worktreesByName: Record; errors: Array<{ name: string; error: string }>; - wasRepoInitialized: boolean; } /** - * Service for managing git worktrees for Arena multi-agent execution. + * Minimal session config file written to disk. + * Callers can extend via the `metadata` field in WorktreeSetupConfig. + */ +interface SessionConfigFile { + sessionId: string; + sourceRepoPath: string; + worktreeNames: string[]; + baseBranch?: string; + createdAt: number; + [key: string]: unknown; +} + +/** + * Service for managing git worktrees. * * Git worktrees allow multiple working directories to share a single repository, - * enabling isolated environments for each Arena agent without copying the entire repo. + * enabling isolated environments without copying the entire repo. */ export class GitWorktreeService { private sourceRepoPath: string; private git: SimpleGit; - private readonly customArenaBaseDir?: string; + private readonly customBaseDir?: string; - constructor(sourceRepoPath: string, customArenaBaseDir?: string) { + constructor(sourceRepoPath: string, customBaseDir?: string) { this.sourceRepoPath = path.resolve(sourceRepoPath); this.git = simpleGit(this.sourceRepoPath); - this.customArenaBaseDir = customArenaBaseDir; + this.customBaseDir = customBaseDir; } /** - * Gets the directory where Arena worktrees are stored. + * Gets the directory where worktrees are stored. * @param customDir - Optional custom base directory override */ - static getArenaBaseDir(customDir?: string): string { + static getBaseDir(customDir?: string): string { if (customDir) { return path.resolve(customDir); } - return path.join(Storage.getGlobalQwenDir(), 'arena'); + return path.join(Storage.getGlobalQwenDir(), WORKTREES_DIR); } /** - * Gets the directory for a specific Arena session. + * Gets the directory for a specific session. * @param customBaseDir - Optional custom base directory override */ - static getArenaSessionDir( - arenaSessionId: string, - customBaseDir?: string, - ): string { + static getSessionDir(sessionId: string, customBaseDir?: string): string { + return path.join(GitWorktreeService.getBaseDir(customBaseDir), sessionId); + } + + /** + * Gets the worktrees directory for a specific session. + * @param customBaseDir - Optional custom base directory override + */ + static getWorktreesDir(sessionId: string, customBaseDir?: string): string { return path.join( - GitWorktreeService.getArenaBaseDir(customBaseDir), - arenaSessionId, + GitWorktreeService.getSessionDir(sessionId, customBaseDir), + WORKTREES_DIR, ); } /** - * Gets the worktrees directory for a specific Arena session. - * @param customBaseDir - Optional custom base directory override + * Instance-level base dir, using the custom dir if provided at construction. */ - static getWorktreesDir( - arenaSessionId: string, - customBaseDir?: string, - ): string { - return path.join( - GitWorktreeService.getArenaSessionDir(arenaSessionId, customBaseDir), - 'worktrees', - ); - } - - /** - * Instance-level arena base dir, using the custom dir if provided at construction. - */ - getArenaBaseDirForInstance(): string { - return GitWorktreeService.getArenaBaseDir(this.customArenaBaseDir); + getBaseDirForInstance(): string { + return GitWorktreeService.getBaseDir(this.customBaseDir); } /** @@ -133,7 +146,7 @@ export class GitWorktreeService { if (!available) { return { available: false, - error: 'Git is not installed. Please install Git to use Arena feature.', + error: 'Git is not installed. Please install Git.', }; } return { available: true }; @@ -177,7 +190,7 @@ export class GitWorktreeService { // Create initial commit so we can create worktrees await this.git.add('.'); - await this.git.commit('Initial commit for Arena', { + await this.git.commit('Initial commit', { '--allow-empty': null, }); @@ -207,24 +220,25 @@ export class GitWorktreeService { } /** - * Creates a single worktree for an Arena agent. + * Creates a single worktree. */ async createWorktree( - arenaSessionId: string, + sessionId: string, name: string, baseBranch?: string, + branchPrefix: string = WORKTREES_DIR, ): Promise { try { const worktreesDir = GitWorktreeService.getWorktreesDir( - arenaSessionId, - this.customArenaBaseDir, + sessionId, + this.customBaseDir, ); await fs.mkdir(worktreesDir, { recursive: true }); // Sanitize name for use as branch and directory name const sanitizedName = this.sanitizeName(name); const worktreePath = path.join(worktreesDir, sanitizedName); - const branchName = `arena/${arenaSessionId}/${sanitizedName}`; + const branchName = `${branchPrefix}/${sessionId}/${sanitizedName}`; // Check if worktree already exists const exists = await this.pathExists(worktreePath); @@ -249,7 +263,7 @@ export class GitWorktreeService { ]); const worktree: WorktreeInfo = { - id: `${arenaSessionId}/${sanitizedName}`, + id: `${sessionId}/${sanitizedName}`, name, path: worktreePath, branch: branchName, @@ -267,19 +281,18 @@ export class GitWorktreeService { } /** - * Sets up all worktrees for an Arena session. - * This is the main entry point for Arena worktree creation. + * Sets up all worktrees for a session. + * This is the main entry point for worktree creation. */ - async setupArenaWorktrees( - config: ArenaWorktreeConfig, - ): Promise { - const result: ArenaWorktreeSetupResult = { + async setupWorktrees( + config: WorktreeSetupConfig, + ): Promise { + const result: WorktreeSetupResult = { success: false, - arenaSessionId: config.arenaSessionId, + sessionId: config.sessionId, worktrees: [], worktreesByName: {}, errors: [], - wasRepoInitialized: false, }; // Validate worktree names early (before touching git) @@ -317,31 +330,31 @@ export class GitWorktreeService { // Ensure source is a git repository const isRepo = await this.isGitRepository(); if (!isRepo) { - const initResult = await this.initializeRepository(); - if (initResult.error) { - result.errors.push({ name: 'initialization', error: initResult.error }); - return result; - } - result.wasRepoInitialized = initResult.initialized; + result.errors.push({ + name: 'repository', + error: 'Source path is not a git repository.', + }); + return result; } - // Create arena session directory - const sessionDir = GitWorktreeService.getArenaSessionDir( - config.arenaSessionId, - this.customArenaBaseDir, + // Create session directory + const sessionDir = GitWorktreeService.getSessionDir( + config.sessionId, + this.customBaseDir, ); await fs.mkdir(sessionDir, { recursive: true }); - // Save arena config for later reference - const arenaConfigPath = path.join(sessionDir, 'config.json'); - const configFile: ArenaConfigFile = { - arenaSessionId: config.arenaSessionId, + // Save session config for later reference + const configPath = path.join(sessionDir, 'config.json'); + const configFile: SessionConfigFile = { + sessionId: config.sessionId, sourceRepoPath: config.sourceRepoPath, worktreeNames: config.worktreeNames, baseBranch: config.baseBranch, createdAt: Date.now(), + ...config.metadata, }; - await fs.writeFile(arenaConfigPath, JSON.stringify(configFile, null, 2)); + await fs.writeFile(configPath, JSON.stringify(configFile, null, 2)); // Capture the current dirty state (tracked: staged + unstaged changes) // without modifying the source working tree or index. @@ -368,12 +381,15 @@ export class GitWorktreeService { // Non-fatal: proceed without untracked files } - // Create worktrees for each agent + const branchPrefix = config.branchPrefix ?? WORKTREES_DIR; + + // Create worktrees for each entry for (const name of config.worktreeNames) { const createResult = await this.createWorktree( - config.arenaSessionId, + config.sessionId, name, config.baseBranch, + branchPrefix, ); if (createResult.success && createResult.worktree) { @@ -390,7 +406,7 @@ export class GitWorktreeService { // If any worktree failed, clean up all created resources and fail if (result.errors.length > 0) { try { - await this.cleanupArenaSession(config.arenaSessionId); + await this.cleanupSession(config.sessionId, branchPrefix); } catch (error) { result.errors.push({ name: 'cleanup', @@ -436,7 +452,7 @@ export class GitWorktreeService { // only the agent's changes, excluding the pre-existing dirty state. try { await wtGit.add(['--all']); - await wtGit.commit(ARENA_BASELINE_MESSAGE, { + await wtGit.commit(BASELINE_COMMIT_MESSAGE, { '--allow-empty': null, '--no-verify': null, }); @@ -450,12 +466,15 @@ export class GitWorktreeService { } /** - * Lists all worktrees for an Arena session. + * Lists all worktrees for a session. */ - async listArenaWorktrees(arenaSessionId: string): Promise { + async listWorktrees( + sessionId: string, + branchPrefix: string = WORKTREES_DIR, + ): Promise { const worktreesDir = GitWorktreeService.getWorktreesDir( - arenaSessionId, - this.customArenaBaseDir, + sessionId, + this.customBaseDir, ); try { @@ -465,7 +484,7 @@ export class GitWorktreeService { for (const entry of entries) { if (entry.isDirectory()) { const worktreePath = path.join(worktreesDir, entry.name); - const branchName = `arena/${arenaSessionId}/${entry.name}`; + const branchName = `${branchPrefix}/${sessionId}/${entry.name}`; // Try to get stats for creation time let createdAt = Date.now(); @@ -477,7 +496,7 @@ export class GitWorktreeService { } worktrees.push({ - id: `${arenaSessionId}/${entry.name}`, + id: `${sessionId}/${entry.name}`, name: entry.name, path: worktreePath, branch: branchName, @@ -523,9 +542,12 @@ export class GitWorktreeService { } /** - * Cleans up all worktrees and branches for an Arena session. + * Cleans up all worktrees and branches for a session. */ - async cleanupArenaSession(arenaSessionId: string): Promise<{ + async cleanupSession( + sessionId: string, + branchPrefix: string = WORKTREES_DIR, + ): Promise<{ success: boolean; removedWorktrees: string[]; removedBranches: string[]; @@ -538,7 +560,7 @@ export class GitWorktreeService { errors: [] as string[], }; - const worktrees = await this.listArenaWorktrees(arenaSessionId); + const worktrees = await this.listWorktrees(sessionId, branchPrefix); // Remove all worktrees for (const worktree of worktrees) { @@ -553,10 +575,10 @@ export class GitWorktreeService { } } - // Remove arena session directory - const sessionDir = GitWorktreeService.getArenaSessionDir( - arenaSessionId, - this.customArenaBaseDir, + // Remove session directory + const sessionDir = GitWorktreeService.getSessionDir( + sessionId, + this.customBaseDir, ); try { await fs.rm(sessionDir, { recursive: true, force: true }); @@ -566,12 +588,12 @@ export class GitWorktreeService { ); } - // Clean up arena branches - const branchPrefix = `arena/${arenaSessionId}/`; + // Clean up branches + const prefix = `${branchPrefix}/${sessionId}/`; try { const branches = await this.git.branch(['-a']); for (const branchName of Object.keys(branches.branches)) { - if (branchName.startsWith(branchPrefix)) { + if (branchName.startsWith(prefix)) { try { await this.git.branch(['-D', branchName]); result.removedBranches.push(branchName); @@ -596,7 +618,7 @@ export class GitWorktreeService { /** * Gets the diff between a worktree and its baseline state. - * Prefers the arena baseline commit (which includes the dirty state overlay) + * Prefers the baseline commit (which includes the dirty state overlay) * so the diff only shows the agent's changes. Falls back to the base branch * when no baseline commit exists. */ @@ -623,7 +645,7 @@ export class GitWorktreeService { /** * Applies raw changes from a worktree back to the target working directory. * - * Diffs from the arena baseline commit (which already includes the user's + * Diffs from the baseline commit (which already includes the user's * dirty state) so the patch only contains the agent's new changes. * Falls back to merge-base when no baseline commit exists. */ @@ -642,7 +664,7 @@ export class GitWorktreeService { const hasBaseline = !!base; if (!base) { - // Fallback: diff from merge-base (legacy / non-arena worktrees) + // Fallback: diff from merge-base const targetHead = (await targetGit.revparse(['HEAD'])).trim(); base = ( await worktreeGit.raw(['merge-base', 'HEAD', targetHead]) @@ -658,8 +680,8 @@ export class GitWorktreeService { } const patchFile = path.join( - this.getArenaBaseDirForInstance(), - `.arena-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`, + this.getBaseDirForInstance(), + `.worktree-apply-${Date.now()}-${Math.random().toString(16).slice(2)}.patch`, ); await fs.mkdir(path.dirname(patchFile), { recursive: true }); await fs.writeFile(patchFile, patch, 'utf-8'); @@ -688,35 +710,35 @@ export class GitWorktreeService { } /** - * Lists all Arena sessions. + * Lists all sessions stored in the worktree base directory. */ - static async listArenaSessions(customBaseDir?: string): Promise< + static async listSessions(customBaseDir?: string): Promise< Array<{ - arenaSessionId: string; + sessionId: string; createdAt: number; sourceRepoPath: string; worktreeCount: number; }> > { - const arenaDir = GitWorktreeService.getArenaBaseDir(customBaseDir); + const baseDir = GitWorktreeService.getBaseDir(customBaseDir); const sessions: Array<{ - arenaSessionId: string; + sessionId: string; createdAt: number; sourceRepoPath: string; worktreeCount: number; }> = []; try { - const entries = await fs.readdir(arenaDir, { withFileTypes: true }); + const entries = await fs.readdir(baseDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { - const configPath = path.join(arenaDir, entry.name, 'config.json'); + const configPath = path.join(baseDir, entry.name, 'config.json'); try { const configContent = await fs.readFile(configPath, 'utf-8'); - const config = JSON.parse(configContent) as ArenaConfigFile; + const config = JSON.parse(configContent) as SessionConfigFile; - const worktreesDir = path.join(arenaDir, entry.name, 'worktrees'); + const worktreesDir = path.join(baseDir, entry.name, WORKTREES_DIR); let worktreeCount = 0; try { const worktreeEntries = await fs.readdir(worktreesDir); @@ -726,7 +748,7 @@ export class GitWorktreeService { } sessions.push({ - arenaSessionId: entry.name, + sessionId: entry.name, createdAt: config.createdAt || Date.now(), sourceRepoPath: config.sourceRepoPath || '', worktreeCount, @@ -744,7 +766,7 @@ export class GitWorktreeService { } /** - * Finds the arena baseline commit in a worktree, if one exists. + * Finds the baseline commit in a worktree, if one exists. * Returns the commit SHA, or null if not found. */ private async resolveBaseline( @@ -755,7 +777,7 @@ export class GitWorktreeService { await worktreeGit.raw([ 'log', '--grep', - ARENA_BASELINE_MESSAGE, + BASELINE_COMMIT_MESSAGE, '--format=%H', '-1', ]) From 0c5deee2630139d6dc50955569f615edb6425deb Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 2 Mar 2026 23:12:33 +0800 Subject: [PATCH 08/82] feat(arena): Add comprehensive telemetry for arena sessions - Add arena_session_started, arena_agent_completed, arena_session_ended events - Implement ArenaManager telemetry hooks with lifecycle tracking and metrics - Update AgentStatistics to support API-provided totalTokenCount and remove estimatedCost - Pass agent session IDs for telemetry correlation in PTY mode This enables detailed observability into arena performance, agent completion rates, and model comparison outcomes. Co-authored-by: Qwen-Coder --- packages/cli/src/config/config.ts | 21 ++- .../src/agents/arena/ArenaManager.test.ts | 3 + .../core/src/agents/arena/ArenaManager.ts | 154 +++++++++++++++++- packages/core/src/agents/arena/types.ts | 4 + .../core/src/agents/runtime/agent-core.ts | 13 +- .../agents/runtime/agent-statistics.test.ts | 20 ++- .../src/agents/runtime/agent-statistics.ts | 13 +- packages/core/src/telemetry/constants.ts | 5 + packages/core/src/telemetry/index.ts | 17 ++ packages/core/src/telemetry/loggers.ts | 92 +++++++++++ packages/core/src/telemetry/metrics.ts | 129 +++++++++++++++ .../src/telemetry/qwen-logger/qwen-logger.ts | 58 +++++++ packages/core/src/telemetry/types.ts | 123 +++++++++++++- packages/core/src/tools/task.test.ts | 1 - 14 files changed, 621 insertions(+), 32 deletions(-) diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index 9d690cc36..ef4a6a88e 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -51,16 +51,16 @@ import { appEvents } from '../utils/events.js'; import { mcpCommand } from '../commands/mcp.js'; // UUID v4 regex pattern for validation -const UUID_REGEX = - /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; +const SESSION_ID_REGEX = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}(-agent-[a-zA-Z0-9_.-]+)?$/i; /** - * Validates if a string is a valid UUID format - * @param value - The string to validate - * @returns True if the string is a valid UUID, false otherwise + * Validates if a string is a valid session ID format. + * Accepts a standard UUID, or a UUID followed by `-agent-{suffix}` + * (used by Arena to give each agent a deterministic session ID). */ -function isValidUUID(value: string): boolean { - return UUID_REGEX.test(value); +function isValidSessionId(value: string): boolean { + return SESSION_ID_REGEX.test(value); } import { isWorkspaceTrusted } from './trustedFolders.js'; @@ -549,10 +549,13 @@ export async function parseArguments(): Promise { if (argv['sessionId'] && (argv['continue'] || argv['resume'])) { return 'Cannot use --session-id with --continue or --resume. Use --session-id to start a new session with a specific ID, or use --continue/--resume to resume an existing session.'; } - if (argv['sessionId'] && !isValidUUID(argv['sessionId'] as string)) { + if ( + argv['sessionId'] && + !isValidSessionId(argv['sessionId'] as string) + ) { return `Invalid --session-id: "${argv['sessionId']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; } - if (argv['resume'] && !isValidUUID(argv['resume'] as string)) { + if (argv['resume'] && !isValidSessionId(argv['resume'] as string)) { return `Invalid --resume: "${argv['resume']}". Must be a valid UUID (e.g., "123e4567-e89b-12d3-a456-426614174000").`; } return true; diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 405af5e5c..b98b5841b 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -61,6 +61,9 @@ const createMockConfig = (workingDir: string) => ({ getTool: () => undefined, }), getAgentsSettings: () => ({}), + getUsageStatisticsEnabled: () => false, + getTelemetryEnabled: () => false, + getTelemetryLogPromptsEnabled: () => false, }); describe('ArenaManager', () => { diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 73e8b0f53..24d9a0562 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -37,6 +37,15 @@ import { safeAgentId, } from './types.js'; import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; +import { + logArenaSessionStarted, + logArenaAgentCompleted, + logArenaSessionEnded, + makeArenaSessionStartedEvent, + makeArenaAgentCompletedEvent, + makeArenaSessionEndedEvent, +} from '../../telemetry/index.js'; +import type { ArenaSessionEndedStatus } from '../../telemetry/index.js'; const debugLogger = createDebugLogger('ARENA'); @@ -74,6 +83,8 @@ export class ArenaManager { private lifecyclePromise: Promise | null = null; /** Cleanup functions for in-process event bridge listeners. */ private eventBridgeCleanups: Array<() => void> = []; + /** Guard to prevent double-emitting the session-ended telemetry event. */ + private sessionEndedLogged = false; constructor(config: Config, callbacks: ArenaCallbacks = {}) { this.config = config; @@ -306,6 +317,16 @@ export class ArenaManager { timestamp: Date.now(), }); + // Log arena session start telemetry + logArenaSessionStarted( + this.config, + makeArenaSessionStartedEvent({ + arena_session_id: this.sessionId, + model_ids: options.models.map((m) => m.modelId), + task_length: options.task.length, + }), + ); + try { // Detect and initialize the backend. // Priority: explicit option > agents.displayMode setting > auto-detect @@ -319,7 +340,9 @@ export class ArenaManager { // If cancelled during backend init, bail out early if (this.masterAbortController?.signal.aborted) { this.sessionStatus = ArenaSessionStatus.CANCELLED; - return this.collectResults(); + const result = await this.collectResults(); + this.emitSessionEnded('cancelled'); + return result; } // Set up worktrees for all agents @@ -329,7 +352,9 @@ export class ArenaManager { // If cancelled during worktree setup, bail out early if (this.masterAbortController?.signal.aborted) { this.sessionStatus = ArenaSessionStatus.CANCELLED; - return this.collectResults(); + const result = await this.collectResults(); + this.emitSessionEnded('cancelled'); + return result; } // Start all agents in parallel via PTY @@ -355,6 +380,11 @@ export class ArenaManager { this.callbacks.onArenaComplete?.(result); + // NOTE: session-ended telemetry is NOT emitted here. + // The session is "done running" but the user hasn't picked a winner + // or discarded yet. The ended event fires from applyAgentResult() + // (status: 'selected') or cleanup/cleanupRuntime (status: 'discarded'). + return result; } catch (error) { this.sessionStatus = ArenaSessionStatus.FAILED; @@ -369,6 +399,9 @@ export class ArenaManager { timestamp: Date.now(), }); + // Log arena session failed telemetry + this.emitSessionEnded('failed'); + this.callbacks.onArenaError?.( error instanceof Error ? error : new Error(errorMessage), ); @@ -396,16 +429,33 @@ export class ArenaManager { // Force stop all PTY processes (sends Ctrl-C) this.backend?.stopAll(); + // Final stats sync so telemetry reflects the latest counters. + // For PTY agents: read each agent's status file one last time. + // For in-process agents: pull counters from the interactive object. + await this.pollAgentStatuses().catch(() => {}); + for (const agent of this.agents.values()) { + if (!isTerminalStatus(agent.status)) { + agent.syncStats?.(); + } + } + // Update agent statuses — skip agents already in a terminal state // (COMPLETED, FAILED, CANCELLED) so we don't overwrite a successful result. for (const agent of this.agents.values()) { if (!isTerminalStatus(agent.status)) { agent.abortController.abort(); + agent.stats.durationMs = Date.now() - agent.startedAt; this.updateAgentStatus(agent.agentId, AgentStatus.CANCELLED); } } this.sessionStatus = ArenaSessionStatus.CANCELLED; + + // NOTE: session-ended telemetry is NOT emitted here. + // start() emits 'cancelled' when it unwinds through its early-cancel + // paths. If cancel() is called after start() has already returned + // (all agents done, user viewing results), the ended event fires + // from cleanup() / cleanupRuntime() instead. } /** @@ -418,6 +468,15 @@ export class ArenaManager { debugLogger.info(`Cleaning up Arena session: ${this.sessionId}`); + // If no session-ended event was emitted yet, emit before tearing down. + // Use 'cancelled' if the session was explicitly stopped, 'discarded' if + // the user simply left without picking a winner. + this.emitSessionEnded( + this.sessionStatus === ArenaSessionStatus.CANCELLED + ? 'cancelled' + : 'discarded', + ); + // Stop polling in case cleanup is called without cancel this.stopPolling(); @@ -437,6 +496,7 @@ export class ArenaManager { this.sessionId = undefined; this.arenaConfig = undefined; this.backend = null; + this.sessionEndedLogged = false; } /** @@ -452,6 +512,13 @@ export class ArenaManager { `Cleaning up Arena runtime (preserving artifacts): ${this.sessionId}`, ); + // If no session-ended event was emitted yet, emit before tearing down. + this.emitSessionEnded( + this.sessionStatus === ArenaSessionStatus.CANCELLED + ? 'cancelled' + : 'discarded', + ); + this.stopPolling(); // Remove in-process event bridge listeners @@ -466,6 +533,7 @@ export class ArenaManager { this.sessionId = undefined; this.arenaConfig = undefined; this.backend = null; + this.sessionEndedLogged = false; } /** @@ -486,7 +554,15 @@ export class ArenaManager { }; } - return this.worktreeService.applyWorktreeChanges(agent.worktree.path); + const applyResult = await this.worktreeService.applyWorktreeChanges( + agent.worktree.path, + ); + + if (applyResult.success) { + this.emitSessionEnded('selected', agent.model.modelId); + } + + return applyResult; } /** @@ -501,6 +577,46 @@ export class ArenaManager { return this.worktreeService.getWorktreeDiff(agent.worktree.path); } + // ─── Private: Telemetry ─────────────────────────────────────── + + /** + * Emit the `arena_session_ended` telemetry event exactly once. + * + * Called from: + * - start() early-cancel paths → 'cancelled' + * - start() catch block → 'failed' + * - applyAgentResult() on success → 'selected' (with winner) + * - cleanup() / cleanupRuntime() → 'discarded' (user left without picking) + */ + private emitSessionEnded( + status: ArenaSessionEndedStatus, + winnerModelId?: string, + ): void { + if (this.sessionEndedLogged) return; + this.sessionEndedLogged = true; + + const agents = Array.from(this.agents.values()); + logArenaSessionEnded( + this.config, + makeArenaSessionEndedEvent({ + arena_session_id: this.sessionId ?? '', + status, + duration_ms: this.startedAt ? Date.now() - this.startedAt : 0, + display_backend: this.backend?.type, + agent_count: agents.length, + completed_agents: agents.filter( + (a) => a.status === AgentStatus.COMPLETED, + ).length, + failed_agents: agents.filter((a) => a.status === AgentStatus.FAILED) + .length, + cancelled_agents: agents.filter( + (a) => a.status === AgentStatus.CANCELLED, + ).length, + winner_model_id: winnerModelId, + }), + ); + } + // ─── Private: Progress ───────────────────────────────────────── /** @@ -635,6 +751,7 @@ export class ArenaManager { status: AgentStatus.INITIALIZING, worktree, abortController: new AbortController(), + agentSessionId: `${this.sessionId}#${agentId}`, stats: { rounds: 0, totalTokens: 0, @@ -855,6 +972,10 @@ export class ArenaManager { args.push('--approval-mode', this.arenaConfig.approvalMode); } + // Pass the agent's session ID so the child CLI uses it for telemetry + // correlation instead of generating a random UUID. + args.push('--session-id', agent.agentSessionId); + // Construct env vars for the agent const arenaSessionDir = this.getArenaSessionDir(); const env: Record = { @@ -968,6 +1089,31 @@ export class ArenaManager { timestamp: Date.now(), }); + // Log arena agent completed telemetry + const agentTelemetryStatus = + newStatus === AgentStatus.COMPLETED + ? ('completed' as const) + : newStatus === AgentStatus.FAILED + ? ('failed' as const) + : ('cancelled' as const); + logArenaAgentCompleted( + this.config, + makeArenaAgentCompletedEvent({ + arena_session_id: this.sessionId ?? '', + agent_session_id: agent.agentSessionId, + agent_model_id: agent.model.modelId, + status: agentTelemetryStatus, + duration_ms: agent.stats.durationMs, + rounds: agent.stats.rounds, + total_tokens: agent.stats.totalTokens, + input_tokens: agent.stats.inputTokens, + output_tokens: agent.stats.outputTokens, + tool_calls: agent.stats.toolCalls, + successful_tool_calls: agent.stats.successfulToolCalls, + failed_tool_calls: agent.stats.failedToolCalls, + }), + ); + this.callbacks.onAgentComplete?.(result); } } @@ -1092,6 +1238,8 @@ export class ArenaManager { }); }; + agent.syncStats = syncStats; + const applyStatus = (incoming: AgentStatus) => { const resolved = this.resolveTransition(agent.status, incoming); if (!resolved) return; diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index 22a002056..b99059cbd 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -262,4 +262,8 @@ export interface ArenaAgentState { executionPromise?: Promise; /** Error if failed */ error?: string; + /** Unique session ID for this agent (for telemetry correlation) */ + agentSessionId: string; + /** Flush latest counters into `stats` (set by in-process event bridge) */ + syncStats?: () => void; } diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 9dfab6e4a..74d7bf1b6 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -109,7 +109,6 @@ export interface ExecutionStats { inputTokens?: number; outputTokens?: number; totalTokens?: number; - estimatedCost?: number; } /** @@ -150,7 +149,6 @@ export class AgentCore { inputTokens: 0, outputTokens: 0, totalTokens: 0, - estimatedCost: 0, }; private toolUsage = new Map< string, @@ -997,6 +995,7 @@ Important Rules: const outTok = Number(usage.candidatesTokenCount || 0); const thoughtTok = Number(usage.thoughtsTokenCount || 0); const cachedTok = Number(usage.cachedContentTokenCount || 0); + const totalTok = Number(usage.totalTokenCount || 0); if ( isFinite(inTok) || isFinite(outTok) || @@ -1008,6 +1007,7 @@ Important Rules: isFinite(outTok) ? outTok : 0, isFinite(thoughtTok) ? thoughtTok : 0, isFinite(cachedTok) ? cachedTok : 0, + isFinite(totalTok) ? totalTok : 0, ); // Mirror legacy fields for compatibility this.executionStats.inputTokens = @@ -1016,13 +1016,8 @@ Important Rules: (this.executionStats.outputTokens || 0) + (isFinite(outTok) ? outTok : 0); this.executionStats.totalTokens = - (this.executionStats.inputTokens || 0) + - (this.executionStats.outputTokens || 0) + - (isFinite(thoughtTok) ? thoughtTok : 0) + - (isFinite(cachedTok) ? cachedTok : 0); - this.executionStats.estimatedCost = - (this.executionStats.inputTokens || 0) * 3e-5 + - (this.executionStats.outputTokens || 0) * 6e-5; + (this.executionStats.totalTokens || 0) + + (isFinite(totalTok) ? totalTok : 0); } this.eventEmitter?.emit(AgentEventType.USAGE_METADATA, { subagentId: this.subagentId, diff --git a/packages/core/src/agents/runtime/agent-statistics.test.ts b/packages/core/src/agents/runtime/agent-statistics.test.ts index 5da21c17d..ec9f6e990 100644 --- a/packages/core/src/agents/runtime/agent-statistics.test.ts +++ b/packages/core/src/agents/runtime/agent-statistics.test.ts @@ -57,7 +57,23 @@ describe('AgentStatistics', () => { const summary = stats.getSummary(); expect(summary.thoughtTokens).toBe(10); expect(summary.cachedTokens).toBe(5); - expect(summary.totalTokens).toBe(165); // 100 + 50 + 10 + 5 + // cachedTokens is a subset of inputTokens, not additive + expect(summary.totalTokens).toBe(160); // 100 + 50 + 10 + }); + + it('should use API-provided totalTokenCount when available', () => { + stats.recordTokens(100, 50, 10, 5, 170); + + const summary = stats.getSummary(); + expect(summary.totalTokens).toBe(170); + }); + + it('should accumulate API totalTokenCount across rounds', () => { + stats.recordTokens(100, 50, 0, 0, 150); + stats.recordTokens(200, 80, 0, 0, 280); + + const summary = stats.getSummary(); + expect(summary.totalTokens).toBe(430); // 150 + 280 }); }); @@ -109,7 +125,7 @@ describe('AgentStatistics', () => { expect(result).toContain('📋 Task Completed: Test task'); expect(result).toContain('🔧 Tool Usage: 1 calls, 100.0% success'); expect(result).toContain('⏱️ Duration: 5.0s | 🔁 Rounds: 2'); - expect(result).toContain('🔢 Tokens: 1,530 (in 1000, out 500)'); + expect(result).toContain('🔢 Tokens: 1,520 (in 1000, out 500)'); }); it('should handle zero tool calls', () => { diff --git a/packages/core/src/agents/runtime/agent-statistics.ts b/packages/core/src/agents/runtime/agent-statistics.ts index 8487d5e0b..55c16f529 100644 --- a/packages/core/src/agents/runtime/agent-statistics.ts +++ b/packages/core/src/agents/runtime/agent-statistics.ts @@ -26,7 +26,6 @@ export interface AgentStatsSummary { thoughtTokens: number; cachedTokens: number; totalTokens: number; - estimatedCost: number; toolUsage: ToolUsageStats[]; } @@ -40,6 +39,7 @@ export class AgentStatistics { private outputTokens = 0; private thoughtTokens = 0; private cachedTokens = 0; + private apiTotalTokens = 0; private toolUsage = new Map(); start(now = Date.now()) { @@ -83,11 +83,13 @@ export class AgentStatistics { output: number, thought: number = 0, cached: number = 0, + total: number = 0, ) { this.inputTokens += Math.max(0, input || 0); this.outputTokens += Math.max(0, output || 0); this.thoughtTokens += Math.max(0, thought || 0); this.cachedTokens += Math.max(0, cached || 0); + this.apiTotalTokens += Math.max(0, total || 0); } getSummary(now = Date.now()): AgentStatsSummary { @@ -98,11 +100,9 @@ export class AgentStatistics { ? (this.successfulToolCalls / totalToolCalls) * 100 : 0; const totalTokens = - this.inputTokens + - this.outputTokens + - this.thoughtTokens + - this.cachedTokens; - const estimatedCost = this.inputTokens * 3e-5 + this.outputTokens * 6e-5; + this.apiTotalTokens > 0 + ? this.apiTotalTokens + : this.inputTokens + this.outputTokens + this.thoughtTokens; return { rounds: this.rounds, totalDurationMs, @@ -115,7 +115,6 @@ export class AgentStatistics { thoughtTokens: this.thoughtTokens, cachedTokens: this.cachedTokens, totalTokens, - estimatedCost, toolUsage: Array.from(this.toolUsage.values()), }; } diff --git a/packages/core/src/telemetry/constants.ts b/packages/core/src/telemetry/constants.ts index cea2188eb..84938b6c0 100644 --- a/packages/core/src/telemetry/constants.ts +++ b/packages/core/src/telemetry/constants.ts @@ -38,6 +38,11 @@ export const EVENT_SKILL_LAUNCH = 'qwen-code.skill_launch'; export const EVENT_AUTH = 'qwen-code.auth'; export const EVENT_USER_FEEDBACK = 'qwen-code.user_feedback'; +// Arena Events +export const EVENT_ARENA_SESSION_STARTED = 'qwen-code.arena_session_started'; +export const EVENT_ARENA_AGENT_COMPLETED = 'qwen-code.arena_agent_completed'; +export const EVENT_ARENA_SESSION_ENDED = 'qwen-code.arena_session_ended'; + // Performance Events export const EVENT_STARTUP_PERFORMANCE = 'qwen-code.startup.performance'; export const EVENT_MEMORY_USAGE = 'qwen-code.memory.usage'; diff --git a/packages/core/src/telemetry/index.ts b/packages/core/src/telemetry/index.ts index 0f5981ed4..3ae3f7133 100644 --- a/packages/core/src/telemetry/index.ts +++ b/packages/core/src/telemetry/index.ts @@ -48,6 +48,9 @@ export { logAuth, logSkillLaunch, logUserFeedback, + logArenaSessionStarted, + logArenaAgentCompleted, + logArenaSessionEnded, } from './loggers.js'; export type { SlashCommandEvent, ChatCompressionEvent } from './types.js'; export { @@ -70,8 +73,18 @@ export { SkillLaunchEvent, UserFeedbackEvent, UserFeedbackRating, + makeArenaSessionStartedEvent, + makeArenaAgentCompletedEvent, + makeArenaSessionEndedEvent, } from './types.js'; export { makeSlashCommandEvent, makeChatCompressionEvent } from './types.js'; +export type { + ArenaSessionStartedEvent, + ArenaAgentCompletedEvent, + ArenaSessionEndedEvent, + ArenaSessionEndedStatus, + ArenaAgentCompletedStatus, +} from './types.js'; export type { TelemetryEvent } from './types.js'; export { SpanStatusCode, ValueType } from '@opentelemetry/api'; export { SemanticAttributes } from '@opentelemetry/semantic-conventions'; @@ -98,6 +111,10 @@ export { recordPerformanceRegression, recordBaselineComparison, isPerformanceMonitoringActive, + // Arena metrics functions + recordArenaSessionStartedMetrics, + recordArenaAgentCompletedMetrics, + recordArenaSessionEndedMetrics, // Performance monitoring types PerformanceMetricType, MemoryMetricType, diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index d15d1bcb7..a3592a298 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -40,6 +40,9 @@ import { EVENT_SKILL_LAUNCH, EVENT_EXTENSION_UPDATE, EVENT_USER_FEEDBACK, + EVENT_ARENA_SESSION_STARTED, + EVENT_ARENA_AGENT_COMPLETED, + EVENT_ARENA_SESSION_ENDED, } from './constants.js'; import { recordApiErrorMetrics, @@ -53,6 +56,9 @@ import { recordSubagentExecutionMetrics, recordTokenUsageMetrics, recordToolCallMetrics, + recordArenaSessionStartedMetrics, + recordArenaAgentCompletedMetrics, + recordArenaSessionEndedMetrics, } from './metrics.js'; import { QwenLogger } from './qwen-logger/qwen-logger.js'; import { isTelemetrySdkInitialized } from './sdk.js'; @@ -90,6 +96,9 @@ import type { AuthEvent, SkillLaunchEvent, UserFeedbackEvent, + ArenaSessionStartedEvent, + ArenaAgentCompletedEvent, + ArenaSessionEndedEvent, } from './types.js'; import type { UiEvent } from './uiTelemetry.js'; import { uiTelemetryService } from './uiTelemetry.js'; @@ -946,3 +955,86 @@ export function logUserFeedback( }; logger.emit(logRecord); } + +export function logArenaSessionStarted( + config: Config, + event: ArenaSessionStartedEvent, +): void { + QwenLogger.getInstance(config)?.logArenaSessionStartedEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + model_ids: JSON.stringify(event.model_ids), + 'event.name': EVENT_ARENA_SESSION_STARTED, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Arena session started. Agents: ${event.model_ids.length}.`, + attributes, + }; + logger.emit(logRecord); + recordArenaSessionStartedMetrics(config); +} + +export function logArenaAgentCompleted( + config: Config, + event: ArenaAgentCompletedEvent, +): void { + QwenLogger.getInstance(config)?.logArenaAgentCompletedEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_ARENA_AGENT_COMPLETED, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Arena agent ${event.agent_model_id} ${event.status}. Duration: ${event.duration_ms}ms. Tokens: ${event.total_tokens}.`, + attributes, + }; + logger.emit(logRecord); + recordArenaAgentCompletedMetrics( + config, + event.agent_model_id, + event.status, + event.duration_ms, + event.input_tokens, + event.output_tokens, + ); +} + +export function logArenaSessionEnded( + config: Config, + event: ArenaSessionEndedEvent, +): void { + QwenLogger.getInstance(config)?.logArenaSessionEndedEvent(event); + if (!isTelemetrySdkInitialized()) return; + + const attributes: LogAttributes = { + ...getCommonAttributes(config), + ...event, + 'event.name': EVENT_ARENA_SESSION_ENDED, + 'event.timestamp': new Date().toISOString(), + }; + + const logger = logs.getLogger(SERVICE_NAME); + const logRecord: LogRecord = { + body: `Arena session ended: ${event.status}.${event.winner_model_id ? ` Winner: ${event.winner_model_id}.` : ''}`, + attributes, + }; + logger.emit(logRecord); + recordArenaSessionEndedMetrics( + config, + event.status, + event.display_backend, + event.duration_ms, + event.winner_model_id, + ); +} diff --git a/packages/core/src/telemetry/metrics.ts b/packages/core/src/telemetry/metrics.ts index 0ab499e0f..f71498c36 100644 --- a/packages/core/src/telemetry/metrics.ts +++ b/packages/core/src/telemetry/metrics.ts @@ -23,6 +23,14 @@ const CONTENT_RETRY_FAILURE_COUNT = `${SERVICE_NAME}.chat.content_retry_failure. const MODEL_SLASH_COMMAND_CALL_COUNT = `${SERVICE_NAME}.slash_command.model.call_count`; export const SUBAGENT_EXECUTION_COUNT = `${SERVICE_NAME}.subagent.execution.count`; +// Arena Metrics +const ARENA_SESSION_COUNT = `${SERVICE_NAME}.arena.session.count`; +const ARENA_SESSION_DURATION = `${SERVICE_NAME}.arena.session.duration`; +const ARENA_AGENT_COUNT = `${SERVICE_NAME}.arena.agent.count`; +const ARENA_AGENT_DURATION = `${SERVICE_NAME}.arena.agent.duration`; +const ARENA_AGENT_TOKENS = `${SERVICE_NAME}.arena.agent.tokens`; +const ARENA_RESULT_SELECTED = `${SERVICE_NAME}.arena.result.selected`; + // Performance Monitoring Metrics const STARTUP_TIME = `${SERVICE_NAME}.startup.duration`; const MEMORY_USAGE = `${SERVICE_NAME}.memory.usage`; @@ -345,6 +353,14 @@ let performanceScoreGauge: Histogram | undefined; let regressionDetectionCounter: Counter | undefined; let regressionPercentageChangeHistogram: Histogram | undefined; let baselineComparisonHistogram: Histogram | undefined; +// Arena Metrics +let arenaSessionCounter: Counter | undefined; +let arenaSessionDurationHistogram: Histogram | undefined; +let arenaAgentCounter: Counter | undefined; +let arenaAgentDurationHistogram: Histogram | undefined; +let arenaAgentTokensCounter: Counter | undefined; +let arenaResultSelectedCounter: Counter | undefined; + let isMetricsInitialized = false; let isPerformanceMonitoringEnabled = false; @@ -373,6 +389,37 @@ export function initializeMetrics(config: Config): void { valueType: ValueType.INT, }); + // Arena metrics + arenaSessionCounter = meter.createCounter(ARENA_SESSION_COUNT, { + description: 'Counts arena sessions by status and display backend.', + valueType: ValueType.INT, + }); + arenaSessionDurationHistogram = meter.createHistogram( + ARENA_SESSION_DURATION, + { + description: 'Duration of arena sessions in milliseconds.', + unit: 'ms', + valueType: ValueType.INT, + }, + ); + arenaAgentCounter = meter.createCounter(ARENA_AGENT_COUNT, { + description: 'Counts arena agent completions by status and model.', + valueType: ValueType.INT, + }); + arenaAgentDurationHistogram = meter.createHistogram(ARENA_AGENT_DURATION, { + description: 'Duration of arena agent execution in milliseconds.', + unit: 'ms', + valueType: ValueType.INT, + }); + arenaAgentTokensCounter = meter.createCounter(ARENA_AGENT_TOKENS, { + description: 'Token usage by arena agents.', + valueType: ValueType.INT, + }); + arenaResultSelectedCounter = meter.createCounter(ARENA_RESULT_SELECTED, { + description: 'Counts arena result selections by model.', + valueType: ValueType.INT, + }); + Object.entries(HISTOGRAM_DEFINITIONS).forEach( ([name, { description, unit, valueType, assign }]) => { assign(meter.createHistogram(name, { description, unit, valueType })); @@ -747,3 +794,85 @@ export function recordSubagentExecutionMetrics( subagentExecutionCounter.add(1, attributes); } + +// ─── Arena Metric Recording Functions ─────────────────────────── + +export function recordArenaSessionStartedMetrics(config: Config): void { + if (!isMetricsInitialized) return; + arenaSessionCounter?.add(1, { + ...baseMetricDefinition.getCommonAttributes(config), + status: 'started', + }); +} + +export function recordArenaAgentCompletedMetrics( + config: Config, + modelId: string, + status: string, + durationMs: number, + inputTokens: number, + outputTokens: number, +): void { + if (!isMetricsInitialized) return; + + const common = baseMetricDefinition.getCommonAttributes(config); + + arenaAgentCounter?.add(1, { + ...common, + status, + model_id: modelId, + }); + + arenaAgentDurationHistogram?.record(durationMs, { + ...common, + model_id: modelId, + }); + + if (inputTokens > 0) { + arenaAgentTokensCounter?.add(inputTokens, { + ...common, + model_id: modelId, + type: 'input', + }); + } + + if (outputTokens > 0) { + arenaAgentTokensCounter?.add(outputTokens, { + ...common, + model_id: modelId, + type: 'output', + }); + } +} + +export function recordArenaSessionEndedMetrics( + config: Config, + status: string, + displayBackend?: string, + durationMs?: number, + winnerModelId?: string, +): void { + if (!isMetricsInitialized) return; + + const common = baseMetricDefinition.getCommonAttributes(config); + + arenaSessionCounter?.add(1, { + ...common, + status, + ...(displayBackend ? { display_backend: displayBackend } : {}), + }); + + if (durationMs !== undefined && arenaSessionDurationHistogram) { + arenaSessionDurationHistogram.record(durationMs, { + ...common, + status, + }); + } + + if (winnerModelId) { + arenaResultSelectedCounter?.add(1, { + ...common, + model_id: winnerModelId, + }); + } +} diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 6d30e13e1..841231aa8 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -45,6 +45,9 @@ import type { RipgrepFallbackEvent, EndSessionEvent, ExtensionUpdateEvent, + ArenaSessionStartedEvent, + ArenaAgentCompletedEvent, + ArenaSessionEndedEvent, } from '../types.js'; import type { RumEvent, @@ -925,6 +928,61 @@ export class QwenLogger { this.flushIfNeeded(); } + // arena events + logArenaSessionStartedEvent(event: ArenaSessionStartedEvent): void { + const rumEvent = this.createActionEvent('arena', 'arena_session_started', { + properties: { + arena_session_id: event.arena_session_id, + model_ids: JSON.stringify(event.model_ids), + task_length: event.task_length, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + + logArenaAgentCompletedEvent(event: ArenaAgentCompletedEvent): void { + const rumEvent = this.createActionEvent('arena', 'arena_agent_completed', { + properties: { + arena_session_id: event.arena_session_id, + agent_session_id: event.agent_session_id, + agent_model_id: event.agent_model_id, + status: event.status, + duration_ms: event.duration_ms, + rounds: event.rounds, + total_tokens: event.total_tokens, + input_tokens: event.input_tokens, + output_tokens: event.output_tokens, + tool_calls: event.tool_calls, + successful_tool_calls: event.successful_tool_calls, + failed_tool_calls: event.failed_tool_calls, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + + logArenaSessionEndedEvent(event: ArenaSessionEndedEvent): void { + const rumEvent = this.createActionEvent('arena', 'arena_session_ended', { + properties: { + arena_session_id: event.arena_session_id, + status: event.status, + duration_ms: event.duration_ms, + display_backend: event.display_backend, + agent_count: event.agent_count, + completed_agents: event.completed_agents, + failed_agents: event.failed_agents, + cancelled_agents: event.cancelled_agents, + winner_model_id: event.winner_model_id, + }, + }); + + this.enqueueLogEvent(rumEvent); + this.flushIfNeeded(); + } + getProxyAgent() { const proxyUrl = this.config?.getProxy(); if (!proxyUrl) return undefined; diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index 98c8d5cac..5524b46bb 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -858,7 +858,128 @@ export type TelemetryEvent = | ModelSlashCommandEvent | AuthEvent | SkillLaunchEvent - | UserFeedbackEvent; + | UserFeedbackEvent + | ArenaSessionStartedEvent + | ArenaAgentCompletedEvent + | ArenaSessionEndedEvent; + +// ─── Arena Telemetry Events ──────────────────────────────────── + +export interface ArenaSessionStartedEvent extends BaseTelemetryEvent { + 'event.name': 'arena_session_started'; + arena_session_id: string; + model_ids: string[]; + task_length: number; +} + +export function makeArenaSessionStartedEvent({ + arena_session_id, + model_ids, + task_length, +}: Omit): ArenaSessionStartedEvent { + return { + 'event.name': 'arena_session_started', + 'event.timestamp': new Date().toISOString(), + arena_session_id, + model_ids, + task_length, + }; +} + +export type ArenaAgentCompletedStatus = 'completed' | 'failed' | 'cancelled'; + +export interface ArenaAgentCompletedEvent extends BaseTelemetryEvent { + 'event.name': 'arena_agent_completed'; + arena_session_id: string; + agent_session_id: string; + agent_model_id: string; + status: ArenaAgentCompletedStatus; + duration_ms: number; + rounds: number; + total_tokens: number; + input_tokens: number; + output_tokens: number; + tool_calls: number; + successful_tool_calls: number; + failed_tool_calls: number; +} + +export function makeArenaAgentCompletedEvent({ + arena_session_id, + agent_session_id, + agent_model_id, + status, + duration_ms, + rounds, + total_tokens, + input_tokens, + output_tokens, + tool_calls, + successful_tool_calls, + failed_tool_calls, +}: Omit): ArenaAgentCompletedEvent { + return { + 'event.name': 'arena_agent_completed', + 'event.timestamp': new Date().toISOString(), + arena_session_id, + agent_session_id, + agent_model_id, + status, + duration_ms, + rounds, + total_tokens, + input_tokens, + output_tokens, + tool_calls, + successful_tool_calls, + failed_tool_calls, + }; +} + +export type ArenaSessionEndedStatus = + | 'selected' + | 'discarded' + | 'failed' + | 'cancelled'; + +export interface ArenaSessionEndedEvent extends BaseTelemetryEvent { + 'event.name': 'arena_session_ended'; + arena_session_id: string; + status: ArenaSessionEndedStatus; + duration_ms: number; + display_backend?: string; + agent_count: number; + completed_agents: number; + failed_agents: number; + cancelled_agents: number; + winner_model_id?: string; +} + +export function makeArenaSessionEndedEvent({ + arena_session_id, + status, + duration_ms, + display_backend, + agent_count, + completed_agents, + failed_agents, + cancelled_agents, + winner_model_id, +}: Omit): ArenaSessionEndedEvent { + return { + 'event.name': 'arena_session_ended', + 'event.timestamp': new Date().toISOString(), + arena_session_id, + status, + duration_ms, + display_backend, + agent_count, + completed_agents, + failed_agents, + cancelled_agents, + winner_model_id, + }; +} export class ExtensionDisableEvent implements BaseTelemetryEvent { 'event.name': 'extension_disable'; diff --git a/packages/core/src/tools/task.test.ts b/packages/core/src/tools/task.test.ts index 28b6168be..3100a771d 100644 --- a/packages/core/src/tools/task.test.ts +++ b/packages/core/src/tools/task.test.ts @@ -318,7 +318,6 @@ describe('TaskTool', () => { inputTokens: 1000, outputTokens: 500, totalTokens: 1500, - estimatedCost: 0.045, toolUsage: [ { name: 'grep', From 1a718b7cf556a5fc99489a84b8041256f3196e86 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 2 Mar 2026 23:20:18 +0800 Subject: [PATCH 09/82] fix(core): Handle Windows EPERM errors and cross-platform paths in arena Add retry logic with exponential backoff for file renames that fail with EPERM/EACCES on Windows during concurrent operations. Fix test to use path.join() for cross-platform compatibility. This improves reliability of arena agent collaboration on Windows. Co-authored-by: Qwen-Coder --- .../core/src/agents/arena/ArenaAgentClient.ts | 28 +++++++++++++++++-- .../src/services/gitWorktreeService.test.ts | 14 +++++++--- 2 files changed, 36 insertions(+), 6 deletions(-) diff --git a/packages/core/src/agents/arena/ArenaAgentClient.ts b/packages/core/src/agents/arena/ArenaAgentClient.ts index 1099825e4..070f57adb 100644 --- a/packages/core/src/agents/arena/ArenaAgentClient.ts +++ b/packages/core/src/agents/arena/ArenaAgentClient.ts @@ -235,6 +235,7 @@ export class ArenaAgentClient { /** * Atomically write JSON data to a file (write temp → rename). + * Retries on EPERM which occurs on Windows under concurrent renames. */ private async atomicWrite( filePath: string, @@ -243,9 +244,8 @@ export class ArenaAgentClient { const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`; try { await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); - await fs.rename(tmpPath, filePath); + await this.renameWithRetry(tmpPath, filePath); } catch (error) { - // Clean up temp file on failure try { await fs.unlink(tmpPath); } catch { @@ -255,6 +255,30 @@ export class ArenaAgentClient { } } + private async renameWithRetry( + src: string, + dest: string, + retries = 3, + delayMs = 50, + ): Promise { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + await fs.rename(src, dest); + return; + } catch (error: unknown) { + const isRetryable = + isNodeError(error) && + (error.code === 'EPERM' || error.code === 'EACCES'); + if (!isRetryable || attempt === retries) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, delayMs * 2 ** attempt), + ); + } + } + } + private async ensureInitialized(): Promise { if (!this.initialized) { await this.init(); diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts index f3cd33ed5..2eb028d98 100644 --- a/packages/core/src/services/gitWorktreeService.test.ts +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -7,6 +7,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import type { Mock } from 'vitest'; import type * as fs from 'node:fs/promises'; +import * as path from 'node:path'; import { GitWorktreeService } from './gitWorktreeService.js'; import { isCommandAvailable } from '../utils/shell-utils.js'; @@ -139,17 +140,22 @@ describe('GitWorktreeService', () => { const result = await service.createWorktree('s1', 'Model A'); + const expectedPath = path.join( + '/mock-qwen', + 'worktrees', + 's1', + 'worktrees', + 'model-a', + ); expect(result.success).toBe(true); expect(result.worktree?.branch).toBe('worktrees/s1/model-a'); - expect(result.worktree?.path).toBe( - '/mock-qwen/worktrees/s1/worktrees/model-a', - ); + expect(result.worktree?.path).toBe(expectedPath); expect(hoistedMockRaw).toHaveBeenCalledWith([ 'worktree', 'add', '-b', 'worktrees/s1/model-a', - '/mock-qwen/worktrees/s1/worktrees/model-a', + expectedPath, 'main', ]); }); From b749e80325c3bf65130bf6ce5ebb8700b52cf7c6 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 2 Mar 2026 23:30:19 +0800 Subject: [PATCH 10/82] chore: fix build errors --- packages/cli/src/acp-integration/acpAgent.ts | 4 ++-- .../src/ui/components/arena/ArenaStatusDialog.tsx | 10 ++++------ .../core/src/agents/runtime/agent-headless.test.ts | 12 ++++++------ 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 37eb96ab2..91efc75ec 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -466,13 +466,13 @@ class GeminiAgent { const currentApprovalMode = config.getApprovalMode(); const availableModes = APPROVAL_MODES.map((mode) => ({ - id: mode as ApprovalModeValue, + id: mode as acp.ApprovalModeValue, name: APPROVAL_MODE_INFO[mode].name, description: APPROVAL_MODE_INFO[mode].description, })); return { - currentModeId: currentApprovalMode as ApprovalModeValue, + currentModeId: currentApprovalMode as acp.ApprovalModeValue, availableModes, }; } diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 09325a603..0786cbac0 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -268,17 +268,15 @@ export function ArenaStatusDialog({ )} - {/* In-process mode: show extra detail row with cost + thought tokens */} - {live && (live.estimatedCost > 0 || live.thoughtTokens > 0) && ( + {/* In-process mode: show extra detail row with thought/cached tokens */} + {live && (live.thoughtTokens > 0 || live.cachedTokens > 0) && ( - {live.estimatedCost > 0 && - `Cost: $${live.estimatedCost.toFixed(4)}`} - {live.estimatedCost > 0 && live.thoughtTokens > 0 && ' · '} {live.thoughtTokens > 0 && `Thinking: ${live.thoughtTokens.toLocaleString()} tok`} + {live.thoughtTokens > 0 && live.cachedTokens > 0 && ' · '} {live.cachedTokens > 0 && - ` · Cached: ${live.cachedTokens.toLocaleString()} tok`} + `Cached: ${live.cachedTokens.toLocaleString()} tok`} )} diff --git a/packages/core/src/agents/runtime/agent-headless.test.ts b/packages/core/src/agents/runtime/agent-headless.test.ts index 43ed2caa9..7271eb094 100644 --- a/packages/core/src/agents/runtime/agent-headless.test.ts +++ b/packages/core/src/agents/runtime/agent-headless.test.ts @@ -473,7 +473,7 @@ describe('subagent.ts', () => { mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -481,7 +481,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(context); + await scope.execute(context); const generationConfig = getGenerationConfigFromMock(); expect(generationConfig.systemInstruction).toContain( @@ -511,7 +511,7 @@ describe('subagent.ts', () => { mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -519,7 +519,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(context); + await scope.execute(context); const generationConfig = getGenerationConfigFromMock(); const sysPrompt = generationConfig.systemInstruction as string; @@ -540,7 +540,7 @@ describe('subagent.ts', () => { mockSendMessageStream.mockImplementation(createMockStream(['stop'])); - const scope = await SubAgentScope.create( + const scope = await AgentHeadless.create( 'test-agent', config, promptConfig, @@ -548,7 +548,7 @@ describe('subagent.ts', () => { defaultRunConfig, ); - await scope.runNonInteractive(context); + await scope.execute(context); const generationConfig = getGenerationConfigFromMock(); const sysPrompt = generationConfig.systemInstruction as string; From 418410eb0cae030cd8b11df217600ccd8984adcb Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 5 Mar 2026 14:21:21 +0800 Subject: [PATCH 11/82] feat(i18n): add Context Usage component translations - Add i18n keys for Context Usage component in all locales - Add 'Model:' prefix label for better clarity - Rename 'Autocompact' to 'Autocompact buffer' Co-authored-by: Qwen-Coder --- packages/cli/src/i18n/locales/de.js | 23 ++++++++++++++++++ packages/cli/src/i18n/locales/en.js | 22 +++++++++++++++++ packages/cli/src/i18n/locales/ja.js | 24 ++++++++++++++++++- packages/cli/src/i18n/locales/pt.js | 22 +++++++++++++++++ packages/cli/src/i18n/locales/ru.js | 23 ++++++++++++++++++ packages/cli/src/i18n/locales/zh.js | 21 ++++++++++++++++ .../src/ui/components/views/ContextUsage.tsx | 6 +++-- 7 files changed, 138 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 1144aa31c..920839944 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1459,4 +1459,27 @@ export default { '{{region}}-Konfiguration erfolgreich aktualisiert. Modell auf "{{model}}" umgeschaltet.', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': 'Erfolgreich mit {{region}} authentifiziert. API-Schlüssel und Modellkonfigurationen wurden in settings.json gespeichert (gesichert).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Kontextnutzung', + 'No API response yet. Send a message to see actual usage.': + 'Noch keine API-Antwort. Senden Sie eine Nachricht, um die tatsächliche Nutzung anzuzeigen.', + 'Estimated pre-conversation overhead': + 'Geschätzte Vorabkosten vor der Unterhaltung', + 'Context window': 'Kontextfenster', + tokens: 'Tokens', + Used: 'Verwendet', + Free: 'Frei', + 'Autocompact buffer': 'Autokomprimierungs-Puffer', + 'Usage by category': 'Verwendung nach Kategorie', + 'System prompt': 'System-Prompt', + 'Built-in tools': 'Integrierte Tools', + 'MCP tools': 'MCP-Tools', + 'Memory files': 'Speicherdateien', + Skills: 'Fähigkeiten', + Messages: 'Nachrichten', + 'Show context window usage breakdown.': + 'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 1c27b760f..d4133df53 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1448,4 +1448,26 @@ export default { '{{region}} configuration updated successfully. Model switched to "{{model}}".', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Context Usage', + 'No API response yet. Send a message to see actual usage.': + 'No API response yet. Send a message to see actual usage.', + 'Estimated pre-conversation overhead': 'Estimated pre-conversation overhead', + 'Context window': 'Context window', + tokens: 'tokens', + Used: 'Used', + Free: 'Free', + 'Autocompact buffer': 'Autocompact buffer', + 'Usage by category': 'Usage by category', + 'System prompt': 'System prompt', + 'Built-in tools': 'Built-in tools', + 'MCP tools': 'MCP tools', + 'Memory files': 'Memory files', + Skills: 'Skills', + Messages: 'Messages', + 'Show context window usage breakdown.': + 'Show context window usage breakdown.', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 634cec49d..0d0418105 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -965,5 +965,27 @@ export default { '{{region}} configuration updated successfully. Model switched to "{{model}}".': '{{region}} の設定が正常に更新されました。モデルが "{{model}}" に切り替わりました。', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': - '{{region}} での認証に成功しました。APIキーとモデル設定が settings.json に保存されました(バックアップ済み)。', + '{{region}} での認証に成功しました。API キーとモデル設定が settings.json に保存されました(バックアップ済み)。', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'コンテキスト使用量', + 'No API response yet. Send a message to see actual usage.': + 'API応答はありません。メッセージを送信して実際の使用量を確認してください。', + 'Estimated pre-conversation overhead': '推定事前会話オーバーヘッド', + 'Context window': 'コンテキストウィンドウ', + tokens: 'トークン', + Used: '使用済み', + Free: '空き', + 'Autocompact buffer': '自動圧縮バッファ', + 'Usage by category': 'カテゴリ別の使用量', + 'System prompt': 'システムプロンプト', + 'Built-in tools': '組み込みツール', + 'MCP tools': 'MCPツール', + 'Memory files': 'メモリファイル', + Skills: 'スキル', + Messages: 'メッセージ', + 'Show context window usage breakdown.': + 'コンテキストウィンドウの使用状況を表示します。', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index 729ebbd74..00ca4fd70 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1453,4 +1453,26 @@ export default { 'Configuração do {{region}} atualizada com sucesso. Modelo alterado para "{{model}}".', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': 'Autenticado com sucesso com {{region}}. Chave de API e configurações de modelo salvas em settings.json (com backup).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Uso do Contexto', + 'No API response yet. Send a message to see actual usage.': + 'Ainda não há resposta da API. Envie uma mensagem para ver o uso real.', + 'Estimated pre-conversation overhead': 'Sobrecarga estimada pré-conversa', + 'Context window': 'Janela de Contexto', + tokens: 'tokens', + Used: 'Usado', + Free: 'Livre', + 'Autocompact buffer': 'Buffer de autocompactação', + 'Usage by category': 'Uso por categoria', + 'System prompt': 'Prompt do sistema', + 'Built-in tools': 'Ferramentas integradas', + 'MCP tools': 'Ferramentas MCP', + 'Memory files': 'Arquivos de memória', + Skills: 'Habilidades', + Messages: 'Mensagens', + 'Show context window usage breakdown.': + 'Exibe a divisão de uso da janela de contexto.', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 867de9b9a..cf248971a 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1463,4 +1463,27 @@ export default { 'Конфигурация {{region}} успешно обновлена. Модель переключена на "{{model}}".', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': 'Успешная аутентификация с {{region}}. API-ключ и конфигурации моделей сохранены в settings.json (резервная копия создана).', + + // ============================================================================ + // Context Usage Component + // ============================================================================ + 'Context Usage': 'Использование контекста', + 'No API response yet. Send a message to see actual usage.': + 'Пока нет ответа от API. Отправьте сообщение, чтобы увидеть фактическое использование.', + 'Estimated pre-conversation overhead': + 'Оценочные накладные расходы перед беседой', + 'Context window': 'Контекстное окно', + tokens: 'токенов', + Used: 'Использовано', + Free: 'Свободно', + 'Autocompact buffer': 'Буфер автоупаковки', + 'Usage by category': 'Использование по категориям', + 'System prompt': 'Системная подсказка', + 'Built-in tools': 'Встроенные инструменты', + 'MCP tools': 'Инструменты MCP', + 'Memory files': 'Файлы памяти', + Skills: 'Навыки', + Messages: 'Сообщения', + 'Show context window usage breakdown.': + 'Показать разбивку использования контекстного окна.', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 5bc2bef92..6702266af 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1281,4 +1281,25 @@ export default { '{{region}} 配置更新成功。模型已切换至 "{{model}}"。', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json (backed up).': '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json(已备份)。', + + // ============================================================================ + // Context Usage + // ============================================================================ + 'Context Usage': '上下文使用情况', + 'Context window': '上下文窗口', + Used: '已用', + Free: '空闲', + 'Autocompact buffer': '自动压缩缓冲区', + 'Usage by category': '分类用量', + 'System prompt': '系统提示', + 'Built-in tools': '内置工具', + 'MCP tools': 'MCP 工具', + 'Memory files': '记忆文件', + Skills: '技能', + Messages: '消息', + tokens: 'tokens', + 'Estimated pre-conversation overhead': '预估对话前开销', + 'No API response yet. Send a message to see actual usage.': + '暂无 API 响应。发送消息以查看实际使用情况。', + 'Show context window usage breakdown.': '显示上下文窗口使用情况分解。', }; diff --git a/packages/cli/src/ui/components/views/ContextUsage.tsx b/packages/cli/src/ui/components/views/ContextUsage.tsx index 67f4bf282..753f40890 100644 --- a/packages/cli/src/ui/components/views/ContextUsage.tsx +++ b/packages/cli/src/ui/components/views/ContextUsage.tsx @@ -205,7 +205,9 @@ export const ContextUsage: React.FC = ({ <> {/* Model name + context window info */} - {modelName} + + {t('Model')}: {modelName} + {t('Context window')}: {formatTokens(contextWindowSize)}{' '} @@ -243,7 +245,7 @@ export const ContextUsage: React.FC = ({ /> Date: Thu, 5 Mar 2026 14:32:09 +0800 Subject: [PATCH 12/82] docs: add /context command documentation - Add /context command to the Interface and Workspace Control table - Document the context window usage breakdown feature Co-authored-by: Qwen-Coder --- docs/users/features/commands.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index ba980db80..b6252f0c2 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -33,6 +33,7 @@ Commands for adjusting interface appearance and work environment. | Command | Description | Usage Examples | | ------------ | ---------------------------------------- | ----------------------------- | | `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | +| `/context` | Show context window usage breakdown | `/context` | | `/theme` | Change Qwen Code visual theme | `/theme` | | `/vim` | Turn input area Vim editing mode on/off | `/vim` | | `/directory` | Manage multi-directory support workspace | `/dir add ./src,./tests` | From b629de35cfe2305d481d9c3a5d09cc1c87783c12 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Thu, 5 Mar 2026 14:43:42 +0800 Subject: [PATCH 13/82] docs: remove CONTEXT_COMMAND.md from source directory Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/CONTEXT_COMMAND.md | 293 ------------------ 1 file changed, 293 deletions(-) delete mode 100644 packages/cli/src/ui/commands/CONTEXT_COMMAND.md diff --git a/packages/cli/src/ui/commands/CONTEXT_COMMAND.md b/packages/cli/src/ui/commands/CONTEXT_COMMAND.md deleted file mode 100644 index de768d4b9..000000000 --- a/packages/cli/src/ui/commands/CONTEXT_COMMAND.md +++ /dev/null @@ -1,293 +0,0 @@ -# `/context` 命令 — 上下文窗口用量分解 - -## 概述 - -`/context` 命令展示当前模型上下文窗口的 token 使用情况。它将整个上下文窗口拆分为多个分类,帮助用户理解 token 花在了哪里,以及还剩多少空间。 - -## 上下文窗口的组成 - -一次 API 请求发送给模型的完整 prompt 包含以下部分: - -``` -┌─────────────────────────────────────────────┐ -│ Context Window (总容量) │ -│ │ -│ ┌─────────────────────────────────────┐ │ -│ │ System Prompt (系统提示词) │ │ -│ │ └─ 核心指令 + 行为规则 │ │ -│ ├─────────────────────────────────────┤ │ -│ │ Tool Declarations (工具声明) │ │ -│ │ ├─ Built-in tools (内置工具) │ │ -│ │ ├─ MCP tools (MCP 工具) │ │ -│ │ └─ SkillTool (技能工具) ◄──────────┼─── 包含所有 skill 的名称+描述 -│ ├─────────────────────────────────────┤ │ -│ │ Memory (用户记忆) │ │ -│ │ └─ QWEN.md + extension configs │ │ -│ ├─────────────────────────────────────┤ │ -│ │ Messages (对话消息) │ │ -│ │ ├─ 用户消息 │ │ -│ │ ├─ 模型回复 │ │ -│ │ └─ 工具调用 & 工具结果 ◄───────────┼─── skill body 在此加载 -│ ├─────────────────────────────────────┤ │ -│ │ Free Space (可用空间) │ │ -│ ├─────────────────────────────────────┤ │ -│ │ Autocompact Buffer (自动压缩缓冲) │ │ -│ └─────────────────────────────────────┘ │ -└─────────────────────────────────────────────┘ -``` - -**不变量**:所有分类之和 = Context Window 总容量。 - -## 各分类详解 - -### 1. System Prompt(系统提示词) - -| 属性 | 说明 | -| ------------ | ------------------------------------------------------------------ | -| **数据来源** | `getCoreSystemPrompt(undefined, modelName)` | -| **包含内容** | 模型的核心行为指令、输出格式要求、安全规则等 | -| **不包含** | Memory 内容(单独计算) | -| **计算方式** | 对系统提示词文本调用 `estimateTokens()` | -| **变化频率** | 基本固定,除非修改了 `QWEN_SYSTEM_MD` 环境变量或 `.qwen/system.md` | - -> **注意**:`getCoreSystemPrompt` 接受 `userMemory` 参数,这里传入 `undefined` 以排除 memory,因为 memory 作为独立分类统计。 - -### 2. Built-in Tools(内置工具) - -| 属性 | 说明 | -| ------------ | ----------------------------------------------------------------------------------------------------- | -| **数据来源** | `toolRegistry.getAllTools()` 中非 MCP、非 SkillTool 的工具 | -| **包含内容** | `read_file`、`edit`、`run_shell_command`、`grep_search`、`glob`、`list_directory` 等核心工具的 schema | -| **计算方式** | `allToolsTokens - skillsTokens - mcpToolsTotalTokens` | -| **详情列表** | 逐项展示每个内置工具的名称和 token 占用,按 token 数降序排列 | - -> **SkillTool** 虽然也是内置工具,但因其内容动态性(嵌入所有 skill 列表),独立作为 **Skills** 分类展示,不在 Built-in tools 中出现。 - -### 2b. MCP Tools(MCP 工具) - -| 属性 | 说明 | -| ------------ | ----------------------------------------------------------------------- | -| **数据来源** | `toolRegistry.getAllTools()` 中 `DiscoveredMCPTool` 实例 | -| **包含内容** | 通过 MCP 协议连接的外部工具服务器提供的工具 schema | -| **计算方式** | 各 MCP 工具 `estimateTokens(JSON.stringify(tool.schema))` 之和 | -| **详情列表** | 逐项展示每个 MCP 工具的名称(`serverName__toolName` 格式)和 token 占用 | -| **条件显示** | 仅当存在 MCP 工具时才显示此分类行和详情 | - -### 3. Skills(技能)⭐ 渐进式披露 - -Skills 采用**两阶段加载**设计: - -| 阶段 | 加载内容 | Token 归属 | 何时加载 | -| ------------ | ---------------------------------------------- | ----------------- | ------------------------------- | -| **第一阶段** | 每个 skill 的 name + 短 description + 使用说明 | **Skills 分类** | 每次 API 请求都发送 | -| **第二阶段** | 完整的 SKILL.md body 内容(详细指令、模板等) | **Messages 分类** | 模型调用 `skill` 工具后按需注入 | - -**`/context` 中 Skills 分类展示的是第一阶段的常驻开销。** - -#### 第一阶段的实现细节 - -SkillTool 在初始化时将所有 skill 信息嵌入其 `description` 字段: - -``` -Execute a skill within the main conversation - - -... 使用说明(~600 字符)... - - - - -pdf -Convert PDF files to text (project) -project - - -xlsx -Process Excel spreadsheets (user) -user - -...更多 skills... - -``` - -这整块文本是 SkillTool 的 tool declaration 的一部分,每次 API 请求都会发送。 - -#### Token 计算方式 - -``` -skillsTokens = estimateTokens(JSON.stringify(skillTool.schema)) -``` - -直接从 ToolRegistry 中获取 SkillTool 的完整 schema 进行估算,确保包含: - -- 使用说明文本(``) -- 所有 skill 的 XML 列表(``) -- schema 参数定义 - -#### 第二阶段(按需加载) - -当模型调用 `skill` 工具时,`SkillToolInvocation.execute()` 会加载完整的 SKILL.md: - -```typescript -const skill = await this.skillManager.loadSkillForRuntime(this.params.skill); -const llmContent = `Base directory: ${baseDir}\n\n${skill.body}\n`; -``` - -这个 body 内容作为工具调用结果注入到对话中,token 开销归入 **Messages** 分类。 - -#### Skills 详情列表 - -每个 skill 的详情行展示该 skill 在第一阶段中的大致占用,按 token 数降序排列。注意: - -- 各 skill 详情的 token 之和 **< Skills 分类总数**,差值是 skills_instructions 指令文本的开销 -- 详情仅展示名称和描述的 token,不包含 schema 参数定义部分 - -### 4. Memory Files(用户记忆) - -| 属性 | 说明 | -| ------------ | -------------------------------------------------------------------------- | -| **数据来源** | `config.getUserMemory()` | -| **包含内容** | `QWEN.md`、extension 配置、`output-language` 等用户级配置文件 | -| **加载位置** | 拼接到 System Prompt 末尾(通过 `getCoreSystemPrompt(userMemory, model)`) | -| **计算方式** | 解析 memory 文本中的 `--- Context from: ---` 标记,分文件估算 token | - -**Memory 内容格式**: - -``` ---- Context from: ~/.qwen/QWEN.md --- -用户自定义规则和偏好... ---- End of Context from: ~/.qwen/QWEN.md --- ---- Context from: ~/.qwen/extensions/config.md --- -扩展配置内容... ---- End of Context from: ~/.qwen/extensions/config.md --- -``` - -> **为什么 System Prompt 不包含 Memory?** 计算 System Prompt token 时传入 `userMemory = undefined`,Memory 作为独立分类展示,避免两个分类重叠。实际 API 请求中 memory 是拼接在 system prompt 末尾的。 - -### 5. Messages(对话消息) - -| 属性 | 说明 | -| ------------ | ---------------------------------------------------------------- | -| **数据来源** | 反推:`totalTokens - systemPrompt - allTools - memory` | -| **包含内容** | 所有用户消息、模型回复、工具调用参数、工具返回结果 | -| **特别包含** | skill body(第二阶段按需加载的内容)、文件读取结果、shell 输出等 | -| **计算方式** | `max(0, apiTotalTokens - estimatedOverhead)` | - -> **注意**:Messages 是通过 API 返回的 `totalTokens` 减去其他分类的估算值得出的,因此它吸收了估算误差。如果 overhead 被高估,Messages 会被相应低估。 - -### 6. Free Space(可用空间) - -| 属性 | 说明 | -| ------------ | ----------------------------------------------------- | -| **计算方式** | `contextWindowSize - totalTokens - autocompactBuffer` | -| **含义** | 在触发自动压缩之前,还能容纳多少 token 的对话内容 | - -### 7. Autocompact Buffer(自动压缩缓冲区) - -| 属性 | 说明 | -| ------------ | ----------------------------------------------------------------- | -| **计算方式** | `(1 - compressionThreshold) × contextWindowSize` | -| **默认值** | `(1 - 0.7) × 131072 = 39322`(约 30% 的上下文窗口) | -| **含义** | 当 token 用量达到 70% 时触发自动压缩,这 30% 的空间作为缓冲区预留 | - -## 两种展示模式 - -### 模式 A:无 API 数据(首次使用,尚未发送消息) - -``` -Context Usage - - No API response yet. Send a message to see actual usage. - - Estimated pre-conversation overhead - Model: glm-5 Context window: 131.1k tokens - - █ System prompt 4.8k tokens (3.7%) - █ System tools 5.2k tokens (4.0%) - █ Memory files 845 tokens (0.6%) - █ Skills 5.1k tokens (3.9%) - ░ Free space 75.8k tokens (57.8%) - ░ Autocompact buffer 39.3k tokens (30.0%) -``` - -- **不显示进度条和 total 数字**:避免估算值与后续 API 实际值产生不合理的对比 -- **不显示 Messages 行**:尚无对话 -- 各分类基于本地启发式估算(`estimateTokens`),可能与实际 API tokenizer 有 ~10% 偏差 - -### 模式 B:有 API 数据(已进行对话) - -``` -Context Usage - - ██████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░ glm-5 - 25.3k/131.1k tokens (19.3%) - - Usage by category - █ System prompt 4.5k tokens (3.4%) - █ System tools 4.9k tokens (3.7%) - █ Memory files 790 tokens (0.6%) - █ Skills 4.8k tokens (3.7%) - █ Messages 10.3k tokens (7.9%) - ░ Free space 66.5k tokens (50.7%) - ░ Autocompact buffer 39.3k tokens (30.0%) -``` - -- **`totalTokens` 来自 API 响应**(`usageMetadata.promptTokenCount`),是最准确的值 -- **当本地估算 > API total 时**:按比例缩放各 overhead 分类,确保分类之和 = totalTokens -- **Messages** = `totalTokens - scaledOverhead`,包含所有对话内容 + 按需加载的 skill body - -## Token 估算方法 - -由于无法直接访问模型的 tokenizer,使用基于字符的启发式估算: - -``` -tokens ≈ ⌈asciiChars / 4 + nonAsciiChars × 1.5⌉ -``` - -| 字符类型 | 比例 | 依据 | -| --------------------------------- | --------------- | -------------------------------- | -| ASCII(英文、JSON 结构字符等) | ~4 字符/token | BPE tokenizer 对英文的平均压缩率 | -| 非 ASCII(中文、日文等 CJK 字符) | ~1.5 token/字符 | CJK 字符通常映射为 1-2 个 token | - -**已知局限**: - -- 不同模型的 tokenizer 有差异,估算可能偏差 ±10-20% -- JSON 结构字符(`{`, `"`, `:` 等)的实际 token 化比率与自然语言不同 -- 当估算偏高时,通过 `overheadScale` 按比例缩放校正 - -## 数据流图 - -``` - ┌──────────────────┐ - │ API Response │ - │ promptTokenCount │ ─── totalTokens (ground truth) - └──────────────────┘ - │ - ┌──────────────────────────┼──────────────────────────┐ - │ │ │ - ▼ ▼ ▼ -estimateTokens() estimateTokens() estimateTokens() - │ │ │ - ▼ ▼ ▼ -systemPromptTokens allToolsTokens memoryFilesTokens - │ - ┌─────┴──────┐ - │ │ - ▼ ▼ - systemToolsTokens skillsTokens - (allTools - skills) (from SkillTool schema) - │ │ - └─────┬──────┘ - │ - ▼ - rawOverhead = systemPrompt + allTools + memory - │ - ┌───────────┼───────────┐ - │ overheadScale │ (= min(1, totalTokens/rawOverhead)) - ▼ ▼ - scaled categories messages = totalTokens - scaledOverhead - │ │ - └───────────┬───────────┘ - ▼ - breakdown output -``` From ef772feea2168487e50c914d6d1a5786c194a653 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Mar 2026 10:14:47 +0800 Subject: [PATCH 14/82] feat: support skills in .agents directory and other provider config directories --- .../core/src/skills/skill-manager.test.ts | 32 +++++++++++---- packages/core/src/skills/skill-manager.ts | 41 ++++++++++++------- 2 files changed, 51 insertions(+), 22 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index d21916143..7cc3be2e4 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -504,17 +504,35 @@ Skill 3 content`); }); }); - describe('getSkillsBaseDir', () => { - it('should return project-level base dir', () => { - const baseDir = manager.getSkillsBaseDir('project'); + describe('getSkillsBaseDirs', () => { + it('should return all project-level base dirs', () => { + const baseDirs = manager.getSkillsBaseDirs('project'); - expect(baseDir).toBe(path.join('/test/project', '.qwen', 'skills')); + expect(baseDirs).toHaveLength(5); + expect(baseDirs).toContain(path.join('/test/project', '.qwen', 'skills')); + expect(baseDirs).toContain( + path.join('/test/project', '.agent', 'skills'), + ); + expect(baseDirs).toContain( + path.join('/test/project', '.cursor', 'skills'), + ); + expect(baseDirs).toContain( + path.join('/test/project', '.codex', 'skills'), + ); + expect(baseDirs).toContain( + path.join('/test/project', '.claude', 'skills'), + ); }); - it('should return user-level base dir', () => { - const baseDir = manager.getSkillsBaseDir('user'); + it('should return all user-level base dirs', () => { + const baseDirs = manager.getSkillsBaseDirs('user'); - expect(baseDir).toBe(path.join('/home/user', '.qwen', 'skills')); + expect(baseDirs).toHaveLength(5); + expect(baseDirs).toContain(path.join('/home/user', '.qwen', 'skills')); + expect(baseDirs).toContain(path.join('/home/user', '.agent', 'skills')); + expect(baseDirs).toContain(path.join('/home/user', '.cursor', 'skills')); + expect(baseDirs).toContain(path.join('/home/user', '.codex', 'skills')); + expect(baseDirs).toContain(path.join('/home/user', '.claude', 'skills')); }); }); diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 05eabdd5a..2344530ad 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -25,6 +25,13 @@ import { normalizeContent } from '../utils/textUtils.js'; const debugLogger = createDebugLogger('SKILL_MANAGER'); const QWEN_CONFIG_DIR = '.qwen'; +const PROVIDER_CONFIG_DIRS = [ + '.qwen', + '.agent', + '.cursor', + '.codex', + '.claude', +]; const SKILLS_CONFIG_DIR = 'skills'; const SKILL_MANIFEST_FILE = 'SKILL.md'; @@ -412,19 +419,18 @@ export class SkillManager { * Gets the base directory for skills at a specific level. * * @param level - Storage level - * @returns Absolute directory path + * @returns Absolute directory paths */ - getSkillsBaseDir(level: SkillLevel): string { - const baseDir = + getSkillsBaseDirs(level: SkillLevel): string[] { + const baseDirs = level === 'project' - ? path.join( - this.config.getProjectRoot(), - QWEN_CONFIG_DIR, - SKILLS_CONFIG_DIR, + ? PROVIDER_CONFIG_DIRS.map((v) => + path.join(this.config.getProjectRoot(), v, SKILLS_CONFIG_DIR), ) - : path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); - - return baseDir; + : PROVIDER_CONFIG_DIRS.map((v) => + path.join(os.homedir(), v, SKILLS_CONFIG_DIR), + ); + return baseDirs; } /** @@ -461,9 +467,13 @@ export class SkillManager { return skills; } - const baseDir = this.getSkillsBaseDir(level); - debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`); - const skills = await this.loadSkillsFromDir(baseDir, level); + const baseDirs = this.getSkillsBaseDirs(level); + const skills: SkillConfig[] = []; + for (let i = 0; i < baseDirs.length; i++) { + debugLogger.debug(`Loading ${level} level skills from: ${baseDirs[i]}`); + const skillsFromDir = await this.loadSkillsFromDir(baseDirs[i], level); + skills.push(...skillsFromDir); + } debugLogger.debug(`Loaded ${skills.length} ${level} level skills`); return skills; } @@ -583,7 +593,8 @@ export class SkillManager { private updateWatchersFromCache(): void { const watchTargets = new Set( (['project', 'user'] as const) - .map((level) => this.getSkillsBaseDir(level)) + .map((level) => this.getSkillsBaseDirs(level)) + .reduce((acc, baseDirs) => acc.concat(baseDirs), []) .filter((baseDir) => fsSync.existsSync(baseDir)), ); @@ -639,7 +650,7 @@ export class SkillManager { } private async ensureUserSkillsDir(): Promise { - const baseDir = this.getSkillsBaseDir('user'); + const baseDir = path.join(os.homedir(), QWEN_CONFIG_DIR, SKILLS_CONFIG_DIR); try { await fs.mkdir(baseDir, { recursive: true }); } catch (error) { From c905b94d78eca6b15844a48433c78e806d55fe1b Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 9 Mar 2026 11:23:08 +0800 Subject: [PATCH 15/82] feat(agents): add settings schema for multi-agent collaboration Add agents.displayMode, arena/team/swarm settings, and refactor acpAgent to use local ApprovalModeValue type. Co-authored-by: Qwen-Coder --- packages/cli/src/acp-integration/acpAgent.ts | 5 +- .../schemas/settings.schema.json | 52 +++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/acp-integration/acpAgent.ts b/packages/cli/src/acp-integration/acpAgent.ts index 02e49b50a..246d80019 100644 --- a/packages/cli/src/acp-integration/acpAgent.ts +++ b/packages/cli/src/acp-integration/acpAgent.ts @@ -58,6 +58,7 @@ import { AcpFileSystemService } from './service/filesystem.js'; import { Readable, Writable } from 'node:stream'; import type { LoadedSettings } from '../config/settings.js'; import { SettingScope } from '../config/settings.js'; +import type { ApprovalModeValue } from './session/types.js'; import { z } from 'zod'; import type { CliArgs } from '../config/config.js'; import { loadCliConfig } from '../config/config.js'; @@ -523,13 +524,13 @@ class QwenAgent implements Agent { const currentApprovalMode = config.getApprovalMode(); const availableModes = APPROVAL_MODES.map((mode) => ({ - id: mode as acp.ApprovalModeValue, + id: mode as ApprovalModeValue, name: APPROVAL_MODE_INFO[mode].name, description: APPROVAL_MODE_INFO[mode].description, })); return { - currentModeId: currentApprovalMode as acp.ApprovalModeValue, + currentModeId: currentApprovalMode as ApprovalModeValue, availableModes, }; } diff --git a/packages/vscode-ide-companion/schemas/settings.schema.json b/packages/vscode-ide-companion/schemas/settings.schema.json index d0eef6ae9..abb6e519a 100644 --- a/packages/vscode-ide-companion/schemas/settings.schema.json +++ b/packages/vscode-ide-companion/schemas/settings.schema.json @@ -574,6 +574,53 @@ "type": "object", "additionalProperties": true }, + "agents": { + "description": "Settings for multi-agent collaboration features (Arena, Team, Swarm).", + "type": "object", + "properties": { + "displayMode": { + "description": "Display mode for multi-agent sessions. \"tmux\" uses tmux panes, \"iterm2\" uses iTerm2 tabs, \"in-process\" runs in the current terminal. Options: in-process, tmux, iterm2", + "enum": [ + "in-process", + "tmux", + "iterm2" + ] + }, + "arena": { + "description": "Settings for Arena (multi-model competitive execution).", + "type": "object", + "properties": { + "worktreeBaseDir": { + "description": "Custom base directory for Arena worktrees. Defaults to ~/.qwen/arena.", + "type": "string" + }, + "preserveArtifacts": { + "description": "When enabled, Arena worktrees and session state files are preserved after the session ends or the main agent exits.", + "type": "boolean", + "default": false + }, + "maxRoundsPerAgent": { + "description": "Maximum number of rounds (turns) each agent can execute. No limit if unset.", + "type": "number" + }, + "timeoutSeconds": { + "description": "Total timeout in seconds for the Arena session. No limit if unset.", + "type": "number" + } + } + }, + "team": { + "description": "Settings for Agent Team (role-based collaborative execution). Reserved for future use.", + "type": "object", + "additionalProperties": true + }, + "swarm": { + "description": "Settings for Agent Swarm (parallel sub-agent execution). Reserved for future use.", + "type": "object", + "additionalProperties": true + } + } + }, "hooksConfig": { "description": "Hook configurations for intercepting and customizing agent behavior.", "type": "object", @@ -612,6 +659,11 @@ } } }, + "experimental": { + "description": "Setting to enable experimental features", + "type": "object", + "properties": {} + }, "$version": { "type": "number", "description": "Settings schema version for migration tracking.", From fa2f2fd5ce08f2605f6fc5fcbc053afeccf4be32 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 9 Mar 2026 16:21:28 +0800 Subject: [PATCH 16/82] feat(arena): Short worktree names and UX improvements - Use 8-char short names derived from session UUID for worktrees - Fix cleanup to use short worktreeDirName - Simplify model display names (remove authType prefix) - Improve messaging when <2 models available - Show agent worktree paths in startup output Prevents long path issues and provides clearer model setup guidance. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/arenaCommand.ts | 4 +- .../ui/components/arena/ArenaStartDialog.tsx | 33 ++++++++++---- .../src/agents/arena/ArenaManager.test.ts | 26 ++++++++--- .../core/src/agents/arena/ArenaManager.ts | 44 +++++++++++++++++-- 4 files changed, 85 insertions(+), 22 deletions(-) diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index fde381e53..51c696886 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -136,9 +136,7 @@ function buildArenaExecutionInput( const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => ({ modelId: parsedModel.modelId, authType: parsedModel.authType ?? defaultAuthType, - displayName: parsedModel.authType - ? `${parsedModel.authType}:${parsedModel.modelId}` - : parsedModel.modelId, + displayName: parsedModel.modelId, })); return { diff --git a/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx index c60e6ddf5..6ce610887 100644 --- a/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStartDialog.tsx @@ -49,7 +49,9 @@ export function ArenaStartDialog({ const selectableModelCount = modelItems.filter( (item) => !item.disabled, ).length; - const shouldShowMoreModelsHint = selectableModelCount < 3; + const needsMoreModels = selectableModelCount < 2; + const shouldShowMoreModelsHint = + selectableModelCount >= 2 && selectableModelCount < 3; useKeypress( (key) => { @@ -107,13 +109,28 @@ export function ArenaStartDialog({ )} - {hasDisabledQwenOauth && ( - - - {t( - 'qwen-oauth models are disabled because they are not supported in Arena.', - )} - + {(hasDisabledQwenOauth || needsMoreModels) && ( + + {hasDisabledQwenOauth && ( + + {t('Note: qwen-oauth models are not supported in Arena.')} + + )} + {needsMoreModels && ( + <> + + {t('Arena requires at least 2 models. To add more:')} + + + {t( + ' - Run /auth to set up a Coding Plan (includes multiple models)', + )} + + + {t(' - Or configure modelProviders in settings.json')} + + + )} )} diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index b98b5841b..e0f7554a5 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -50,7 +50,10 @@ vi.mock('../../services/gitWorktreeService.js', () => { }); // Mock the Config class -const createMockConfig = (workingDir: string) => ({ +const createMockConfig = ( + workingDir: string, + arenaSettings: Record = {}, +) => ({ getWorkingDir: () => workingDir, getModel: () => 'test-model', getSessionId: () => 'test-session', @@ -60,7 +63,7 @@ const createMockConfig = (workingDir: string) => ({ getFunctionDeclarationsFiltered: () => [], getTool: () => undefined, }), - getAgentsSettings: () => ({}), + getAgentsSettings: () => ({ arena: arenaSettings }), getUsageStatisticsEnabled: () => false, getTelemetryEnabled: () => false, getTelemetryLogPromptsEnabled: () => false, @@ -74,7 +77,8 @@ describe('ArenaManager', () => { beforeEach(async () => { // Create a temp directory - no need for git repo since we mock GitWorktreeService tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'arena-test-')); - mockConfig = createMockConfig(tempDir); + // Use tempDir as worktreeBaseDir to avoid slow filesystem access in deriveWorktreeDirName + mockConfig = createMockConfig(tempDir, { worktreeBaseDir: tempDir }); mockBackend = createMockBackend(); hoistedMockDetectBackend.mockResolvedValue({ backend: mockBackend }); @@ -362,13 +366,14 @@ describe('ArenaManager', () => { // auto-exit is on by default, so agents terminate quickly. await manager.start(createValidStartOptions()); - const sessionIdBeforeCleanup = manager.getSessionId(); await manager.cleanup(); expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); + // cleanupSession is called with worktreeDirName (short ID), not the full sessionId. + // For 'test-session', the short ID is 'testsess' (first 8 chars with dashes removed). expect(hoistedMockCleanupSession).toHaveBeenCalledWith( - sessionIdBeforeCleanup, + 'testsess', 'arena', ); expect(manager.getBackend()).toBeNull(); @@ -439,8 +444,15 @@ function createValidStartOptions() { } async function waitForMicrotask(): Promise { - await Promise.resolve(); - await Promise.resolve(); + // Use setImmediate (or setTimeout fallback) to yield to the event loop + // and allow other async operations (like the start() method) to progress. + await new Promise((resolve) => { + if (typeof setImmediate === 'function') { + setImmediate(resolve); + } else { + setTimeout(resolve, 0); + } + }); } async function waitForCondition( diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 24d9a0562..172ef632f 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -71,6 +71,8 @@ export class ArenaManager { private cachedResult: ArenaSessionResult | null = null; private sessionId: string | undefined; + /** Short directory name used for worktree paths (derived from sessionId). */ + private worktreeDirName: string | undefined; private sessionStatus: ArenaSessionStatus = ArenaSessionStatus.INITIALIZING; private agents: Map = new Map(); private arenaConfig: ArenaConfig | undefined; @@ -271,6 +273,7 @@ export class ArenaManager { } this.sessionId = this.config.getSessionId(); + this.worktreeDirName = await this.deriveWorktreeDirName(this.sessionId); this.startedAt = Date.now(); this.sessionStatus = ArenaSessionStatus.INITIALIZING; this.masterAbortController = new AbortController(); @@ -357,8 +360,17 @@ export class ArenaManager { return result; } + // Emit worktree info for each agent + const worktreeInfo = Array.from(this.agents.values()) + .map( + (agent, i) => + ` ${i + 1}. ${agent.model.displayName || agent.model.modelId} → ${agent.worktree.path}`, + ) + .join('\n'); + this.emitProgress(`Environment ready. Agent worktrees:\n${worktreeInfo}`); + // Start all agents in parallel via PTY - this.emitProgress('Environment ready. Launching agents…'); + this.emitProgress('Launching agents…'); this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); @@ -489,11 +501,12 @@ export class ArenaManager { } // Clean up worktrees - await this.worktreeService.cleanupSession(this.sessionId, 'arena'); + await this.worktreeService.cleanupSession(this.worktreeDirName!, 'arena'); this.agents.clear(); this.cachedResult = null; this.sessionId = undefined; + this.worktreeDirName = undefined; this.arenaConfig = undefined; this.backend = null; this.sessionEndedLogged = false; @@ -531,6 +544,7 @@ export class ArenaManager { this.agents.clear(); this.cachedResult = null; this.sessionId = undefined; + this.worktreeDirName = undefined; this.arenaConfig = undefined; this.backend = null; this.sessionEndedLogged = false; @@ -705,6 +719,28 @@ export class ArenaManager { // ─── Private: Worktree Setup ─────────────────────────────────── + /** + * Derive a short, filesystem-friendly directory name from the full session ID. + * Uses the first 8 hex characters of the UUID. If that path already exists, + * appends a numeric suffix (-2, -3, …) until an unused name is found. + */ + private async deriveWorktreeDirName(sessionId: string): Promise { + const shortId = sessionId.replaceAll('-', '').slice(0, 8); + let candidate = shortId; + let suffix = 2; + + while (true) { + const candidatePath = path.join(this.arenaBaseDir, candidate); + try { + await fs.access(candidatePath); + candidate = `${shortId}-${suffix}`; + suffix++; + } catch { + return candidate; + } + } + } + private async setupWorktrees(): Promise { if (!this.arenaConfig) { throw new Error('Arena config not initialized'); @@ -717,7 +753,7 @@ export class ArenaManager { ); const result = await this.worktreeService.setupWorktrees({ - sessionId: this.arenaConfig.sessionId, + sessionId: this.worktreeDirName!, sourceRepoPath: this.arenaConfig.sourceRepoPath, worktreeNames, branchPrefix: 'arena', @@ -1143,7 +1179,7 @@ export class ArenaManager { throw new Error('Arena config not initialized'); } return GitWorktreeService.getSessionDir( - this.arenaConfig.sessionId, + this.worktreeDirName!, this.arenaBaseDir, ); } From 1673b04fad4019b784f4f50ac2e88acfd5590c19 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Mar 2026 16:28:53 +0800 Subject: [PATCH 17/82] fix test ci --- .../core/src/skills/skill-manager.test.ts | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 7cc3be2e4..446e457d8 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -391,42 +391,53 @@ You are a helpful assistant. describe('listSkills', () => { beforeEach(() => { - // Mock directory listing for skills directories (with Dirent objects) - vi.mocked(fs.readdir) - .mockResolvedValueOnce([ - { - name: 'skill1', - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false, - }, - { - name: 'skill2', - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false, - }, - { - name: 'not-a-dir.txt', - isDirectory: () => false, - isFile: () => true, - isSymbolicLink: () => false, - }, - ] as unknown as Awaited>) - .mockResolvedValueOnce([ - { - name: 'skill3', - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false, - }, - { - name: 'skill1', - isDirectory: () => true, - isFile: () => false, - isSymbolicLink: () => false, - }, - ] as unknown as Awaited>); + // Mock directory listing based on path to handle multiple base dirs per level + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.readdir).mockImplementation((dirPath: any) => { + const pathStr = String(dirPath); + if (pathStr.includes('/test/project') && pathStr.includes('.qwen')) { + return Promise.resolve([ + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill2', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'not-a-dir.txt', + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, + ] as unknown as Awaited>); + } + if (pathStr.includes('/home/user') && pathStr.includes('.qwen')) { + return Promise.resolve([ + { + name: 'skill3', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + { + name: 'skill1', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + ] as unknown as Awaited>); + } + // Other provider dirs (.agent, .cursor, .codex, .claude) return empty + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); vi.mocked(fs.access).mockResolvedValue(undefined); From 7e9c5843e88d2ce3a40761083d311ab73e405e9f Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Mar 2026 16:46:28 +0800 Subject: [PATCH 18/82] fix test --- packages/core/src/skills/skill-manager.test.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 446e457d8..5784011a5 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -391,11 +391,19 @@ You are a helpful assistant. describe('listSkills', () => { beforeEach(() => { - // Mock directory listing based on path to handle multiple base dirs per level + // Mock directory listing based on path to handle multiple base dirs per level. + // Use path.join to construct expected paths so separators match on all platforms. + const projectQwenSkillsDir = path.join( + '/test/project', + '.qwen', + 'skills', + ); + const userQwenSkillsDir = path.join('/home/user', '.qwen', 'skills'); + // eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(fs.readdir).mockImplementation((dirPath: any) => { const pathStr = String(dirPath); - if (pathStr.includes('/test/project') && pathStr.includes('.qwen')) { + if (pathStr === projectQwenSkillsDir) { return Promise.resolve([ { name: 'skill1', @@ -417,7 +425,7 @@ You are a helpful assistant. }, ] as unknown as Awaited>); } - if (pathStr.includes('/home/user') && pathStr.includes('.qwen')) { + if (pathStr === userQwenSkillsDir) { return Promise.resolve([ { name: 'skill3', From c3c8b39a29f019af7755aaf3c81d4c505eded689 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 9 Mar 2026 17:08:28 +0800 Subject: [PATCH 19/82] fix: deduplicate same-name skills across provider dirs and fix cross-platform test --- .../core/src/skills/skill-manager.test.ts | 68 +++++++++++++++++++ packages/core/src/skills/skill-manager.ts | 23 +++++-- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 5784011a5..bd047e431 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -73,6 +73,14 @@ describe('SkillManager', () => { if (yamlString.includes('name: regular-skill')) { return { name: 'regular-skill', description: 'A regular skill' }; } + if (yamlString.includes('name: shared-skill')) { + const desc = yamlString.includes('From qwen dir') + ? 'From qwen dir' + : yamlString.includes('From agent dir') + ? 'From agent dir' + : 'A shared skill'; + return { name: 'shared-skill', description: desc }; + } if (!yamlString.includes('name:')) { return { description: 'A test skill' }; // Missing name case } @@ -502,6 +510,66 @@ Skill 3 content`); expect(projectSkills.every((s) => s.level === 'project')).toBe(true); }); + it('should deduplicate same-name skills across provider dirs within a level', async () => { + // Override readdir to return the same skill name from both .qwen and .agent dirs + vi.mocked(fs.readdir).mockReset(); + const projectQwenDir = path.join('/test/project', '.qwen', 'skills'); + const projectAgentDir = path.join('/test/project', '.agent', 'skills'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + vi.mocked(fs.readdir).mockImplementation((dirPath: any) => { + const pathStr = String(dirPath); + if (pathStr === projectQwenDir) { + return Promise.resolve([ + { + name: 'shared-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + ] as unknown as Awaited>); + } + if (pathStr === projectAgentDir) { + return Promise.resolve([ + { + name: 'shared-skill', + isDirectory: () => true, + isFile: () => false, + isSymbolicLink: () => false, + }, + ] as unknown as Awaited>); + } + return Promise.resolve( + [] as unknown as Awaited>, + ); + }); + + vi.mocked(fs.readFile).mockImplementation((filePath) => { + const pathStr = String(filePath); + if (pathStr.includes('.qwen') && pathStr.includes('shared-skill')) { + return Promise.resolve( + `---\nname: shared-skill\ndescription: From qwen dir\n---\nQwen content`, + ); + } + if (pathStr.includes('.agent') && pathStr.includes('shared-skill')) { + return Promise.resolve( + `---\nname: shared-skill\ndescription: From agent dir\n---\nAgent content`, + ); + } + return Promise.reject(new Error('File not found')); + }); + + const skills = await manager.listSkills({ + level: 'project', + force: true, + }); + + // Only one instance should remain, from .qwen (first in PROVIDER_CONFIG_DIRS) + expect(skills).toHaveLength(1); + expect(skills[0].name).toBe('shared-skill'); + expect(skills[0].description).toBe('From qwen dir'); + }); + it('should handle empty directories', async () => { vi.mocked(fs.readdir).mockReset(); vi.mocked(fs.readdir).mockResolvedValue( diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index 2344530ad..fed6f4b98 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -28,9 +28,9 @@ const QWEN_CONFIG_DIR = '.qwen'; const PROVIDER_CONFIG_DIRS = [ '.qwen', '.agent', + '.claude', '.cursor', '.codex', - '.claude', ]; const SKILLS_CONFIG_DIR = 'skills'; const SKILL_MANIFEST_FILE = 'SKILL.md'; @@ -467,12 +467,25 @@ export class SkillManager { return skills; } + // Iterate provider directories in PROVIDER_CONFIG_DIRS order. + // The first directory that contains a skill with a given name wins, + // so the order defines implicit precedence (.qwen > .agent > .cursor > ...). const baseDirs = this.getSkillsBaseDirs(level); const skills: SkillConfig[] = []; - for (let i = 0; i < baseDirs.length; i++) { - debugLogger.debug(`Loading ${level} level skills from: ${baseDirs[i]}`); - const skillsFromDir = await this.loadSkillsFromDir(baseDirs[i], level); - skills.push(...skillsFromDir); + const seenNames = new Set(); + for (const baseDir of baseDirs) { + debugLogger.debug(`Loading ${level} level skills from: ${baseDir}`); + const skillsFromDir = await this.loadSkillsFromDir(baseDir, level); + for (const skill of skillsFromDir) { + if (seenNames.has(skill.name)) { + debugLogger.debug( + `Skipping duplicate skill at ${level} level: ${skill.name} from ${baseDir}`, + ); + continue; + } + seenNames.add(skill.name); + skills.push(skill); + } } debugLogger.debug(`Loaded ${skills.length} ${level} level skills`); return skills; From eaef9efe90acbd56391e3ea91f0d386a6859729f Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Mon, 9 Mar 2026 21:33:48 +0800 Subject: [PATCH 20/82] feat(arena): add IDLE status for agent follow-up task support - Introduce AgentStatus.IDLE for agents that finished work but can accept follow-up messages - Add isSettledStatus() helper to check if agent is settled (IDLE or terminal) - Update ArenaManager to transition to IDLE after agents finish initial task - Keep agent tabs visible when session is IDLE so users can continue interacting - Fix listener cleanup to not detach on IDLE (agents remain alive) - Update tests to expect 'idle' status after successful completion This enables the arena collaboration feature where agents can receive additional tasks after completing their initial work. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/arenaCommand.ts | 19 ++++++---- packages/cli/src/ui/components/Composer.tsx | 4 +- .../src/ui/components/arena/ArenaCards.tsx | 21 +++++++--- .../ui/components/arena/ArenaStatusDialog.tsx | 6 ++- .../cli/src/ui/hooks/useArenaInProcess.ts | 30 ++++++++++----- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 4 +- packages/cli/src/ui/utils/displayUtils.ts | 2 + .../core/src/agents/arena/ArenaManager.ts | 38 +++++++++++++++---- packages/core/src/agents/arena/types.ts | 4 +- .../src/agents/backends/InProcessBackend.ts | 9 ++++- .../agents/runtime/agent-interactive.test.ts | 19 +++++----- .../src/agents/runtime/agent-interactive.ts | 8 +++- .../core/src/agents/runtime/agent-types.ts | 12 ++++-- 13 files changed, 125 insertions(+), 51 deletions(-) diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 51c696886..80c1b0a90 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -334,7 +334,7 @@ function executeArenaCommand( }) .then( () => { - debugLogger.debug('Arena session completed'); + debugLogger.debug('Arena agents settled'); }, (error) => { const message = error instanceof Error ? error.message : String(error); @@ -344,13 +344,18 @@ function executeArenaCommand( // Clear the stored manager so subsequent /arena start calls // are not blocked by the stale reference after a startup failure. config.setArenaManager(null); + + // Detach listeners on failure — session is done for good. + for (const detach of detachListeners) { + detach(); + } }, - ) - .finally(() => { - for (const detach of detachListeners) { - detach(); - } - }); + ); + + // NOTE: listeners are NOT detached when start() resolves because agents + // may still be alive (IDLE) and accept follow-up tasks. The listeners + // reference this manager's emitter, so they are garbage collected when + // the manager is cleaned up and replaced. // Store so that stop can wait for start() to fully unwind before cleanup manager.setLifecyclePromise(lifecycle); diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 193549245..78eefabc3 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -104,8 +104,8 @@ export const Composer = () => { {/* Exclusive area: only one component visible at a time */} {/* Hide footer when a confirmation dialog (e.g. ask_user_question) is active */} - {!showSuggestions && - uiState.streamingState !== StreamingState.WaitingForConfirmation && + {uiState.isInputActive && + !showSuggestions && (showShortcuts ? ( ) : ( diff --git a/packages/cli/src/ui/components/arena/ArenaCards.tsx b/packages/cli/src/ui/components/arena/ArenaCards.tsx index fe6db8075..1ad7d8e2a 100644 --- a/packages/cli/src/ui/components/arena/ArenaCards.tsx +++ b/packages/cli/src/ui/components/arena/ArenaCards.tsx @@ -148,11 +148,13 @@ export const ArenaSessionCard: React.FC = ({ const colChanges = 10; const titleLabel = - sessionStatus === 'completed' - ? 'Arena Complete' - : sessionStatus === 'cancelled' - ? 'Arena Cancelled' - : 'Arena Failed'; + sessionStatus === 'idle' + ? 'Agents Status · Idle' + : sessionStatus === 'completed' + ? 'Arena Complete' + : sessionStatus === 'cancelled' + ? 'Arena Cancelled' + : 'Arena Failed'; return ( = ({ {/* Hint */} + {sessionStatus === 'idle' && ( + + + Switch to an agent tab to continue, or{' '} + /arena select to pick a + winner. + + + )} {sessionStatus === 'completed' && ( diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 0786cbac0..1a126c102 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -12,7 +12,7 @@ import { type ArenaAgentState, type InProcessBackend, type AgentStatsSummary, - isTerminalStatus, + isSettledStatus, ArenaSessionStatus, DISPLAY_MODE, } from '@qwen-code/qwen-code-core'; @@ -46,7 +46,7 @@ function pad( } function getElapsedMs(agent: ArenaAgentState): number { - if (isTerminalStatus(agent.status)) { + if (isSettledStatus(agent.status)) { return agent.stats.durationMs; } return Date.now() - agent.startedAt; @@ -61,6 +61,8 @@ function getSessionStatusLabel(status: ArenaSessionStatus): { return { text: 'Running', color: theme.status.success }; case ArenaSessionStatus.INITIALIZING: return { text: 'Initializing', color: theme.status.warning }; + case ArenaSessionStatus.IDLE: + return { text: 'Idle', color: theme.status.success }; case ArenaSessionStatus.COMPLETED: return { text: 'Completed', color: theme.status.success }; case ArenaSessionStatus.CANCELLED: diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts index 7cb29d312..0f7db9220 100644 --- a/packages/cli/src/ui/hooks/useArenaInProcess.ts +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -18,9 +18,11 @@ import { useEffect, useRef } from 'react'; import { ArenaEventType, + ArenaSessionStatus, DISPLAY_MODE, type ArenaManager, type ArenaAgentStartEvent, + type ArenaSessionCompleteEvent, type Config, type InProcessBackend, } from '@qwen-code/qwen-code-core'; @@ -123,9 +125,9 @@ export function useArenaInProcess(config: Config): void { tryRegister(MAX_AGENT_RETRIES); }; - // On session end, unregister agents, remove listeners from this - // manager, and resume polling for a genuinely new manager instance. - const onSessionEnd = () => { + // Tear down agent tabs, remove listeners, and resume polling for + // a genuinely new manager instance. + const teardown = () => { actionsRef.current.unregisterAll(); for (const timeout of retryTimeouts) { clearTimeout(timeout); @@ -133,8 +135,8 @@ export function useArenaInProcess(config: Config): void { retryTimeouts.clear(); // Remove listeners eagerly so they don't fire again emitter.off(ArenaEventType.AGENT_START, onAgentStart); - emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); - emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.off(ArenaEventType.SESSION_ERROR, teardown); detachListeners = null; // Keep attachedManager reference — prevents reattach to this // same (completed) manager on the next poll tick. @@ -144,14 +146,24 @@ export function useArenaInProcess(config: Config): void { } }; + // When agents settle to IDLE the session is still alive — keep + // the tab bar so users can continue interacting with agents. + // Only tear down on truly terminal session statuses. + const onSessionComplete = (event: ArenaSessionCompleteEvent) => { + if (event.result.status === ArenaSessionStatus.IDLE) { + return; + } + teardown(); + }; + emitter.on(ArenaEventType.AGENT_START, onAgentStart); - emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionEnd); - emitter.on(ArenaEventType.SESSION_ERROR, onSessionEnd); + emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.on(ArenaEventType.SESSION_ERROR, teardown); detachListeners = () => { emitter.off(ArenaEventType.AGENT_START, onAgentStart); - emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionEnd); - emitter.off(ArenaEventType.SESSION_ERROR, onSessionEnd); + emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); + emitter.off(ArenaEventType.SESSION_ERROR, teardown); }; }; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 5faa39a2f..5cfdc782f 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -67,8 +67,8 @@ export const DefaultAppLayout: React.FC = () => { - {/* Tab bar: visible whenever in-process agents exist */} - {hasAgents && } + {/* Tab bar: visible whenever in-process agents exist and input is active */} + {hasAgents && !uiState.dialogsVisible && } ); }; diff --git a/packages/cli/src/ui/utils/displayUtils.ts b/packages/cli/src/ui/utils/displayUtils.ts index 7f422e250..4f8fabb16 100644 --- a/packages/cli/src/ui/utils/displayUtils.ts +++ b/packages/cli/src/ui/utils/displayUtils.ts @@ -17,6 +17,8 @@ export interface StatusLabel { export function getArenaStatusLabel(status: AgentStatus): StatusLabel { switch (status) { + case AgentStatus.IDLE: + return { icon: '✓', text: 'Idle', color: theme.status.success }; case AgentStatus.COMPLETED: return { icon: '✓', text: 'Done', color: theme.status.success }; case AgentStatus.CANCELLED: diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 172ef632f..b17341fc5 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -36,7 +36,11 @@ import { ARENA_MAX_AGENTS, safeAgentId, } from './types.js'; -import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; +import { + AgentStatus, + isTerminalStatus, + isSettledStatus, +} from '../runtime/agent-types.js'; import { logArenaSessionStarted, logArenaAgentCompleted, @@ -374,9 +378,10 @@ export class ArenaManager { this.sessionStatus = ArenaSessionStatus.RUNNING; await this.runAgents(); - // Only mark as completed if not already cancelled/timed out + // Mark session as idle (agents finished but still alive) unless + // already cancelled/timed out. if (this.sessionStatus === ArenaSessionStatus.RUNNING) { - this.sessionStatus = ArenaSessionStatus.COMPLETED; + this.sessionStatus = ArenaSessionStatus.IDLE; } // Collect results (uses this.sessionStatus for result status) @@ -1114,6 +1119,25 @@ export class ArenaManager { timestamp: Date.now(), }); + // Emit progress messages for follow-up transitions (only after + // the initial task — the session is IDLE once all agents first settle). + if (this.sessionStatus === ArenaSessionStatus.IDLE) { + const displayName = agent.model.displayName || agent.model.modelId; + if ( + previousStatus === AgentStatus.IDLE && + newStatus === AgentStatus.RUNNING + ) { + this.emitProgress( + `Agent ${displayName} is working on a follow-up task…`, + ); + } else if ( + previousStatus === AgentStatus.RUNNING && + newStatus === AgentStatus.IDLE + ) { + this.emitProgress(`Agent ${displayName} finished follow-up task.`); + } + } + // Emit AGENT_COMPLETE when agent reaches a terminal status if (isTerminalStatus(newStatus)) { const result = this.buildAgentResult(agent); @@ -1194,7 +1218,7 @@ export class ArenaManager { return new Promise((resolve) => { const checkSettled = () => { for (const agent of this.agents.values()) { - if (!isTerminalStatus(agent.status)) { + if (!isSettledStatus(agent.status)) { return false; } } @@ -1283,7 +1307,7 @@ export class ArenaManager { agent.error = interactive.getLastRoundError() || interactive.getError(); } - if (isTerminalStatus(resolved)) { + if (isSettledStatus(resolved)) { agent.stats.durationMs = Date.now() - agent.startedAt; } this.updateAgentStatus(agent.agentId, resolved); @@ -1337,9 +1361,9 @@ export class ArenaManager { const consolidatedAgents: Record = {}; for (const agent of this.agents.values()) { - // Only poll agents that are still alive (RUNNING) + // Only poll agents that are actively working if ( - isTerminalStatus(agent.status) || + isSettledStatus(agent.status) || agent.status === AgentStatus.INITIALIZING ) { continue; diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index b99059cbd..aaf3e2dae 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -21,7 +21,9 @@ export enum ArenaSessionStatus { INITIALIZING = 'initializing', /** Session is running */ RUNNING = 'running', - /** Session completed (all agents finished) */ + /** All agents finished their current task and are idle (can accept follow-ups) */ + IDLE = 'idle', + /** Session completed for good (winner selected or explicit end) */ COMPLETED = 'completed', /** Session was cancelled */ CANCELLED = 'cancelled', diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 24b898bb4..5109c91bd 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -20,7 +20,7 @@ import { createContentGenerator, } from '../../core/contentGenerator.js'; import { AUTH_ENV_MAPPINGS } from '../../models/constants.js'; -import { AgentStatus } from '../runtime/agent-types.js'; +import { AgentStatus, isTerminalStatus } from '../runtime/agent-types.js'; import { AgentCore } from '../runtime/agent-core.js'; import { AgentEventEmitter } from '../runtime/agent-events.js'; import { ContextState } from '../runtime/agent-headless.js'; @@ -130,9 +130,14 @@ export class InProcessBackend implements Backend { const context = new ContextState(); await interactive.start(context); - // Watch for completion and fire exit callback + // Watch for completion and fire exit callback — but only for + // truly terminal statuses. IDLE means the agent is still alive + // and can accept follow-up messages. void interactive.waitForCompletion().then(() => { const status = interactive.getStatus(); + if (!isTerminalStatus(status)) { + return; + } const exitCode = status === AgentStatus.COMPLETED ? 0 diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 9c3162d22..f0ac9fb88 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -114,7 +114,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); expect(core.runReasoningLoop).toHaveBeenCalledOnce(); @@ -123,6 +123,7 @@ describe('AgentInteractive', () => { expect(agent.getMessages()[0]?.content).toBe('Do something'); await agent.shutdown(); + expect(agent.getStatus()).toBe('completed'); }); it('should process enqueued messages', async () => { @@ -134,7 +135,7 @@ describe('AgentInteractive', () => { agent.enqueueMessage('Hello'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); expect(core.runReasoningLoop).toHaveBeenCalledOnce(); @@ -197,7 +198,7 @@ describe('AgentInteractive', () => { // Second message works fine agent.enqueueMessage('recover'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); expect(callCount).toBe(2); }); @@ -313,7 +314,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const assistantMsgs = agent @@ -352,12 +353,12 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); agent.enqueueMessage('second message'); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); expect(runCount).toBe(2); }); @@ -399,7 +400,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); @@ -458,7 +459,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); @@ -517,7 +518,7 @@ describe('AgentInteractive', () => { await agent.start(context); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('completed'); + expect(agent.getStatus()).toBe('idle'); }); const messages = agent.getMessages(); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 4970077e0..7e35a96db 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -323,12 +323,16 @@ export class AgentInteractive { // ─── Private Helpers ─────────────────────────────────────── - /** Emit terminal status for the just-completed round. */ + /** + * Settle status after the run loop empties. + * On success → IDLE (agent stays alive for follow-up messages). + * On error → FAILED (terminal). + */ private settleRoundStatus(): void { if (this.lastRoundError) { this.setStatus(AgentStatus.FAILED); } else { - this.setStatus(AgentStatus.COMPLETED); + this.setStatus(AgentStatus.IDLE); } } diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index 2684406c1..ca7e283f6 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -99,28 +99,34 @@ export enum AgentTerminateMode { * Canonical lifecycle status for any agent (headless, interactive, arena). * * State machine: - * INITIALIZING → RUNNING ⇄ COMPLETED / FAILED / CANCELLED + * INITIALIZING → RUNNING → IDLE ⇄ RUNNING → … → COMPLETED / FAILED / CANCELLED * * - INITIALIZING: Setting up (creating chat, loading tools). * - RUNNING: Actively processing (model thinking / tool execution). - * - COMPLETED: Finished successfully (may re-enter RUNNING on new input). + * - IDLE: Finished current work, waiting — can accept new messages. + * - COMPLETED: Finished for good (explicit shutdown). No further interaction. * - FAILED: Finished with error (API failure, process crash, etc.). * - CANCELLED: Cancelled by user or system. */ export enum AgentStatus { INITIALIZING = 'initializing', RUNNING = 'running', + IDLE = 'idle', COMPLETED = 'completed', FAILED = 'failed', CANCELLED = 'cancelled', } -/** True for COMPLETED, FAILED, CANCELLED — agent is done working. */ +/** True for COMPLETED, FAILED, CANCELLED — agent is done for good. */ export const isTerminalStatus = (s: AgentStatus): boolean => s === AgentStatus.COMPLETED || s === AgentStatus.FAILED || s === AgentStatus.CANCELLED; +/** True for terminal statuses OR IDLE — agent has settled (not actively working). */ +export const isSettledStatus = (s: AgentStatus): boolean => + s === AgentStatus.IDLE || isTerminalStatus(s); + /** * Lightweight configuration for an AgentInteractive instance. * Carries only interactive-specific parameters; the heavy runtime From 06bef3b91f52d8bd23368a7fac803c394fd68d38 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 10 Mar 2026 14:33:40 +0800 Subject: [PATCH 21/82] fix dirs in getUserSkillsDirs --- packages/core/src/config/storage.ts | 14 ++++++++++++-- packages/core/src/skills/skill-manager.ts | 12 +++--------- packages/core/src/tools/ls.test.ts | 2 +- packages/core/src/tools/ls.ts | 6 +++--- packages/core/src/tools/read-file.test.ts | 2 +- packages/core/src/tools/read-file.ts | 6 +++--- packages/core/src/tools/shell.test.ts | 2 +- packages/core/src/tools/shell.ts | 8 ++++---- packages/core/src/utils/paths.ts | 4 ++++ 9 files changed, 32 insertions(+), 24 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 3293280a8..0272b5b8c 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,6 +12,13 @@ import { getProjectHash, sanitizeCwd } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; +export const SKILL_PROVIDER_CONFIG_DIRS = [ + '.qwen', + '.agent', + '.claude', + '.cursor', + '.codex', +]; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; @@ -133,8 +140,11 @@ export class Storage { return path.join(this.getExtensionsDir(), 'qwen-extension.json'); } - getUserSkillsDir(): string { - return path.join(Storage.getGlobalQwenDir(), 'skills'); + getUserSkillsDirs(): string[] { + const homeDir = os.homedir() || os.tmpdir(); + return SKILL_PROVIDER_CONFIG_DIRS.map((dir) => + path.join(homeDir, dir, 'skills'), + ); } getHistoryFilePath(): string { diff --git a/packages/core/src/skills/skill-manager.ts b/packages/core/src/skills/skill-manager.ts index fed6f4b98..6df002f23 100644 --- a/packages/core/src/skills/skill-manager.ts +++ b/packages/core/src/skills/skill-manager.ts @@ -21,17 +21,11 @@ import type { Config } from '../config/config.js'; import { validateConfig } from './skill-load.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; +import { SKILL_PROVIDER_CONFIG_DIRS } from '../config/storage.js'; const debugLogger = createDebugLogger('SKILL_MANAGER'); const QWEN_CONFIG_DIR = '.qwen'; -const PROVIDER_CONFIG_DIRS = [ - '.qwen', - '.agent', - '.claude', - '.cursor', - '.codex', -]; const SKILLS_CONFIG_DIR = 'skills'; const SKILL_MANIFEST_FILE = 'SKILL.md'; @@ -424,10 +418,10 @@ export class SkillManager { getSkillsBaseDirs(level: SkillLevel): string[] { const baseDirs = level === 'project' - ? PROVIDER_CONFIG_DIRS.map((v) => + ? SKILL_PROVIDER_CONFIG_DIRS.map((v) => path.join(this.config.getProjectRoot(), v, SKILLS_CONFIG_DIR), ) - : PROVIDER_CONFIG_DIRS.map((v) => + : SKILL_PROVIDER_CONFIG_DIRS.map((v) => path.join(os.homedir(), v, SKILLS_CONFIG_DIR), ); return baseDirs; diff --git a/packages/core/src/tools/ls.test.ts b/packages/core/src/tools/ls.test.ts index 39a6b7b31..cbb12fbaa 100644 --- a/packages/core/src/tools/ls.test.ts +++ b/packages/core/src/tools/ls.test.ts @@ -42,7 +42,7 @@ describe('LSTool', () => { respectQwenIgnore: true, }), storage: { - getUserSkillsDir: () => userSkillsBase, + getUserSkillsDirs: () => [userSkillsBase], }, } as unknown as Config; diff --git a/packages/core/src/tools/ls.ts b/packages/core/src/tools/ls.ts index b8edbe163..eb46da308 100644 --- a/packages/core/src/tools/ls.ts +++ b/packages/core/src/tools/ls.ts @@ -9,7 +9,7 @@ import path from 'node:path'; import type { ToolInvocation, ToolResult } from './tools.js'; import { BaseDeclarativeTool, BaseToolInvocation, Kind } from './tools.js'; import { makeRelative, shortenPath } from '../utils/paths.js'; -import { isSubpath } from '../utils/paths.js'; +import { isSubpaths } from '../utils/paths.js'; import type { Config } from '../config/config.js'; import { DEFAULT_FILE_FILTERING_OPTIONS } from '../config/constants.js'; import { ToolErrorType } from './tool-error.js'; @@ -315,8 +315,8 @@ export class LSTool extends BaseDeclarativeTool { return `Path must be absolute: ${params.path}`; } - const userSkillsBase = this.config.storage.getUserSkillsDir(); - const isUnderUserSkills = isSubpath(userSkillsBase, params.path); + const userSkillsBases = this.config.storage.getUserSkillsDirs(); + const isUnderUserSkills = isSubpaths(userSkillsBases, params.path); const workspaceContext = this.config.getWorkspaceContext(); if ( diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index ec07a6995..a36af964a 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -40,7 +40,7 @@ describe('ReadFileTool', () => { getWorkspaceContext: () => createMockWorkspaceContext(tempRootDir), storage: { getProjectTempDir: () => path.join(tempRootDir, '.temp'), - getUserSkillsDir: () => path.join(os.homedir(), '.qwen', 'skills'), + getUserSkillsDirs: () => [path.join(os.homedir(), '.qwen', 'skills')], }, getTruncateToolOutputThreshold: () => 2500, getTruncateToolOutputLines: () => 500, diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index e09a1ac58..4d3d43ac7 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -20,7 +20,7 @@ import { FileOperation } from '../telemetry/metrics.js'; import { getProgrammingLanguage } from '../telemetry/telemetry-utils.js'; import { logFileOperation } from '../telemetry/loggers.js'; import { FileOperationEvent } from '../telemetry/types.js'; -import { isSubpath } from '../utils/paths.js'; +import { isSubpaths, isSubpath } from '../utils/paths.js'; import { Storage } from '../config/storage.js'; /** @@ -186,12 +186,12 @@ export class ReadFileTool extends BaseDeclarativeTool< const workspaceContext = this.config.getWorkspaceContext(); const globalTempDir = Storage.getGlobalTempDir(); const projectTempDir = this.config.storage.getProjectTempDir(); - const userSkillsDir = this.config.storage.getUserSkillsDir(); + const userSkillsDirs = this.config.storage.getUserSkillsDirs(); const resolvedFilePath = path.resolve(filePath); const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath) || isSubpath(globalTempDir, resolvedFilePath); - const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); + const isWithinUserSkills = isSubpaths(userSkillsDirs, resolvedFilePath); if ( !workspaceContext.isPathWithinWorkspace(filePath) && diff --git a/packages/core/src/tools/shell.test.ts b/packages/core/src/tools/shell.test.ts index d03509451..0720cadf7 100644 --- a/packages/core/src/tools/shell.test.ts +++ b/packages/core/src/tools/shell.test.ts @@ -60,7 +60,7 @@ describe('ShellTool', () => { .fn() .mockReturnValue(createMockWorkspaceContext('/test/dir')), storage: { - getUserSkillsDir: vi.fn().mockReturnValue('/test/dir/.qwen/skills'), + getUserSkillsDirs: vi.fn().mockReturnValue(['/test/dir/.qwen/skills']), }, getGeminiClient: vi.fn(), getGitCoAuthor: vi.fn().mockReturnValue({ diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 01a9ac5cf..14f2a6777 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -34,7 +34,7 @@ import type { import { ShellExecutionService } from '../services/shellExecutionService.js'; import { formatMemoryUsage } from '../utils/formatters.js'; import type { AnsiOutput } from '../utils/terminalSerializer.js'; -import { isSubpath } from '../utils/paths.js'; +import { isSubpaths } from '../utils/paths.js'; import { getCommandRoots, isCommandAllowed, @@ -621,10 +621,10 @@ export class ShellTool extends BaseDeclarativeTool< return 'Directory must be an absolute path.'; } - const userSkillsDir = this.config.storage.getUserSkillsDir(); + const userSkillsDirs = this.config.storage.getUserSkillsDirs(); const resolvedDirectoryPath = path.resolve(params.directory); - const isWithinUserSkills = isSubpath( - userSkillsDir, + const isWithinUserSkills = isSubpaths( + userSkillsDirs, resolvedDirectoryPath, ); if (isWithinUserSkills) { diff --git a/packages/core/src/utils/paths.ts b/packages/core/src/utils/paths.ts index dc4434ece..6e6bdfa49 100644 --- a/packages/core/src/utils/paths.ts +++ b/packages/core/src/utils/paths.ts @@ -241,6 +241,10 @@ export function isSubpath(parentPath: string, childPath: string): boolean { ); } +export function isSubpaths(parentPath: string[], childPath: string): boolean { + return parentPath.some((p) => isSubpath(p, childPath)); +} + /** * Resolves a path with tilde (~) expansion and relative path resolution. * Handles tilde expansion for home directory and resolves relative paths From 89f8751233085a09115bb0b5d6be0ac03f576387 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 10 Mar 2026 16:53:10 +0800 Subject: [PATCH 22/82] feat(cli): add agent composer UI and refactor text input handling - Extract shared BaseTextInput component with readline keyboard handling - Add AgentComposer and AgentFooter components for agent interaction - Add useAgentStreamingState hook for managing agent streaming state - Refactor InputPrompt to use BaseTextInput with agent tab bar focus support - Move calculatePromptWidths to shared layoutUtils - Disable auto-accept indicator on agent tabs (agents handle their own) This enables a dedicated input experience for agent tabs with proper focus management and keyboard navigation between main input and agent tabs. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/AppContainer.tsx | 7 +- .../cli/src/ui/components/BaseTextInput.tsx | 287 +++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 481 ++++++++---------- .../src/ui/components/LoadingIndicator.tsx | 2 +- .../LoadingIndicator.test.tsx.snap | 4 +- .../components/agent-view/AgentComposer.tsx | 284 +++++++++++ .../ui/components/agent-view/AgentFooter.tsx | 66 +++ .../ui/components/agent-view/AgentTabBar.tsx | 52 +- .../cli/src/ui/components/agent-view/index.ts | 2 + .../cli/src/ui/contexts/AgentViewContext.tsx | 119 ++++- .../src/ui/hooks/useAgentStreamingState.ts | 165 ++++++ .../src/ui/hooks/useAutoAcceptIndicator.ts | 5 +- .../cli/src/ui/layouts/DefaultAppLayout.tsx | 59 ++- packages/cli/src/ui/utils/layoutUtils.ts | 40 ++ .../core/src/agents/runtime/agent-core.ts | 13 + .../src/agents/runtime/agent-interactive.ts | 5 + packages/core/src/core/client.ts | 1 + packages/core/src/core/geminiChat.test.ts | 8 +- packages/core/src/core/geminiChat.ts | 10 +- 19 files changed, 1273 insertions(+), 337 deletions(-) create mode 100644 packages/cli/src/ui/components/BaseTextInput.tsx create mode 100644 packages/cli/src/ui/components/agent-view/AgentComposer.tsx create mode 100644 packages/cli/src/ui/components/agent-view/AgentFooter.tsx create mode 100644 packages/cli/src/ui/hooks/useAgentStreamingState.ts create mode 100644 packages/cli/src/ui/utils/layoutUtils.ts diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 3aeaaffaf..7445051f0 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -676,16 +676,17 @@ export const AppContainer = (props: AppContainerProps) => { // Track whether suggestions are visible for Tab key handling const [hasSuggestionsVisible, setHasSuggestionsVisible] = useState(false); - // Auto-accept indicator + const agentViewState = useAgentViewState(); + + // Auto-accept indicator — disabled on agent tabs (agents handle their own) const showAutoAcceptIndicator = useAutoAcceptIndicator({ config, addItem: historyManager.addItem, onApprovalModeChange: handleApprovalModeChange, shouldBlockTab: () => hasSuggestionsVisible, + disabled: agentViewState.activeView !== 'main', }); - const agentViewState = useAgentViewState(); - const { messageQueue, addMessage, clearQueue, getQueuedMessagesText } = useMessageQueue({ isConfigInitialized, diff --git a/packages/cli/src/ui/components/BaseTextInput.tsx b/packages/cli/src/ui/components/BaseTextInput.tsx new file mode 100644 index 000000000..07eb1a693 --- /dev/null +++ b/packages/cli/src/ui/components/BaseTextInput.tsx @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview BaseTextInput — shared text input component with rendering + * and common readline keyboard handling. + * + * Provides: + * - Viewport line rendering from a TextBuffer with cursor display + * - Placeholder support when buffer is empty + * - Configurable border/prefix styling + * - Standard readline shortcuts (Ctrl+A/E/K/U/W, Escape, etc.) + * - An `onKeypress` interceptor so consumers can layer custom behavior + * + * Used by both InputPrompt (with syntax highlighting + complex key handling) + * and AgentComposer (with minimal customization). + */ + +import type React from 'react'; +import { useCallback } from 'react'; +import { Box, Text } from 'ink'; +import chalk from 'chalk'; +import type { TextBuffer } from './shared/text-buffer.js'; +import type { Key } from '../hooks/useKeypress.js'; +import { useKeypress } from '../hooks/useKeypress.js'; +import { keyMatchers, Command } from '../keyMatchers.js'; +import { cpSlice, cpLen } from '../utils/textUtils.js'; +import { theme } from '../semantic-colors.js'; + +// ─── Types ────────────────────────────────────────────────── + +export interface RenderLineOptions { + /** The text content of this visual line. */ + lineText: string; + /** Whether the cursor is on this visual line. */ + isOnCursorLine: boolean; + /** The cursor column within this visual line (visual col, not logical). */ + cursorCol: number; + /** Whether the cursor should be rendered. */ + showCursor: boolean; + /** Index of this line within the rendered viewport (0-based). */ + visualLineIndex: number; + /** Absolute visual line index (scrollVisualRow + visualLineIndex). */ + absoluteVisualIndex: number; + /** The underlying text buffer. */ + buffer: TextBuffer; + /** The first visible visual row (scroll offset). */ + scrollVisualRow: number; +} + +export interface BaseTextInputProps { + /** The text buffer driving this input. */ + buffer: TextBuffer; + /** Called when the user submits (Enter). Buffer is cleared automatically. */ + onSubmit: (text: string) => void; + /** + * Optional key interceptor. Called before default readline handling. + * Return `true` if the key was handled (skips default processing). + */ + onKeypress?: (key: Key) => boolean; + /** Whether to show the blinking block cursor. Defaults to true. */ + showCursor?: boolean; + /** Placeholder text shown when the buffer is empty. */ + placeholder?: string; + /** Custom prefix node (defaults to `> `). */ + prefix?: React.ReactNode; + /** Border color for the input box. */ + borderColor?: string; + /** Whether keyboard handling is active. Defaults to true. */ + isActive?: boolean; + /** + * Custom line renderer for advanced rendering (e.g. syntax highlighting). + * When not provided, lines are rendered as plain text with cursor overlay. + */ + renderLine?: (opts: RenderLineOptions) => React.ReactNode; +} + +// ─── Default line renderer ────────────────────────────────── + +/** + * Renders a single visual line with an inverse-video block cursor. + * Uses codepoint-aware string operations for Unicode/emoji safety. + */ +export function defaultRenderLine({ + lineText, + isOnCursorLine, + cursorCol, + showCursor, +}: RenderLineOptions): React.ReactNode { + if (!isOnCursorLine || !showCursor) { + return {lineText || ' '}; + } + + const len = cpLen(lineText); + + // Cursor past end of line — append inverse space + if (cursorCol >= len) { + return ( + + {lineText} + {chalk.inverse(' ') + '\u200B'} + + ); + } + + const before = cpSlice(lineText, 0, cursorCol); + const cursorChar = cpSlice(lineText, cursorCol, cursorCol + 1); + const after = cpSlice(lineText, cursorCol + 1); + + return ( + + {before} + {chalk.inverse(cursorChar)} + {after} + + ); +} + +// ─── Component ────────────────────────────────────────────── + +export const BaseTextInput: React.FC = ({ + buffer, + onSubmit, + onKeypress, + showCursor = true, + placeholder, + prefix, + borderColor, + isActive = true, + renderLine = defaultRenderLine, +}) => { + // ── Keyboard handling ── + + const handleKey = useCallback( + (key: Key) => { + // Let the consumer intercept first + if (onKeypress?.(key)) { + return; + } + + // ── Standard readline shortcuts ── + + // Submit (Enter, no modifiers) + if (keyMatchers[Command.SUBMIT](key)) { + if (buffer.text.trim()) { + const text = buffer.text; + buffer.setText(''); + onSubmit(text); + } + return; + } + + // Newline (Shift+Enter, Ctrl+Enter, Ctrl+J) + if (keyMatchers[Command.NEWLINE](key)) { + buffer.newline(); + return; + } + + // Escape → clear input + if (keyMatchers[Command.ESCAPE](key)) { + if (buffer.text.length > 0) { + buffer.setText(''); + } + return; + } + + // Ctrl+C → clear input + if (keyMatchers[Command.CLEAR_INPUT](key)) { + if (buffer.text.length > 0) { + buffer.setText(''); + } + return; + } + + // Ctrl+A → home + if (keyMatchers[Command.HOME](key)) { + buffer.move('home'); + return; + } + + // Ctrl+E → end + if (keyMatchers[Command.END](key)) { + buffer.move('end'); + return; + } + + // Ctrl+K → kill to end of line + if (keyMatchers[Command.KILL_LINE_RIGHT](key)) { + buffer.killLineRight(); + return; + } + + // Ctrl+U → kill to start of line + if (keyMatchers[Command.KILL_LINE_LEFT](key)) { + buffer.killLineLeft(); + return; + } + + // Ctrl+W / Alt+Backspace → delete word backward + if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) { + buffer.deleteWordLeft(); + return; + } + + // Ctrl+X Ctrl+E → open in external editor + if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { + buffer.openInExternalEditor(); + return; + } + + // Backspace + if ( + key.name === 'backspace' || + key.sequence === '\x7f' || + (key.ctrl && key.name === 'h') + ) { + buffer.backspace(); + return; + } + + // Fallthrough — delegate to buffer's built-in input handler + buffer.handleInput(key); + }, + [buffer, onSubmit, onKeypress], + ); + + useKeypress(handleKey, { isActive }); + + // ── Rendering ── + + const linesToRender = buffer.viewportVisualLines; + const [cursorVisualRow, cursorVisualCol] = buffer.visualCursor; + const scrollVisualRow = buffer.visualScrollRow; + + const resolvedBorderColor = borderColor ?? theme.border.focused; + const resolvedPrefix = prefix ?? ( + {'> '} + ); + + return ( + + {resolvedPrefix} + + {buffer.text.length === 0 && placeholder ? ( + showCursor ? ( + + {chalk.inverse(placeholder.slice(0, 1))} + {placeholder.slice(1)} + + ) : ( + {placeholder} + ) + ) : ( + linesToRender.map((lineText, idx) => { + const absoluteVisualIndex = scrollVisualRow + idx; + const isOnCursorLine = absoluteVisualIndex === cursorVisualRow; + + return ( + + {renderLine({ + lineText, + isOnCursorLine, + cursorCol: cursorVisualCol, + showCursor, + visualLineIndex: idx, + absoluteVisualIndex, + buffer, + scrollVisualRow, + })} + + ); + }) + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 5c2925afc..02cc8dafe 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -18,7 +18,6 @@ import { useShellHistory } from '../hooks/useShellHistory.js'; import { useReverseSearchCompletion } from '../hooks/useReverseSearchCompletion.js'; import { useCommandCompletion } from '../hooks/useCommandCompletion.js'; import type { Key } from '../hooks/useKeypress.js'; -import { useKeypress } from '../hooks/useKeypress.js'; import { keyMatchers, Command } from '../keyMatchers.js'; import type { CommandContext, SlashCommand } from '../commands/types.js'; import type { Config } from '@qwen-code/qwen-code-core'; @@ -43,7 +42,13 @@ import { useShellFocusState } from '../contexts/ShellFocusContext.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useKeypressContext } from '../contexts/KeypressContext.js'; +import { + useAgentViewState, + useAgentViewActions, +} from '../contexts/AgentViewContext.js'; import { FEEDBACK_DIALOG_KEYS } from '../FeedbackDialog.js'; +import { BaseTextInput } from './BaseTextInput.js'; +import type { RenderLineOptions } from './BaseTextInput.js'; /** * Represents an attachment (e.g., pasted image) displayed above the input prompt @@ -78,30 +83,8 @@ export interface InputPromptProps { isEmbeddedShellFocused?: boolean; } -// The input content, input container, and input suggestions list may have different widths -export const calculatePromptWidths = (terminalWidth: number) => { - const widthFraction = 0.9; - const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2) - const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! ' - const MIN_CONTENT_WIDTH = 2; - - const innerContentWidth = - Math.floor(terminalWidth * widthFraction) - - FRAME_PADDING_AND_BORDER - - PROMPT_PREFIX_WIDTH; - - const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth); - const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH; - const containerWidth = inputWidth + FRAME_OVERHEAD; - const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0)); - - return { - inputWidth, - containerWidth, - suggestionsWidth, - frameOverhead: FRAME_OVERHEAD, - } as const; -}; +// Re-export from shared utils for backwards compatibility +export { calculatePromptWidths } from '../utils/layoutUtils.js'; // Large paste placeholder thresholds const LARGE_PASTE_CHAR_THRESHOLD = 1000; @@ -132,6 +115,9 @@ export const InputPrompt: React.FC = ({ const uiState = useUIState(); const uiActions = useUIActions(); const { pasteWorkaround } = useKeypressContext(); + const { agents, agentTabBarFocused } = useAgentViewState(); + const { setAgentTabBarFocused } = useAgentViewActions(); + const hasAgents = agents.size > 0; const [justNavigatedHistory, setJustNavigatedHistory] = useState(false); const [escPressCount, setEscPressCount] = useState(0); const [showEscapePrompt, setShowEscapePrompt] = useState(false); @@ -225,7 +211,8 @@ export const InputPrompt: React.FC = ({ const resetCommandSearchCompletionState = commandSearchCompletion.resetCompletionState; - const showCursor = focus && isShellFocused && !isEmbeddedShellFocused; + const showCursor = + focus && isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused; const resetEscapeState = useCallback(() => { if (escapeTimerRef.current) { @@ -411,13 +398,30 @@ export const InputPrompt: React.FC = ({ }, []); const handleInput = useCallback( - (key: Key) => { + (key: Key): boolean => { + // When the tab bar has focus, block all non-printable keys so arrow + // keys and shortcuts don't interfere. Printable characters fall + // through to BaseTextInput's default handler so the first keystroke + // appears in the input immediately (the tab bar handler releases + // focus on the same event). + if (agentTabBarFocused) { + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta + ) { + return false; // let BaseTextInput type the character + } + return true; // consume non-printable keys + } + // TODO(jacobr): this special case is likely not needed anymore. // We should probably stop supporting paste if the InputPrompt is not // focused. /// We want to handle paste even when not focused to support drag and drop. if (!focus && !key.paste) { - return; + return true; } if (key.paste) { @@ -459,18 +463,18 @@ export const InputPrompt: React.FC = ({ // Normal paste handling for small content buffer.handleInput(key); } - return; + return true; } if (vimHandleInput && vimHandleInput(key)) { - return; + return true; } // Handle feedback dialog keyboard interactions when dialog is open if (uiState.isFeedbackDialogOpen) { // If it's one of the feedback option keys (1-4), let FeedbackDialog handle it if ((FEEDBACK_DIALOG_KEYS as readonly string[]).includes(key.name)) { - return; + return true; } else { // For any other key, close feedback dialog temporarily and continue with normal processing uiActions.temporaryCloseFeedbackDialog(); @@ -496,7 +500,7 @@ export const InputPrompt: React.FC = ({ } setShellModeActive(!shellModeActive); buffer.setText(''); // Clear the '!' from input - return; + return true; } // Toggle keyboard shortcuts display with "?" when buffer is empty @@ -507,7 +511,7 @@ export const InputPrompt: React.FC = ({ onToggleShortcuts ) { onToggleShortcuts(); - return; + return true; } // Hide shortcuts on any other key press @@ -537,33 +541,33 @@ export const InputPrompt: React.FC = ({ setReverseSearchActive, reverseSearchCompletion.resetCompletionState, ); - return; + return true; } if (commandSearchActive) { cancelSearch( setCommandSearchActive, commandSearchCompletion.resetCompletionState, ); - return; + return true; } if (shellModeActive) { setShellModeActive(false); resetEscapeState(); - return; + return true; } if (completion.showSuggestions) { completion.resetCompletionState(); setExpandedSuggestionIndex(-1); resetEscapeState(); - return; + return true; } // Handle double ESC for clearing input if (escPressCount === 0) { if (buffer.text === '') { - return; + return true; } setEscPressCount(1); setShowEscapePrompt(true); @@ -579,7 +583,7 @@ export const InputPrompt: React.FC = ({ resetCompletionState(); resetEscapeState(); } - return; + return true; } // Ctrl+Y: Retry the last failed request. @@ -589,19 +593,19 @@ export const InputPrompt: React.FC = ({ // If no failed request exists, a message will be shown to the user. if (keyMatchers[Command.RETRY_LAST](key)) { uiActions.handleRetryLastPrompt(); - return; + return true; } if (shellModeActive && keyMatchers[Command.REVERSE_SEARCH](key)) { setReverseSearchActive(true); setTextBeforeReverseSearch(buffer.text); setCursorPosition(buffer.cursor); - return; + return true; } if (keyMatchers[Command.CLEAR_SCREEN](key)) { onClearScreen(); - return; + return true; } if (reverseSearchActive || commandSearchActive) { @@ -626,29 +630,29 @@ export const InputPrompt: React.FC = ({ if (showSuggestions) { if (keyMatchers[Command.NAVIGATION_UP](key)) { navigateUp(); - return; + return true; } if (keyMatchers[Command.NAVIGATION_DOWN](key)) { navigateDown(); - return; + return true; } if (keyMatchers[Command.COLLAPSE_SUGGESTION](key)) { if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) { setExpandedSuggestionIndex(-1); - return; + return true; } } if (keyMatchers[Command.EXPAND_SUGGESTION](key)) { if (suggestions[activeSuggestionIndex].value.length >= MAX_WIDTH) { setExpandedSuggestionIndex(activeSuggestionIndex); - return; + return true; } } if (keyMatchers[Command.ACCEPT_SUGGESTION_REVERSE_SEARCH](key)) { sc.handleAutocomplete(activeSuggestionIndex); resetState(); setActive(false); - return; + return true; } } @@ -660,7 +664,7 @@ export const InputPrompt: React.FC = ({ handleSubmitAndClear(textToSubmit); resetState(); setActive(false); - return; + return true; } // Prevent up/down from falling through to regular history navigation @@ -668,14 +672,14 @@ export const InputPrompt: React.FC = ({ keyMatchers[Command.NAVIGATION_UP](key) || keyMatchers[Command.NAVIGATION_DOWN](key) ) { - return; + return true; } } // If the command is a perfect match, pressing enter should execute it. if (completion.isPerfectMatch && keyMatchers[Command.RETURN](key)) { handleSubmitAndClear(buffer.text); - return; + return true; } if (completion.showSuggestions) { @@ -683,12 +687,12 @@ export const InputPrompt: React.FC = ({ if (keyMatchers[Command.COMPLETION_UP](key)) { completion.navigateUp(); setExpandedSuggestionIndex(-1); // Reset expansion when navigating - return; + return true; } if (keyMatchers[Command.COMPLETION_DOWN](key)) { completion.navigateDown(); setExpandedSuggestionIndex(-1); // Reset expansion when navigating - return; + return true; } } @@ -703,7 +707,7 @@ export const InputPrompt: React.FC = ({ setExpandedSuggestionIndex(-1); // Reset expansion after selection } } - return; + return true; } } @@ -711,28 +715,28 @@ export const InputPrompt: React.FC = ({ if (isAttachmentMode && attachments.length > 0) { if (key.name === 'left') { setSelectedAttachmentIndex((i) => Math.max(0, i - 1)); - return; + return true; } if (key.name === 'right') { setSelectedAttachmentIndex((i) => Math.min(attachments.length - 1, i + 1), ); - return; + return true; } if (keyMatchers[Command.NAVIGATION_DOWN](key)) { // Exit attachment mode and return to input setIsAttachmentMode(false); setSelectedAttachmentIndex(-1); - return; + return true; } if (key.name === 'backspace' || key.name === 'delete') { handleAttachmentDelete(selectedAttachmentIndex); - return; + return true; } if (key.name === 'return' || key.name === 'escape') { setIsAttachmentMode(false); setSelectedAttachmentIndex(-1); - return; + return true; } // For other keys, exit attachment mode and let input handle them setIsAttachmentMode(false); @@ -753,7 +757,7 @@ export const InputPrompt: React.FC = ({ ) { setIsAttachmentMode(true); setSelectedAttachmentIndex(attachments.length - 1); - return; + return true; } if (!shellModeActive) { @@ -761,16 +765,16 @@ export const InputPrompt: React.FC = ({ setCommandSearchActive(true); setTextBeforeReverseSearch(buffer.text); setCursorPosition(buffer.cursor); - return; + return true; } if (keyMatchers[Command.HISTORY_UP](key)) { inputHistory.navigateUp(); - return; + return true; } if (keyMatchers[Command.HISTORY_DOWN](key)) { inputHistory.navigateDown(); - return; + return true; } // Handle arrow-up/down for history on single-line or at edges if ( @@ -779,27 +783,33 @@ export const InputPrompt: React.FC = ({ (buffer.visualCursor[0] === 0 && buffer.visualScrollRow === 0)) ) { inputHistory.navigateUp(); - return; + return true; } if ( keyMatchers[Command.NAVIGATION_DOWN](key) && (buffer.allVisualLines.length === 1 || buffer.visualCursor[0] === buffer.allVisualLines.length - 1) ) { - inputHistory.navigateDown(); - return; + if (inputHistory.navigateDown()) { + return true; + } + if (hasAgents) { + setAgentTabBarFocused(true); + return true; + } + return true; } } else { // Shell History Navigation if (keyMatchers[Command.NAVIGATION_UP](key)) { const prevCommand = shellHistory.getPreviousCommand(); if (prevCommand !== null) buffer.setText(prevCommand); - return; + return true; } if (keyMatchers[Command.NAVIGATION_DOWN](key)) { const nextCommand = shellHistory.getNextCommand(); if (nextCommand !== null) buffer.setText(nextCommand); - return; + return true; } } @@ -810,7 +820,7 @@ export const InputPrompt: React.FC = ({ // paste markers may not work reliably and Enter key events can leak from pasted text. if (pasteWorkaround && recentPasteTime !== null) { // Paste occurred recently, ignore this submit to prevent auto-execution - return; + return true; } const [row, col] = buffer.cursor; @@ -823,65 +833,21 @@ export const InputPrompt: React.FC = ({ handleSubmitAndClear(buffer.text); } } - return; - } - - // Newline insertion - if (keyMatchers[Command.NEWLINE](key)) { - buffer.newline(); - return; - } - - // Ctrl+A (Home) / Ctrl+E (End) - if (keyMatchers[Command.HOME](key)) { - buffer.move('home'); - return; - } - if (keyMatchers[Command.END](key)) { - buffer.move('end'); - return; - } - // Ctrl+C (Clear input) - if (keyMatchers[Command.CLEAR_INPUT](key)) { - if (buffer.text.length > 0) { - buffer.setText(''); - resetCompletionState(); - } - return; - } - - // Kill line commands - if (keyMatchers[Command.KILL_LINE_RIGHT](key)) { - buffer.killLineRight(); - return; - } - if (keyMatchers[Command.KILL_LINE_LEFT](key)) { - buffer.killLineLeft(); - return; - } - - if (keyMatchers[Command.DELETE_WORD_BACKWARD](key)) { - buffer.deleteWordLeft(); - return; - } - - // External editor - if (keyMatchers[Command.OPEN_EXTERNAL_EDITOR](key)) { - buffer.openInExternalEditor(); - return; + return true; } // Ctrl+V for clipboard image paste if (keyMatchers[Command.PASTE_CLIPBOARD_IMAGE](key)) { handleClipboardImage(); - return; + return true; } // Handle backspace with placeholder-aware deletion if ( - key.name === 'backspace' || - key.sequence === '\x7f' || - (key.ctrl && key.name === 'h') + pendingPastes.size > 0 && + (key.name === 'backspace' || + key.sequence === '\x7f' || + (key.ctrl && key.name === 'h')) ) { const text = buffer.text; const [row, col] = buffer.cursor; @@ -894,7 +860,6 @@ export const InputPrompt: React.FC = ({ offset += col; // Check if we're at the end of any placeholder - let placeholderDeleted = false; for (const placeholder of pendingPastes.keys()) { const placeholderStart = offset - placeholder.length; if ( @@ -913,20 +878,22 @@ export const InputPrompt: React.FC = ({ if (parsed) { freePlaceholderId(parsed.charCount, parsed.id); } - placeholderDeleted = true; - break; + return true; } } - - if (!placeholderDeleted) { - // Normal backspace behavior - buffer.backspace(); - } - return; + // No placeholder matched — fall through to BaseTextInput's default backspace } - // Fall back to the text buffer's default input handling for all other keys - buffer.handleInput(key); + // Ctrl+C with completion active — also reset completion state + if (keyMatchers[Command.CLEAR_INPUT](key)) { + if (buffer.text.length > 0) { + resetCompletionState(); + } + // Fall through to BaseTextInput's default CLEAR_INPUT handler + } + + // All remaining keys (readline shortcuts, text input) handled by BaseTextInput + return false; }, [ focus, @@ -964,17 +931,89 @@ export const InputPrompt: React.FC = ({ pendingPastes, parsePlaceholder, freePlaceholderId, + agentTabBarFocused, + hasAgents, + setAgentTabBarFocused, ], ); - useKeypress(handleInput, { - isActive: !isEmbeddedShellFocused, - }); + const renderLineWithHighlighting = useCallback( + (opts: RenderLineOptions): React.ReactNode => { + const { + lineText, + isOnCursorLine, + cursorCol: cursorVisualColAbsolute, + showCursor: showCursorOpt, + absoluteVisualIndex, + buffer: buf, + } = opts; + const mapEntry = buf.visualToLogicalMap[absoluteVisualIndex]; + const [logicalLineIdx, logicalStartCol] = mapEntry; + const logicalLine = buf.lines[logicalLineIdx] || ''; + const tokens = parseInputForHighlighting(logicalLine, logicalLineIdx); - const linesToRender = buffer.viewportVisualLines; - const [cursorVisualRowAbsolute, cursorVisualColAbsolute] = - buffer.visualCursor; - const scrollVisualRow = buffer.visualScrollRow; + const visualStart = logicalStartCol; + const visualEnd = logicalStartCol + cpLen(lineText); + const segments = buildSegmentsForVisualSlice( + tokens, + visualStart, + visualEnd, + ); + + const renderedLine: React.ReactNode[] = []; + let charCount = 0; + segments.forEach((seg, segIdx) => { + const segLen = cpLen(seg.text); + let display = seg.text; + + if (isOnCursorLine) { + const segStart = charCount; + const segEnd = segStart + segLen; + if ( + cursorVisualColAbsolute >= segStart && + cursorVisualColAbsolute < segEnd + ) { + const charToHighlight = cpSlice( + seg.text, + cursorVisualColAbsolute - segStart, + cursorVisualColAbsolute - segStart + 1, + ); + const highlighted = showCursorOpt + ? chalk.inverse(charToHighlight) + : charToHighlight; + display = + cpSlice(seg.text, 0, cursorVisualColAbsolute - segStart) + + highlighted + + cpSlice(seg.text, cursorVisualColAbsolute - segStart + 1); + } + charCount = segEnd; + } + + const color = + seg.type === 'command' || seg.type === 'file' + ? theme.text.accent + : theme.text.primary; + + renderedLine.push( + + {display} + , + ); + }); + + if (isOnCursorLine && cursorVisualColAbsolute === cpLen(lineText)) { + // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace + renderedLine.push( + + {showCursorOpt ? chalk.inverse(' ') + '\u200B' : ' \u200B'} + , + ); + } + + return {renderedLine}; + }, + [], + ); const getActiveCompletion = () => { if (commandSearchActive) return commandSearchCompletion; @@ -1011,10 +1050,33 @@ export const InputPrompt: React.FC = ({ } const borderColor = - isShellFocused && !isEmbeddedShellFocused + isShellFocused && !isEmbeddedShellFocused && !agentTabBarFocused ? (statusColor ?? theme.border.focused) : theme.border.default; + const prefixNode = ( + + {shellModeActive ? ( + reverseSearchActive ? ( + + (r:){' '} + + ) : ( + '!' + ) + ) : commandSearchActive ? ( + (r:) + ) : showYoloStyling ? ( + '*' + ) : ( + '>' + )}{' '} + + ); + return ( <> {attachments.length > 0 && ( @@ -1034,142 +1096,17 @@ export const InputPrompt: React.FC = ({ ))} )} - - - {shellModeActive ? ( - reverseSearchActive ? ( - - (r:){' '} - - ) : ( - '!' - ) - ) : commandSearchActive ? ( - (r:) - ) : showYoloStyling ? ( - '*' - ) : ( - '>' - )}{' '} - - - {buffer.text.length === 0 && placeholder ? ( - showCursor ? ( - - {chalk.inverse(placeholder.slice(0, 1))} - {placeholder.slice(1)} - - ) : ( - {placeholder} - ) - ) : ( - linesToRender.map((lineText, visualIdxInRenderedSet) => { - const absoluteVisualIdx = - scrollVisualRow + visualIdxInRenderedSet; - const mapEntry = buffer.visualToLogicalMap[absoluteVisualIdx]; - const cursorVisualRow = cursorVisualRowAbsolute - scrollVisualRow; - const isOnCursorLine = - focus && visualIdxInRenderedSet === cursorVisualRow; - - const renderedLine: React.ReactNode[] = []; - - const [logicalLineIdx, logicalStartCol] = mapEntry; - const logicalLine = buffer.lines[logicalLineIdx] || ''; - const tokens = parseInputForHighlighting( - logicalLine, - logicalLineIdx, - ); - - const visualStart = logicalStartCol; - const visualEnd = logicalStartCol + cpLen(lineText); - const segments = buildSegmentsForVisualSlice( - tokens, - visualStart, - visualEnd, - ); - - let charCount = 0; - segments.forEach((seg, segIdx) => { - const segLen = cpLen(seg.text); - let display = seg.text; - - if (isOnCursorLine) { - const relativeVisualColForHighlight = cursorVisualColAbsolute; - const segStart = charCount; - const segEnd = segStart + segLen; - if ( - relativeVisualColForHighlight >= segStart && - relativeVisualColForHighlight < segEnd - ) { - const charToHighlight = cpSlice( - seg.text, - relativeVisualColForHighlight - segStart, - relativeVisualColForHighlight - segStart + 1, - ); - const highlighted = showCursor - ? chalk.inverse(charToHighlight) - : charToHighlight; - display = - cpSlice( - seg.text, - 0, - relativeVisualColForHighlight - segStart, - ) + - highlighted + - cpSlice( - seg.text, - relativeVisualColForHighlight - segStart + 1, - ); - } - charCount = segEnd; - } - - const color = - seg.type === 'command' || seg.type === 'file' - ? theme.text.accent - : theme.text.primary; - - renderedLine.push( - - {display} - , - ); - }); - - if ( - isOnCursorLine && - cursorVisualColAbsolute === cpLen(lineText) - ) { - // Add zero-width space after cursor to prevent Ink from trimming trailing whitespace - renderedLine.push( - - {showCursor ? chalk.inverse(' ') + '\u200B' : ' \u200B'} - , - ); - } - - return ( - - {renderedLine} - - ); - }) - )} - - + isActive={!isEmbeddedShellFocused} + renderLine={renderLineWithHighlighting} + /> {shouldShowSuggestions && ( = ({ : null; return ( - + {/* Main loading line */} > should truncate long primary text instead of wrapping 1`] = ` -"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to -Spinner cancel, 5s)" +" MockResponding This is an extremely long loading phrase that should be truncated in (esc to + Spinner cancel, 5s)" `; diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx new file mode 100644 index 000000000..8c4d18b82 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -0,0 +1,284 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview AgentComposer — footer area for in-process agent tabs. + * + * Replaces the main Composer when an agent tab is active so that: + * - The loading indicator reflects the agent's status (not the main agent) + * - The input prompt sends messages to the agent (via enqueueMessage) + * - Keyboard events are scoped — no conflict with the main InputPrompt + * + * Wraps its content in a local StreamingContext.Provider so reusable + * components like LoadingIndicator and GeminiRespondingSpinner read the + * agent's derived streaming state instead of the main agent's. + */ + +import { Box, Text, useStdin } from 'ink'; +import { useCallback, useEffect, useMemo } from 'react'; +import { + AgentStatus, + ApprovalMode, + APPROVAL_MODES, +} from '@qwen-code/qwen-code-core'; +import { + useAgentViewState, + useAgentViewActions, +} from '../../contexts/AgentViewContext.js'; +import { useConfig } from '../../contexts/ConfigContext.js'; +import { StreamingContext } from '../../contexts/StreamingContext.js'; +import { StreamingState } from '../../types.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; +import { useAgentStreamingState } from '../../hooks/useAgentStreamingState.js'; +import { useKeypress, type Key } from '../../hooks/useKeypress.js'; +import { useTextBuffer } from '../shared/text-buffer.js'; +import { calculatePromptWidths } from '../../utils/layoutUtils.js'; +import { BaseTextInput } from '../BaseTextInput.js'; +import { LoadingIndicator } from '../LoadingIndicator.js'; +import { AgentFooter } from './AgentFooter.js'; +import { keyMatchers, Command } from '../../keyMatchers.js'; +import { theme } from '../../semantic-colors.js'; +import { t } from '../../../i18n/index.js'; + +// ─── Types ────────────────────────────────────────────────── + +interface AgentComposerProps { + agentId: string; +} + +// ─── Component ────────────────────────────────────────────── + +export const AgentComposer: React.FC = ({ agentId }) => { + const { agents, agentTabBarFocused, agentShellFocused, agentApprovalModes } = + useAgentViewState(); + const { + setAgentInputBufferText, + setAgentTabBarFocused, + setAgentApprovalMode, + } = useAgentViewActions(); + const agent = agents.get(agentId); + const interactiveAgent = agent?.interactiveAgent; + + const config = useConfig(); + const { columns: terminalWidth } = useTerminalSize(); + const { inputWidth } = calculatePromptWidths(terminalWidth); + const { stdin, setRawMode } = useStdin(); + + const { + status, + streamingState, + isInputActive, + elapsedTime, + lastPromptTokenCount, + } = useAgentStreamingState(interactiveAgent); + + // ── Escape to cancel the active agent round ── + + useKeypress( + (key) => { + if ( + key.name === 'escape' && + streamingState === StreamingState.Responding + ) { + interactiveAgent?.cancelCurrentRound(); + } + }, + { + isActive: + streamingState === StreamingState.Responding && !agentShellFocused, + }, + ); + + // ── Shift+Tab to cycle this agent's approval mode ── + + const agentApprovalMode = + agentApprovalModes.get(agentId) ?? ApprovalMode.DEFAULT; + + useKeypress( + (key) => { + const isShiftTab = key.shift && key.name === 'tab'; + const isWindowsTab = + process.platform === 'win32' && + key.name === 'tab' && + !key.ctrl && + !key.meta; + if (isShiftTab || isWindowsTab) { + const currentIndex = APPROVAL_MODES.indexOf(agentApprovalMode); + const nextIndex = + currentIndex === -1 ? 0 : (currentIndex + 1) % APPROVAL_MODES.length; + setAgentApprovalMode(agentId, APPROVAL_MODES[nextIndex]!); + } + }, + { isActive: !agentShellFocused }, + ); + + // ── Input buffer (independent from main agent) ── + + const isValidPath = useCallback((): boolean => false, []); + + const buffer = useTextBuffer({ + initialText: '', + viewport: { height: 3, width: inputWidth }, + stdin, + setRawMode, + isValidPath, + }); + + // Sync agent buffer text to context so AgentTabBar can guard tab switching + useEffect(() => { + setAgentInputBufferText(buffer.text); + return () => setAgentInputBufferText(''); + }, [buffer.text, setAgentInputBufferText]); + + // When agent input is not active (agent running, completed, etc.), + // auto-focus the tab bar so arrow keys switch tabs directly. + // We also depend on streamingState so that transitions like + // WaitingForConfirmation → Responding re-trigger the effect — the + // approval keypress releases tab-bar focus (printable char handler), + // but isInputActive stays false throughout, so without this extra + // dependency the focus would never be restored. + useEffect(() => { + if (!isInputActive) { + setAgentTabBarFocused(true); + } + }, [isInputActive, streamingState, setAgentTabBarFocused]); + + // ── Focus management between input and tab bar ── + + const handleKeypress = useCallback( + (key: Key): boolean => { + // When tab bar has focus, block all non-printable keys so they don't + // act on the hidden buffer. Printable characters fall through to + // BaseTextInput naturally; the tab bar handler releases focus on the + // same event so the keystroke appears in the input immediately. + if (agentTabBarFocused) { + if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta + ) { + return false; // let BaseTextInput type the character + } + return true; // consume non-printable keys + } + + // Down arrow at the bottom edge (or empty buffer) → focus the tab bar + if (keyMatchers[Command.NAVIGATION_DOWN](key)) { + if ( + buffer.text === '' || + buffer.allVisualLines.length === 1 || + buffer.visualCursor[0] === buffer.allVisualLines.length - 1 + ) { + setAgentTabBarFocused(true); + return true; + } + } + return false; + }, + [buffer, agentTabBarFocused, setAgentTabBarFocused], + ); + + const handleSubmit = useCallback( + (text: string) => { + const trimmed = text.trim(); + if (!trimmed || !interactiveAgent) return; + interactiveAgent.enqueueMessage(trimmed); + }, + [interactiveAgent], + ); + + // ── Render ── + + const statusLabel = useMemo(() => { + switch (status) { + case AgentStatus.COMPLETED: + return { text: t('Completed'), color: theme.status.success }; + case AgentStatus.FAILED: + return { + text: t('Failed: {{error}}', { + error: + interactiveAgent?.getError() ?? + interactiveAgent?.getLastRoundError() ?? + 'unknown', + }), + color: theme.status.error, + }; + case AgentStatus.CANCELLED: + return { text: t('Cancelled'), color: theme.text.secondary }; + default: + return null; + } + }, [status, interactiveAgent]); + + // ── Approval-mode styling (mirrors main InputPrompt) ── + + const isYolo = agentApprovalMode === ApprovalMode.YOLO; + const isAutoAccept = agentApprovalMode !== ApprovalMode.DEFAULT; + + const statusColor = isYolo + ? theme.status.errorDim + : isAutoAccept + ? theme.status.warningDim + : undefined; + + const inputBorderColor = + !isInputActive || agentTabBarFocused + ? theme.border.default + : (statusColor ?? theme.border.focused); + + const prefixNode = ( + {isYolo ? '*' : '>'} + ); + + return ( + + + {/* Loading indicator — mirrors main Composer but reads agent's + streaming state via the overridden StreamingContext. */} + + + {/* Terminal status for completed/failed agents */} + {statusLabel && ( + + {statusLabel.text} + + )} + + {/* Input prompt — always visible, like the main Composer */} + + + {/* Footer: approval mode + context usage */} + {isInputActive && ( + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentFooter.tsx b/packages/cli/src/ui/components/agent-view/AgentFooter.tsx new file mode 100644 index 000000000..7b05e4e47 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentFooter.tsx @@ -0,0 +1,66 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Lightweight footer for agent tabs showing approval mode + * and context usage. Mirrors the main Footer layout but without + * main-agent-specific concerns (vim mode, shell mode, exit prompts, etc.). + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { ApprovalMode } from '@qwen-code/qwen-code-core'; +import { AutoAcceptIndicator } from '../AutoAcceptIndicator.js'; +import { ContextUsageDisplay } from '../ContextUsageDisplay.js'; +import { theme } from '../../semantic-colors.js'; + +interface AgentFooterProps { + approvalMode: ApprovalMode | undefined; + promptTokenCount: number; + contextWindowSize: number | undefined; + terminalWidth: number; +} + +export const AgentFooter: React.FC = ({ + approvalMode, + promptTokenCount, + contextWindowSize, + terminalWidth, +}) => { + const showApproval = + approvalMode !== undefined && approvalMode !== ApprovalMode.DEFAULT; + const showContext = promptTokenCount > 0 && contextWindowSize !== undefined; + + if (!showApproval && !showContext) { + return null; + } + + return ( + + + {showApproval ? ( + + ) : null} + + + {showContext && ( + + + + )} + + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index 1d526b9b0..a502363b4 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -8,7 +8,12 @@ * @fileoverview AgentTabBar — horizontal tab strip for in-process agent views. * * Rendered at the top of the terminal whenever in-process agents are registered. - * Left/Right arrow keys cycle through tabs when the input buffer is empty. + * + * On the main tab, Left/Right switch tabs when the input buffer is empty. + * On agent tabs, the tab bar uses an exclusive-focus model: + * - Down arrow at the input's bottom edge focuses the tab bar + * - Left/Right switch tabs only when the tab bar is focused + * - Up arrow or typing returns focus to the input * * Tab indicators: running, idle/completed, failed, cancelled */ @@ -36,6 +41,8 @@ function statusIndicator(agent: RegisteredAgent): { case AgentStatus.RUNNING: case AgentStatus.INITIALIZING: return { symbol: '\u25CF', color: theme.status.warning }; // ● running + case AgentStatus.IDLE: + return { symbol: '\u25CF', color: theme.status.success }; // ● idle (ready) case AgentStatus.COMPLETED: return { symbol: '\u2713', color: theme.status.success }; // ✓ completed case AgentStatus.FAILED: @@ -50,20 +57,32 @@ function statusIndicator(agent: RegisteredAgent): { // ─── Component ────────────────────────────────────────────── export const AgentTabBar: React.FC = () => { - const { activeView, agents, agentShellFocused } = useAgentViewState(); - const { switchToNext, switchToPrevious } = useAgentViewActions(); - const { buffer, embeddedShellFocused } = useUIState(); + const { activeView, agents, agentShellFocused, agentTabBarFocused } = + useAgentViewState(); + const { switchToNext, switchToPrevious, setAgentTabBarFocused } = + useAgentViewActions(); + const { embeddedShellFocused } = useUIState(); - // Left/Right arrow keys switch tabs when the input buffer is empty - // and no embedded shell (main or agent tab) has input focus. useKeypress( (key) => { - if (buffer.text !== '' || embeddedShellFocused || agentShellFocused) - return; + if (embeddedShellFocused || agentShellFocused) return; + if (!agentTabBarFocused) return; + if (key.name === 'left') { switchToPrevious(); } else if (key.name === 'right') { switchToNext(); + } else if (key.name === 'up') { + setAgentTabBarFocused(false); + } else if ( + key.sequence && + key.sequence.length === 1 && + !key.ctrl && + !key.meta + ) { + // Printable character → return focus to input (key falls through + // to BaseTextInput's useKeypress and gets typed normally) + setAgentTabBarFocused(false); } }, { isActive: true }, @@ -89,12 +108,18 @@ export const AgentTabBar: React.FC = () => { return () => cleanups.forEach((fn) => fn()); }, [agents, forceRender]); + const isFocused = agentTabBarFocused; + + // Navigation hint varies by context + const hint = isFocused ? '\u2190/\u2192 switch \u2191 input' : '\u2193 tabs'; + return ( {/* Main tab */} { {/* Separator */} - {'\u2502'} + + {'\u2502'} + {/* Agent tabs */} {[...agents.entries()].map(([agentId, agent]) => { @@ -118,19 +145,22 @@ export const AgentTabBar: React.FC = () => { {` ${agent.displayName} `} - {` ${symbol}`} + + {` ${symbol}`} + ); })} {/* Navigation hint */} - ←/→ + {hint} ); diff --git a/packages/cli/src/ui/components/agent-view/index.ts b/packages/cli/src/ui/components/agent-view/index.ts index 30c4ea7b9..caa00a18a 100644 --- a/packages/cli/src/ui/components/agent-view/index.ts +++ b/packages/cli/src/ui/components/agent-view/index.ts @@ -6,4 +6,6 @@ export { AgentTabBar } from './AgentTabBar.js'; export { AgentChatView } from './AgentChatView.js'; +export { AgentComposer } from './AgentComposer.js'; +export { AgentFooter } from './AgentFooter.js'; export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index 4a95b5a3e..f50f46109 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -22,7 +22,10 @@ import { useMemo, useState, } from 'react'; -import type { AgentInteractive } from '@qwen-code/qwen-code-core'; +import { + type AgentInteractive, + type ApprovalMode, +} from '@qwen-code/qwen-code-core'; // ─── Types ────────────────────────────────────────────────── @@ -39,6 +42,12 @@ export interface AgentViewState { agents: ReadonlyMap; /** Whether any agent tab's embedded shell currently has input focus. */ agentShellFocused: boolean; + /** Current text in the active agent tab's input buffer (empty when on main). */ + agentInputBufferText: string; + /** Whether the tab bar has keyboard focus (vs the agent input). */ + agentTabBarFocused: boolean; + /** Per-agent approval modes (keyed by agentId). */ + agentApprovalModes: ReadonlyMap; } export interface AgentViewActions { @@ -55,6 +64,9 @@ export interface AgentViewActions { unregisterAgent(agentId: string): void; unregisterAll(): void; setAgentShellFocused(focused: boolean): void; + setAgentInputBufferText(text: string): void; + setAgentTabBarFocused(focused: boolean): void; + setAgentApprovalMode(agentId: string, mode: ApprovalMode): void; } // ─── Context ──────────────────────────────────────────────── @@ -62,28 +74,43 @@ export interface AgentViewActions { const AgentViewStateContext = createContext(null); const AgentViewActionsContext = createContext(null); +// ─── Defaults (used when no provider is mounted) ──────────── + +const DEFAULT_STATE: AgentViewState = { + activeView: 'main', + agents: new Map(), + agentShellFocused: false, + agentInputBufferText: '', + agentTabBarFocused: false, + agentApprovalModes: new Map(), +}; + +const noop = () => {}; + +const DEFAULT_ACTIONS: AgentViewActions = { + switchToMain: noop, + switchToAgent: noop, + switchToNext: noop, + switchToPrevious: noop, + registerAgent: noop, + unregisterAgent: noop, + unregisterAll: noop, + setAgentShellFocused: noop, + setAgentInputBufferText: noop, + setAgentTabBarFocused: noop, + setAgentApprovalMode: noop, +}; + // ─── Hook: useAgentViewState ──────────────────────────────── export function useAgentViewState(): AgentViewState { - const ctx = useContext(AgentViewStateContext); - if (!ctx) { - throw new Error( - 'useAgentViewState must be used within an AgentViewProvider', - ); - } - return ctx; + return useContext(AgentViewStateContext) ?? DEFAULT_STATE; } // ─── Hook: useAgentViewActions ────────────────────────────── export function useAgentViewActions(): AgentViewActions { - const ctx = useContext(AgentViewActionsContext); - if (!ctx) { - throw new Error( - 'useAgentViewActions must be used within an AgentViewProvider', - ); - } - return ctx; + return useContext(AgentViewActionsContext) ?? DEFAULT_ACTIONS; } // ─── Provider ─────────────────────────────────────────────── @@ -98,11 +125,17 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { () => new Map(), ); const [agentShellFocused, setAgentShellFocused] = useState(false); + const [agentInputBufferText, setAgentInputBufferText] = useState(''); + const [agentTabBarFocused, setAgentTabBarFocused] = useState(false); + const [agentApprovalModes, setAgentApprovalModes] = useState< + Map + >(() => new Map()); // ── Navigation ── const switchToMain = useCallback(() => { setActiveView('main'); + setAgentTabBarFocused(false); }, []); const switchToAgent = useCallback( @@ -142,6 +175,13 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { next.set(agentId, { interactiveAgent, displayName, color }); return next; }); + // Seed approval mode from the agent's own config + const mode = interactiveAgent.getCore().runtimeContext.getApprovalMode(); + setAgentApprovalModes((prev) => { + const next = new Map(prev); + next.set(agentId, mode); + return next; + }); }, [], ); @@ -153,19 +193,58 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { next.delete(agentId); return next; }); + setAgentApprovalModes((prev) => { + if (!prev.has(agentId)) return prev; + const next = new Map(prev); + next.delete(agentId); + return next; + }); setActiveView((current) => (current === agentId ? 'main' : current)); }, []); const unregisterAll = useCallback(() => { setAgents(new Map()); + setAgentApprovalModes(new Map()); setActiveView('main'); + setAgentTabBarFocused(false); }, []); + const setAgentApprovalMode = useCallback( + (agentId: string, mode: ApprovalMode) => { + // Update the agent's runtime config so tool scheduling picks it up + const agent = agents.get(agentId); + if (agent) { + agent.interactiveAgent.getCore().runtimeContext.setApprovalMode(mode); + } + // Update UI state + setAgentApprovalModes((prev) => { + const next = new Map(prev); + next.set(agentId, mode); + return next; + }); + }, + [agents], + ); + // ── Memoized values ── const state: AgentViewState = useMemo( - () => ({ activeView, agents, agentShellFocused }), - [activeView, agents, agentShellFocused], + () => ({ + activeView, + agents, + agentShellFocused, + agentInputBufferText, + agentTabBarFocused, + agentApprovalModes, + }), + [ + activeView, + agents, + agentShellFocused, + agentInputBufferText, + agentTabBarFocused, + agentApprovalModes, + ], ); const actions: AgentViewActions = useMemo( @@ -178,6 +257,9 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { unregisterAgent, unregisterAll, setAgentShellFocused, + setAgentInputBufferText, + setAgentTabBarFocused, + setAgentApprovalMode, }), [ switchToMain, @@ -188,6 +270,9 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { unregisterAgent, unregisterAll, setAgentShellFocused, + setAgentInputBufferText, + setAgentTabBarFocused, + setAgentApprovalMode, ], ); diff --git a/packages/cli/src/ui/hooks/useAgentStreamingState.ts b/packages/cli/src/ui/hooks/useAgentStreamingState.ts new file mode 100644 index 000000000..d53776242 --- /dev/null +++ b/packages/cli/src/ui/hooks/useAgentStreamingState.ts @@ -0,0 +1,165 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Hook that subscribes to an AgentInteractive's events and + * derives streaming state, elapsed time, input-active flag, and status. + * + * Extracts the common reactivity + derived-state pattern shared by + * AgentComposer and AgentChatView so each component only deals with + * layout and interaction. + */ + +import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; +import { + AgentStatus, + AgentEventType, + isTerminalStatus, + type AgentInteractive, + type AgentEventEmitter, +} from '@qwen-code/qwen-code-core'; +import { StreamingState } from '../types.js'; +import { useTimer } from './useTimer.js'; + +// ─── Types ────────────────────────────────────────────────── + +export interface AgentStreamingInfo { + /** The agent's current lifecycle status. */ + status: AgentStatus | undefined; + /** Derived streaming state for StreamingContext / LoadingIndicator. */ + streamingState: StreamingState; + /** Whether the agent can accept user input right now. */ + isInputActive: boolean; + /** Seconds elapsed while in Responding state (resets each cycle). */ + elapsedTime: number; + /** Prompt token count from the most recent round (for context usage). */ + lastPromptTokenCount: number; +} + +// ─── Hook ─────────────────────────────────────────────────── + +/** + * Subscribe to an AgentInteractive's events and derive UI streaming state. + * + * @param interactiveAgent - The agent instance, or undefined if not yet registered. + * @param events - Which event types trigger a re-render. Defaults to + * STATUS_CHANGE, TOOL_WAITING_APPROVAL, and TOOL_RESULT — sufficient for + * composer / footer use. Callers like AgentChatView can pass a broader set + * (e.g. include TOOL_CALL, ROUND_END, TOOL_OUTPUT_UPDATE) for richer updates. + */ +export function useAgentStreamingState( + interactiveAgent: AgentInteractive | undefined, + events?: ReadonlyArray<(typeof AgentEventType)[keyof typeof AgentEventType]>, +): AgentStreamingInfo { + // ── Force-render on agent events ── + + const [, setTick] = useState(0); + const tickRef = useRef(0); + const forceRender = useCallback(() => { + tickRef.current += 1; + setTick(tickRef.current); + }, []); + + // ── Track last prompt token count from USAGE_METADATA events ── + + const [lastPromptTokenCount, setLastPromptTokenCount] = useState( + () => interactiveAgent?.getLastPromptTokenCount() ?? 0, + ); + + const subscribedEvents = events ?? DEFAULT_EVENTS; + + useEffect(() => { + if (!interactiveAgent) return; + const emitter: AgentEventEmitter | undefined = + interactiveAgent.getEventEmitter(); + if (!emitter) return; + + const handler = () => forceRender(); + for (const evt of subscribedEvents) { + emitter.on(evt, handler); + } + + // Dedicated listener for usage metadata — updates React state directly + // so the token count is available immediately (even if no other event + // triggers a re-render). Prefers totalTokenCount (prompt + output) + // because output becomes history for the next round, matching + // geminiChat.ts. + const usageHandler = (event: { + usage?: { totalTokenCount?: number; promptTokenCount?: number }; + }) => { + const count = + event?.usage?.totalTokenCount ?? event?.usage?.promptTokenCount; + if (typeof count === 'number' && count > 0) { + setLastPromptTokenCount(count); + } + }; + emitter.on(AgentEventType.USAGE_METADATA, usageHandler); + + return () => { + for (const evt of subscribedEvents) { + emitter.off(evt, handler); + } + emitter.off(AgentEventType.USAGE_METADATA, usageHandler); + }; + }, [interactiveAgent, forceRender, subscribedEvents]); + + // ── Derived state ── + + const status = interactiveAgent?.getStatus(); + const pendingApprovals = interactiveAgent?.getPendingApprovals(); + const hasPendingApprovals = + pendingApprovals !== undefined && pendingApprovals.size > 0; + + const streamingState = useMemo(() => { + if (hasPendingApprovals) { + return StreamingState.WaitingForConfirmation; + } + if (status === AgentStatus.RUNNING || status === AgentStatus.INITIALIZING) { + return StreamingState.Responding; + } + return StreamingState.Idle; + }, [status, hasPendingApprovals]); + + const isInputActive = + streamingState === StreamingState.Idle && + status !== undefined && + !isTerminalStatus(status); + + // ── Timer (resets each time we enter Responding) ── + + const [timerResetKey, setTimerResetKey] = useState(0); + const prevStreamingRef = useRef(streamingState); + useEffect(() => { + if ( + streamingState === StreamingState.Responding && + prevStreamingRef.current !== StreamingState.Responding + ) { + setTimerResetKey((k) => k + 1); + } + prevStreamingRef.current = streamingState; + }, [streamingState]); + + const elapsedTime = useTimer( + streamingState === StreamingState.Responding, + timerResetKey, + ); + + return { + status, + streamingState, + isInputActive, + elapsedTime, + lastPromptTokenCount, + }; +} + +// ─── Defaults ─────────────────────────────────────────────── + +const DEFAULT_EVENTS = [ + AgentEventType.STATUS_CHANGE, + AgentEventType.TOOL_WAITING_APPROVAL, + AgentEventType.TOOL_RESULT, +] as const; diff --git a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts index 3135a362b..3d075f8a6 100644 --- a/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts +++ b/packages/cli/src/ui/hooks/useAutoAcceptIndicator.ts @@ -19,6 +19,8 @@ export interface UseAutoAcceptIndicatorArgs { addItem?: (item: HistoryItemWithoutId, timestamp: number) => void; onApprovalModeChange?: (mode: ApprovalMode) => void; shouldBlockTab?: () => boolean; + /** When true, the keyboard handler is disabled (e.g. agent tab is active). */ + disabled?: boolean; } export function useAutoAcceptIndicator({ @@ -26,6 +28,7 @@ export function useAutoAcceptIndicator({ addItem, onApprovalModeChange, shouldBlockTab, + disabled, }: UseAutoAcceptIndicatorArgs): ApprovalMode { const currentConfigValue = config.getApprovalMode(); const [showAutoAcceptIndicator, setShowAutoAcceptIndicator] = @@ -78,7 +81,7 @@ export function useAutoAcceptIndicator({ } } }, - { isActive: true }, + { isActive: !disabled }, ); return showAutoAcceptIndicator; diff --git a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx index 5cfdc782f..ddb3f2df0 100644 --- a/packages/cli/src/ui/layouts/DefaultAppLayout.tsx +++ b/packages/cli/src/ui/layouts/DefaultAppLayout.tsx @@ -13,6 +13,7 @@ import { Composer } from '../components/Composer.js'; import { ExitWarning } from '../components/ExitWarning.js'; import { AgentTabBar } from '../components/agent-view/AgentTabBar.js'; import { AgentChatView } from '../components/agent-view/AgentChatView.js'; +import { AgentComposer } from '../components/agent-view/AgentComposer.js'; import { useUIState } from '../contexts/UIStateContext.js'; import { useUIActions } from '../contexts/UIActionsContext.js'; import { useAgentViewState } from '../contexts/AgentViewContext.js'; @@ -24,6 +25,7 @@ export const DefaultAppLayout: React.FC = () => { const { activeView, agents } = useAgentViewState(); const { columns: terminalWidth } = useTerminalSize(); const hasAgents = agents.size > 0; + const isAgentTab = activeView !== 'main' && agents.has(activeView); // Clear terminal on view switch so previous view's output // is removed. refreshStatic clears the terminal and bumps the @@ -39,33 +41,38 @@ export const DefaultAppLayout: React.FC = () => { return ( - {/* Content area: only the active view is rendered. - Conditional rendering avoids Ink's display="none" bug - where Static items remain visible even when the parent is hidden. - Each mount gets a fresh instance that re-renders items - on the cleared terminal. */} - {activeView !== 'main' && agents.has(activeView) ? ( - - ) : ( - - )} - - {/* Shared footer — single instance keeps mainControlsRef attached - regardless of which tab is active so height measurement stays - current. */} - - {uiState.dialogsVisible ? ( - - + {isAgentTab ? ( + <> + {/* Agent view: chat history + agent-specific composer */} + + + + - ) : ( - - )} - - + + ) : ( + <> + {/* Main view: conversation history + main composer / dialogs */} + + + {uiState.dialogsVisible ? ( + + + + ) : ( + + )} + + + + )} {/* Tab bar: visible whenever in-process agents exist and input is active */} {hasAgents && !uiState.dialogsVisible && } diff --git a/packages/cli/src/ui/utils/layoutUtils.ts b/packages/cli/src/ui/utils/layoutUtils.ts new file mode 100644 index 000000000..208babcfc --- /dev/null +++ b/packages/cli/src/ui/utils/layoutUtils.ts @@ -0,0 +1,40 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Shared layout calculation utilities for the terminal UI. + */ + +/** + * Calculate the widths for the input prompt area based on terminal width. + * + * Returns the content width (for the text buffer), the total container width + * (including border + padding + prefix), the suggestions dropdown width, + * and the frame overhead constant. + */ +export const calculatePromptWidths = (terminalWidth: number) => { + const widthFraction = 0.9; + const FRAME_PADDING_AND_BORDER = 4; // Border (2) + padding (2) + const PROMPT_PREFIX_WIDTH = 2; // '> ' or '! ' + const MIN_CONTENT_WIDTH = 2; + + const innerContentWidth = + Math.floor(terminalWidth * widthFraction) - + FRAME_PADDING_AND_BORDER - + PROMPT_PREFIX_WIDTH; + + const inputWidth = Math.max(MIN_CONTENT_WIDTH, innerContentWidth); + const FRAME_OVERHEAD = FRAME_PADDING_AND_BORDER + PROMPT_PREFIX_WIDTH; + const containerWidth = inputWidth + FRAME_OVERHEAD; + const suggestionsWidth = Math.max(20, Math.floor(terminalWidth * 1.0)); + + return { + inputWidth, + containerWidth, + suggestionsWidth, + frameOverhead: FRAME_OVERHEAD, + } as const; +}; diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 74d7bf1b6..d1646604a 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -150,6 +150,12 @@ export class AgentCore { outputTokens: 0, totalTokens: 0, }; + /** + * The prompt token count from the most recent model response. + * Exposed so UI hooks can seed initial state without waiting for events. + */ + lastPromptTokenCount = 0; + private toolUsage = new Map< string, { @@ -996,6 +1002,13 @@ Important Rules: const thoughtTok = Number(usage.thoughtsTokenCount || 0); const cachedTok = Number(usage.cachedContentTokenCount || 0); const totalTok = Number(usage.totalTokenCount || 0); + // Prefer totalTokenCount (prompt + output) for context usage — the + // output from this round becomes history for the next, matching + // the approach in geminiChat.ts. + const contextTok = isFinite(totalTok) && totalTok > 0 ? totalTok : inTok; + if (isFinite(contextTok) && contextTok > 0) { + this.lastPromptTokenCount = contextTok; + } if ( isFinite(inTok) || isFinite(outTok) || diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 7e35a96db..2f688b908 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -276,6 +276,11 @@ export class AgentInteractive { return this.core.getExecutionSummary(); } + /** The prompt token count from the most recent model call. */ + getLastPromptTokenCount(): number { + return this.core.lastPromptTokenCount; + } + getCore(): AgentCore { return this.core; } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index c47fa0a4b..e03159517 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -206,6 +206,7 @@ export class GeminiClient { }, history, this.config.getChatRecordingService(), + uiTelemetryService, ); } catch (error) { await reportError( diff --git a/packages/core/src/core/geminiChat.test.ts b/packages/core/src/core/geminiChat.test.ts index 4f69b62eb..c1c254fc5 100644 --- a/packages/core/src/core/geminiChat.test.ts +++ b/packages/core/src/core/geminiChat.test.ts @@ -124,7 +124,13 @@ describe('GeminiChat', async () => { // Disable 429 simulation for tests setSimulate429(false); // Reset history for each test by creating a new instance - chat = new GeminiChat(mockConfig, config, []); + chat = new GeminiChat( + mockConfig, + config, + [], + undefined, + uiTelemetryService, + ); }); afterEach(() => { diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index f58bcdb61..2ee83971f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -33,7 +33,7 @@ import { ContentRetryEvent, ContentRetryFailureEvent, } from '../telemetry/types.js'; -import { uiTelemetryService } from '../telemetry/uiTelemetry.js'; +import type { UiTelemetryService } from '../telemetry/uiTelemetry.js'; const debugLogger = createDebugLogger('QWEN_CODE_CHAT'); @@ -234,12 +234,16 @@ export class GeminiChat { * @param history - Optional initial conversation history. * @param chatRecordingService - Optional recording service. If provided, chat * messages will be recorded. + * @param telemetryService - Optional UI telemetry service. When provided, + * prompt token counts are reported on each API response. Pass `undefined` + * for sub-agent chats to avoid overwriting the main agent's context usage. */ constructor( private readonly config: Config, private readonly generationConfig: GenerateContentConfig = {}, private history: Content[] = [], private readonly chatRecordingService?: ChatRecordingService, + private readonly telemetryService?: UiTelemetryService, ) { validateHistory(history); } @@ -637,8 +641,8 @@ export class GeminiChat { usageMetadata = chunk.usageMetadata; const lastPromptTokenCount = usageMetadata.totalTokenCount ?? usageMetadata.promptTokenCount; - if (lastPromptTokenCount) { - uiTelemetryService.setLastPromptTokenCount(lastPromptTokenCount); + if (lastPromptTokenCount && this.telemetryService) { + this.telemetryService.setLastPromptTokenCount(lastPromptTokenCount); } } From 9f7e3e054f2e538479034d27411aeeca46d58068 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 10 Mar 2026 19:45:14 +0800 Subject: [PATCH 23/82] feat(arena): forward chat history to spawned agents MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add stripStartupContext to remove env-info from parent history and pass chatHistory through ArenaManager → InProcessBackend → AgentInteractive → AgentCore. This allows arena agents to start with conversational context from the main session. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/arenaCommand.ts | 14 ++++ .../components/agent-view/AgentChatView.tsx | 1 + .../src/agents/arena/ArenaManager.test.ts | 39 ++++++++++ .../core/src/agents/arena/ArenaManager.ts | 2 + packages/core/src/agents/arena/types.ts | 9 +++ .../agents/backends/InProcessBackend.test.ts | 28 +++++++ .../src/agents/backends/InProcessBackend.ts | 1 + packages/core/src/agents/backends/types.ts | 7 ++ .../core/src/agents/runtime/agent-core.ts | 7 ++ .../agents/runtime/agent-interactive.test.ts | 36 +++++++++ .../src/agents/runtime/agent-interactive.ts | 5 +- .../core/src/agents/runtime/agent-types.ts | 5 ++ packages/core/src/index.ts | 1 + .../core/src/utils/environmentContext.test.ts | 74 +++++++++++++++++++ packages/core/src/utils/environmentContext.ts | 22 +++++- 15 files changed, 249 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 80c1b0a90..b051e9c0c 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -21,6 +21,7 @@ import { ArenaSessionStatus, AuthType, createDebugLogger, + stripStartupContext, type Config, type ArenaModelConfig, type ArenaAgentErrorEvent, @@ -171,6 +172,18 @@ function executeArenaCommand( ui: CommandContext['ui'], input: ArenaExecutionInput, ): void { + // Capture the main session's chat history so arena agents start with + // conversational context. Strip the leading startup context (env info + // user message + model ack) because each agent generates its own for + // its worktree directory — keeping the parent's would duplicate it. + let chatHistory; + try { + const fullHistory = config.getGeminiClient().getHistory(); + chatHistory = stripStartupContext(fullHistory); + } catch { + debugLogger.debug('Could not retrieve chat history for arena agents'); + } + const manager = new ArenaManager(config); const emitter = manager.getEventEmitter(); const detachListeners: Array<() => void> = []; @@ -331,6 +344,7 @@ function executeArenaCommand( cols, rows, approvalMode: input.approvalMode, + chatHistory, }) .then( () => { diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx index 20eb0adc0..371c8bb27 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -155,6 +155,7 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { ), // eslint-disable-next-line react-hooks/exhaustive-deps [ + agentId, messages.length, pendingApprovals?.size, liveOutputs?.size, diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index e0f7554a5..3ffcaa3b3 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -334,6 +334,45 @@ describe('ArenaManager', () => { }); }); + describe('chat history forwarding', () => { + it('should pass chatHistory to backend spawnAgent calls', async () => { + const manager = new ArenaManager(mockConfig as never); + const chatHistory = [ + { role: 'user' as const, parts: [{ text: 'prior question' }] }, + { role: 'model' as const, parts: [{ text: 'prior answer' }] }, + ]; + + await manager.start({ + ...createValidStartOptions(), + chatHistory, + }); + + // Both agents should have been spawned with chatHistory in + // the inProcess config. + expect(mockBackend.spawnAgent).toHaveBeenCalledTimes(2); + for (const call of mockBackend.spawnAgent.mock.calls) { + const spawnConfig = call[0] as { + inProcess?: { chatHistory?: unknown }; + }; + expect(spawnConfig.inProcess?.chatHistory).toEqual(chatHistory); + } + }); + + it('should pass undefined chatHistory when not provided', async () => { + const manager = new ArenaManager(mockConfig as never); + + await manager.start(createValidStartOptions()); + + expect(mockBackend.spawnAgent).toHaveBeenCalledTimes(2); + for (const call of mockBackend.spawnAgent.mock.calls) { + const spawnConfig = call[0] as { + inProcess?: { chatHistory?: unknown }; + }; + expect(spawnConfig.inProcess?.chatHistory).toBeUndefined(); + } + }); + }); + describe('active session lifecycle', () => { it('cancel should stop backend and move session to CANCELLED', async () => { const manager = new ArenaManager(mockConfig as never); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index b17341fc5..be92757a0 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -294,6 +294,7 @@ export class ArenaManager { timeoutSeconds: options.timeoutSeconds ?? arenaSettings?.timeoutSeconds, approvalMode: options.approvalMode, sourceRepoPath, + chatHistory: options.chatHistory, }; debugLogger.info(`Starting Arena session: ${this.sessionId}`); @@ -1065,6 +1066,7 @@ export class ArenaManager { apiKey: model.apiKey, baseUrl: model.baseUrl, }, + chatHistory: this.arenaConfig?.chatHistory, }, }; diff --git a/packages/core/src/agents/arena/types.ts b/packages/core/src/agents/arena/types.ts index aaf3e2dae..5b9a9ecab 100644 --- a/packages/core/src/agents/arena/types.ts +++ b/packages/core/src/agents/arena/types.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { Content } from '@google/genai'; import type { WorktreeInfo } from '../../services/gitWorktreeService.js'; import type { DisplayMode } from '../backends/types.js'; import type { AgentStatus } from '../runtime/agent-types.js'; @@ -65,6 +66,8 @@ export interface ArenaConfig { approvalMode?: string; /** Source repository path */ sourceRepoPath: string; + /** Chat history from the parent session for agent context seeding. */ + chatHistory?: Content[]; } /** @@ -161,6 +164,12 @@ export interface ArenaStartOptions { rows?: number; /** Display mode preference */ displayMode?: DisplayMode; + /** + * Optional chat history from the main session to seed each arena agent + * with conversational context. When provided, this history is prepended + * to each agent's chat so they understand the prior conversation. + */ + chatHistory?: Content[]; } /** diff --git a/packages/core/src/agents/backends/InProcessBackend.test.ts b/packages/core/src/agents/backends/InProcessBackend.test.ts index 6c4734f32..83bf1caca 100644 --- a/packages/core/src/agents/backends/InProcessBackend.test.ts +++ b/packages/core/src/agents/backends/InProcessBackend.test.ts @@ -407,6 +407,34 @@ describe('InProcessBackend', () => { expect(result).toBe(true); }); + describe('chat history', () => { + it('should pass chatHistory to AgentInteractive config', async () => { + await backend.init(); + + const chatHistory = [ + { role: 'user' as const, parts: [{ text: 'prior question' }] }, + { role: 'model' as const, parts: [{ text: 'prior answer' }] }, + ]; + const config = createSpawnConfig('agent-1'); + config.inProcess!.chatHistory = chatHistory; + + await backend.spawnAgent(config); + + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + expect(agent!.config.chatHistory).toEqual(chatHistory); + }); + + it('should leave chatHistory undefined when not provided', async () => { + await backend.init(); + await backend.spawnAgent(createSpawnConfig('agent-1')); + + const agent = backend.getAgent('agent-1'); + expect(agent).toBeDefined(); + expect(agent!.config.chatHistory).toBeUndefined(); + }); + }); + describe('auth isolation', () => { it('should create per-agent ContentGenerator when authOverrides is provided', async () => { await backend.init(); diff --git a/packages/core/src/agents/backends/InProcessBackend.ts b/packages/core/src/agents/backends/InProcessBackend.ts index 5109c91bd..c53892cbc 100644 --- a/packages/core/src/agents/backends/InProcessBackend.ts +++ b/packages/core/src/agents/backends/InProcessBackend.ts @@ -114,6 +114,7 @@ export class InProcessBackend implements Backend { initialTask: inProcessConfig.initialTask, maxTurnsPerMessage: runConfig.max_turns, maxTimeMinutesPerMessage: runConfig.max_time_minutes, + chatHistory: inProcessConfig.chatHistory, }, core, ); diff --git a/packages/core/src/agents/backends/types.ts b/packages/core/src/agents/backends/types.ts index 0b706b08f..98678fd0f 100644 --- a/packages/core/src/agents/backends/types.ts +++ b/packages/core/src/agents/backends/types.ts @@ -11,6 +11,7 @@ * These types are used across different agent orchestration modes. */ +import type { Content } from '@google/genai'; import type { AnsiOutput } from '../../utils/terminalSerializer.js'; import type { PromptConfig, @@ -93,6 +94,12 @@ export interface InProcessSpawnConfig { apiKey?: string; baseUrl?: string; }; + /** + * Optional chat history from the parent session. When provided, this + * history is prepended to the agent's chat so it has conversational + * context from the session that spawned it. + */ + chatHistory?: Content[]; } /** diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index d1646604a..5e43e3e5a 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -94,6 +94,12 @@ export interface CreateChatOptions { * Used by AgentInteractive for persistent interactive agents. */ interactive?: boolean; + /** + * Optional conversation history from a parent session. When provided, + * this history is prepended to the chat so the agent has prior + * conversational context (e.g., from the main session that spawned it). + */ + extraHistory?: Content[]; } /** @@ -219,6 +225,7 @@ export class AgentCore { const startHistory = [ ...envHistory, + ...(options?.extraHistory ?? []), ...(this.promptConfig.initialMessages ?? []), ]; diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index f0ac9fb88..40ed6f3c1 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -533,6 +533,42 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); + // ─── Chat History ──────────────────────────────────────────── + + it('should pass chatHistory as extraHistory to createChat', async () => { + const { core } = createMockCore(); + const chatHistory = [ + { role: 'user' as const, parts: [{ text: 'earlier question' }] }, + { role: 'model' as const, parts: [{ text: 'earlier answer' }] }, + ]; + const config = createConfig({ chatHistory }); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + expect(core.createChat).toHaveBeenCalledWith(context, { + interactive: true, + extraHistory: chatHistory, + }); + + await agent.shutdown(); + }); + + it('should pass undefined extraHistory when chatHistory is not set', async () => { + const { core } = createMockCore(); + const config = createConfig(); + const agent = new AgentInteractive(config, core); + + await agent.start(context); + + expect(core.createChat).toHaveBeenCalledWith(context, { + interactive: true, + extraHistory: undefined, + }); + + await agent.shutdown(); + }); + // ─── Events ──────────────────────────────────────────────── it('should emit status_change events', async () => { diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 2f688b908..5abc035dd 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -98,7 +98,10 @@ export class AgentInteractive { async start(context: ContextState): Promise { this.setStatus(AgentStatus.INITIALIZING); - this.chat = await this.core.createChat(context, { interactive: true }); + this.chat = await this.core.createChat(context, { + interactive: true, + extraHistory: this.config.chatHistory, + }); if (!this.chat) { this.error = 'Failed to create chat session'; this.setStatus(AgentStatus.FAILED); diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index ca7e283f6..07610d9c0 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -143,6 +143,11 @@ export interface AgentInteractiveConfig { maxTurnsPerMessage?: number; /** Max wall-clock minutes per enqueued message (default: unlimited). */ maxTimeMinutesPerMessage?: number; + /** + * Optional conversation history from a parent session to seed the + * agent's chat with prior context. + */ + chatHistory?: Content[]; } /** diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a92824352..d81079817 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -102,6 +102,7 @@ export * from './utils/promptIdContext.js'; export * from './utils/thoughtUtils.js'; export * from './utils/toml-to-markdown-converter.js'; export * from './utils/yaml-parser.js'; +export * from './utils/environmentContext.js'; // Config resolution utilities export * from './utils/configResolver.js'; diff --git a/packages/core/src/utils/environmentContext.test.ts b/packages/core/src/utils/environmentContext.test.ts index 0b24a9b01..6c2258c78 100644 --- a/packages/core/src/utils/environmentContext.test.ts +++ b/packages/core/src/utils/environmentContext.test.ts @@ -18,6 +18,7 @@ import { getEnvironmentContext, getDirectoryContextString, getInitialChatHistory, + stripStartupContext, } from './environmentContext.js'; import type { Config } from '../config/config.js'; import { getFolderStructure } from './getFolderStructure.js'; @@ -223,3 +224,76 @@ describe('getInitialChatHistory', () => { expect(history).toEqual([]); }); }); + +describe('stripStartupContext', () => { + it('should strip the env context + model ack from the start of history', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'This is the Qwen Code...' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + + const result = stripStartupContext(history); + expect(result).toEqual([ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]); + }); + + it('should return history unchanged when no startup context is present', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi there' }] }, + ]; + + const result = stripStartupContext(history); + expect(result).toEqual(history); + }); + + it('should return empty array when history is only the startup context', () => { + const history: Content[] = [ + { role: 'user', parts: [{ text: 'This is the Qwen Code...' }] }, + { + role: 'model', + parts: [{ text: 'Got it. Thanks for the context!' }], + }, + ]; + + const result = stripStartupContext(history); + expect(result).toEqual([]); + }); + + it('should return history unchanged when it has fewer than 2 entries', () => { + expect(stripStartupContext([])).toEqual([]); + expect( + stripStartupContext([{ role: 'user', parts: [{ text: 'Hello' }] }]), + ).toEqual([{ role: 'user', parts: [{ text: 'Hello' }] }]); + }); + + it('should round-trip with getInitialChatHistory', async () => { + const mockConfig = { + getSkipStartupContext: vi.fn().mockReturnValue(false), + getWorkspaceContext: vi.fn().mockReturnValue({ + getDirectories: vi.fn().mockReturnValue(['/test/dir']), + }), + getFileService: vi.fn(), + }; + + const conversation: Content[] = [ + { role: 'user', parts: [{ text: 'Hello' }] }, + { role: 'model', parts: [{ text: 'Hi' }] }, + ]; + + const withStartup = await getInitialChatHistory( + mockConfig as unknown as Config, + conversation, + ); + const stripped = stripStartupContext(withStartup); + + expect(stripped).toEqual(conversation); + }); +}); diff --git a/packages/core/src/utils/environmentContext.ts b/packages/core/src/utils/environmentContext.ts index 4f5c03209..4d6fe0ab7 100644 --- a/packages/core/src/utils/environmentContext.ts +++ b/packages/core/src/utils/environmentContext.ts @@ -69,6 +69,8 @@ ${directoryContext} return [{ text: context }]; } +const STARTUP_CONTEXT_MODEL_ACK = 'Got it. Thanks for the context!'; + export async function getInitialChatHistory( config: Config, extraHistory?: Content[], @@ -87,8 +89,26 @@ export async function getInitialChatHistory( }, { role: 'model', - parts: [{ text: 'Got it. Thanks for the context!' }], + parts: [{ text: STARTUP_CONTEXT_MODEL_ACK }], }, ...(extraHistory ?? []), ]; } + +/** + * Strip the leading startup context (env-info user message + model ack) + * from a chat history. Used when forwarding a parent session's history + * to a child agent that will generate its own startup context for its + * own working directory. + */ +export function stripStartupContext(history: Content[]): Content[] { + if (history.length < 2) return history; + + const secondEntry = history[1]; + const ackText = secondEntry?.parts?.[0]?.text; + if (secondEntry?.role === 'model' && ackText === STARTUP_CONTEXT_MODEL_ACK) { + return history.slice(2); + } + + return history; +} From addbdcb0ef1c2b8fb99f90d6ece66dde1f050570 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 10 Mar 2026 20:37:08 +0800 Subject: [PATCH 24/82] feat(arena): add info message for forwarded chat history - Add info message when chatHistory is passed to spawned agents - Add tests for info message presence and absence This provides visibility to users when chat history context is included in spawned agent sessions. Co-authored-by: Qwen-Coder --- .../agents/runtime/agent-interactive.test.ts | 31 +++++++++++++++++++ .../src/agents/runtime/agent-interactive.ts | 7 +++++ 2 files changed, 38 insertions(+) diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 40ed6f3c1..2683a6783 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -554,6 +554,37 @@ describe('AgentInteractive', () => { await agent.shutdown(); }); + it('should add info message when chatHistory is present', async () => { + const { core } = createMockCore(); + const chatHistory = [ + { role: 'user' as const, parts: [{ text: 'earlier question' }] }, + { role: 'model' as const, parts: [{ text: 'earlier answer' }] }, + ]; + const agent = new AgentInteractive(createConfig({ chatHistory }), core); + + await agent.start(context); + + const messages = agent.getMessages(); + expect(messages).toHaveLength(1); + expect(messages[0]).toMatchObject({ + role: 'info', + content: 'History context from parent session included (2 messages)', + }); + + await agent.shutdown(); + }); + + it('should not add info message when chatHistory is absent', async () => { + const { core } = createMockCore(); + const agent = new AgentInteractive(createConfig(), core); + + await agent.start(context); + + expect(agent.getMessages()).toHaveLength(0); + + await agent.shutdown(); + }); + it('should pass undefined extraHistory when chatHistory is not set', async () => { const { core } = createMockCore(); const config = createConfig(); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index 5abc035dd..c7883f669 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -111,6 +111,13 @@ export class AgentInteractive { this.toolsList = this.core.prepareTools(); this.core.stats.start(Date.now()); + if (this.config.chatHistory?.length) { + this.addMessage( + 'info', + `History context from parent session included (${this.config.chatHistory.length} messages)`, + ); + } + if (this.config.initialTask) { this.queue.enqueue(this.config.initialTask); this.executionPromise = this.runLoop(); From d7aa98a0c087b0a0aa367a6536bf58ba0550fd5a Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 10 Mar 2026 21:45:30 +0800 Subject: [PATCH 25/82] refactor(arena): move arena-bridge to context and add reactive manager tracking - Move useArenaInProcess from AppContainer to AgentViewProvider - Replace polling with config.onArenaManagerChange() callback - Add success-type progress messages when agents finish tasks - Add isSuccessStatus helper for IDLE/COMPLETED status checks - Reset input history position when arena session starts This improves separation of concerns and eliminates the 500ms polling interval in favor of immediate reactive updates when the arena manager changes. Co-authored-by: Qwen-Coder --- packages/cli/src/gemini.tsx | 2 +- packages/cli/src/ui/AppContainer.test.tsx | 3 - packages/cli/src/ui/AppContainer.tsx | 4 - packages/cli/src/ui/commands/arenaCommand.ts | 12 +- .../cli/src/ui/components/InputPrompt.tsx | 11 + .../ui/components/arena/ArenaSelectDialog.tsx | 6 +- .../cli/src/ui/contexts/AgentViewContext.tsx | 14 +- .../cli/src/ui/hooks/useArenaInProcess.ts | 202 ++++++++---------- packages/cli/src/ui/hooks/useInputHistory.ts | 2 + .../core/src/agents/arena/ArenaManager.ts | 34 ++- .../core/src/agents/arena/arena-events.ts | 2 +- .../core/src/agents/runtime/agent-types.ts | 4 + packages/core/src/config/config.ts | 16 +- 13 files changed, 178 insertions(+), 134 deletions(-) diff --git a/packages/cli/src/gemini.tsx b/packages/cli/src/gemini.tsx index 21d109c49..9913a5400 100644 --- a/packages/cli/src/gemini.tsx +++ b/packages/cli/src/gemini.tsx @@ -163,7 +163,7 @@ export async function startInteractiveUI( > - + ({ unregisterAll: vi.fn(), })), })); -vi.mock('./hooks/useArenaInProcess.js', () => ({ - useArenaInProcess: vi.fn(), -})); vi.mock('./components/shared/text-buffer.js'); vi.mock('./hooks/useLogger.js'); diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index 7445051f0..273108e89 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -98,7 +98,6 @@ import { import { useCodingPlanUpdates } from './hooks/useCodingPlanUpdates.js'; import { ShellFocusContext } from './contexts/ShellFocusContext.js'; import { useAgentViewState } from './contexts/AgentViewContext.js'; -import { useArenaInProcess } from './hooks/useArenaInProcess.js'; import { t } from '../i18n/index.js'; import { useWelcomeBack } from './hooks/useWelcomeBack.js'; import { useDialogClose } from './hooks/useDialogClose.js'; @@ -818,9 +817,6 @@ export const AppContainer = (props: AppContainerProps) => { const isFocused = useFocus(); useBracketedPaste(); - // Bridge arena in-process events to AgentViewContext - useArenaInProcess(config); - // Context file names computation const contextFileNames = useMemo(() => { const fromSettings = settings.merged.context?.fileName; diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index b051e9c0c..f17c2ce2e 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -16,8 +16,8 @@ import { CommandKind } from './types.js'; import { ArenaManager, ArenaEventType, - AgentStatus, isTerminalStatus, + isSuccessStatus, ArenaSessionStatus, AuthType, createDebugLogger, @@ -238,7 +238,9 @@ function executeArenaCommand( return; } - if (event.type === 'info') { + if (event.type === 'success') { + addAndRecordArenaMessage(MessageType.SUCCESS, event.message); + } else if (event.type === 'info') { addAndRecordArenaMessage(MessageType.INFO, event.message); } else { addAndRecordArenaMessage( @@ -597,9 +599,7 @@ export const arenaCommand: SlashCommand = { } const agents = manager.getAgentStates(); - const hasSuccessful = agents.some( - (a) => a.status === AgentStatus.COMPLETED, - ); + const hasSuccessful = agents.some((a) => isSuccessStatus(a.status)); if (!hasSuccessful) { return { @@ -616,7 +616,7 @@ export const arenaCommand: SlashCommand = { const matchingAgent = agents.find((a) => { const label = a.model.displayName || a.model.modelId; return ( - a.status === AgentStatus.COMPLETED && + isSuccessStatus(a.status) && (label.toLowerCase() === trimmedArgs.toLowerCase() || a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase()) ); diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 02cc8dafe..4fd3bb216 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -338,6 +338,17 @@ export const InputPrompt: React.FC = ({ onChange: customSetTextAndResetCompletionSignal, }); + // When an arena session starts (agents appear), reset history position so + // that pressing down-arrow immediately focuses the agent tab bar instead + // of cycling through input history. + const prevHasAgentsRef = useRef(hasAgents); + useEffect(() => { + if (hasAgents && !prevHasAgentsRef.current) { + inputHistory.resetHistoryNav(); + } + prevHasAgentsRef.current = hasAgents; + }, [hasAgents, inputHistory]); + // Effect to reset completion if history navigation just occurred and set the text useEffect(() => { if (justNavigatedHistory) { diff --git a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx index 19a322ed1..1f8b5a6e4 100644 --- a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx @@ -9,7 +9,7 @@ import { useCallback, useMemo } from 'react'; import { Box, Text } from 'ink'; import { type ArenaManager, - AgentStatus, + isSuccessStatus, type Config, } from '@qwen-code/qwen-code-core'; import { theme } from '../../semantic-colors.js'; @@ -138,7 +138,7 @@ export function ArenaSelectDialog({ // Build diff summary from cached result if available let diffAdditions = 0; let diffDeletions = 0; - if (agent.status === AgentStatus.COMPLETED && result) { + if (isSuccessStatus(agent.status) && result) { const agentResult = result.agents.find( (a) => a.agentId === agent.agentId, ); @@ -182,7 +182,7 @@ export function ArenaSelectDialog({ value: agent.agentId, title, description, - disabled: agent.status !== AgentStatus.COMPLETED, + disabled: !isSuccessStatus(agent.status), }; }), [agents, result], diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index f50f46109..cb85ab4f2 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -25,7 +25,9 @@ import { import { type AgentInteractive, type ApprovalMode, + type Config, } from '@qwen-code/qwen-code-core'; +import { useArenaInProcess } from '../hooks/useArenaInProcess.js'; // ─── Types ────────────────────────────────────────────────── @@ -116,10 +118,14 @@ export function useAgentViewActions(): AgentViewActions { // ─── Provider ─────────────────────────────────────────────── interface AgentViewProviderProps { + config?: Config; children: React.ReactNode; } -export function AgentViewProvider({ children }: AgentViewProviderProps) { +export function AgentViewProvider({ + config, + children, +}: AgentViewProviderProps) { const [activeView, setActiveView] = useState('main'); const [agents, setAgents] = useState>( () => new Map(), @@ -276,6 +282,12 @@ export function AgentViewProvider({ children }: AgentViewProviderProps) { ], ); + // ── Arena in-process bridge ── + // Bridge arena manager events to agent registration. The hook is kept + // in its own file for separation of concerns; it's called here so the + // provider is the single owner of agent tab lifecycle. + useArenaInProcess(config ?? null, actions); + return ( diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts index 0f7db9220..c5793490b 100644 --- a/packages/cli/src/ui/hooks/useArenaInProcess.ts +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -6,13 +6,13 @@ /** * @fileoverview useArenaInProcess — bridges ArenaManager in-process events - * to the AgentViewContext for React-based agent tab navigation. + * to AgentViewContext agent registration. * - * When an arena session starts with an InProcessBackend, this hook: - * 1. Listens to AGENT_START events from ArenaManager - * 2. Retrieves the AgentInteractive from InProcessBackend - * 3. Registers it with AgentViewContext - * 4. Cleans up on SESSION_COMPLETE / SESSION_ERROR / unmount + * Subscribes to `config.onArenaManagerChange()` to react immediately when + * the arena manager is set or cleared. Event listeners are attached to the + * manager's emitter as soon as it appears — the backend is resolved lazily + * inside the AGENT_START handler, which only fires after the backend is + * initialized. */ import { useEffect, useRef } from 'react'; @@ -20,17 +20,16 @@ import { ArenaEventType, ArenaSessionStatus, DISPLAY_MODE, - type ArenaManager, type ArenaAgentStartEvent, + type ArenaManager, type ArenaSessionCompleteEvent, type Config, type InProcessBackend, } from '@qwen-code/qwen-code-core'; -import { useAgentViewActions } from '../contexts/AgentViewContext.js'; +import type { AgentViewActions } from '../contexts/AgentViewContext.js'; import { theme } from '../semantic-colors.js'; -// Palette of colors for agent tabs (cycles for >N agents) -const getAgentColors = () => [ +const AGENT_COLORS = [ theme.text.accent, theme.text.link, theme.status.success, @@ -39,78 +38,85 @@ const getAgentColors = () => [ theme.status.error, ]; -export function useArenaInProcess(config: Config): void { - const actions = useAgentViewActions(); +/** + * Bridge arena in-process events to agent tab registration/unregistration. + * + * Called by AgentViewProvider — accepts config and actions directly so the + * hook has no dependency on AgentViewContext (avoiding a circular import). + */ +export function useArenaInProcess( + config: Config | null, + actions: AgentViewActions, +): void { const actionsRef = useRef(actions); actionsRef.current = actions; useEffect(() => { - // Poll for arena manager (it's set asynchronously by the /arena start command) - let checkInterval: ReturnType | null = null; - // Track the manager instance (not just a boolean) so we never - // reattach to the same completed manager after SESSION_COMPLETE. - let attachedManager: ArenaManager | null = null; - let detachListeners: (() => void) | null = null; - // Pending agent-registration retry timeouts (cancelled on session end & unmount). + if (!config) return; + + let detachArenaListeners: (() => void) | null = null; const retryTimeouts = new Set>(); - const tryAttach = () => { - const manager: ArenaManager | null = config.getArenaManager(); - // Skip if no manager or if it's the same instance we already handled - if (!manager || manager === attachedManager) return; + /** Remove agent tabs, cancel pending retries, and detach arena events. */ + const detachSession = () => { + actionsRef.current.unregisterAll(); + for (const t of retryTimeouts) clearTimeout(t); + retryTimeouts.clear(); + detachArenaListeners?.(); + detachArenaListeners = null; + }; - const backend = manager.getBackend(); - if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return; - - attachedManager = manager; - if (checkInterval) { - clearInterval(checkInterval); - checkInterval = null; - } - - const inProcessBackend = backend as InProcessBackend; + /** Attach to an arena manager's event emitter. The backend is resolved + * lazily — we only need it when registering agents, not at subscribe + * time. This avoids the race where setArenaManager fires before + * manager.start() initializes the backend. */ + const attachSession = (manager: ArenaManager) => { const emitter = manager.getEventEmitter(); - const agentColors = getAgentColors(); let colorIndex = 0; - // Register agents that already started (race condition if events - // fired before we attached) - const existingAgents = manager.getAgentStates(); - for (const agentState of existingAgents) { - const interactive = inProcessBackend.getAgent(agentState.agentId); - if (interactive) { - const displayName = - agentState.model.displayName || agentState.model.modelId; - const color = agentColors[colorIndex % agentColors.length]!; - colorIndex++; - actionsRef.current.registerAgent( - agentState.agentId, - interactive, - displayName, - color, - ); + const nextColor = () => AGENT_COLORS[colorIndex++ % AGENT_COLORS.length]!; + + /** Resolve the InProcessBackend, or null if not applicable. */ + const getInProcessBackend = (): InProcessBackend | null => { + const backend = manager.getBackend(); + if (!backend || backend.type !== DISPLAY_MODE.IN_PROCESS) return null; + return backend as InProcessBackend; + }; + + // Register agents that already started (events may have fired before + // the callback was attached). + const inProcessBackend = getInProcessBackend(); + if (inProcessBackend) { + for (const agentState of manager.getAgentStates()) { + const interactive = inProcessBackend.getAgent(agentState.agentId); + if (interactive) { + actionsRef.current.registerAgent( + agentState.agentId, + interactive, + agentState.model.displayName || agentState.model.modelId, + nextColor(), + ); + } } } - // Listen for new agent starts. - // AGENT_START is emitted by ArenaManager *before* backend.spawnAgent() - // creates the AgentInteractive, so getAgent() may still return - // undefined. We retry with a short poll to bridge the gap. - const MAX_AGENT_RETRIES = 20; - const AGENT_RETRY_INTERVAL_MS = 50; + // AGENT_START fires *before* backend.spawnAgent() creates the + // AgentInteractive, so getAgent() may return undefined. Retry briefly. + const MAX_RETRIES = 20; + const RETRY_MS = 50; const onAgentStart = (event: ArenaAgentStartEvent) => { const tryRegister = (retriesLeft: number) => { - const interactive = inProcessBackend.getAgent(event.agentId); + const backend = getInProcessBackend(); + if (!backend) return; // not an in-process session + + const interactive = backend.getAgent(event.agentId); if (interactive) { - const displayName = event.model.displayName || event.model.modelId; - const color = agentColors[colorIndex % agentColors.length]!; - colorIndex++; actionsRef.current.registerAgent( event.agentId, interactive, - displayName, - color, + event.model.displayName || event.model.modelId, + nextColor(), ); return; } @@ -118,70 +124,52 @@ export function useArenaInProcess(config: Config): void { const timeout = setTimeout(() => { retryTimeouts.delete(timeout); tryRegister(retriesLeft - 1); - }, AGENT_RETRY_INTERVAL_MS); + }, RETRY_MS); retryTimeouts.add(timeout); } }; - tryRegister(MAX_AGENT_RETRIES); + tryRegister(MAX_RETRIES); }; - // Tear down agent tabs, remove listeners, and resume polling for - // a genuinely new manager instance. - const teardown = () => { - actionsRef.current.unregisterAll(); - for (const timeout of retryTimeouts) { - clearTimeout(timeout); - } - retryTimeouts.clear(); - // Remove listeners eagerly so they don't fire again - emitter.off(ArenaEventType.AGENT_START, onAgentStart); - emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); - emitter.off(ArenaEventType.SESSION_ERROR, teardown); - detachListeners = null; - // Keep attachedManager reference — prevents reattach to this - // same (completed) manager on the next poll tick. - // Polling will pick up a new manager once /arena start creates one. - if (!checkInterval) { - checkInterval = setInterval(tryAttach, 500); - } - }; - - // When agents settle to IDLE the session is still alive — keep - // the tab bar so users can continue interacting with agents. - // Only tear down on truly terminal session statuses. const onSessionComplete = (event: ArenaSessionCompleteEvent) => { - if (event.result.status === ArenaSessionStatus.IDLE) { - return; - } - teardown(); + // IDLE means agents finished but the session is still alive for + // follow-up interaction — keep the tab bar. + if (event.result.status === ArenaSessionStatus.IDLE) return; + detachSession(); }; + const onSessionError = () => detachSession(); + emitter.on(ArenaEventType.AGENT_START, onAgentStart); emitter.on(ArenaEventType.SESSION_COMPLETE, onSessionComplete); - emitter.on(ArenaEventType.SESSION_ERROR, teardown); + emitter.on(ArenaEventType.SESSION_ERROR, onSessionError); - detachListeners = () => { + detachArenaListeners = () => { emitter.off(ArenaEventType.AGENT_START, onAgentStart); emitter.off(ArenaEventType.SESSION_COMPLETE, onSessionComplete); - emitter.off(ArenaEventType.SESSION_ERROR, teardown); + emitter.off(ArenaEventType.SESSION_ERROR, onSessionError); }; }; - // Check immediately, then poll every 500ms - tryAttach(); - if (!attachedManager) { - checkInterval = setInterval(tryAttach, 500); + const handleManagerChange = (manager: ArenaManager | null) => { + detachSession(); + if (manager) { + attachSession(manager); + } + }; + + // Subscribe to future changes. + config.onArenaManagerChange(handleManagerChange); + + // Handle the case where a manager already exists when we mount. + const current = config.getArenaManager(); + if (current) { + attachSession(current); } return () => { - if (checkInterval) { - clearInterval(checkInterval); - } - for (const timeout of retryTimeouts) { - clearTimeout(timeout); - } - retryTimeouts.clear(); - detachListeners?.(); + config.onArenaManagerChange(null); + detachSession(); }; }, [config]); } diff --git a/packages/cli/src/ui/hooks/useInputHistory.ts b/packages/cli/src/ui/hooks/useInputHistory.ts index 58fc9d4a6..65e0256a5 100644 --- a/packages/cli/src/ui/hooks/useInputHistory.ts +++ b/packages/cli/src/ui/hooks/useInputHistory.ts @@ -18,6 +18,7 @@ export interface UseInputHistoryReturn { handleSubmit: (value: string) => void; navigateUp: () => boolean; navigateDown: () => boolean; + resetHistoryNav: () => void; } export function useInputHistory({ @@ -107,5 +108,6 @@ export function useInputHistory({ handleSubmit, navigateUp, navigateDown, + resetHistoryNav, }; } diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index be92757a0..a14dd3e06 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -40,6 +40,7 @@ import { AgentStatus, isTerminalStatus, isSettledStatus, + isSuccessStatus, } from '../runtime/agent-types.js'; import { logArenaSessionStarted, @@ -567,7 +568,7 @@ export class ArenaManager { return { success: false, error: `Agent ${agentId} not found` }; } - if (agent.status !== AgentStatus.COMPLETED) { + if (!isSuccessStatus(agent.status)) { return { success: false, error: `Agent ${agentId} has not completed (current status: ${agent.status})`, @@ -643,11 +644,14 @@ export class ArenaManager { * Emit a progress message via SESSION_UPDATE so the UI can display * setup status. */ - private emitProgress(message: string): void { + private emitProgress( + message: string, + type: 'info' | 'warning' | 'success' = 'info', + ): void { if (!this.sessionId) return; this.eventEmitter.emit(ArenaEventType.SESSION_UPDATE, { sessionId: this.sessionId, - type: 'info', + type, message, timestamp: Date.now(), }); @@ -1121,10 +1125,23 @@ export class ArenaManager { timestamp: Date.now(), }); + const displayName = agent.model.displayName || agent.model.modelId; + + // Emit a success message when an agent finishes its initial task. + if ( + this.sessionStatus === ArenaSessionStatus.RUNNING && + previousStatus === AgentStatus.RUNNING && + newStatus === AgentStatus.IDLE + ) { + this.emitProgress( + `Agent ${displayName} finished initial task.`, + 'success', + ); + } + // Emit progress messages for follow-up transitions (only after // the initial task — the session is IDLE once all agents first settle). if (this.sessionStatus === ArenaSessionStatus.IDLE) { - const displayName = agent.model.displayName || agent.model.modelId; if ( previousStatus === AgentStatus.IDLE && newStatus === AgentStatus.RUNNING @@ -1136,7 +1153,10 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress(`Agent ${displayName} finished follow-up task.`); + this.emitProgress( + `Agent ${displayName} finished follow-up task.`, + 'success', + ); } } @@ -1529,8 +1549,8 @@ export class ArenaManager { for (const agent of this.agents.values()) { const result = this.buildAgentResult(agent); - // Get diff for completed agents (they finished their task) - if (agent.status === AgentStatus.COMPLETED) { + // Get diff for agents that finished their task (IDLE or COMPLETED) + if (isSuccessStatus(agent.status)) { try { result.diff = await this.worktreeService.getWorktreeDiff( agent.worktree.path, diff --git a/packages/core/src/agents/arena/arena-events.ts b/packages/core/src/agents/arena/arena-events.ts index 20f82d6d5..def7c2444 100644 --- a/packages/core/src/agents/arena/arena-events.ts +++ b/packages/core/src/agents/arena/arena-events.ts @@ -117,7 +117,7 @@ export interface ArenaAgentStatusChangeEvent { /** * Event payload for session update (informational or warning). */ -export type ArenaSessionUpdateType = 'info' | 'warning'; +export type ArenaSessionUpdateType = 'info' | 'warning' | 'success'; export interface ArenaSessionUpdateEvent { sessionId: string; diff --git a/packages/core/src/agents/runtime/agent-types.ts b/packages/core/src/agents/runtime/agent-types.ts index 07610d9c0..d1204098a 100644 --- a/packages/core/src/agents/runtime/agent-types.ts +++ b/packages/core/src/agents/runtime/agent-types.ts @@ -123,6 +123,10 @@ export const isTerminalStatus = (s: AgentStatus): boolean => s === AgentStatus.FAILED || s === AgentStatus.CANCELLED; +/** True for IDLE or COMPLETED — agent finished its work successfully. */ +export const isSuccessStatus = (s: AgentStatus): boolean => + s === AgentStatus.IDLE || s === AgentStatus.COMPLETED; + /** True for terminal statuses OR IDLE — agent has settled (not actively working). */ export const isSettledStatus = (s: AgentStatus): boolean => s === AgentStatus.IDLE || isTerminalStatus(s); diff --git a/packages/core/src/config/config.ts b/packages/core/src/config/config.ts index 0cf8ba637..9feed5ce8 100644 --- a/packages/core/src/config/config.ts +++ b/packages/core/src/config/config.ts @@ -546,6 +546,9 @@ export class Config { private readonly skipNextSpeakerCheck: boolean; private shellExecutionConfig: ShellExecutionConfig; private arenaManager: ArenaManager | null = null; + private arenaManagerChangeCallback: + | ((manager: ArenaManager | null) => void) + | null = null; private readonly arenaAgentClient: ArenaAgentClient | null; private readonly agentsSettings: AgentsCollabSettings; private readonly skipLoopDetection: boolean; @@ -1369,6 +1372,17 @@ export class Config { setArenaManager(manager: ArenaManager | null): void { this.arenaManager = manager; + this.arenaManagerChangeCallback?.(manager); + } + + /** + * Register a callback invoked whenever the arena manager changes. + * Pass `null` to unsubscribe. Only one subscriber is supported. + */ + onArenaManagerChange( + cb: ((manager: ArenaManager | null) => void) | null, + ): void { + this.arenaManagerChangeCallback = cb; } getArenaAgentClient(): ArenaAgentClient | null { @@ -1393,7 +1407,7 @@ export class Config { } else { await manager.cleanup(); } - this.arenaManager = null; + this.setArenaManager(null); } getApprovalMode(): ApprovalMode { From cecc960254b731e725409af20e07e9c1a2f14ca8 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 11 Mar 2026 11:04:46 +0800 Subject: [PATCH 26/82] feat(arena): improve agent UI with header info and simplify worktree branches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add AgentHeader component showing model, path, and git branch - Separate modelId and modelName in RegisteredAgent for cleaner display - Simplify worktree branch naming from worktrees/session/name to base-session-name - Change loading text from "Agent is working…" to "Thinking…" - Make agent footer always visible (not just when input is active) This improves the agent collaboration UX by providing context about each agent's environment and simplifies the git worktree management. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/arenaCommand.ts | 20 ++++-- .../components/agent-view/AgentChatView.tsx | 43 ++++++++++--- .../components/agent-view/AgentComposer.tsx | 20 +++--- .../ui/components/agent-view/AgentHeader.tsx | 64 +++++++++++++++++++ .../ui/components/agent-view/AgentTabBar.tsx | 2 +- .../cli/src/ui/components/agent-view/index.ts | 1 + .../ui/components/arena/ArenaStatusDialog.tsx | 14 +--- .../cli/src/ui/contexts/AgentViewContext.tsx | 18 ++++-- .../cli/src/ui/hooks/useArenaInProcess.ts | 6 +- .../src/agents/arena/ArenaManager.test.ts | 5 +- .../core/src/agents/arena/ArenaManager.ts | 7 +- .../src/services/gitWorktreeService.test.ts | 49 +++++++------- .../core/src/services/gitWorktreeService.ts | 59 ++++++++--------- 13 files changed, 200 insertions(+), 108 deletions(-) create mode 100644 packages/cli/src/ui/components/agent-view/AgentHeader.tsx diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index f17c2ce2e..bf9f44387 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -133,12 +133,20 @@ function buildArenaExecutionInput( const defaultAuthType = contentGeneratorConfig?.authType ?? AuthType.USE_OPENAI; - // Build ArenaModelConfig for each model - const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => ({ - modelId: parsedModel.modelId, - authType: parsedModel.authType ?? defaultAuthType, - displayName: parsedModel.modelId, - })); + // Build ArenaModelConfig for each model, resolving display names from + // the model registry when available. + const modelsConfig = config.getModelsConfig(); + const models: ArenaModelConfig[] = parsed.models.map((parsedModel) => { + const authType = + (parsedModel.authType as AuthType | undefined) ?? defaultAuthType; + const registryModels = modelsConfig.getAvailableModelsForAuthType(authType); + const resolved = registryModels.find((m) => m.id === parsedModel.modelId); + return { + modelId: parsedModel.modelId, + authType, + displayName: resolved?.label ?? parsedModel.modelId, + }; + }); return { task: parsed.task, diff --git a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx index 371c8bb27..485316436 100644 --- a/packages/cli/src/ui/components/agent-view/AgentChatView.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentChatView.tsx @@ -26,6 +26,7 @@ import { useMemo, useState, useEffect, useCallback, useRef } from 'react'; import { AgentStatus, AgentEventType, + getGitBranch, type AgentStatusChangeEvent, } from '@qwen-code/qwen-code-core'; import { @@ -40,6 +41,7 @@ import { theme } from '../../semantic-colors.js'; import { GeminiRespondingSpinner } from '../GeminiRespondingSpinner.js'; import { useKeypress } from '../../hooks/useKeypress.js'; import { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; +import { AgentHeader } from './AgentHeader.js'; // ─── Main Component ───────────────────────────────────────── @@ -188,7 +190,17 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { const committedItems = allItems.slice(0, splitIndex); const pendingItems = allItems.slice(splitIndex); - if (!agent || !interactiveAgent) { + const core = interactiveAgent?.getCore(); + const agentWorkingDir = core?.runtimeContext.getTargetDir() ?? ''; + // Cache the branch — it won't change during the agent's lifetime and + // getGitBranch uses synchronous execSync which blocks the render loop. + const agentGitBranch = useMemo( + () => (agentWorkingDir ? getGitBranch(agentWorkingDir) : ''), + // eslint-disable-next-line react-hooks/exhaustive-deps + [agentId], + ); + + if (!agent || !interactiveAgent || !core) { return ( @@ -198,6 +210,8 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { ); } + const agentModelId = core.modelConfig.model ?? ''; + return ( {/* Committed message history. @@ -206,15 +220,24 @@ export const AgentChatView = ({ agentId }: AgentChatViewProps) => { all items on the cleared screen. */} ( - - ))} + items={[ + , + ...committedItems.map((item) => ( + + )), + ]} > {(item) => item} diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index 8c4d18b82..3d8062bfa 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -242,7 +242,7 @@ export const AgentComposer: React.FC = ({ agentId }) => { = ({ agentId }) => { /> {/* Footer: approval mode + context usage */} - {isInputActive && ( - - )} + ); diff --git a/packages/cli/src/ui/components/agent-view/AgentHeader.tsx b/packages/cli/src/ui/components/agent-view/AgentHeader.tsx new file mode 100644 index 000000000..1bf9d4c34 --- /dev/null +++ b/packages/cli/src/ui/components/agent-view/AgentHeader.tsx @@ -0,0 +1,64 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @fileoverview Compact header for agent tabs, visually distinct from the + * main view's boxed logo header. Shows model, working directory, and git + * branch in a bordered info panel. + */ + +import type React from 'react'; +import { Box, Text } from 'ink'; +import { shortenPath, tildeifyPath } from '@qwen-code/qwen-code-core'; +import { theme } from '../../semantic-colors.js'; +import { useTerminalSize } from '../../hooks/useTerminalSize.js'; + +interface AgentHeaderProps { + modelId: string; + modelName?: string; + workingDirectory: string; + gitBranch?: string; +} + +export const AgentHeader: React.FC = ({ + modelId, + modelName, + workingDirectory, + gitBranch, +}) => { + const { columns: terminalWidth } = useTerminalSize(); + const maxPathLen = Math.max(20, terminalWidth - 12); + const displayPath = shortenPath(tildeifyPath(workingDirectory), maxPathLen); + + const modelText = + modelName && modelName !== modelId ? `${modelId} (${modelName})` : modelId; + + return ( + + + {'Model: '} + {modelText} + + + {'Path: '} + {displayPath} + + {gitBranch && ( + + {'Branch: '} + {gitBranch} + + )} + + ); +}; diff --git a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx index a502363b4..c7b0b113c 100644 --- a/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentTabBar.tsx @@ -149,7 +149,7 @@ export const AgentTabBar: React.FC = () => { backgroundColor={isActive ? theme.border.default : undefined} color={isActive ? undefined : agent.color || theme.text.secondary} > - {` ${agent.displayName} `} + {` ${agent.modelId} `} {` ${symbol}`} diff --git a/packages/cli/src/ui/components/agent-view/index.ts b/packages/cli/src/ui/components/agent-view/index.ts index caa00a18a..c1e595c22 100644 --- a/packages/cli/src/ui/components/agent-view/index.ts +++ b/packages/cli/src/ui/components/agent-view/index.ts @@ -6,6 +6,7 @@ export { AgentTabBar } from './AgentTabBar.js'; export { AgentChatView } from './AgentChatView.js'; +export { AgentHeader } from './AgentHeader.js'; export { AgentComposer } from './AgentComposer.js'; export { AgentFooter } from './AgentFooter.js'; export { agentMessagesToHistoryItems } from './agentHistoryAdapter.js'; diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index 1a126c102..a6409b793 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -213,7 +213,7 @@ export function ArenaStatusDialog({ {/* Agent rows */} {agents.map((agent) => { - const label = agent.model.displayName || agent.model.modelId; + const label = agent.model.modelId; const { text: statusText, color } = getArenaStatusLabel(agent.status); const elapsed = getElapsedMs(agent); @@ -270,18 +270,6 @@ export function ArenaStatusDialog({ )} - {/* In-process mode: show extra detail row with thought/cached tokens */} - {live && (live.thoughtTokens > 0 || live.cachedTokens > 0) && ( - - - {live.thoughtTokens > 0 && - `Thinking: ${live.thoughtTokens.toLocaleString()} tok`} - {live.thoughtTokens > 0 && live.cachedTokens > 0 && ' · '} - {live.cachedTokens > 0 && - `Cached: ${live.cachedTokens.toLocaleString()} tok`} - - - )} ); })} diff --git a/packages/cli/src/ui/contexts/AgentViewContext.tsx b/packages/cli/src/ui/contexts/AgentViewContext.tsx index cb85ab4f2..b2c35e6d3 100644 --- a/packages/cli/src/ui/contexts/AgentViewContext.tsx +++ b/packages/cli/src/ui/contexts/AgentViewContext.tsx @@ -33,7 +33,10 @@ import { useArenaInProcess } from '../hooks/useArenaInProcess.js'; export interface RegisteredAgent { interactiveAgent: AgentInteractive; - displayName: string; + /** Model identifier shown in tabs and paths (e.g. "glm-5"). */ + modelId: string; + /** Human-friendly model name (e.g. "GLM 5"). */ + modelName?: string; color: string; } @@ -60,8 +63,9 @@ export interface AgentViewActions { registerAgent( agentId: string, interactiveAgent: AgentInteractive, - displayName: string, + modelId: string, color: string, + modelName?: string, ): void; unregisterAgent(agentId: string): void; unregisterAll(): void; @@ -173,12 +177,18 @@ export function AgentViewProvider({ ( agentId: string, interactiveAgent: AgentInteractive, - displayName: string, + modelId: string, color: string, + modelName?: string, ) => { setAgents((prev) => { const next = new Map(prev); - next.set(agentId, { interactiveAgent, displayName, color }); + next.set(agentId, { + interactiveAgent, + modelId, + color, + modelName, + }); return next; }); // Seed approval mode from the agent's own config diff --git a/packages/cli/src/ui/hooks/useArenaInProcess.ts b/packages/cli/src/ui/hooks/useArenaInProcess.ts index c5793490b..c75634a2a 100644 --- a/packages/cli/src/ui/hooks/useArenaInProcess.ts +++ b/packages/cli/src/ui/hooks/useArenaInProcess.ts @@ -93,8 +93,9 @@ export function useArenaInProcess( actionsRef.current.registerAgent( agentState.agentId, interactive, - agentState.model.displayName || agentState.model.modelId, + agentState.model.modelId, nextColor(), + agentState.model.displayName, ); } } @@ -115,8 +116,9 @@ export function useArenaInProcess( actionsRef.current.registerAgent( event.agentId, interactive, - event.model.displayName || event.model.modelId, + event.model.modelId, nextColor(), + event.model.displayName, ); return; } diff --git a/packages/core/src/agents/arena/ArenaManager.test.ts b/packages/core/src/agents/arena/ArenaManager.test.ts index 3ffcaa3b3..a21f15d63 100644 --- a/packages/core/src/agents/arena/ArenaManager.test.ts +++ b/packages/core/src/agents/arena/ArenaManager.test.ts @@ -411,10 +411,7 @@ describe('ArenaManager', () => { expect(mockBackend.cleanup).toHaveBeenCalledTimes(1); // cleanupSession is called with worktreeDirName (short ID), not the full sessionId. // For 'test-session', the short ID is 'testsess' (first 8 chars with dashes removed). - expect(hoistedMockCleanupSession).toHaveBeenCalledWith( - 'testsess', - 'arena', - ); + expect(hoistedMockCleanupSession).toHaveBeenCalledWith('testsess'); expect(manager.getBackend()).toBeNull(); expect(manager.getSessionId()).toBeUndefined(); }); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index a14dd3e06..e271de7d2 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -508,7 +508,7 @@ export class ArenaManager { } // Clean up worktrees - await this.worktreeService.cleanupSession(this.worktreeDirName!, 'arena'); + await this.worktreeService.cleanupSession(this.worktreeDirName!); this.agents.clear(); this.cachedResult = null; @@ -758,15 +758,12 @@ export class ArenaManager { debugLogger.info('Setting up worktrees for Arena agents'); - const worktreeNames = this.arenaConfig.models.map( - (m) => m.displayName || m.modelId, - ); + const worktreeNames = this.arenaConfig.models.map((m) => m.modelId); const result = await this.worktreeService.setupWorktrees({ sessionId: this.worktreeDirName!, sourceRepoPath: this.arenaConfig.sourceRepoPath, worktreeNames, - branchPrefix: 'arena', metadata: { arenaSessionId: this.arenaConfig.sessionId }, }); diff --git a/packages/core/src/services/gitWorktreeService.test.ts b/packages/core/src/services/gitWorktreeService.test.ts index 2eb028d98..f34eb1ca2 100644 --- a/packages/core/src/services/gitWorktreeService.test.ts +++ b/packages/core/src/services/gitWorktreeService.test.ts @@ -148,13 +148,13 @@ describe('GitWorktreeService', () => { 'model-a', ); expect(result.success).toBe(true); - expect(result.worktree?.branch).toBe('worktrees/s1/model-a'); + expect(result.worktree?.branch).toBe('main-s1-model-a'); expect(result.worktree?.path).toBe(expectedPath); expect(hoistedMockRaw).toHaveBeenCalledWith([ 'worktree', 'add', '-b', - 'worktrees/s1/model-a', + 'main-s1-model-a', expectedPath, 'main', ]); @@ -228,7 +228,7 @@ describe('GitWorktreeService', () => { expect(result.success).toBe(false); expect(result.errors).toContainEqual({ name: 'b', error: 'boom' }); - expect(cleanupSpy).toHaveBeenCalledWith('s1', 'worktrees'); + expect(cleanupSpy).toHaveBeenCalledWith('s1'); }); it('listWorktrees should return empty array when session dir does not exist', async () => { @@ -256,31 +256,34 @@ describe('GitWorktreeService', () => { expect(hoistedMockRaw).toHaveBeenNthCalledWith(2, ['worktree', 'prune']); }); - it('cleanupSession should remove prefixed branches only', async () => { + it('cleanupSession should remove branches from listed worktrees', async () => { const service = new GitWorktreeService('/repo'); - vi.spyOn(service, 'listWorktrees').mockResolvedValue([]); - hoistedMockBranch.mockImplementation((args?: string[]) => { - if (args?.[0] === '-a') { - return Promise.resolve({ - branches: { - main: {}, - 'worktrees/s1/a': {}, - 'worktrees/s1/b': {}, - }, - }); - } - return Promise.resolve({ branches: {} }); - }); + vi.spyOn(service, 'listWorktrees').mockResolvedValue([ + { + id: 's1/a', + name: 'a', + path: '/w/a', + branch: 'main-s1-a', + isActive: true, + createdAt: Date.now(), + }, + { + id: 's1/b', + name: 'b', + path: '/w/b', + branch: 'main-s1-b', + isActive: true, + createdAt: Date.now(), + }, + ]); + vi.spyOn(service, 'removeWorktree').mockResolvedValue({ success: true }); const result = await service.cleanupSession('s1'); expect(result.success).toBe(true); - expect(result.removedBranches).toEqual([ - 'worktrees/s1/a', - 'worktrees/s1/b', - ]); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/a']); - expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'worktrees/s1/b']); + expect(result.removedBranches).toEqual(['main-s1-a', 'main-s1-b']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'main-s1-a']); + expect(hoistedMockBranch).toHaveBeenCalledWith(['-D', 'main-s1-b']); expect(hoistedMockRaw).toHaveBeenCalledWith(['worktree', 'prune']); }); diff --git a/packages/core/src/services/gitWorktreeService.ts b/packages/core/src/services/gitWorktreeService.ts index 5683fcdf0..6ceebf11e 100644 --- a/packages/core/src/services/gitWorktreeService.ts +++ b/packages/core/src/services/gitWorktreeService.ts @@ -6,6 +6,7 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import { execSync } from 'node:child_process'; import { simpleGit, CheckRepoActions } from 'simple-git'; import type { SimpleGit } from 'simple-git'; import { Storage } from '../config/storage.js'; @@ -51,8 +52,6 @@ export interface WorktreeSetupConfig { worktreeNames: string[]; /** Base branch to create worktrees from (defaults to current branch) */ baseBranch?: string; - /** Branch prefix for worktree branches (default: 'worktrees') */ - branchPrefix?: string; /** Extra metadata to persist alongside the session config */ metadata?: Record; } @@ -226,7 +225,6 @@ export class GitWorktreeService { sessionId: string, name: string, baseBranch?: string, - branchPrefix: string = WORKTREES_DIR, ): Promise { try { const worktreesDir = GitWorktreeService.getWorktreesDir( @@ -238,7 +236,6 @@ export class GitWorktreeService { // Sanitize name for use as branch and directory name const sanitizedName = this.sanitizeName(name); const worktreePath = path.join(worktreesDir, sanitizedName); - const branchName = `${branchPrefix}/${sessionId}/${sanitizedName}`; // Check if worktree already exists const exists = await this.pathExists(worktreePath); @@ -251,6 +248,8 @@ export class GitWorktreeService { // Determine base branch const base = baseBranch || (await this.getCurrentBranch()); + const shortSession = sessionId.slice(0, 6); + const branchName = `${base}-${shortSession}-${sanitizedName}`; // Create the worktree with a new branch await this.git.raw([ @@ -381,15 +380,12 @@ export class GitWorktreeService { // Non-fatal: proceed without untracked files } - const branchPrefix = config.branchPrefix ?? WORKTREES_DIR; - // Create worktrees for each entry for (const name of config.worktreeNames) { const createResult = await this.createWorktree( config.sessionId, name, config.baseBranch, - branchPrefix, ); if (createResult.success && createResult.worktree) { @@ -406,7 +402,7 @@ export class GitWorktreeService { // If any worktree failed, clean up all created resources and fail if (result.errors.length > 0) { try { - await this.cleanupSession(config.sessionId, branchPrefix); + await this.cleanupSession(config.sessionId); } catch (error) { result.errors.push({ name: 'cleanup', @@ -468,10 +464,7 @@ export class GitWorktreeService { /** * Lists all worktrees for a session. */ - async listWorktrees( - sessionId: string, - branchPrefix: string = WORKTREES_DIR, - ): Promise { + async listWorktrees(sessionId: string): Promise { const worktreesDir = GitWorktreeService.getWorktreesDir( sessionId, this.customBaseDir, @@ -484,7 +477,18 @@ export class GitWorktreeService { for (const entry of entries) { if (entry.isDirectory()) { const worktreePath = path.join(worktreesDir, entry.name); - const branchName = `${branchPrefix}/${sessionId}/${entry.name}`; + + // Read the actual branch from the worktree + let branchName = ''; + try { + branchName = execSync('git rev-parse --abbrev-ref HEAD', { + cwd: worktreePath, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + } catch { + // Fallback if git command fails + } // Try to get stats for creation time let createdAt = Date.now(); @@ -544,10 +548,7 @@ export class GitWorktreeService { /** * Cleans up all worktrees and branches for a session. */ - async cleanupSession( - sessionId: string, - branchPrefix: string = WORKTREES_DIR, - ): Promise<{ + async cleanupSession(sessionId: string): Promise<{ success: boolean; removedWorktrees: string[]; removedBranches: string[]; @@ -560,7 +561,11 @@ export class GitWorktreeService { errors: [] as string[], }; - const worktrees = await this.listWorktrees(sessionId, branchPrefix); + // Collect actual branch names from worktrees before removing them + const worktrees = await this.listWorktrees(sessionId); + const worktreeBranches = new Set( + worktrees.map((w) => w.branch).filter(Boolean), + ); // Remove all worktrees for (const worktree of worktrees) { @@ -588,18 +593,14 @@ export class GitWorktreeService { ); } - // Clean up branches - const prefix = `${branchPrefix}/${sessionId}/`; + // Clean up branches that belonged to the worktrees try { - const branches = await this.git.branch(['-a']); - for (const branchName of Object.keys(branches.branches)) { - if (branchName.startsWith(prefix)) { - try { - await this.git.branch(['-D', branchName]); - result.removedBranches.push(branchName); - } catch { - // Branch might already be deleted, ignore - } + for (const branchName of worktreeBranches) { + try { + await this.git.branch(['-D', branchName]); + result.removedBranches.push(branchName); + } catch { + // Branch might already be deleted, ignore } } } catch { From 3233d16b5c4986cd9ca3119e766cda4732c7d12a Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 11 Mar 2026 11:56:05 +0800 Subject: [PATCH 27/82] feat(arena): add system reminder and status file support for agent collaboration Co-authored-by: Qwen-Coder Extract atomic file write utility into reusable module. Add arena system reminder injection so orchestrating agents can discover active arena sessions. Support in-process mode status file writing for external consumers. This enables agent-to-agent collaboration where a parent agent can monitor and coordinate arena sessions via file-based status files. --- packages/cli/src/ui/commands/arenaCommand.ts | 28 ++--- .../ui/components/arena/ArenaSelectDialog.tsx | 4 +- .../core/src/agents/arena/ArenaAgentClient.ts | 56 +-------- .../core/src/agents/arena/ArenaManager.ts | 111 +++++++++++++----- packages/core/src/config/storage.ts | 5 + packages/core/src/core/client.ts | 13 ++ packages/core/src/core/prompts.ts | 10 ++ packages/core/src/tools/read-file.ts | 5 +- .../core/src/utils/atomicFileWrite.test.ts | 63 ++++++++++ packages/core/src/utils/atomicFileWrite.ts | 72 ++++++++++++ 10 files changed, 265 insertions(+), 102 deletions(-) create mode 100644 packages/core/src/utils/atomicFileWrite.test.ts create mode 100644 packages/core/src/utils/atomicFileWrite.ts diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index bf9f44387..118308eaf 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -215,10 +215,7 @@ function executeArenaCommand( const handleSessionStart = (event: ArenaSessionStartEvent) => { const modelList = event.models - .map( - (model, index) => - ` ${index + 1}. ${model.displayName || model.modelId}`, - ) + .map((model, index) => ` ${index + 1}. ${model.modelId}`) .join('\n'); // SESSION_START fires synchronously before the first await in // ArenaManager.start(), so the slash command processor's finally @@ -230,9 +227,10 @@ function executeArenaCommand( }; const handleAgentStart = (event: ArenaAgentStartEvent) => { - const label = event.model.displayName || event.model.modelId; - agentLabels.set(event.agentId, label); - debugLogger.debug(`Arena agent started: ${label} (${event.agentId})`); + agentLabels.set(event.agentId, event.model.modelId); + debugLogger.debug( + `Arena agent started: ${event.model.modelId} (${event.agentId})`, + ); }; const handleSessionUpdate = (event: ArenaSessionUpdateEvent) => { @@ -269,7 +267,7 @@ function executeArenaCommand( const buildAgentCardData = ( result: ArenaAgentCompleteEvent['result'], ): ArenaAgentCardData => ({ - label: result.model.displayName || result.model.modelId, + label: result.model.modelId, status: result.status, durationMs: result.stats.durationMs, totalTokens: result.stats.totalTokens, @@ -621,14 +619,11 @@ export const arenaCommand: SlashCommand = { // Handle direct model selection via args if (trimmedArgs) { - const matchingAgent = agents.find((a) => { - const label = a.model.displayName || a.model.modelId; - return ( + const matchingAgent = agents.find( + (a) => isSuccessStatus(a.status) && - (label.toLowerCase() === trimmedArgs.toLowerCase() || - a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase()) - ); - }); + a.model.modelId.toLowerCase() === trimmedArgs.toLowerCase(), + ); if (!matchingAgent) { return { @@ -638,8 +633,7 @@ export const arenaCommand: SlashCommand = { }; } - const label = - matchingAgent.model.displayName || matchingAgent.model.modelId; + const label = matchingAgent.model.modelId; const result = await manager.applyAgentResult(matchingAgent.agentId); if (!result.success) { return { diff --git a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx index 1f8b5a6e4..661c4ee55 100644 --- a/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaSelectDialog.tsx @@ -72,7 +72,7 @@ export function ArenaSelectDialog({ const agent = mgr.getAgentState(agentId) ?? mgr.getAgentStates().find((item) => item.agentId === agentId); - const label = agent?.model.displayName || agent?.model.modelId || agentId; + const label = agent?.model.modelId || agentId; const result = await mgr.applyAgentResult(agentId); if (!result.success) { @@ -130,7 +130,7 @@ export function ArenaSelectDialog({ const items: Array> = useMemo( () => agents.map((agent) => { - const label = agent.model.displayName || agent.model.modelId; + const label = agent.model.modelId; const statusInfo = getArenaStatusLabel(agent.status); const duration = formatDuration(agent.stats.durationMs); const tokens = agent.stats.totalTokens.toLocaleString(); diff --git a/packages/core/src/agents/arena/ArenaAgentClient.ts b/packages/core/src/agents/arena/ArenaAgentClient.ts index 070f57adb..12780f8de 100644 --- a/packages/core/src/agents/arena/ArenaAgentClient.ts +++ b/packages/core/src/agents/arena/ArenaAgentClient.ts @@ -6,9 +6,9 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; -import * as crypto from 'node:crypto'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { isNodeError } from '../../utils/errors.js'; +import { atomicWriteJSON } from '../../utils/atomicFileWrite.js'; import { uiTelemetryService } from '../../telemetry/uiTelemetry.js'; import type { ArenaAgentStats, @@ -109,7 +109,7 @@ export class ArenaAgentClient { error: null, }; - await this.atomicWrite(this.statusFilePath, statusFile); + await atomicWriteJSON(this.statusFilePath, statusFile); } /** @@ -158,7 +158,7 @@ export class ArenaAgentClient { error: null, }; - await this.atomicWrite(this.statusFilePath, statusFile); + await atomicWriteJSON(this.statusFilePath, statusFile); } /** @@ -179,7 +179,7 @@ export class ArenaAgentClient { error: errorMessage, }; - await this.atomicWrite(this.statusFilePath, statusFile); + await atomicWriteJSON(this.statusFilePath, statusFile); } /** @@ -200,7 +200,7 @@ export class ArenaAgentClient { error: null, }; - await this.atomicWrite(this.statusFilePath, statusFile); + await atomicWriteJSON(this.statusFilePath, statusFile); } /** @@ -233,52 +233,6 @@ export class ArenaAgentClient { }; } - /** - * Atomically write JSON data to a file (write temp → rename). - * Retries on EPERM which occurs on Windows under concurrent renames. - */ - private async atomicWrite( - filePath: string, - data: ArenaStatusFile, - ): Promise { - const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`; - try { - await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); - await this.renameWithRetry(tmpPath, filePath); - } catch (error) { - try { - await fs.unlink(tmpPath); - } catch { - // Ignore cleanup errors - } - throw error; - } - } - - private async renameWithRetry( - src: string, - dest: string, - retries = 3, - delayMs = 50, - ): Promise { - for (let attempt = 0; attempt <= retries; attempt++) { - try { - await fs.rename(src, dest); - return; - } catch (error: unknown) { - const isRetryable = - isNodeError(error) && - (error.code === 'EPERM' || error.code === 'EACCES'); - if (!isRetryable || attempt === retries) { - throw error; - } - await new Promise((resolve) => - setTimeout(resolve, delayMs * 2 ** attempt), - ); - } - } - } - private async ensureInitialized(): Promise { if (!this.initialized) { await this.init(); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index e271de7d2..427076666 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -4,7 +4,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import * as crypto from 'node:crypto'; import * as fs from 'node:fs/promises'; import * as path from 'node:path'; import { GitWorktreeService } from '../../services/gitWorktreeService.js'; @@ -13,6 +12,7 @@ import type { Config } from '../../config/config.js'; import { getCoreSystemPrompt } from '../../core/prompts.js'; import { createDebugLogger } from '../../utils/debugLogger.js'; import { isNodeError } from '../../utils/errors.js'; +import { atomicWriteJSON } from '../../utils/atomicFileWrite.js'; import type { AnsiOutput } from '../../utils/terminalSerializer.js'; import { ArenaEventEmitter, ArenaEventType } from './arena-events.js'; import type { AgentSpawnConfig, Backend, DisplayMode } from '../index.js'; @@ -370,7 +370,7 @@ export class ArenaManager { const worktreeInfo = Array.from(this.agents.values()) .map( (agent, i) => - ` ${i + 1}. ${agent.model.displayName || agent.model.modelId} → ${agent.worktree.path}`, + ` ${i + 1}. ${agent.model.modelId} → ${agent.worktree.path}`, ) .join('\n'); this.emitProgress(`Environment ready. Agent worktrees:\n${worktreeInfo}`); @@ -1045,7 +1045,7 @@ export class ArenaManager { cols: this.terminalCols, rows: this.terminalRows, inProcess: { - agentName: model.displayName || model.modelId, + agentName: model.modelId, initialTask: this.arenaConfig?.task, runtimeConfig: { promptConfig: { @@ -1122,7 +1122,7 @@ export class ArenaManager { timestamp: Date.now(), }); - const displayName = agent.model.displayName || agent.model.modelId; + const label = agent.model.modelId; // Emit a success message when an agent finishes its initial task. if ( @@ -1130,10 +1130,7 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress( - `Agent ${displayName} finished initial task.`, - 'success', - ); + this.emitProgress(`Agent ${label} finished initial task.`, 'success'); } // Emit progress messages for follow-up transitions (only after @@ -1143,17 +1140,12 @@ export class ArenaManager { previousStatus === AgentStatus.IDLE && newStatus === AgentStatus.RUNNING ) { - this.emitProgress( - `Agent ${displayName} is working on a follow-up task…`, - ); + this.emitProgress(`Agent ${label} is working on a follow-up task…`); } else if ( previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress( - `Agent ${displayName} finished follow-up task.`, - 'success', - ); + this.emitProgress(`Agent ${label} finished follow-up task.`, 'success'); } } @@ -1211,13 +1203,19 @@ export class ArenaManager { }; } - // ─── Private: Arena Session Directory ───────────────────────── + // ─── Arena Session Directory ────────────────────────────────── /** * Get the arena session directory for the current session. * All status and control files are stored here. + * + * Returns the absolute path to the session directory, e.g. + * `~/.qwen/worktrees//`. The directory contains: + * - `config.json` — consolidated session config + per-agent status + * - `agents/.json` — individual agent status files + * - `control/` — control signals (shutdown, cancel) */ - private getArenaSessionDir(): string { + getArenaSessionDir(): string { if (!this.arenaConfig) { throw new Error('Arena config not initialized'); } @@ -1337,9 +1335,19 @@ export class ArenaManager { const onStatusChange = (event: AgentStatusChangeEvent) => { syncStats(); applyStatus(event.newStatus); + // Write status files so external consumers get a consistent + // file-based view regardless of backend mode. + this.flushInProcessStatusFiles().catch((err) => + debugLogger.error('Failed to flush in-process status files:', err), + ); }; - const onUsageMetadata = () => syncStats(); + const onUsageMetadata = () => { + syncStats(); + this.flushInProcessStatusFiles().catch((err) => + debugLogger.error('Failed to flush in-process status files:', err), + ); + }; emitter.on(AgentEventType.STATUS_CHANGE, onStatusChange); emitter.on(AgentEventType.USAGE_METADATA, onUsageMetadata); @@ -1357,6 +1365,12 @@ export class ArenaManager { syncStats(); applyStatus(interactive.getStatus()); } + + // Flush status files once after reconciliation so that agents which + // already settled before the bridge was attached still get written to disk. + this.flushInProcessStatusFiles().catch((err) => + debugLogger.error('Failed to flush in-process status files:', err), + ); } /** @@ -1470,19 +1484,7 @@ export class ArenaManager { config.updatedAt = Date.now(); config.agents = agents; - // Atomic write - const tmpPath = `${configPath}.${crypto.randomBytes(4).toString('hex')}.tmp`; - try { - await fs.writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf-8'); - await fs.rename(tmpPath, configPath); - } catch (writeError) { - try { - await fs.unlink(tmpPath); - } catch { - // Ignore cleanup errors - } - throw writeError; - } + await atomicWriteJSON(configPath, config); } catch (error) { debugLogger.error( 'Failed to write consolidated status to config.json:', @@ -1491,6 +1493,53 @@ export class ArenaManager { } } + /** + * Build an ArenaStatusFile snapshot from in-memory agent state. + */ + private buildStatusFile(agent: ArenaAgentState): ArenaStatusFile { + return { + agentId: agent.agentId, + status: agent.status, + updatedAt: Date.now(), + rounds: agent.stats.rounds, + stats: { ...agent.stats }, + finalSummary: null, + error: agent.error ?? null, + }; + } + + /** + * Write status files for all in-process agents and update the + * consolidated config.json. + * + * In PTY mode these files are written by ArenaAgentClient inside each + * child process. In in-process mode there is no child process, so the + * ArenaManager writes them directly so that external consumers + * (e.g. an orchestrating agent) get a consistent file-based view + * regardless of backend. + */ + private async flushInProcessStatusFiles(): Promise { + const sessionDir = this.getArenaSessionDir(); + const agentsDir = path.join(sessionDir, 'agents'); + await fs.mkdir(agentsDir, { recursive: true }); + + const consolidatedAgents: Record = {}; + + for (const agent of this.agents.values()) { + const statusFile = this.buildStatusFile(agent); + const filePath = path.join( + agentsDir, + `${safeAgentId(agent.agentId)}.json`, + ); + await atomicWriteJSON(filePath, statusFile); + consolidatedAgents[agent.agentId] = statusFile; + } + + if (Object.keys(consolidatedAgents).length > 0) { + await this.writeConsolidatedStatus(consolidatedAgents); + } + } + /** * Write a control signal to the arena session's control/ directory. * The child agent consumes (reads + deletes) this file. diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 3293280a8..5de57ab0c 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -17,6 +17,7 @@ const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; const IDE_DIR_NAME = 'ide'; const DEBUG_DIR_NAME = 'debug'; +const ARENA_DIR_NAME = 'arena'; export class Storage { private readonly targetDir: string; @@ -77,6 +78,10 @@ export class Storage { return path.join(Storage.getGlobalQwenDir(), BIN_DIR_NAME); } + static getGlobalArenaDir(): string { + return path.join(Storage.getGlobalQwenDir(), ARENA_DIR_NAME); + } + getQwenDir(): string { return path.join(this.targetDir, QWEN_DIR); } diff --git a/packages/core/src/core/client.ts b/packages/core/src/core/client.ts index e03159517..acd2c321d 100644 --- a/packages/core/src/core/client.ts +++ b/packages/core/src/core/client.ts @@ -23,6 +23,7 @@ const debugLogger = createDebugLogger('CLIENT'); import type { ContentGenerator } from './contentGenerator.js'; import { GeminiChat } from './geminiChat.js'; import { + getArenaSystemReminder, getCoreSystemPrompt, getCustomSystemPrompt, getPlanModeSystemReminder, @@ -577,6 +578,18 @@ export class GeminiClient { ); } + // add arena system reminder if an arena session is active + const arenaManager = this.config.getArenaManager(); + if (arenaManager) { + try { + const sessionDir = arenaManager.getArenaSessionDir(); + const configPath = `${sessionDir}/config.json`; + systemReminders.push(getArenaSystemReminder(configPath)); + } catch { + // Arena config not yet initialized — skip + } + } + requestToSent = [...systemReminders, ...requestToSent]; } diff --git a/packages/core/src/core/prompts.ts b/packages/core/src/core/prompts.ts index bdf4c6dc1..21d21c2c5 100644 --- a/packages/core/src/core/prompts.ts +++ b/packages/core/src/core/prompts.ts @@ -859,6 +859,16 @@ Plan mode is active. The user indicated that they do not want you to execute yet `; } +/** + * Generates a system reminder about an active Arena session. + * + * @param configFilePath - Absolute path to the arena session's `config.json` + * @returns A formatted system reminder string wrapped in XML tags + */ +export function getArenaSystemReminder(configFilePath: string): string { + return `An Arena session is active. For details, read: ${configFilePath}. This message is for internal use only. Do not mention this to user in your response.`; +} + // ============================================================================ // Insight Analysis Prompts // ============================================================================ diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index e09a1ac58..82457d234 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -187,16 +187,19 @@ export class ReadFileTool extends BaseDeclarativeTool< const globalTempDir = Storage.getGlobalTempDir(); const projectTempDir = this.config.storage.getProjectTempDir(); const userSkillsDir = this.config.storage.getUserSkillsDir(); + const arenaDir = Storage.getGlobalArenaDir(); const resolvedFilePath = path.resolve(filePath); const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath) || isSubpath(globalTempDir, resolvedFilePath); + const isWithinArenaDir = isSubpath(arenaDir, resolvedFilePath); const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); if ( !workspaceContext.isPathWithinWorkspace(filePath) && !isWithinTempDir && - !isWithinUserSkills + !isWithinUserSkills && + !isWithinArenaDir ) { const directories = workspaceContext.getDirectories(); return `File path must be within one of the workspace directories: ${directories.join( diff --git a/packages/core/src/utils/atomicFileWrite.test.ts b/packages/core/src/utils/atomicFileWrite.test.ts new file mode 100644 index 000000000..7d30caed0 --- /dev/null +++ b/packages/core/src/utils/atomicFileWrite.test.ts @@ -0,0 +1,63 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import * as os from 'node:os'; +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { atomicWriteJSON } from './atomicFileWrite.js'; + +describe('atomicWriteJSON', () => { + let tmpDir: string; + + beforeEach(async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'atomic-write-test-')); + }); + + afterEach(async () => { + await fs.rm(tmpDir, { recursive: true, force: true }); + }); + + it('should write valid JSON to the target file', async () => { + const filePath = path.join(tmpDir, 'test.json'); + const data = { hello: 'world', count: 42 }; + + await atomicWriteJSON(filePath, data); + + const content = await fs.readFile(filePath, 'utf-8'); + expect(JSON.parse(content)).toEqual(data); + }); + + it('should pretty-print with 2-space indent', async () => { + const filePath = path.join(tmpDir, 'test.json'); + await atomicWriteJSON(filePath, { a: 1 }); + + const content = await fs.readFile(filePath, 'utf-8'); + expect(content).toBe(JSON.stringify({ a: 1 }, null, 2)); + }); + + it('should overwrite existing file atomically', async () => { + const filePath = path.join(tmpDir, 'test.json'); + await atomicWriteJSON(filePath, { version: 1 }); + await atomicWriteJSON(filePath, { version: 2 }); + + const content = await fs.readFile(filePath, 'utf-8'); + expect(JSON.parse(content)).toEqual({ version: 2 }); + }); + + it('should not leave temp files on success', async () => { + const filePath = path.join(tmpDir, 'test.json'); + await atomicWriteJSON(filePath, { ok: true }); + + const files = await fs.readdir(tmpDir); + expect(files).toEqual(['test.json']); + }); + + it('should throw if parent directory does not exist', async () => { + const filePath = path.join(tmpDir, 'nonexistent', 'test.json'); + await expect(atomicWriteJSON(filePath, {})).rejects.toThrow(); + }); +}); diff --git a/packages/core/src/utils/atomicFileWrite.ts b/packages/core/src/utils/atomicFileWrite.ts new file mode 100644 index 000000000..e79a05738 --- /dev/null +++ b/packages/core/src/utils/atomicFileWrite.ts @@ -0,0 +1,72 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as crypto from 'node:crypto'; +import * as fs from 'node:fs/promises'; +import { isNodeError } from './errors.js'; + +export interface AtomicWriteOptions { + /** Number of rename retries on EPERM/EACCES (default: 3). */ + retries?: number; + /** Base delay in ms for exponential backoff (default: 50). */ + delayMs?: number; +} + +/** + * Atomically write a JSON value to a file. + * + * Writes to a temporary file first, then renames it to the target path. + * On POSIX `fs.rename` is atomic, so readers never see a partial file. + * On Windows the rename can fail with EPERM under concurrent access, + * so we retry with exponential backoff. + * + * The parent directory of `filePath` must already exist. + */ +export async function atomicWriteJSON( + filePath: string, + data: unknown, + options?: AtomicWriteOptions, +): Promise { + const retries = options?.retries ?? 3; + const delayMs = options?.delayMs ?? 50; + + const tmpPath = `${filePath}.${crypto.randomBytes(4).toString('hex')}.tmp`; + try { + await fs.writeFile(tmpPath, JSON.stringify(data, null, 2), 'utf-8'); + await renameWithRetry(tmpPath, filePath, retries, delayMs); + } catch (error) { + try { + await fs.unlink(tmpPath); + } catch { + // Ignore cleanup errors + } + throw error; + } +} + +async function renameWithRetry( + src: string, + dest: string, + retries: number, + delayMs: number, +): Promise { + for (let attempt = 0; attempt <= retries; attempt++) { + try { + await fs.rename(src, dest); + return; + } catch (error: unknown) { + const isRetryable = + isNodeError(error) && + (error.code === 'EPERM' || error.code === 'EACCES'); + if (!isRetryable || attempt === retries) { + throw error; + } + await new Promise((resolve) => + setTimeout(resolve, delayMs * 2 ** attempt), + ); + } + } +} From 4ee94715df482595ad6eeda52b495c915b983725 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Thu, 12 Mar 2026 16:57:44 +0800 Subject: [PATCH 28/82] feat(arena): improve cancellation handling and simplify to in-process mode Co-authored-by: Qwen-Coder - Track user-initiated cancellation separately from failures - Cancel round immediately when user denies a tool call - Add message queue to handle input during streaming - Add info messages during Arena operations (apply, stop, cleanup) - Disable tmux/iTerm2 backends (only in-process mode supported) - Polish UI: green tool count, updated warning prefix This improves the Arena UX by providing clearer feedback and properly handling user cancellations without treating them as failures. --- packages/cli/src/config/settingsSchema.ts | 6 +- packages/cli/src/ui/commands/arenaCommand.ts | 5 +- .../components/agent-view/AgentComposer.tsx | 32 ++++++- .../ui/components/arena/ArenaSelectDialog.tsx | 8 ++ .../ui/components/arena/ArenaStatusDialog.tsx | 6 +- .../ui/components/arena/ArenaStopDialog.tsx | 8 ++ .../ui/components/messages/StatusMessages.tsx | 2 +- .../src/ui/hooks/useAgentStreamingState.ts | 3 +- .../core/src/agents/arena/ArenaManager.ts | 32 +++++-- packages/core/src/agents/backends/detect.ts | 92 ++++++++++--------- .../core/src/agents/runtime/agent-events.ts | 2 + .../agents/runtime/agent-interactive.test.ts | 2 +- .../src/agents/runtime/agent-interactive.ts | 20 +++- 13 files changed, 153 insertions(+), 65 deletions(-) diff --git a/packages/cli/src/config/settingsSchema.ts b/packages/cli/src/config/settingsSchema.ts index 284d8cae2..4a84e8a45 100644 --- a/packages/cli/src/config/settingsSchema.ts +++ b/packages/cli/src/config/settingsSchema.ts @@ -1193,12 +1193,12 @@ const SETTINGS_SCHEMA = { requiresRestart: false, default: undefined as string | undefined, description: - 'Display mode for multi-agent sessions. "tmux" uses tmux panes, "iterm2" uses iTerm2 tabs, "in-process" runs in the current terminal.', + 'Display mode for multi-agent sessions. Currently only "in-process" is supported.', showInDialog: false, options: [ { value: 'in-process', label: 'In-process' }, - { value: 'tmux', label: 'tmux' }, - { value: 'iterm2', label: 'iTerm2' }, + // { value: 'tmux', label: 'tmux' }, + // { value: 'iterm2', label: 'iTerm2' }, ], }, arena: { diff --git a/packages/cli/src/ui/commands/arenaCommand.ts b/packages/cli/src/ui/commands/arenaCommand.ts index 118308eaf..c178a021d 100644 --- a/packages/cli/src/ui/commands/arenaCommand.ts +++ b/packages/cli/src/ui/commands/arenaCommand.ts @@ -249,10 +249,7 @@ function executeArenaCommand( } else if (event.type === 'info') { addAndRecordArenaMessage(MessageType.INFO, event.message); } else { - addAndRecordArenaMessage( - MessageType.WARNING, - `Arena warning: ${event.message}`, - ); + addAndRecordArenaMessage(MessageType.WARNING, event.message); } }; diff --git a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx index 3d8062bfa..d26d5db2f 100644 --- a/packages/cli/src/ui/components/agent-view/AgentComposer.tsx +++ b/packages/cli/src/ui/components/agent-view/AgentComposer.tsx @@ -18,9 +18,10 @@ */ import { Box, Text, useStdin } from 'ink'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { AgentStatus, + isTerminalStatus, ApprovalMode, APPROVAL_MODES, } from '@qwen-code/qwen-code-core'; @@ -38,6 +39,7 @@ import { useTextBuffer } from '../shared/text-buffer.js'; import { calculatePromptWidths } from '../../utils/layoutUtils.js'; import { BaseTextInput } from '../BaseTextInput.js'; import { LoadingIndicator } from '../LoadingIndicator.js'; +import { QueuedMessageDisplay } from '../QueuedMessageDisplay.js'; import { AgentFooter } from './AgentFooter.js'; import { keyMatchers, Command } from '../../keyMatchers.js'; import { theme } from '../../semantic-colors.js'; @@ -182,13 +184,35 @@ export const AgentComposer: React.FC = ({ agentId }) => { [buffer, agentTabBarFocused, setAgentTabBarFocused], ); + // ── Message queue (accumulate while streaming, flush as one prompt on idle) ── + + const [messageQueue, setMessageQueue] = useState([]); + + // When agent becomes idle (and not terminal), flush queued messages. + useEffect(() => { + if ( + streamingState === StreamingState.Idle && + messageQueue.length > 0 && + status !== undefined && + !isTerminalStatus(status) + ) { + const combined = messageQueue.join('\n'); + setMessageQueue([]); + interactiveAgent?.enqueueMessage(combined); + } + }, [streamingState, messageQueue, interactiveAgent, status]); + const handleSubmit = useCallback( (text: string) => { const trimmed = text.trim(); if (!trimmed || !interactiveAgent) return; - interactiveAgent.enqueueMessage(trimmed); + if (streamingState === StreamingState.Idle) { + interactiveAgent.enqueueMessage(trimmed); + } else { + setMessageQueue((prev) => [...prev, trimmed]); + } }, - [interactiveAgent], + [interactiveAgent, streamingState], ); // ── Render ── @@ -255,6 +279,8 @@ export const AgentComposer: React.FC = ({ agentId }) => { )} + + {/* Input prompt — always visible, like the main Composer */} item.agentId === agentId); const label = agent?.model.modelId || agentId; + pushMessage({ + messageType: 'info', + content: `Applying changes from ${label}…`, + }); const result = await mgr.applyAgentResult(agentId); if (!result.success) { pushMessage({ @@ -111,6 +115,10 @@ export function ArenaSelectDialog({ } try { + pushMessage({ + messageType: 'info', + content: 'Discarding Arena results and cleaning up…', + }); await config.cleanupArenaRuntime(true); pushMessage({ messageType: 'info', diff --git a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx index a6409b793..e4a48031a 100644 --- a/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStatusDialog.tsx @@ -264,7 +264,11 @@ export function ArenaStatusDialog({ {failedToolCalls} ) : ( - + 0 ? theme.status.success : theme.text.primary + } + > {pad(String(toolCalls), colTools - 1, 'right')} )} diff --git a/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx index a790e20c2..65f363793 100644 --- a/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx +++ b/packages/cli/src/ui/components/arena/ArenaStopDialog.tsx @@ -80,9 +80,17 @@ export function ArenaStopDialog({ sessionStatus === ArenaSessionStatus.RUNNING || sessionStatus === ArenaSessionStatus.INITIALIZING ) { + pushMessage({ + messageType: 'info', + content: 'Stopping Arena agents…', + }); await mgr.cancel(); } await mgr.waitForSettled(); + pushMessage({ + messageType: 'info', + content: 'Cleaning up Arena resources…', + }); if (action === 'preserve') { await mgr.cleanupRuntime(); diff --git a/packages/cli/src/ui/components/messages/StatusMessages.tsx b/packages/cli/src/ui/components/messages/StatusMessages.tsx index e6e945bbd..b6b026a28 100644 --- a/packages/cli/src/ui/components/messages/StatusMessages.tsx +++ b/packages/cli/src/ui/components/messages/StatusMessages.tsx @@ -75,7 +75,7 @@ export const SuccessMessage: React.FC = ({ text }) => ( export const WarningMessage: React.FC = ({ text }) => ( diff --git a/packages/cli/src/ui/hooks/useAgentStreamingState.ts b/packages/cli/src/ui/hooks/useAgentStreamingState.ts index d53776242..881f715b2 100644 --- a/packages/cli/src/ui/hooks/useAgentStreamingState.ts +++ b/packages/cli/src/ui/hooks/useAgentStreamingState.ts @@ -124,7 +124,8 @@ export function useAgentStreamingState( }, [status, hasPendingApprovals]); const isInputActive = - streamingState === StreamingState.Idle && + (streamingState === StreamingState.Idle || + streamingState === StreamingState.Responding) && status !== undefined && !isTerminalStatus(status); diff --git a/packages/core/src/agents/arena/ArenaManager.ts b/packages/core/src/agents/arena/ArenaManager.ts index 427076666..6a386158f 100644 --- a/packages/core/src/agents/arena/ArenaManager.ts +++ b/packages/core/src/agents/arena/ArenaManager.ts @@ -1105,7 +1105,11 @@ export class ArenaManager { return incoming; } - private updateAgentStatus(agentId: string, newStatus: AgentStatus): void { + private updateAgentStatus( + agentId: string, + newStatus: AgentStatus, + options?: { roundCancelledByUser?: boolean }, + ): void { const agent = this.agents.get(agentId); if (!agent) { return; @@ -1130,7 +1134,11 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress(`Agent ${label} finished initial task.`, 'success'); + if (options?.roundCancelledByUser) { + this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning'); + } else { + this.emitProgress(`Agent ${label} finished initial task.`, 'success'); + } } // Emit progress messages for follow-up transitions (only after @@ -1145,7 +1153,14 @@ export class ArenaManager { previousStatus === AgentStatus.RUNNING && newStatus === AgentStatus.IDLE ) { - this.emitProgress(`Agent ${label} finished follow-up task.`, 'success'); + if (options?.roundCancelledByUser) { + this.emitProgress(`Agent ${label} is cancelled by user.`, 'warning'); + } else { + this.emitProgress( + `Agent ${label} finished follow-up task.`, + 'success', + ); + } } } @@ -1317,7 +1332,10 @@ export class ArenaManager { agent.syncStats = syncStats; - const applyStatus = (incoming: AgentStatus) => { + const applyStatus = ( + incoming: AgentStatus, + options?: { roundCancelledByUser?: boolean }, + ) => { const resolved = this.resolveTransition(agent.status, incoming); if (!resolved) return; if (resolved === AgentStatus.FAILED) { @@ -1327,14 +1345,16 @@ export class ArenaManager { if (isSettledStatus(resolved)) { agent.stats.durationMs = Date.now() - agent.startedAt; } - this.updateAgentStatus(agent.agentId, resolved); + this.updateAgentStatus(agent.agentId, resolved, options); }; // Sync stats before mapping so counters are up-to-date even when // the provider omits usage_metadata events. const onStatusChange = (event: AgentStatusChangeEvent) => { syncStats(); - applyStatus(event.newStatus); + applyStatus(event.newStatus, { + roundCancelledByUser: event.roundCancelledByUser, + }); // Write status files so external consumers get a consistent // file-based view regardless of backend mode. this.flushInProcessStatusFiles().catch((err) => diff --git a/packages/core/src/agents/backends/detect.ts b/packages/core/src/agents/backends/detect.ts index c8c43c2c8..f94d8c41d 100644 --- a/packages/core/src/agents/backends/detect.ts +++ b/packages/core/src/agents/backends/detect.ts @@ -6,10 +6,10 @@ import { createDebugLogger } from '../../utils/debugLogger.js'; import type { Config } from '../../config/config.js'; -import { TmuxBackend } from './TmuxBackend.js'; +// import { TmuxBackend } from './TmuxBackend.js'; import { InProcessBackend } from './InProcessBackend.js'; import { type Backend, DISPLAY_MODE, type DisplayMode } from './types.js'; -import { isTmuxAvailable } from './tmux-commands.js'; +// import { isTmuxAvailable } from './tmux-commands.js'; const debugLogger = createDebugLogger('BACKEND_DETECT'); @@ -35,44 +35,54 @@ export async function detectBackend( preference: DisplayMode | undefined, runtimeContext: Config, ): Promise { - // 1. User explicit preference - if (preference === DISPLAY_MODE.IN_PROCESS) { - debugLogger.info('Using InProcessBackend (user preference)'); - return { backend: new InProcessBackend(runtimeContext) }; - } + // Currently only in-process mode is supported. Other backends (tmux, + // iterm2) are kept in the codebase but not wired up as entry points. + const warning = + preference && preference !== DISPLAY_MODE.IN_PROCESS + ? `Display mode "${preference}" is not currently supported. Using in-process mode instead.` + : undefined; + debugLogger.info('Using InProcessBackend'); + return { backend: new InProcessBackend(runtimeContext), warning }; - if (preference === DISPLAY_MODE.ITERM2) { - throw new Error( - `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, - ); - } - - if (preference === DISPLAY_MODE.TMUX) { - debugLogger.info('Using TmuxBackend (user preference)'); - return { backend: new TmuxBackend() }; - } - - // 2. Auto-detect - if (process.env['TMUX']) { - debugLogger.info('Detected $TMUX — attempting TmuxBackend'); - return { backend: new TmuxBackend() }; - } - - // Other terminals (including iTerm2): use tmux external session mode if available. - if (isTmuxAvailable()) { - debugLogger.info( - 'tmux is available — using TmuxBackend external session mode', - ); - return { backend: new TmuxBackend() }; - } - - // Fallback: use InProcessBackend - debugLogger.info( - 'No PTY backend available — falling back to InProcessBackend', - ); - return { - backend: new InProcessBackend(runtimeContext), - warning: - 'tmux is not available. Using in-process mode (no split-pane terminal view).', - }; + // --- Disabled backends (kept for future use) --- + // // 1. User explicit preference + // if (preference === DISPLAY_MODE.IN_PROCESS) { + // debugLogger.info('Using InProcessBackend (user preference)'); + // return { backend: new InProcessBackend(runtimeContext) }; + // } + // + // if (preference === DISPLAY_MODE.ITERM2) { + // throw new Error( + // `Arena display mode "${DISPLAY_MODE.ITERM2}" is not implemented yet. Please use "${DISPLAY_MODE.TMUX}" or "${DISPLAY_MODE.IN_PROCESS}".`, + // ); + // } + // + // if (preference === DISPLAY_MODE.TMUX) { + // debugLogger.info('Using TmuxBackend (user preference)'); + // return { backend: new TmuxBackend() }; + // } + // + // // 2. Auto-detect + // if (process.env['TMUX']) { + // debugLogger.info('Detected $TMUX — attempting TmuxBackend'); + // return { backend: new TmuxBackend() }; + // } + // + // // Other terminals (including iTerm2): use tmux external session mode if available. + // if (isTmuxAvailable()) { + // debugLogger.info( + // 'tmux is available — using TmuxBackend external session mode', + // ); + // return { backend: new TmuxBackend() }; + // } + // + // // Fallback: use InProcessBackend + // debugLogger.info( + // 'No PTY backend available — falling back to InProcessBackend', + // ); + // return { + // backend: new InProcessBackend(runtimeContext), + // warning: + // 'tmux is not available. Using in-process mode (no split-pane terminal view).', + // }; } diff --git a/packages/core/src/agents/runtime/agent-events.ts b/packages/core/src/agents/runtime/agent-events.ts index 643608681..4626bb0cd 100644 --- a/packages/core/src/agents/runtime/agent-events.ts +++ b/packages/core/src/agents/runtime/agent-events.ts @@ -176,6 +176,8 @@ export interface AgentStatusChangeEvent { agentId: string; previousStatus: AgentStatus; newStatus: AgentStatus; + /** True when the transition to IDLE was caused by user cancelling the round. */ + roundCancelledByUser?: boolean; timestamp: number; } diff --git a/packages/core/src/agents/runtime/agent-interactive.test.ts b/packages/core/src/agents/runtime/agent-interactive.test.ts index 2683a6783..5560b665f 100644 --- a/packages/core/src/agents/runtime/agent-interactive.test.ts +++ b/packages/core/src/agents/runtime/agent-interactive.test.ts @@ -234,7 +234,7 @@ describe('AgentInteractive', () => { resolveLoop!(); await vi.waitFor(() => { - expect(agent.getStatus()).toBe('failed'); + expect(agent.getStatus()).toBe('idle'); }); await agent.shutdown(); diff --git a/packages/core/src/agents/runtime/agent-interactive.ts b/packages/core/src/agents/runtime/agent-interactive.ts index c7883f669..42e9dedce 100644 --- a/packages/core/src/agents/runtime/agent-interactive.ts +++ b/packages/core/src/agents/runtime/agent-interactive.ts @@ -25,9 +25,10 @@ import type { AgentCore } from './agent-core.js'; import type { ContextState } from './agent-headless.js'; import type { GeminiChat } from '../../core/geminiChat.js'; import type { FunctionDeclaration } from '@google/genai'; -import type { - ToolCallConfirmationDetails, - ToolResultDisplay, +import { + ToolConfirmationOutcome, + type ToolCallConfirmationDetails, + type ToolResultDisplay, } from '../../tools/tools.js'; import { AsyncMessageQueue } from '../../utils/asyncMessageQueue.js'; import { @@ -64,6 +65,7 @@ export class AgentInteractive { private chat: GeminiChat | undefined; private toolsList: FunctionDeclaration[] = []; private processing = false; + private roundCancelledByUser = false; // Pending tool approval requests. Keyed by callId. // Populated by TOOL_WAITING_APPROVAL, removed by TOOL_RESULT or when @@ -161,6 +163,7 @@ export class AgentInteractive { this.setStatus(AgentStatus.RUNNING); this.lastRoundError = undefined; + this.roundCancelledByUser = false; this.roundAbortController = new AbortController(); // Propagate master abort to round @@ -199,6 +202,8 @@ export class AgentInteractive { this.lastRoundError = `Terminated: ${result.terminateMode}`; } } catch (err) { + // User-initiated cancellation already logged by cancelCurrentRound(). + if (this.roundCancelledByUser) return; // Agent survives round errors — log and settle status in runLoop. const errorMessage = err instanceof Error ? err.message : String(err); this.lastRoundError = errorMessage; @@ -220,6 +225,7 @@ export class AgentInteractive { * Adds a visible "cancelled" info message and clears pending approvals. */ cancelCurrentRound(): void { + this.roundCancelledByUser = true; this.roundAbortController?.abort(); this.pendingApprovals.clear(); this.addMessage('info', 'Agent round cancelled.', { @@ -344,7 +350,7 @@ export class AgentInteractive { * On error → FAILED (terminal). */ private settleRoundStatus(): void { - if (this.lastRoundError) { + if (this.lastRoundError && !this.roundCancelledByUser) { this.setStatus(AgentStatus.FAILED); } else { this.setStatus(AgentStatus.IDLE); @@ -361,6 +367,7 @@ export class AgentInteractive { agentId: this.config.agentId, previousStatus, newStatus, + roundCancelledByUser: this.roundCancelledByUser || undefined, timestamp: Date.now(), }); } @@ -462,6 +469,11 @@ export class AgentInteractive { timestamp: Date.now(), } as AgentToolOutputUpdateEvent); await event.respond(outcome, payload); + // When the user denies a tool, cancel the round immediately + // so the agent doesn't waste a turn "acknowledging" the denial. + if (outcome === ToolConfirmationOutcome.Cancel) { + this.cancelCurrentRound(); + } }, } as ToolCallConfirmationDetails; From f11758c6bcd87f89a194d1cfe1163de229330b3a Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Fri, 13 Mar 2026 02:32:09 -0700 Subject: [PATCH 29/82] add extension for hooks --- .../core/src/extension/claude-converter.ts | 76 ++++++++++- .../core/src/extension/extensionManager.ts | 127 ++++++++++++++++++ 2 files changed, 198 insertions(+), 5 deletions(-) diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 6c333c9aa..1e14c4bab 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -16,6 +16,7 @@ import type { ExtensionInstallMetadata, MCPServerConfig, } from '../config/config.js'; +import type { HookEventName, HookDefinition } from '../hooks/types.js'; import { cloneFromGit, downloadFromGitHubRelease } from './github.js'; import { createHash } from 'node:crypto'; import { copyDirectory } from './gemini-converter.js'; @@ -40,7 +41,7 @@ export interface ClaudePluginConfig { commands?: string | string[]; agents?: string | string[]; skills?: string | string[]; - hooks?: string; + hooks?: string | { [K in HookEventName]?: HookDefinition[] }; mcpServers?: string | Record; outputStyles?: string | string[]; lspServers?: string | Record; @@ -312,12 +313,21 @@ export function convertClaudeToQwenConfig( } } - // Warn about unsupported fields + // Parse hooks + let hooks: { [K in HookEventName]?: HookDefinition[] } | undefined; if (claudeConfig.hooks) { - debugLogger.warn( - `[Claude Converter] Hooks are not yet supported in ${claudeConfig.name}`, - ); + if (typeof claudeConfig.hooks === 'string') { + // If it's a string, it's a file path, we handle it later in the conversion process + // hooks will be loaded from file path in the convertClaudePluginPackage function + } else { + // Assume it's already in the correct format + hooks = claudeConfig.hooks as { [K in HookEventName]?: HookDefinition[] }; + } + } else { + hooks = undefined; } + + // Warn about unsupported fields if (claudeConfig.outputStyles) { debugLogger.warn( `[Claude Converter] Output styles are not yet supported in ${claudeConfig.name}`, @@ -329,6 +339,7 @@ export function convertClaudeToQwenConfig( version: claudeConfig.version, mcpServers, lspServers: claudeConfig.lspServers, + hooks, // Assign the properly typed hooks variable }; } @@ -461,6 +472,61 @@ export async function convertClaudePluginPackage( // Otherwise, keep the existing folder from pluginSource (default behavior) } + // Step 7: Handle hooks from file paths if needed + if (mergedConfig.hooks && typeof mergedConfig.hooks === 'string') { + const hooksPath = path.isAbsolute(mergedConfig.hooks) + ? mergedConfig.hooks + : path.join(pluginSource, mergedConfig.hooks); + + if (fs.existsSync(hooksPath)) { + try { + const hooksContent = fs.readFileSync(hooksPath, 'utf-8'); + const parsedHooks = JSON.parse(hooksContent); + + // Check if the file has a top-level "hooks" property (like Claude plugins use) + // or if the entire file content is the hooks object + let hooksData; + if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') { + hooksData = parsedHooks.hooks as { + [K in HookEventName]?: HookDefinition[]; + }; + } else { + // Assume the entire file content is the hooks object + hooksData = parsedHooks as { + [K in HookEventName]?: HookDefinition[]; + }; + } + + // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} + // Replace ${CLAUDE_PLUGIN_ROOT} with the pluginSource path + const processedHooks = JSON.parse(JSON.stringify(hooksData)); + for (const eventName in processedHooks) { + const eventHooks = processedHooks[eventName as HookEventName]; + if (eventHooks && Array.isArray(eventHooks)) { + for (const hookDef of eventHooks) { + if (hookDef.hooks && Array.isArray(hookDef.hooks)) { + for (const hook of hookDef.hooks) { + if (hook.type === 'command' && hook.command) { + hook.command = hook.command.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + pluginSource, + ); + } + } + } + } + } + } + + mergedConfig.hooks = processedHooks; + } catch (error) { + debugLogger.warn( + `Failed to parse hooks file ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + // Step 9.1: Convert collected agent files from Claude format to Qwen format const agentsDestDir = path.join(tmpDir, 'agents'); await convertAgentFiles(agentsDestDir); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 3af573ac7..ebb03c62f 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -11,6 +11,7 @@ import type { SubagentConfig, ClaudeMarketplaceConfig, } from '../index.js'; +import type { HookEventName, HookDefinition } from '../hooks/types.js'; import { Storage, Config, @@ -100,6 +101,7 @@ export interface Extension { commands?: string[]; skills?: SkillConfig[]; agents?: SubagentConfig[]; + hooks?: { [K in HookEventName]?: HookDefinition[] }; } export interface ExtensionConfig { @@ -112,6 +114,7 @@ export interface ExtensionConfig { skills?: string | string[]; agents?: string | string[]; settings?: ExtensionSetting[]; + hooks?: { [K in HookEventName]?: HookDefinition[] }; } export interface ExtensionUpdateInfo { @@ -662,6 +665,53 @@ export class ExtensionManager { `${effectiveExtensionPath}/agents`, ); + if (config.hooks) { + // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} + extension.hooks = this.substituteHookVariables( + config.hooks, + effectiveExtensionPath, + ); + } + + // Also load hooks from hooks directory if available and not already set + if (!extension.hooks) { + const hooksDir = path.join(effectiveExtensionPath, 'hooks'); + const hooksJsonPath = path.join(hooksDir, 'hooks.json'); + + if (fs.existsSync(hooksJsonPath)) { + try { + const hooksContent = fs.readFileSync(hooksJsonPath, 'utf-8'); + const parsedHooks = JSON.parse(hooksContent); + + // Check if the file has a top-level "hooks" property or if the entire file content is the hooks object + let hooksData; + if (parsedHooks.hooks && typeof parsedHooks.hooks === 'object') { + hooksData = parsedHooks.hooks as { + [K in HookEventName]?: HookDefinition[]; + }; + } else { + // Assume the entire file content is the hooks object + hooksData = parsedHooks as { + [K in HookEventName]?: HookDefinition[]; + }; + } + + // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} + extension.hooks = this.substituteHookVariables( + hooksData, + effectiveExtensionPath, + ); + } catch (error) { + debugLogger.warn( + `Failed to parse hooks file ${hooksJsonPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } + + // Replace variables in all markdown files in the extension + this.performVariableReplacement(effectiveExtensionPath); + return extension; } catch (e) { debugLogger.warn( @@ -673,6 +723,83 @@ export class ExtensionManager { } } + /** + * Substitute variables in hook configurations, particularly ${CLAUDE_PLUGIN_ROOT} + */ + private substituteHookVariables( + hooks: { [K in HookEventName]?: HookDefinition[] } | undefined, + extensionPath: string, + ): { [K in HookEventName]?: HookDefinition[] } | undefined { + if (!hooks) return hooks; + + // Deep clone the hooks to avoid modifying the original + const clonedHooks = JSON.parse(JSON.stringify(hooks)); + + // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path in all command hooks + for (const eventName in clonedHooks) { + const eventHooks = clonedHooks[eventName as HookEventName]; + if (eventHooks && Array.isArray(eventHooks)) { + for (const hookDef of eventHooks) { + if (hookDef.hooks && Array.isArray(hookDef.hooks)) { + for (const hook of hookDef.hooks) { + if (hook.type === 'command' && hook.command) { + hook.command = hook.command.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + extensionPath, + ); + } + } + } + } + } + } + + return clonedHooks; + } + + /** + * Perform variable replacement in all markdown files of the extension + */ + private performVariableReplacement(extensionPath: string): void { + const globPattern = '**/*.md'; + const globOptions = { + cwd: extensionPath, + nodir: true, + }; + + try { + const mdFiles = glob.sync(globPattern, globOptions); + + for (const file of mdFiles) { + const filePath = path.join(extensionPath, file); + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path + const updatedContent = content.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + extensionPath, + ); + + // Only write if content was actually changed + if (updatedContent !== content) { + fs.writeFileSync(filePath, updatedContent, 'utf8'); + debugLogger.debug(`Updated variables in file: ${filePath}`); + } + } catch (error) { + debugLogger.warn( + `Failed to process file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } catch (error) { + debugLogger.warn( + `Failed to scan extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { From 368c45d7bf6ca783f6ddb03333dc53511f0ca903 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Fri, 13 Mar 2026 07:28:23 -0700 Subject: [PATCH 30/82] adapt claude to qwen code --- .../prompt-processors/shellProcessor.ts | 7 +- .../core/src/extension/extensionManager.ts | 78 +++++++++++++++++-- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/packages/cli/src/services/prompt-processors/shellProcessor.ts b/packages/cli/src/services/prompt-processors/shellProcessor.ts index 2a6df7161..a3e30bf66 100644 --- a/packages/cli/src/services/prompt-processors/shellProcessor.ts +++ b/packages/cli/src/services/prompt-processors/shellProcessor.ts @@ -109,10 +109,9 @@ export class ShellProcessor implements IPromptProcessor { return { ...injection, resolvedCommand: undefined }; } - const resolvedCommand = command.replaceAll( - SHORTHAND_ARGS_PLACEHOLDER, - userArgsEscaped, - ); + const resolvedCommand = command + .replaceAll(SHORTHAND_ARGS_PLACEHOLDER, userArgsEscaped) // Replace {{args}} + .replaceAll('$ARGUMENTS', userArgsEscaped); // Replace $ARGUMENTS return { ...injection, resolvedCommand }; }, ); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index ebb03c62f..5a61b4070 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -761,14 +761,15 @@ export class ExtensionManager { * Perform variable replacement in all markdown files of the extension */ private performVariableReplacement(extensionPath: string): void { - const globPattern = '**/*.md'; - const globOptions = { + // Process markdown files + const mdGlobPattern = '**/*.md'; + const mdGlobOptions = { cwd: extensionPath, nodir: true, }; try { - const mdFiles = glob.sync(globPattern, globOptions); + const mdFiles = glob.sync(mdGlobPattern, mdGlobOptions); for (const file of mdFiles) { const filePath = path.join(extensionPath, file); @@ -782,10 +783,19 @@ export class ExtensionManager { extensionPath, ); + // Replace Markdown shell syntax ```! ... ``` with system-recognized !{...} syntax + // This regex finds code blocks with ! language identifier and captures their content + const updatedMdContent = updatedContent.replace( + /```!(?:\s*\n)?([\s\S]*?)\n*```/g, + '!{$1}', + ); + // Only write if content was actually changed - if (updatedContent !== content) { - fs.writeFileSync(filePath, updatedContent, 'utf8'); - debugLogger.debug(`Updated variables in file: ${filePath}`); + if (updatedMdContent !== content) { + fs.writeFileSync(filePath, updatedMdContent, 'utf8'); + debugLogger.debug( + `Updated variables and syntax in file: ${filePath}`, + ); } } catch (error) { debugLogger.warn( @@ -795,7 +805,61 @@ export class ExtensionManager { } } catch (error) { debugLogger.warn( - `Failed to scan extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, + `Failed to scan markdown files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Process shell script files + const scriptGlobPattern = '**/*.sh'; + const scriptGlobOptions = { + cwd: extensionPath, + nodir: true, + }; + + try { + const scriptFiles = glob.sync(scriptGlobPattern, scriptGlobOptions); + + for (const file of scriptFiles) { + const filePath = path.join(extensionPath, file); + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Replace references to "role":"assistant" with "type":"assistant" in shell scripts + const updatedScriptContent = content.replace( + /"role":"assistant"/g, + '"type":"assistant"', + ); + + // Replace transcript parsing logic to adapt to actual transcript structure + // Change from .message.content | map(select(.type == "text")) to .message.parts | map(select(has("text"))) + const adaptedScriptContent = updatedScriptContent.replace( + /\.message\.content\s*\|\s*map\(select\(\.type\s*==\s*"text"\)\)/g, + '.message.parts | map(select(has("text")))', + ); + + // Replace references to ".claude" with ".qwen" in shell scripts + const finalScriptContent = adaptedScriptContent.replace( + /\.claude/g, + '.qwen', + ); + + // Only write if content was actually changed + if (finalScriptContent !== content) { + fs.writeFileSync(filePath, finalScriptContent, 'utf8'); + debugLogger.debug( + `Updated transcript format and replaced .claude with .qwen in shell script: ${filePath}`, + ); + } + } catch (error) { + debugLogger.warn( + `Failed to process shell script file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } catch (error) { + debugLogger.warn( + `Failed to scan shell script files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, ); } } From 4de9688543728408807ccbf94b355fdc4be2697e Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Sun, 15 Mar 2026 14:39:33 +0800 Subject: [PATCH 31/82] feat(cli): add detail mode to /context and track loaded skill bodies Co-authored-by: Qwen-Coder --- .../cli/src/ui/commands/clearCommand.test.ts | 1 + packages/cli/src/ui/commands/clearCommand.ts | 15 +- .../cli/src/ui/commands/contextCommand.ts | 107 ++++++++---- .../src/ui/components/HistoryItemDisplay.tsx | 1 + .../src/ui/components/views/ContextUsage.tsx | 153 ++++++++++++------ packages/cli/src/ui/types.ts | 7 + packages/core/src/tools/skill.ts | 40 ++++- 7 files changed, 246 insertions(+), 78 deletions(-) diff --git a/packages/cli/src/ui/commands/clearCommand.test.ts b/packages/cli/src/ui/commands/clearCommand.test.ts index e94c974fb..1617a2f75 100644 --- a/packages/cli/src/ui/commands/clearCommand.test.ts +++ b/packages/cli/src/ui/commands/clearCommand.test.ts @@ -40,6 +40,7 @@ describe('clearCommand', () => { resetChat: mockResetChat, }) as unknown as GeminiClient, startNewSession: mockStartNewSession, + getToolRegistry: () => undefined, }, }, session: { diff --git a/packages/cli/src/ui/commands/clearCommand.ts b/packages/cli/src/ui/commands/clearCommand.ts index dd774934b..4f3530861 100644 --- a/packages/cli/src/ui/commands/clearCommand.ts +++ b/packages/cli/src/ui/commands/clearCommand.ts @@ -7,7 +7,11 @@ import type { SlashCommand } from './types.js'; import { CommandKind } from './types.js'; import { t } from '../../i18n/index.js'; -import { uiTelemetryService } from '@qwen-code/qwen-code-core'; +import { + uiTelemetryService, + ToolNames, + SkillTool, +} from '@qwen-code/qwen-code-core'; export const clearCommand: SlashCommand = { name: 'clear', @@ -25,6 +29,15 @@ export const clearCommand: SlashCommand = { // Reset UI telemetry metrics for the new session uiTelemetryService.reset(); + // Clear loaded-skills tracking so /context doesn't show stale data + const skillTool = config + .getToolRegistry() + ?.getAllTools() + .find((tool) => tool.name === ToolNames.SKILL); + if (skillTool instanceof SkillTool) { + skillTool.clearLoadedSkills(); + } + if (newSessionId && context.session.startNewSession) { context.session.startNewSession(newSessionId); } diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index e4df88029..b4b7f4f04 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -23,6 +23,8 @@ import { getCoreSystemPrompt, DEFAULT_TOKEN_LIMIT, ToolNames, + SkillTool, + buildSkillLlmContent, } from '@qwen-code/qwen-code-core'; import { t } from '../../i18n/index.js'; @@ -88,10 +90,15 @@ function parseMemoryFiles(memoryContent: string): ContextMemoryDetail[] { export const contextCommand: SlashCommand = { name: 'context', get description() { - return t('Show context window usage breakdown.'); + return t( + 'Show context window usage breakdown. Use "/context detail" for per-item breakdown.', + ); }, kind: CommandKind.BUILT_IN, - action: async (context: CommandContext) => { + action: async (context: CommandContext, args?: string) => { + const showDetails = + args?.trim().toLowerCase() === 'detail' || + args?.trim().toLowerCase() === '-d'; const { config } = context.services; if (!config) { context.ui.addItem( @@ -153,30 +160,51 @@ export const contextCommand: SlashCommand = { const memoryFilesTokens = memoryFiles.reduce((sum, f) => sum + f.tokens, 0); // 5. Skills (progressive disclosure) - // The SkillTool's description embeds all skill name+description listings - // plus ~600 chars of instruction text. This is the "always in context" - // cost. The full SKILL.md body is only loaded on-demand when the model - // invokes the skill tool (and that cost appears in Messages). - // - // To get an accurate total, we read the SkillTool's actual schema from - // the registry rather than reconstructing from a template. + // Two cost components: + // a) Tool definition: SkillTool's description embeds all skill + // name+description listings plus instruction text — always in context. + // b) Loaded bodies: When the model invokes a skill, the full SKILL.md + // body is injected into the conversation as a tool result. We track + // which skills have been loaded and attribute their body tokens here + // so the "Skills" category accurately reflects the total cost. const skillTool = allTools.find((tool) => tool.name === ToolNames.SKILL); - const skillToolTotalTokens = skillTool + const skillToolDefinitionTokens = skillTool ? estimateTokens(JSON.stringify(skillTool.schema)) : 0; - // Per-skill breakdown for detail display (proportional to description length) + // Determine which skills have been loaded in this session + const loadedSkillNames: ReadonlySet = + skillTool instanceof SkillTool + ? skillTool.getLoadedSkillNames() + : new Set(); + + // Per-skill breakdown: listing cost + body cost for loaded skills const skillManager = config.getSkillManager(); const skillConfigs = skillManager ? await skillManager.listSkills() : []; - const skills: ContextSkillDetail[] = skillConfigs.map((skill) => ({ - name: skill.name, - tokens: estimateTokens( + let loadedBodiesTokens = 0; + const skills: ContextSkillDetail[] = skillConfigs.map((skill) => { + const listingTokens = estimateTokens( `\n\n${skill.name}\n\n\n${skill.description} (${skill.level})\n\n\n${skill.level}\n\n`, - ), - })); - // Use the SkillTool's actual schema tokens as the total, not the sum of - // individual estimates (which would miss the instruction wrapper text). - const skillsTokens = skillToolTotalTokens; + ); + const isLoaded = loadedSkillNames.has(skill.name); + let bodyTokens: number | undefined; + if (isLoaded && skill.body) { + const baseDir = skill.filePath + ? skill.filePath.replace(/\/[^/]+$/, '') + : ''; + bodyTokens = estimateTokens(buildSkillLlmContent(baseDir, skill.body)); + loadedBodiesTokens += bodyTokens; + } + return { + name: skill.name, + tokens: listingTokens, + loaded: isLoaded, + bodyTokens, + }; + }); + + // Total skills cost = tool definition + loaded bodies + const skillsTokens = skillToolDefinitionTokens + loadedBodiesTokens; // 6. Autocompact buffer const compressionThreshold = @@ -187,8 +215,14 @@ export const contextCommand: SlashCommand = { ? Math.round((1 - compressionThreshold) * contextWindowSize) : 0; - // 7. Calculate raw overhead (allToolsTokens already includes skills) - const rawOverhead = systemPromptTokens + allToolsTokens + memoryFilesTokens; + // 7. Calculate raw overhead + // allToolsTokens includes the skill tool definition; loadedBodiesTokens + // covers the on-demand skill bodies now attributed to Skills. + const rawOverhead = + systemPromptTokens + + allToolsTokens + + memoryFilesTokens + + loadedBodiesTokens; // 8. Determine total tokens and build breakdown const isEstimated = apiTotalTokens === 0; @@ -219,14 +253,15 @@ export const contextCommand: SlashCommand = { // once real API data arrives. totalTokens = 0; displaySystemPrompt = systemPromptTokens; - // builtinTools category = allTools - skills - mcpTools + // Skills = tool definition + loaded bodies + displaySkills = skillsTokens; + // builtinTools = allTools minus skills-definition minus mcpTools displayBuiltinTools = Math.max( 0, - allToolsTokens - skillsTokens - mcpToolsTotalTokens, + allToolsTokens - skillToolDefinitionTokens - mcpToolsTotalTokens, ); displayMcpTools = mcpToolsTotalTokens; displayMemoryFiles = memoryFilesTokens; - displaySkills = skillsTokens; messagesTokens = 0; // Free space accounts for the estimated overhead freeSpace = Math.max( @@ -249,16 +284,24 @@ export const contextCommand: SlashCommand = { displaySystemPrompt = Math.round(systemPromptTokens * overheadScale); const scaledAllTools = Math.round(allToolsTokens * overheadScale); displayMemoryFiles = Math.round(memoryFilesTokens * overheadScale); + // Skills = tool definition + loaded bodies (scaled together) displaySkills = Math.round(skillsTokens * overheadScale); const scaledMcpTotal = Math.round(mcpToolsTotalTokens * overheadScale); displayMcpTools = scaledMcpTotal; + // builtinTools = allTools minus skill-definition minus mcpTools + const scaledSkillDefinition = Math.round( + skillToolDefinitionTokens * overheadScale, + ); displayBuiltinTools = Math.max( 0, - scaledAllTools - displaySkills - scaledMcpTotal, + scaledAllTools - scaledSkillDefinition - scaledMcpTotal, ); const scaledOverhead = - displaySystemPrompt + scaledAllTools + displayMemoryFiles; + displaySystemPrompt + + scaledAllTools + + displayMemoryFiles + + Math.round(loadedBodiesTokens * overheadScale); messagesTokens = Math.max(0, totalTokens - scaledOverhead); freeSpace = Math.max( @@ -278,7 +321,16 @@ export const contextCommand: SlashCommand = { detailBuiltinTools = scaleDetail(builtinTools); detailMcpTools = scaleDetail(mcpTools); detailMemoryFiles = scaleDetail(memoryFiles); - detailSkills = scaleDetail(skills); + detailSkills = + overheadScale < 1 + ? skills.map((item) => ({ + ...item, + tokens: Math.round(item.tokens * overheadScale), + bodyTokens: item.bodyTokens + ? Math.round(item.bodyTokens * overheadScale) + : undefined, + })) + : skills; } const breakdown: ContextCategoryBreakdown = { @@ -303,6 +355,7 @@ export const contextCommand: SlashCommand = { memoryFiles: detailMemoryFiles, skills: detailSkills, isEstimated, + showDetails, }; context.ui.addItem(contextUsageItem, Date.now()); diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index d53d233e0..6b2fb7cba 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -193,6 +193,7 @@ const HistoryItemDisplayComponent: React.FC = ({ memoryFiles={itemForDisplay.memoryFiles} skills={itemForDisplay.skills} isEstimated={itemForDisplay.isEstimated} + showDetails={itemForDisplay.showDetails} /> )} {itemForDisplay.type === 'insight_progress' && ( diff --git a/packages/cli/src/ui/components/views/ContextUsage.tsx b/packages/cli/src/ui/components/views/ContextUsage.tsx index 753f40890..f6bed1d26 100644 --- a/packages/cli/src/ui/components/views/ContextUsage.tsx +++ b/packages/cli/src/ui/components/views/ContextUsage.tsx @@ -33,6 +33,8 @@ interface ContextUsageProps { skills: ContextSkillDetail[]; /** True when totalTokens is estimated (no API call yet) */ isEstimated?: boolean; + /** When true, show per-item detail breakdowns. Default: false (compact). */ + showDetails?: boolean; } /** @@ -152,6 +154,7 @@ export const ContextUsage: React.FC = ({ memoryFiles, skills, isEstimated, + showDetails = false, }) => { const percentage = contextWindowSize > 0 ? (totalTokens / contextWindowSize) * 100 : 0; @@ -164,7 +167,13 @@ export const ContextUsage: React.FC = ({ const sortedMemoryFiles = [...memoryFiles].sort( (a, b) => b.tokens - a.tokens, ); - const sortedSkills = [...skills].sort((a, b) => b.tokens - a.tokens); + // Sort skills: loaded first, then by total token cost descending + const sortedSkills = [...skills].sort((a, b) => { + if (a.loaded !== b.loaded) return a.loaded ? -1 : 1; + const aTotal = a.tokens + (a.bodyTokens ?? 0); + const bTotal = b.tokens + (b.bodyTokens ?? 0); + return bTotal - aTotal; + }); return ( = ({ /> )} - {/* Built-in tools detail */} - {sortedBuiltinTools.length > 0 && ( - - - {t('Built-in tools')} - - {sortedBuiltinTools.map((tool) => ( - - ))} - - )} + {showDetails ? ( + <> + {/* Built-in tools detail */} + {sortedBuiltinTools.length > 0 && ( + + + {t('Built-in tools')} + + {sortedBuiltinTools.map((tool) => ( + + ))} + + )} - {/* MCP Tools detail */} - {sortedMcpTools.length > 0 && ( - - - {t('MCP tools')} - - {sortedMcpTools.map((tool) => ( - - ))} - - )} + {/* MCP Tools detail */} + {sortedMcpTools.length > 0 && ( + + + {t('MCP tools')} + + {sortedMcpTools.map((tool) => ( + + ))} + + )} - {/* Memory files detail */} - {sortedMemoryFiles.length > 0 && ( - - - {t('Memory files')} - - {sortedMemoryFiles.map((file) => ( - - ))} - - )} + {/* Memory files detail */} + {sortedMemoryFiles.length > 0 && ( + + + {t('Memory files')} + + {sortedMemoryFiles.map((file) => ( + + ))} + + )} - {/* Skills detail */} - {sortedSkills.length > 0 && ( - - - {t('Skills')} + {/* Skills detail */} + {sortedSkills.length > 0 && ( + + + {t('Skills')} + + {sortedSkills.map((skill) => ( + + + {'\u2514'} + + + {truncateName(skill.name, DETAIL_NAME_MAX_LEN)} + + {skill.loaded && ( + {t('active')} + )} + + + + {formatTokens(skill.tokens)} {t('tokens')} + + + + {skill.loaded && + skill.bodyTokens != null && + skill.bodyTokens > 0 && ( + + {' \u2514'} + + + {t('body loaded')} + + + + + +{formatTokens(skill.bodyTokens)} {t('tokens')} + + + + )} + + ))} + + )} + + ) : ( + + + {t('Run /context detail for per-item breakdown.')} - {sortedSkills.map((skill) => ( - - ))} )} diff --git a/packages/cli/src/ui/types.ts b/packages/cli/src/ui/types.ts index 21b354c75..7d75f8bca 100644 --- a/packages/cli/src/ui/types.ts +++ b/packages/cli/src/ui/types.ts @@ -282,7 +282,12 @@ export interface ContextMemoryDetail { export interface ContextSkillDetail { name: string; + /** Token cost of the skill listing (name+description) in the tool definition */ tokens: number; + /** Whether this skill has been invoked and its full body loaded into context */ + loaded?: boolean; + /** Token cost of the loaded SKILL.md body (only set when loaded is true) */ + bodyTokens?: number; } export type HistoryItemContextUsage = HistoryItemBase & { @@ -297,6 +302,8 @@ export type HistoryItemContextUsage = HistoryItemBase & { skills: ContextSkillDetail[]; /** True when totalTokens is estimated (no API call yet) rather than from API response */ isEstimated?: boolean; + /** When true, show per-item detail sections (tools, memory, skills). Default: false (compact). */ + showDetails?: boolean; }; export type HistoryItemInsightProgress = HistoryItemBase & { diff --git a/packages/core/src/tools/skill.ts b/packages/core/src/tools/skill.ts index 68ec7dd55..b97f52c27 100644 --- a/packages/core/src/tools/skill.ts +++ b/packages/core/src/tools/skill.ts @@ -20,6 +20,15 @@ export interface SkillParams { skill: string; } +/** + * Builds the LLM-facing content string when a skill body is injected. + * Shared between SkillToolInvocation (runtime) and /context (estimation) + * so that token estimates stay in sync with actual usage. + */ +export function buildSkillLlmContent(baseDir: string, body: string): string { + return `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${body}\n`; +} + /** * Skill tool that enables the model to access skill definitions. * The tool dynamically loads available skills and includes them in its description @@ -30,6 +39,7 @@ export class SkillTool extends BaseDeclarativeTool { private skillManager: SkillManager; private availableSkills: SkillConfig[] = []; + private loadedSkillNames: Set = new Set(); constructor(private readonly config: Config) { // Initialize with a basic schema first @@ -176,12 +186,34 @@ ${skillDescriptions} } protected createInvocation(params: SkillParams) { - return new SkillToolInvocation(this.config, this.skillManager, params); + return new SkillToolInvocation( + this.config, + this.skillManager, + params, + (name: string) => this.loadedSkillNames.add(name), + ); } getAvailableSkillNames(): string[] { return this.availableSkills.map((skill) => skill.name); } + + /** + * Returns the set of skill names that have been successfully loaded + * (invoked) during the current session. Used by /context to attribute + * loaded skill body tokens separately from the tool-definition cost. + */ + getLoadedSkillNames(): ReadonlySet { + return this.loadedSkillNames; + } + + /** + * Clears the loaded-skills tracking. Should be called when the session + * is reset (e.g. /clear) so that stale body-token data is not shown. + */ + clearLoadedSkills(): void { + this.loadedSkillNames.clear(); + } } class SkillToolInvocation extends BaseToolInvocation { @@ -189,6 +221,7 @@ class SkillToolInvocation extends BaseToolInvocation { private readonly config: Config, private readonly skillManager: SkillManager, params: SkillParams, + private readonly onSkillLoaded: (name: string) => void, ) { super(params); } @@ -245,11 +278,10 @@ class SkillToolInvocation extends BaseToolInvocation { this.config, new SkillLaunchEvent(this.params.skill, true), ); + this.onSkillLoaded(this.params.skill); const baseDir = path.dirname(skill.filePath); - - // Build markdown content for LLM (show base dir, then body) - const llmContent = `Base directory for this skill: ${baseDir}\nImportant: ALWAYS resolve absolute paths from this base directory when working with skills.\n\n${skill.body}\n`; + const llmContent = buildSkillLlmContent(baseDir, skill.body); return { llmContent: [{ text: llmContent }], From d4608afc2dbc1b38ed56859beec8c97b842e4ad3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Mon, 16 Mar 2026 21:15:20 +0800 Subject: [PATCH 32/82] feat: init claw skill --- .qwen/skills/qwen-code-claw/SKILL.md | 71 ++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) create mode 100644 .qwen/skills/qwen-code-claw/SKILL.md diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md new file mode 100644 index 000000000..e129b7300 --- /dev/null +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -0,0 +1,71 @@ +--- +name: qwen-code-claw +description: 使用QwenCode作为Code Agent完成代码理解、项目生成、feature、fix bug、重构等各种编程相关需求 +--- + +# qwen-code + +## When to use this skill + +## What qwen-code is + +## Install + +```bash +npm i -g @qwen-code/qwen-code +``` + +For normal session reuse, prefer a global install over `npx`. + +## acpx + +You should always use acpx to run qwen-code over the Agent Client Protocol +(ACP) instead of scraping PTY sessions. + +Please do the following to use acpx: + +1. Install acpx globally (recommended) or use npx: + npm install -g acpx@latest + + Or without installing: + npx acpx@latest + +2. Install the acpx skill so you have the full reference available: + npx acpx@latest --skill install acpx + +3. Read the acpx skill reference so you know every command, flag, and + workflow pattern: + https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md + +4. If you need the full CLI reference with all options and examples: + https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md + +From now on, when you need to delegate work to qwen-code, use acpx +instead of spawning raw terminal sessions. For example: +npx acpx@latest qwen "fix the failing tests" +npx acpx@latest qwen "refactor the auth module" +npx acpx@latest qwen exec "one-shot: summarize this repo" + +通常情况下,你应该使用approval-all参数来防止任务中断 + +## Practical workflows + +Persistent repo assistant: + +```bash +acpx qwen 'inspect failing tests and propose a fix plan' +acpx qwen 'apply the smallest safe fix and run tests' +``` + +One-shot script step: + +```bash +acpx qwen exec 'summarize repo purpose in 3 lines' +``` + +Parallel named streams: + +```bash +acpx qwen -s backend 'fix API pagination bug' +acpx qwen -s docs 'draft changelog entry for release' +``` From 46b9c75f832621637fe17f1ba692d6e6057481f0 Mon Sep 17 00:00:00 2001 From: zach Date: Mon, 16 Mar 2026 14:16:03 +0000 Subject: [PATCH 33/82] fix(cli): show newest-first history for Ctrl+R command search --- .../src/ui/components/InputPrompt.test.tsx | 19 +++++++++++++++++++ .../cli/src/ui/components/InputPrompt.tsx | 9 +++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/InputPrompt.test.tsx b/packages/cli/src/ui/components/InputPrompt.test.tsx index d5ace1c53..49b92dd74 100644 --- a/packages/cli/src/ui/components/InputPrompt.test.tsx +++ b/packages/cli/src/ui/components/InputPrompt.test.tsx @@ -1956,6 +1956,25 @@ describe('InputPrompt', () => { }); describe('command search (Ctrl+R when not in shell)', () => { + it('passes newest-first user history to command search', async () => { + props.shellModeActive = false; + props.userMessages = ['oldest', 'middle', 'newest']; + + const { unmount } = renderWithProviders(); + await wait(); + + const commandSearchCall = + mockedUseReverseSearchCompletion.mock.calls.find( + ([, history]) => + Array.isArray(history) && + history.length === 3 && + history.includes('newest'), + ); + + expect(commandSearchCall?.[1]).toEqual(['newest', 'middle', 'oldest']); + unmount(); + }); + it('enters command search on Ctrl+R and shows suggestions', async () => { props.shellModeActive = false; diff --git a/packages/cli/src/ui/components/InputPrompt.tsx b/packages/cli/src/ui/components/InputPrompt.tsx index 09c2b27f1..f4372cc2a 100644 --- a/packages/cli/src/ui/components/InputPrompt.tsx +++ b/packages/cli/src/ui/components/InputPrompt.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useCallback, useEffect, useState, useRef } from 'react'; +import { useCallback, useEffect, useMemo, useState, useRef } from 'react'; import { Box, Text } from 'ink'; import { SuggestionsDisplay, MAX_WIDTH } from './SuggestionsDisplay.js'; import { theme } from '../semantic-colors.js'; @@ -213,9 +213,14 @@ export const InputPrompt: React.FC = ({ reverseSearchActive, ); + const commandSearchHistory = useMemo( + () => [...userMessages].reverse(), + [userMessages], + ); + const commandSearchCompletion = useReverseSearchCompletion( buffer, - userMessages, + commandSearchHistory, commandSearchActive, ); From 12293033b4ce4fe28dc3d46be800e4039cc08831 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 17 Mar 2026 14:29:02 +0800 Subject: [PATCH 34/82] refactor(agents): remove outputFile from tool result events Remove unused outputFile property from AgentToolResultEvent and its associated test case. This property is not needed for agent tool result handling. Co-authored-by: Qwen-Coder --- .../agent-view/agentHistoryAdapter.test.ts | 18 ------------------ packages/core/src/agents/runtime/agent-core.ts | 1 - 2 files changed, 19 deletions(-) diff --git a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts index c63093642..afedfc2b6 100644 --- a/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts +++ b/packages/cli/src/ui/components/agent-view/agentHistoryAdapter.test.ts @@ -331,24 +331,6 @@ describe('agentMessagesToHistoryItems — tool metadata', () => { expect(group.tools[0]!.resultDisplay).toBe('file contents'); }); - it('forwards outputFile from tool_result', () => { - const items = agentMessagesToHistoryItems( - [ - toolCallMsg('c1', 'shell'), - toolResultMsg('c1', 'shell', { - success: true, - outputFile: '/tmp/output.txt', - }), - ], - noApprovals, - ); - const group = items[0] as Extract< - (typeof items)[0], - { type: 'tool_group' } - >; - expect(group.tools[0]!.outputFile).toBe('/tmp/output.txt'); - }); - it('forwards renderOutputAsMarkdown from tool_call', () => { const items = agentMessagesToHistoryItems( [ diff --git a/packages/core/src/agents/runtime/agent-core.ts b/packages/core/src/agents/runtime/agent-core.ts index 5e43e3e5a..fb63cb530 100644 --- a/packages/core/src/agents/runtime/agent-core.ts +++ b/packages/core/src/agents/runtime/agent-core.ts @@ -650,7 +650,6 @@ export class AgentCore { error: errorMessage, responseParts: call.response.responseParts, resultDisplay: call.response.resultDisplay, - outputFile: call.response.outputFile, durationMs: duration, timestamp: Date.now(), } as AgentToolResultEvent); From e133627e8a83fd42809f8eec7552fd69334ec7b6 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 17 Mar 2026 15:45:17 +0800 Subject: [PATCH 35/82] feat(core): execute task tools concurrently for improved performance Task tools spawn independent sub-agents with no shared mutable state, making them safe to run in parallel. This change executes all task tools concurrently while keeping other tools sequential to preserve any implicit ordering the model may rely on. Co-authored-by: Qwen-Coder --- .../core/src/core/coreToolScheduler.test.ts | 226 ++++++++++++++++++ packages/core/src/core/coreToolScheduler.ts | 25 +- 2 files changed, 248 insertions(+), 3 deletions(-) diff --git a/packages/core/src/core/coreToolScheduler.test.ts b/packages/core/src/core/coreToolScheduler.test.ts index 3411fff50..918ade81c 100644 --- a/packages/core/src/core/coreToolScheduler.test.ts +++ b/packages/core/src/core/coreToolScheduler.test.ts @@ -2583,3 +2583,229 @@ describe('CoreToolScheduler plan mode with ask_user_question', () => { expect(completedCalls[0].status).toBe('cancelled'); }); }); + +describe('Concurrent task tool execution', () => { + function createScheduler( + tools: Map, + onAllToolCallsComplete: Mock, + onToolCallsUpdate: Mock, + ) { + const mockToolRegistry = { + getTool: (name: string) => tools.get(name), + getFunctionDeclarations: () => [], + tools, + discovery: {}, + registerTool: () => {}, + getToolByName: (name: string) => tools.get(name), + getToolByDisplayName: () => undefined, + getTools: () => [...tools.values()], + discoverTools: async () => {}, + getAllTools: () => [...tools.values()], + getToolsByServer: () => [], + } as unknown as ToolRegistry; + + const mockConfig = { + getSessionId: () => 'test-session-id', + getUsageStatisticsEnabled: () => true, + getDebugMode: () => false, + getApprovalMode: () => ApprovalMode.AUTO_EDIT, + getAllowedTools: () => [], + getContentGeneratorConfig: () => ({ + model: 'test-model', + authType: 'gemini', + }), + getShellExecutionConfig: () => ({ + terminalWidth: 90, + terminalHeight: 30, + }), + storage: { + getProjectTempDir: () => '/tmp', + }, + getTruncateToolOutputThreshold: () => + DEFAULT_TRUNCATE_TOOL_OUTPUT_THRESHOLD, + getTruncateToolOutputLines: () => DEFAULT_TRUNCATE_TOOL_OUTPUT_LINES, + getToolRegistry: () => mockToolRegistry, + getUseModelRouter: () => false, + getGeminiClient: () => null, + getChatRecordingService: () => undefined, + } as unknown as Config; + + return new CoreToolScheduler({ + config: mockConfig, + onAllToolCallsComplete, + onToolCallsUpdate, + getPreferredEditor: () => 'vscode', + onEditorClose: vi.fn(), + }); + } + + it('should execute multiple task tools concurrently', async () => { + const executionLog: string[] = []; + + const taskTool = new MockTool({ + name: 'task', + execute: async (params) => { + const id = (params as { id: string }).id; + executionLog.push(`start:${id}`); + // Simulate async work — concurrent tasks will interleave here + await new Promise((r) => setTimeout(r, 50)); + executionLog.push(`end:${id}`); + return { + llmContent: `Task ${id} done`, + returnDisplay: `Task ${id} done`, + }; + }, + }); + + const tools = new Map([['task', taskTool]]); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createScheduler( + tools, + onAllToolCallsComplete, + onToolCallsUpdate, + ); + + const abortController = new AbortController(); + const requests = [ + { + callId: '1', + name: 'task', + args: { id: 'A' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + { + callId: '2', + name: 'task', + args: { id: 'B' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + { + callId: '3', + name: 'task', + args: { id: 'C' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + ]; + + await scheduler.schedule(requests, abortController.signal); + + // All tasks should have completed + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(3); + expect(completedCalls.every((c) => c.status === 'success')).toBe(true); + + // Verify concurrency: all tasks should start before any finishes + // With sequential execution, the log would be [start:A, end:A, start:B, end:B, ...] + // With concurrent execution, all starts happen before any end + const startIndices = executionLog + .filter((e) => e.startsWith('start:')) + .map((e) => executionLog.indexOf(e)); + const firstEnd = executionLog.findIndex((e) => e.startsWith('end:')); + expect(startIndices.every((i) => i < firstEnd)).toBe(true); + }); + + it('should run task tools concurrently while other tools run sequentially', async () => { + const executionLog: string[] = []; + + const taskTool = new MockTool({ + name: 'task', + execute: async (params) => { + const id = (params as { id: string }).id; + executionLog.push(`task:start:${id}`); + await new Promise((r) => setTimeout(r, 50)); + executionLog.push(`task:end:${id}`); + return { + llmContent: `Task ${id} done`, + returnDisplay: `Task ${id} done`, + }; + }, + }); + + const readTool = new MockTool({ + name: 'read_file', + execute: async (params) => { + const id = (params as { id: string }).id; + executionLog.push(`read:start:${id}`); + await new Promise((r) => setTimeout(r, 20)); + executionLog.push(`read:end:${id}`); + return { + llmContent: `Read ${id} done`, + returnDisplay: `Read ${id} done`, + }; + }, + }); + + const tools = new Map([ + ['task', taskTool], + ['read_file', readTool], + ]); + const onAllToolCallsComplete = vi.fn(); + const onToolCallsUpdate = vi.fn(); + const scheduler = createScheduler( + tools, + onAllToolCallsComplete, + onToolCallsUpdate, + ); + + const abortController = new AbortController(); + const requests = [ + { + callId: '1', + name: 'read_file', + args: { id: '1' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + { + callId: '2', + name: 'task', + args: { id: 'A' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + { + callId: '3', + name: 'read_file', + args: { id: '2' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + { + callId: '4', + name: 'task', + args: { id: 'B' }, + isClientInitiated: false, + prompt_id: 'p1', + }, + ]; + + await scheduler.schedule(requests, abortController.signal); + + expect(onAllToolCallsComplete).toHaveBeenCalled(); + const completedCalls = onAllToolCallsComplete.mock + .calls[0][0] as ToolCall[]; + expect(completedCalls).toHaveLength(4); + expect(completedCalls.every((c) => c.status === 'success')).toBe(true); + + // Non-task tools should execute sequentially: read:1 finishes before read:2 starts + const read1End = executionLog.indexOf('read:end:1'); + const read2Start = executionLog.indexOf('read:start:2'); + expect(read1End).toBeLessThan(read2Start); + + // Task tools should execute concurrently: both start before either ends + const taskAStart = executionLog.indexOf('task:start:A'); + const taskBStart = executionLog.indexOf('task:start:B'); + const firstTaskEnd = Math.min( + executionLog.indexOf('task:end:A'), + executionLog.indexOf('task:end:B'), + ); + expect(taskAStart).toBeLessThan(firstTaskEnd); + expect(taskBStart).toBeLessThan(firstTaskEnd); + }); +}); diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index 7a8ab2895..20e60bd4d 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1081,9 +1081,28 @@ export class CoreToolScheduler { (call) => call.status === 'scheduled', ); - for (const toolCall of callsToExecute) { - await this.executeSingleToolCall(toolCall, signal); - } + // Task tools are safe to run concurrently — they spawn independent + // sub-agents with no shared mutable state. All other tools run + // sequentially in their original order to preserve any implicit + // ordering the model may rely on. + const taskCalls = callsToExecute.filter( + (call) => call.request.name === ToolNames.TASK, + ); + const otherCalls = callsToExecute.filter( + (call) => call.request.name !== ToolNames.TASK, + ); + + const taskPromise = Promise.all( + taskCalls.map((tc) => this.executeSingleToolCall(tc, signal)), + ); + + const othersPromise = (async () => { + for (const toolCall of otherCalls) { + await this.executeSingleToolCall(toolCall, signal); + } + })(); + + await Promise.all([taskPromise, othersPromise]); } } From 1788be9c57d0f7e97b5c8fc379fd215cae22190b Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 17 Mar 2026 16:38:14 +0800 Subject: [PATCH 36/82] refactor(search): implement backend fuzzy search and improve file handling - Removed client-side filtering for search queries; fuzzy search is now handled by the backend. - Enhanced file search initialization and caching mechanisms in FileMessageHandler. - Added file watchers for cache invalidation on file system changes. - Updated completion trigger logic to prioritize '@' over '/' for path-like queries. - Reset last query on file selection to ensure fresh search results. This refactor improves search efficiency and maintains accurate file references in the application. --- .../vscode-ide-companion/src/webview/App.tsx | 17 +- .../webview/handlers/FileMessageHandler.ts | 154 ++++++++++++++++-- .../src/webview/handlers/MessageRouter.ts | 10 +- .../src/webview/hooks/file/useFileContext.ts | 4 +- .../src/webview/hooks/useCompletionTrigger.ts | 7 +- .../src/webview/providers/MessageHandler.ts | 5 + .../src/webview/providers/WebViewProvider.ts | 4 + 7 files changed, 175 insertions(+), 26 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index bb503f307..65d38b96e 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -134,18 +134,11 @@ export const App: React.FC = () => { }), ); - if (query && query.length >= 1) { - const lowerQuery = query.toLowerCase(); - return allItems.filter( - (item) => - item.label.toLowerCase().includes(lowerQuery) || - (item.description && - item.description.toLowerCase().includes(lowerQuery)), - ); - } + // Fuzzy search is handled by the backend (FileSearchFactory) + // No client-side filtering needed - results are already fuzzy-matched // If first time and still loading, show a placeholder - if (allItems.length === 0) { + if (allItems.length === 0 && query && query.length >= 1) { return [ { id: 'loading-files', @@ -678,7 +671,9 @@ export const App: React.FC = () => { // Replace from trigger to cursor with selected value const textBeforeCursor = text.substring(0, cursorPos); const atPos = textBeforeCursor.lastIndexOf('@'); - const slashPos = textBeforeCursor.lastIndexOf('/'); + // Only consider slash as trigger if we're in slash command mode + const slashPos = + completion.triggerChar === '/' ? textBeforeCursor.lastIndexOf('/') : -1; const triggerPos = Math.max(atPos, slashPos); if (triggerPos >= 0) { diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index 4e6e43575..7086e6080 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -14,6 +14,10 @@ import { } from '../../utils/editorGroupUtils.js'; import { ReadonlyFileSystemProvider } from '../../services/readonlyFileSystemProvider.js'; import { FileDiscoveryService } from '@qwen-code/qwen-code-core/src/services/fileDiscoveryService.js'; +import { + FileSearchFactory, + type FileSearch, +} from '@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; /** @@ -25,6 +29,9 @@ export class FileMessageHandler extends BaseMessageHandler { string, FileDiscoveryService >(); + private readonly fileSearchInstances = new Map(); + private readonly fileSearchInitializing = new Map>(); + private readonly fileWatchers: vscode.Disposable[] = []; private readonly globSpecialChars = new Set([ '\\', '*', @@ -51,6 +58,110 @@ export class FileMessageHandler extends BaseMessageHandler { ].includes(messageType); } + private async getOrCreateFileSearch( + rootPath: string, + ): Promise { + const existing = this.fileSearchInstances.get(rootPath); + if (existing) { + return existing; + } + + const initializing = this.fileSearchInitializing.get(rootPath); + if (initializing) { + await initializing; + return this.fileSearchInstances.get(rootPath) ?? null; + } + + const initPromise = (async () => { + const search = FileSearchFactory.create({ + projectRoot: rootPath, + ignoreDirs: ['.git', 'node_modules'], + useGitignore: true, + useQwenignore: false, + cache: true, + cacheTtl: 30000, + enableRecursiveFileSearch: true, + enableFuzzySearch: true, + }); + await search.initialize(); + this.fileSearchInstances.set(rootPath, search); + })(); + + this.fileSearchInitializing.set(rootPath, initPromise); + + try { + await initPromise; + return this.fileSearchInstances.get(rootPath) ?? null; + } catch (error) { + this.fileSearchInitializing.delete(rootPath); + console.error( + '[FileMessageHandler] Failed to initialize file search:', + error, + ); + return null; + } + } + + private invalidateFileSearchCache(rootPath: string): void { + this.fileSearchInstances.delete(rootPath); + this.fileSearchInitializing.delete(rootPath); + console.log( + '[FileMessageHandler] Invalidated file search cache for:', + rootPath, + ); + } + + setupFileWatchers(): vscode.Disposable { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (!workspaceFolders) { + return { dispose: () => {} }; + } + + for (const folder of workspaceFolders) { + const rootPath = folder.uri.fsPath; + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, '**/*'), + ); + + watcher.onDidCreate(() => { + this.invalidateFileSearchCache(rootPath); + }); + + watcher.onDidDelete(() => { + this.invalidateFileSearchCache(rootPath); + }); + + watcher.onDidChange(() => { + this.invalidateFileSearchCache(rootPath); + }); + + this.fileWatchers.push(watcher); + } + + const foldersChangeListener = vscode.workspace.onDidChangeWorkspaceFolders( + (e) => { + for (const folder of e.removed) { + const rootPath = folder.uri.fsPath; + this.invalidateFileSearchCache(rootPath); + } + for (const folder of e.added) { + this.invalidateFileSearchCache(folder.uri.fsPath); + } + }, + ); + + this.fileWatchers.push(foldersChangeListener); + + return { + dispose: () => { + for (const watcher of this.fileWatchers) { + watcher.dispose(); + } + this.fileWatchers.length = 0; + }, + }; + } + async handle(message: { type: string; data?: unknown }): Promise { const data = message.data as Record | undefined; @@ -282,20 +393,43 @@ export class FileMessageHandler extends BaseMessageHandler { // Search or show recent files if (query) { - const includePattern = `**/*${this.buildCaseInsensitiveGlob(query)}*`; - // Query mode: perform filesystem search (may take longer on large workspaces) console.log( - '[FileMessageHandler] Searching workspace files for query', + '[FileMessageHandler] Searching workspace files with fuzzy search for query', query, ); - const uris = await vscode.workspace.findFiles( - includePattern, - '**/{.git,node_modules}/**', - 50, - ); - for (const uri of uris) { - addFile(uri); + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + for (const folder of workspaceFolders) { + const rootPath = folder.uri.fsPath; + const fileSearch = await this.getOrCreateFileSearch(rootPath); + if (!fileSearch) { + continue; + } + + const relativePaths = await fileSearch.search(query, { + maxResults: 50, + }); + + for (let relativePath of relativePaths) { + const isDirectory = relativePath.endsWith('/'); + if (isDirectory) { + relativePath = relativePath.slice(0, -1); + } + const absolutePath = vscode.Uri.joinPath( + folder.uri, + relativePath, + ).fsPath; + + files.push({ + id: absolutePath, + label: relativePath, + description: relativePath, + path: absolutePath, + }); + addedPaths.add(absolutePath); + } + } } } else { // Non-query mode: respond quickly with currently active and open files diff --git a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts index 9cb401b43..2f1b862cc 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/MessageRouter.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type * as vscode from 'vscode'; import type { IMessageHandler } from './BaseMessageHandler.js'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; @@ -24,6 +25,7 @@ export class MessageRouter { private handlers: IMessageHandler[] = []; private sessionHandler: SessionMessageHandler; private authHandler: AuthMessageHandler; + private fileHandler: FileMessageHandler; private currentConversationId: string | null = null; private permissionHandler: | ((message: PermissionResponseMessage) => void) @@ -48,7 +50,7 @@ export class MessageRouter { sendToWebView, ); - const fileHandler = new FileMessageHandler( + this.fileHandler = new FileMessageHandler( agentManager, conversationStore, currentConversationId, @@ -72,12 +74,16 @@ export class MessageRouter { // Register handlers in order of priority this.handlers = [ this.sessionHandler, - fileHandler, + this.fileHandler, editorHandler, this.authHandler, ]; } + setupFileWatchers(): vscode.Disposable { + return this.fileHandler.setupFileWatchers(); + } + /** * Route message to appropriate handler */ diff --git a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts index 0f5296550..50344ac0e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/file/useFileContext.ts @@ -123,10 +123,12 @@ export const useFileContext = (vscode: VSCodeAPI) => { ); /** - * Add file reference + * Add file reference (called when user selects a file from completion) + * Also resets the last query so that backspacing and re-typing will trigger a fresh search */ const addFileReference = useCallback((fileName: string, filePath: string) => { fileReferenceMap.current.set(fileName, filePath); + lastQueryRef.current = undefined; }, []); /** diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index f3a660366..6fad7cba5 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -305,10 +305,13 @@ export function useCompletionTrigger( let triggerPos = -1; let triggerChar: '@' | '/' | null = null; - if (lastAtMatch > lastSlashMatch) { + // Priority: @ trigger takes precedence over / trigger + // This allows path-like queries (e.g., "src/components/Button") in @ mentions + // But skip if the trigger is inside a file tag + if (lastAtMatch >= 0) { triggerPos = lastAtMatch; triggerChar = '@'; - } else if (lastSlashMatch > lastAtMatch) { + } else if (lastSlashMatch >= 0) { triggerPos = lastSlashMatch; triggerChar = '/'; } diff --git a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts index a06fd1a3b..d400fa727 100644 --- a/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/providers/MessageHandler.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type * as vscode from 'vscode'; import type { QwenAgentManager } from '../../services/qwenAgentManager.js'; import type { ConversationStore } from '../../services/conversationStore.js'; import type { @@ -86,4 +87,8 @@ export class MessageHandler { appendStreamContent(chunk: string): void { this.router.appendStreamContent(chunk); } + + setupFileWatchers(): vscode.Disposable { + return this.router.setupFileWatchers(); + } } diff --git a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts index e8e5e3f74..c54fa4af4 100644 --- a/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts +++ b/packages/vscode-ide-companion/src/webview/providers/WebViewProvider.ts @@ -89,6 +89,10 @@ export class WebViewProvider { await this.forceReLogin(); }); + // Setup file watchers for cache invalidation + const fileWatcherDisposable = this.messageHandler.setupFileWatchers(); + this.disposables.push(fileWatcherDisposable); + // Setup agent callbacks this.agentManager.onMessage((message) => { // Do not suppress messages during checkpoint saves. From 1a977b62f3b2aab5fc225399889bcb1c14cc9fd4 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 17 Mar 2026 16:50:25 +0800 Subject: [PATCH 37/82] refactor(skills): improve PR review workflow for better agent coordination - Checkout PR branch instead of remote viewing for full file access - Save PR context to temp file to avoid repeating in agent prompts - Add guidance to prevent 4x diff duplication across agents - Include environment restoration step after review This enables agents to read files directly and use git diff against base branch, improving review quality and reducing prompt bloat. Co-authored-by: Qwen-Coder --- packages/core/src/skills/bundled/review/SKILL.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/core/src/skills/bundled/review/SKILL.md b/packages/core/src/skills/bundled/review/SKILL.md index 14e5f27e6..957031c7a 100644 --- a/packages/core/src/skills/bundled/review/SKILL.md +++ b/packages/core/src/skills/bundled/review/SKILL.md @@ -15,15 +15,16 @@ You are an expert code reviewer. Your job is to review code changes and provide ## Step 1: Determine what to review -Based on the arguments provided: +Your goal here is to understand the scope of changes so you can dispatch agents effectively in Step 2. Based on the arguments provided: - **No arguments**: Review local uncommitted changes - Run `git diff` and `git diff --staged` to get all changes - If both diffs are empty, inform the user there are no changes to review and stop here — do not proceed to the review agents - **PR number or URL** (e.g., `123` or `https://github.com/.../pull/123`): - - Run `gh pr view ` to get PR details - - Run `gh pr diff ` to get the diff + - Save the current branch name, stash any local changes (`git stash --include-untracked`), then `gh pr checkout ` + - Run `gh pr view ` and save the output (title, description, base branch, etc.) to a temp file (e.g., `/tmp/pr-review-context.md`) so agents can read it without you repeating it in each prompt + - Note the base branch (e.g., `main`) — agents will use `git diff ...HEAD` to get the diff and can read files directly - **File path** (e.g., `src/foo.ts`): - Run `git diff HEAD -- ` to get recent changes @@ -33,6 +34,8 @@ Based on the arguments provided: Launch **four parallel review agents** to analyze the changes from different angles. Each agent should focus exclusively on its dimension. +**IMPORTANT**: Do NOT paste the full diff into each agent's prompt — this duplicates it 4x. Instead, give each agent the command to obtain the diff, a concise summary of what the changes are about, and its review focus. Each agent can read files and search the codebase on its own. + ### Agent 1: Correctness & Security Focus areas: @@ -77,9 +80,11 @@ Focus areas: - Unexpected side effects or hidden coupling - Anything else that looks off — trust your instincts -## Step 3: Aggregate and present findings +## Step 3: Restore environment and present findings -Combine results from all four agents into a single, well-organized review. Use this format: +If you checked out a PR branch in Step 1, restore the original state first: check out the original branch, `git stash pop` if changes were stashed, and remove the temp file. + +Then combine results from all four agents into a single, well-organized review. Use this format: ### Summary From 78faa365cbf421e193ef6642ae0b7b8b17228348 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 17 Mar 2026 17:13:23 +0800 Subject: [PATCH 38/82] feat(tools): allow read-file access to OS temp directory - Add os.tmpdir() to allowed paths in read-file tool - Add tests for reading files from OS temp directory - Add terminal capture scenario for PR review testing This supports the PR review workflow which saves context to temp files. Co-authored-by: Qwen-Coder --- .../scenarios/pr-2371-review.ts | 18 +++++++++++ packages/core/src/tools/read-file.test.ts | 30 +++++++++++++++++++ packages/core/src/tools/read-file.ts | 5 +++- 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 integration-tests/terminal-capture/scenarios/pr-2371-review.ts diff --git a/integration-tests/terminal-capture/scenarios/pr-2371-review.ts b/integration-tests/terminal-capture/scenarios/pr-2371-review.ts new file mode 100644 index 000000000..0752f0a20 --- /dev/null +++ b/integration-tests/terminal-capture/scenarios/pr-2371-review.ts @@ -0,0 +1,18 @@ +import type { ScenarioConfig } from '../scenario-runner.js'; + +export default { + name: 'pr-2371-review', + spawn: ['node', 'dist/cli.js', '--yolo'], + terminal: { title: 'qwen-code', cwd: '../../..' }, + flow: [ + { + type: '/review https://github.com/QwenLM/qwen-code/pull/2371', + streaming: { + delayMs: 5000, + intervalMs: 10000, // Every 10s + count: 60, // 10 minutes total (60 * 10s) + gif: true, + }, + }, + ], +} satisfies ScenarioConfig; diff --git a/packages/core/src/tools/read-file.test.ts b/packages/core/src/tools/read-file.test.ts index f6f140afc..1878c3805 100644 --- a/packages/core/src/tools/read-file.test.ts +++ b/packages/core/src/tools/read-file.test.ts @@ -94,6 +94,14 @@ describe('ReadFileTool', () => { expect(typeof result).not.toBe('string'); }); + it('should allow access to files in OS temp directory', () => { + const params: ReadFileToolParams = { + absolute_path: path.join(os.tmpdir(), 'pr-review-context.md'), + }; + const result = tool.build(params); + expect(typeof result).not.toBe('string'); + }); + it('should show temp directory in error message when path is outside workspace and temp dir', () => { const params: ReadFileToolParams = { absolute_path: '/completely/outside/path.txt', @@ -427,6 +435,28 @@ describe('ReadFileTool', () => { expect(result.returnDisplay).toBe(''); }); + it('should successfully read files from OS temp directory', async () => { + const osTempFile = await fsp.mkdtemp( + path.join(os.tmpdir(), 'read-file-test-'), + ); + const tempFilePath = path.join(osTempFile, 'pr-review-context.md'); + const tempFileContent = '## PR #123\nFix encoding issues'; + await fsp.writeFile(tempFilePath, tempFileContent, 'utf-8'); + + try { + const params: ReadFileToolParams = { absolute_path: tempFilePath }; + const invocation = tool.build(params) as ToolInvocation< + ReadFileToolParams, + ToolResult + >; + + const result = await invocation.execute(abortSignal); + expect(result.llmContent).toBe(tempFileContent); + } finally { + await fsp.rm(osTempFile, { recursive: true, force: true }); + } + }); + describe('with .qwenignore', () => { beforeEach(async () => { await fsp.writeFile( diff --git a/packages/core/src/tools/read-file.ts b/packages/core/src/tools/read-file.ts index e09a1ac58..215ae5c36 100644 --- a/packages/core/src/tools/read-file.ts +++ b/packages/core/src/tools/read-file.ts @@ -4,6 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import os from 'node:os'; import path from 'node:path'; import { makeRelative, shortenPath } from '../utils/paths.js'; import type { ToolInvocation, ToolLocation, ToolResult } from './tools.js'; @@ -188,9 +189,11 @@ export class ReadFileTool extends BaseDeclarativeTool< const projectTempDir = this.config.storage.getProjectTempDir(); const userSkillsDir = this.config.storage.getUserSkillsDir(); const resolvedFilePath = path.resolve(filePath); + const osTempDir = os.tmpdir(); const isWithinTempDir = isSubpath(projectTempDir, resolvedFilePath) || - isSubpath(globalTempDir, resolvedFilePath); + isSubpath(globalTempDir, resolvedFilePath) || + isSubpath(osTempDir, resolvedFilePath); const isWithinUserSkills = isSubpath(userSkillsDir, resolvedFilePath); if ( From 9a3041335f1a0ede738cb034081566c1f085b764 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 18:11:22 +0800 Subject: [PATCH 39/82] feat: add auth command --- .qwen/skills/qwen-code-claw/SKILL.md | 174 +++++- packages/cli/src/commands/auth.ts | 78 +++ packages/cli/src/commands/auth/handler.ts | 509 ++++++++++++++++++ .../commands/auth/interactiveSelector.test.ts | 421 +++++++++++++++ .../src/commands/auth/interactiveSelector.ts | 166 ++++++ packages/cli/src/commands/auth/status.test.ts | 287 ++++++++++ packages/cli/src/config/config.ts | 3 + 7 files changed, 1616 insertions(+), 22 deletions(-) create mode 100644 packages/cli/src/commands/auth.ts create mode 100644 packages/cli/src/commands/auth/handler.ts create mode 100644 packages/cli/src/commands/auth/interactiveSelector.test.ts create mode 100644 packages/cli/src/commands/auth/interactiveSelector.ts create mode 100644 packages/cli/src/commands/auth/status.test.ts diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md index e129b7300..9c080f332 100644 --- a/.qwen/skills/qwen-code-claw/SKILL.md +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -1,71 +1,201 @@ --- name: qwen-code-claw -description: 使用QwenCode作为Code Agent完成代码理解、项目生成、feature、fix bug、重构等各种编程相关需求 +description: Use Qwen Code as a Code Agent for code understanding, project generation, features, bug fixes, refactoring, and various programming tasks --- -# qwen-code +# Qwen Code Claw -## When to use this skill +## When to Use This Skill -## What qwen-code is +Use this skill when you need to: + +- Understand codebases or ask questions about source code +- Generate new projects or add new features +- Review pull requests in the codebase +- Fix bugs or refactor existing code +- Execute various programming tasks such as code review, testing, documentation generation, etc. +- Collaborate with other tools and agents to complete complex development tasks ## Install ```bash -npm i -g @qwen-code/qwen-code +npm install -g @qwen-code/qwen-code ``` For normal session reuse, prefer a global install over `npx`. -## acpx +## Authentication -You should always use acpx to run qwen-code over the Agent Client Protocol -(ACP) instead of scraping PTY sessions. +Check if authentication is already configured: -Please do the following to use acpx: +```bash +qwen auth status +``` -1. Install acpx globally (recommended) or use npx: +If authentication exists, skip this section. If not authenticated, check if the `BAILIAN_CODING_PLAN_API_KEY` environment variable exists: + +```bash +echo $BAILIAN_CODING_PLAN_API_KEY +``` + +**If `BAILIAN_CODING_PLAN_API_KEY` exists**, authenticate directly: + +```bash +qwen auth code-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY +``` + +**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `code-plan`: + +```bash +qwen auth +``` + +Or configure custom API after launching Qwen Code via `/auth`. + +## ACPX Integration + +You should always use `acpx` to run Qwen Code over the Agent Client Protocol (ACP) instead of scraping PTY sessions. + +To use acpx: + +1. **Install acpx globally** (recommended) or use npx: + + ```bash npm install -g acpx@latest + ``` Or without installing: + + ```bash npx acpx@latest + ``` -2. Install the acpx skill so you have the full reference available: +2. **Install the acpx skill** for full reference: + + ```bash npx acpx@latest --skill install acpx + ``` -3. Read the acpx skill reference so you know every command, flag, and - workflow pattern: - https://raw.githubusercontent.com/openclaw/acpx/main/skills/acpx/SKILL.md +3. **Read the acpx skill reference** for every command, flag, and workflow pattern: + See the [ACPX Reference](#acpx-reference) section below for details -4. If you need the full CLI reference with all options and examples: +4. **For complete CLI reference with all options and examples**: https://raw.githubusercontent.com/openclaw/acpx/main/docs/CLI.md -From now on, when you need to delegate work to qwen-code, use acpx -instead of spawning raw terminal sessions. For example: +From now on, when you need to delegate work to Qwen Code, use `acpx` instead of spawning raw terminal sessions. For example: + +```bash npx acpx@latest qwen "fix the failing tests" npx acpx@latest qwen "refactor the auth module" npx acpx@latest qwen exec "one-shot: summarize this repo" +``` -通常情况下,你应该使用approval-all参数来防止任务中断 +> **Tip:** You should typically use the `--approve-all` flag to prevent task interruptions. -## Practical workflows +## Practical Workflows -Persistent repo assistant: +### Persistent Repository Assistant ```bash acpx qwen 'inspect failing tests and propose a fix plan' acpx qwen 'apply the smallest safe fix and run tests' ``` -One-shot script step: +### One-Shot Script Steps ```bash acpx qwen exec 'summarize repo purpose in 3 lines' ``` -Parallel named streams: +### Parallel Named Streams ```bash acpx qwen -s backend 'fix API pagination bug' acpx qwen -s docs 'draft changelog entry for release' ``` + +### Queue Follow-ups Without Waiting + +```bash +acpx qwen 'run full test suite and investigate failures' +acpx qwen --no-wait 'after tests, summarize root causes and next steps' +``` + +### Machine-Readable Output for Orchestration + +```bash +acpx --format json qwen 'review current branch changes' > events.ndjson +``` + +### Repository-Wide Review with Permissive Mode + +```bash +acpx --cwd ~/repos/my-project --approve-all qwen -s pr-123 \ + 'review PR #123 for regressions and propose minimal patch' +``` + +## Approval Modes + +- `--approve-all`: No interactive prompts +- `--approve-reads` (default): Auto-approve reads/searches, prompt for writes +- `--deny-all`: Deny all permission requests + +If every permission request is denied/cancelled and none are approved, `acpx` exits with permission denied. + +## Best Practices + +1. Use **named sessions** for organizing different types of development tasks +2. Use `--no-wait` for long-running tasks to avoid blocking +3. Use `--approve-all` for non-interactive batch operations +4. Use `--format json` for automation and script integration +5. Use `--cwd` to manage context across multiple projects + +## ACPX Reference + +### Built-in Agent Registry + +Well-known agent names resolve to commands: + +- `qwen` → `qwen --acp` + +### Command Syntax + +```bash +# Default (prompt mode, persistent session) +acpx [global options] [prompt text...] +acpx [global options] prompt [options] [prompt text...] + +# One-shot execution +acpx [global options] exec [options] [prompt text...] + +# Session management +acpx [global options] cancel [-s ] +acpx [global options] set-mode [-s ] +acpx [global options] set [-s ] +acpx [global options] status [-s ] +acpx [global options] sessions [list | new [--name ] | close [name] | show [name] | history [name] [--limit ]] +acpx [global options] config [show | init] + +# With explicit agent +acpx [global options] [options] [prompt text...] +acpx [global options] prompt [options] [prompt text...] +acpx [global options] exec [options] [prompt text...] +``` + +> **Note:** If prompt text is omitted and stdin is piped, `acpx` reads prompt from stdin. + +### Global Options + +| Option | Description | +| --------------------- | ------------------------------------------------------------ | +| `--agent ` | Raw ACP agent command (fallback mechanism) | +| `--cwd ` | Session working directory | +| `--approve-all` | Auto-approve all requests | +| `--approve-reads` | Auto-approve reads/searches, prompt for writes (default) | +| `--deny-all` | Deny all requests | +| `--format ` | Output format: `text`, `json`, `quiet` | +| `--timeout ` | Maximum wait time (positive integer) | +| `--ttl ` | Idle TTL for queue owners (default: `300`, `0` disables TTL) | +| `--verbose` | Verbose ACP/debug logs to stderr | + +Flags are mutually exclusive where applicable. diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts new file mode 100644 index 000000000..0e6cfcb80 --- /dev/null +++ b/packages/cli/src/commands/auth.ts @@ -0,0 +1,78 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { CommandModule , Argv } from 'yargs'; +import { + handleQwenAuth, + runInteractiveAuth, + showAuthStatus, +} from './auth/handler.js'; +import { t } from '../i18n/index.js'; + + +// Define subcommands separately +const qwenOauthCommand = { + command: 'qwen-oauth', + describe: t('Authenticate using Qwen OAuth'), + handler: async () => { + await handleQwenAuth('qwen-oauth', {}); + }, +}; + +const codePlanCommand = { + command: 'code-plan', + describe: t('Authenticate using Alibaba Cloud Coding Plan'), + builder: (yargs: Argv) => + yargs + .option('region', { + alias: 'r', + describe: t('Region for Coding Plan (china/global)'), + type: 'string', + }) + .option('key', { + alias: 'k', + describe: t('API key for Coding Plan'), + type: 'string', + }), + handler: async (argv: { region?: string; key?: string }) => { + const region = argv['region'] as string | undefined; + const key = argv['key'] as string | undefined; + + // If region and key are provided, use them directly + if (region && key) { + await handleQwenAuth('code-plan', { region, key }); + } else { + // Otherwise, prompt interactively + await handleQwenAuth('code-plan', {}); + } + }, +}; + +const statusCommand = { + command: 'status', + describe: t('Show current authentication status'), + handler: async () => { + await showAuthStatus(); + }, +}; + +export const authCommand: CommandModule = { + command: 'auth', + describe: t( + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan', + ), + builder: (yargs: Argv) => + yargs + .command(qwenOauthCommand) + .command(codePlanCommand) + .command(statusCommand) + .demandCommand(0) // Don't require a subcommand + .version(false), + handler: async () => { + // This handler is for when no subcommand is provided - show interactive menu + await runInteractiveAuth(); + }, +}; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts new file mode 100644 index 000000000..b75f6b208 --- /dev/null +++ b/packages/cli/src/commands/auth/handler.ts @@ -0,0 +1,509 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AuthType, + getErrorMessage, + type Config, + type ProviderModelConfig as ModelConfig, +} from '@qwen-code/qwen-code-core'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; +import { t } from '../../i18n/index.js'; +import { + getCodingPlanConfig, + isCodingPlanConfig, + CodingPlanRegion, + CODING_PLAN_ENV_KEY, +} from '../../constants/codingPlan.js'; +import { getPersistScopeForModelSelection } from '../../config/modelProvidersScope.js'; +import { backupSettingsFile } from '../../utils/settingsUtils.js'; +import { loadSettings, type LoadedSettings } from '../../config/settings.js'; +import { loadCliConfig } from '../../config/config.js'; +import type { CliArgs } from '../../config/config.js'; +import { InteractiveSelector } from './interactiveSelector.js'; + +interface QwenAuthOptions { + region?: string; + key?: string; +} + +interface CodingPlanSettings { + region?: CodingPlanRegion; + version?: string; +} + +interface MergedSettingsWithCodingPlan { + security?: { + auth?: { + selectedType?: string; + }; + }; + codingPlan?: CodingPlanSettings; + model?: { + name?: string; + }; + modelProviders?: Record; + env?: Record; +} + +/** + * Handles the authentication process based on the specified command and options + */ +export async function handleQwenAuth( + command: 'qwen-oauth' | 'code-plan', + options: QwenAuthOptions, +) { + try { + const settings = loadSettings(); + + // Create a minimal argv for config loading + const minimalArgv: CliArgs = { + query: undefined, + model: undefined, + sandbox: undefined, + sandboxImage: undefined, + debug: undefined, + prompt: undefined, + promptInteractive: undefined, + yolo: undefined, + approvalMode: undefined, + telemetry: undefined, + checkpointing: undefined, + telemetryTarget: undefined, + telemetryOtlpEndpoint: undefined, + telemetryOtlpProtocol: undefined, + telemetryLogPrompts: undefined, + telemetryOutfile: undefined, + allowedMcpServerNames: undefined, + allowedTools: undefined, + acp: undefined, + experimentalAcp: undefined, + experimentalLsp: undefined, + experimentalHooks: undefined, + extensions: [], + listExtensions: undefined, + openaiLogging: undefined, + openaiApiKey: undefined, + openaiBaseUrl: undefined, + openaiLoggingDir: undefined, + proxy: undefined, + includeDirectories: undefined, + tavilyApiKey: undefined, + googleApiKey: undefined, + googleSearchEngineId: undefined, + webSearchDefault: undefined, + screenReader: undefined, + inputFormat: undefined, + outputFormat: undefined, + includePartialMessages: undefined, + chatRecording: undefined, + continue: undefined, + resume: undefined, + sessionId: undefined, + maxSessionTurns: undefined, + coreTools: undefined, + excludeTools: undefined, + authType: undefined, + channel: undefined, + }; + + // Create a minimal config to access settings and storage + const config = await loadCliConfig( + settings.merged, + minimalArgv, + process.cwd(), + [], // No extensions for auth command + ); + + if (command === 'qwen-oauth') { + await handleQwenOAuth(config, settings); + } else if (command === 'code-plan') { + await handleCodePlanAuth(config, settings, options); + } + + // Exit after authentication is complete + writeStdoutLine(t('Authentication completed successfully.')); + process.exit(0); + } catch (error) { + writeStderrLine(getErrorMessage(error)); + process.exit(1); + } +} + +/** + * Handles Qwen OAuth authentication + */ +async function handleQwenOAuth( + config: Config, + settings: LoadedSettings, +): Promise { + writeStdoutLine(t('Starting Qwen OAuth authentication...')); + + try { + await config.refreshAuth(AuthType.QWEN_OAUTH); + + // Persist the auth type + const authTypeScope = getPersistScopeForModelSelection(settings); + settings.setValue( + authTypeScope, + 'security.auth.selectedType', + AuthType.QWEN_OAUTH, + ); + + writeStdoutLine(t('Successfully authenticated with Qwen OAuth.')); + process.exit(0); + } catch (error) { + writeStderrLine( + t('Failed to authenticate with Qwen OAuth: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} + +/** + * Handles Alibaba Cloud Coding Plan authentication + */ +async function handleCodePlanAuth( + config: Config, + settings: LoadedSettings, + options: QwenAuthOptions, +): Promise { + const { region, key } = options; + + let selectedRegion: CodingPlanRegion; + let selectedKey: string; + + // If region and key are provided as options, use them + if (region && key) { + selectedRegion = + region.toLowerCase() === 'global' + ? CodingPlanRegion.GLOBAL + : CodingPlanRegion.CHINA; + selectedKey = key; + } else { + // Otherwise, prompt interactively + selectedRegion = await promptForRegion(); + selectedKey = await promptForKey(); + } + + writeStdoutLine(t('Processing Alibaba Cloud Coding Plan authentication...')); + + try { + // Get configuration based on region + const { template, version } = getCodingPlanConfig(selectedRegion); + + // Get persist scope + const authTypeScope = getPersistScopeForModelSelection(settings); + + // Backup settings file before modification + const settingsFile = settings.forScope(authTypeScope); + backupSettingsFile(settingsFile.path); + + // Store api-key in settings.env (unified env key) + settings.setValue(authTypeScope, `env.${CODING_PLAN_ENV_KEY}`, selectedKey); + + // Sync to process.env immediately so refreshAuth can read the apiKey + process.env[CODING_PLAN_ENV_KEY] = selectedKey; + + // Generate model configs from template + const newConfigs = template.map((templateConfig) => ({ + ...templateConfig, + envKey: CODING_PLAN_ENV_KEY, + })); + + // Get existing configs + const existingConfigs = + (settings.merged.modelProviders as Record)?.[ + AuthType.USE_OPENAI + ] || []; + + // Filter out all existing Coding Plan configs (mutually exclusive) + const nonCodingPlanConfigs = existingConfigs.filter( + (existing) => !isCodingPlanConfig(existing.baseUrl, existing.envKey), + ); + + // Add new Coding Plan configs at the beginning + const updatedConfigs = [...newConfigs, ...nonCodingPlanConfigs]; + + // Persist to modelProviders + settings.setValue( + authTypeScope, + `modelProviders.${AuthType.USE_OPENAI}`, + updatedConfigs, + ); + + // Also persist authType + settings.setValue( + authTypeScope, + 'security.auth.selectedType', + AuthType.USE_OPENAI, + ); + + // Persist coding plan region + settings.setValue(authTypeScope, 'codingPlan.region', selectedRegion); + + // Persist coding plan version (single field for backward compatibility) + settings.setValue(authTypeScope, 'codingPlan.version', version); + + // If there are configs, use the first one as the model + if (updatedConfigs.length > 0 && updatedConfigs[0]?.id) { + settings.setValue( + authTypeScope, + 'model.name', + (updatedConfigs[0] as ModelConfig).id, + ); + } + + // Refresh auth with the new configuration + await config.refreshAuth(AuthType.USE_OPENAI); + + writeStdoutLine( + t('Successfully authenticated with Alibaba Cloud Coding Plan.'), + ); + } catch (error) { + writeStderrLine( + t('Failed to authenticate with Coding Plan: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} + +/** + * Prompts the user to select a region using an interactive selector + */ +async function promptForRegion(): Promise { + const selector = new InteractiveSelector( + [ + { + value: CodingPlanRegion.CHINA, + label: t('中国 (China)'), + description: t('阿里云百炼 (aliyun.com)'), + }, + { + value: CodingPlanRegion.GLOBAL, + label: t('Global'), + description: t('Alibaba Cloud (alibabacloud.com)'), + }, + ], + t('Select region for Coding Plan:'), + ); + + return await selector.select(); +} + +/** + * Prompts the user to enter an API key + */ +async function promptForKey(): Promise { + // Create a simple password-style input (without echoing characters) + const stdin = process.stdin; + const stdout = process.stdout; + + stdout.write(t('Enter your Coding Plan API key: ')); + + // Set raw mode to capture keystrokes + const wasRaw = stdin.isRaw; + if (stdin.setRawMode) { + stdin.setRawMode(true); + } + stdin.resume(); + + return new Promise((resolve, reject) => { + let input = ''; + + const onData = (chunk: string) => { + for (const char of chunk) { + switch (char) { + case '\r': // Enter + case '\n': + stdin.removeListener('data', onData); + if (stdin.setRawMode) { + stdin.setRawMode(wasRaw); + } + stdout.write('\n'); // New line after input + resolve(input); + return; + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + if (stdin.setRawMode) { + stdin.setRawMode(wasRaw); + } + stdout.write('^C\n'); + reject(new Error('Interrupted')); + return; + case '\x08': // Backspace + case '\x7F': // Delete + if (input.length > 0) { + input = input.slice(0, -1); + // Move cursor back, print space, move back again + stdout.write('\x1B[D \x1B[D'); + } + break; + default: + // Add character to input + input += char; + // Print asterisk instead of the actual character for security + stdout.write('*'); + break; + } + } + }; + + stdin.on('data', onData); + }); +} + +/** + * Runs the interactive authentication flow + */ +export async function runInteractiveAuth() { + const selector = new InteractiveSelector( + [ + { + value: 'qwen-oauth' as const, + label: t('Qwen OAuth'), + description: t('Free · Up to 1,000 requests/day · Qwen latest models'), + }, + { + value: 'code-plan' as const, + label: t('Alibaba Cloud Coding Plan'), + description: t( + 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models', + ), + }, + ], + t('Select authentication method:'), + ); + + const choice = await selector.select(); + + if (choice === 'code-plan') { + await handleQwenAuth('code-plan', {}); + } else { + await handleQwenAuth('qwen-oauth', {}); + } +} + +/** + * Shows the current authentication status + */ +export async function showAuthStatus(): Promise { + try { + const settings = loadSettings(); + const mergedSettings = settings.merged as MergedSettingsWithCodingPlan; + + writeStdoutLine(t('\n=== Authentication Status ===\n')); + + // Check for selected auth type + const selectedType = mergedSettings.security?.auth?.selectedType; + + if (!selectedType) { + writeStdoutLine(t('⚠️ No authentication method configured.\n')); + writeStdoutLine(t('Run one of the following commands to get started:\n')); + writeStdoutLine( + t( + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)', + ), + ); + writeStdoutLine( + t( + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ), + ); + writeStdoutLine(t('Or simply run:')); + writeStdoutLine( + t(' qwen auth - Interactive authentication setup\n'), + ); + process.exit(0); + } + + // Display status based on auth type + if (selectedType === AuthType.QWEN_OAUTH) { + writeStdoutLine(t('✓ Authentication Method: Qwen OAuth')); + writeStdoutLine(t(' Type: Free tier')); + writeStdoutLine(t(' Limit: Up to 1,000 requests/day')); + writeStdoutLine(t(' Models: Qwen latest models\n')); + } else if (selectedType === AuthType.USE_OPENAI) { + // Check for Coding Plan configuration + const codingPlanRegion = mergedSettings.codingPlan?.region; + const codingPlanVersion = mergedSettings.codingPlan?.version; + const modelName = mergedSettings.model?.name; + + // Check if API key is set in environment + const hasApiKey = + !!process.env[CODING_PLAN_ENV_KEY] || + !!mergedSettings.env?.[CODING_PLAN_ENV_KEY]; + + if (hasApiKey) { + writeStdoutLine( + t('✓ Authentication Method: Alibaba Cloud Coding Plan'), + ); + + if (codingPlanRegion) { + const regionDisplay = + codingPlanRegion === CodingPlanRegion.CHINA + ? t('中国 (China) - 阿里云百炼') + : t('Global - Alibaba Cloud'); + writeStdoutLine(t(' Region: {{region}}', { region: regionDisplay })); + } + + if (modelName) { + writeStdoutLine( + t(' Current Model: {{model}}', { model: modelName }), + ); + } + + if (codingPlanVersion) { + writeStdoutLine( + t(' Config Version: {{version}}', { + version: codingPlanVersion.substring(0, 8) + '...', + }), + ); + } + + writeStdoutLine(t(' Status: API key configured\n')); + } else { + writeStdoutLine( + t( + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ), + ); + writeStdoutLine( + t(' Issue: API key not found in environment or settings\n'), + ); + writeStdoutLine(t(' Run `qwen auth code-plan` to re-configure.\n')); + } + } else { + writeStdoutLine( + t('✓ Authentication Method: {{type}}', { type: selectedType }), + ); + writeStdoutLine(t(' Status: Configured\n')); + } + + // Show available commands + writeStdoutLine(t('---')); + writeStdoutLine(t('Commands:')); + writeStdoutLine( + t(' qwen auth - Change authentication method'), + ); + writeStdoutLine(t(' qwen auth status - Show this status')); + writeStdoutLine(t(' qwen auth qwen-oauth - Switch to Qwen OAuth')); + writeStdoutLine(t(' qwen auth code-plan - Switch to Coding Plan\n')); + + process.exit(0); + } catch (error) { + writeStderrLine( + t('Failed to check authentication status: {{error}}', { + error: getErrorMessage(error), + }), + ); + process.exit(1); + } +} diff --git a/packages/cli/src/commands/auth/interactiveSelector.test.ts b/packages/cli/src/commands/auth/interactiveSelector.test.ts new file mode 100644 index 000000000..e580cb3bf --- /dev/null +++ b/packages/cli/src/commands/auth/interactiveSelector.test.ts @@ -0,0 +1,421 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { InteractiveSelector } from './interactiveSelector.js'; +import { stdin, stdout } from 'node:process'; + +describe('InteractiveSelector', () => { + const mockOptions = [ + { value: 'option1', label: 'Option 1', description: 'First option' }, + { value: 'option2', label: 'Option 2', description: 'Second option' }, + { value: 'option3', label: 'Option 3', description: 'Third option' }, + ]; + + const mockPrompt = 'Select an option:'; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + describe('constructor', () => { + it('should create an instance with default prompt', () => { + const selector = new InteractiveSelector(mockOptions); + expect(selector).toBeInstanceOf(InteractiveSelector); + }); + + it('should create an instance with custom prompt', () => { + const selector = new InteractiveSelector(mockOptions, mockPrompt); + expect(selector).toBeInstanceOf(InteractiveSelector); + }); + }); + + describe('select', () => { + it('should reject if raw mode is not available', async () => { + // Mock stdin without setRawMode + const originalSetRawMode = stdin.setRawMode; + (stdin as any).setRawMode = undefined; + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + + await expect(selector.select()).rejects.toThrow( + 'Raw mode not available. Please run in an interactive terminal.', + ); + + // Restore + (stdin as any).setRawMode = originalSetRawMode; + }); + + it('should select first option with Enter key', async () => { + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockSetEncoding = vi.fn(); + const mockRemoveListener = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + // Simulate Enter key press + setTimeout(() => callback('\r'), 0); + return stdin; + }); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).setEncoding = mockSetEncoding; + (stdin as any).removeListener = mockRemoveListener; + (stdin as any).on = mockOn; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const result = await selector.select(); + + expect(result).toBe('option1'); + expect(mockSetRawMode).toHaveBeenCalledWith(true); + expect(mockResume).toHaveBeenCalled(); + + stdoutWriteSpy.mockRestore(); + }); + + it('should select second option after arrow down then Enter', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Simulate arrow down + dataCallback('\x1B[B'); + + // Simulate Enter + setTimeout(() => dataCallback('\r'), 0); + + const result = await selectPromise; + + expect(result).toBe('option2'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should handle arrow up navigation', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Move down twice + dataCallback('\x1B[B'); + dataCallback('\x1B[B'); + + // Move up once + dataCallback('\x1B[A'); + + // Simulate Enter + setTimeout(() => dataCallback('\r'), 0); + + const result = await selectPromise; + + expect(result).toBe('option2'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should reject with Ctrl+C', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Simulate Ctrl+C + setTimeout(() => dataCallback('\x03'), 0); + + await expect(selectPromise).rejects.toThrow('Interrupted'); + }); + + it('should wrap around when navigating past last option', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Move down past last option (should wrap to first) + dataCallback('\x1B[B'); + dataCallback('\x1B[B'); + dataCallback('\x1B[B'); // Now at option1 again (wrapped) + + // Simulate Enter + setTimeout(() => dataCallback('\r'), 0); + + const result = await selectPromise; + + expect(result).toBe('option1'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should wrap around when navigating before first option', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Move up from first option (should wrap to last) + dataCallback('\x1B[A'); + + // Simulate Enter + setTimeout(() => dataCallback('\r'), 0); + + const result = await selectPromise; + + expect(result).toBe('option3'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should ignore arrow left/right keys', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Press arrow right (should be ignored) + dataCallback('\x1B[C'); + + // Press arrow left (should be ignored) + dataCallback('\x1B[D'); + + // Press Enter - should still select first option + setTimeout(() => dataCallback('\r'), 0); + + const result = await selectPromise; + + expect(result).toBe('option1'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should handle newline character as Enter', async () => { + let dataCallback!: (chunk: string) => void; + + const mockSetRawMode = vi.fn(); + const mockResume = vi.fn(); + const mockOn = vi.fn((event: any, callback: any) => { + dataCallback = callback; + return stdin; + }); + const mockRemoveListener = vi.fn(); + + (stdin as any).isRaw = false; + (stdin as any).setRawMode = mockSetRawMode; + (stdin as any).resume = mockResume; + (stdin as any).on = mockOn; + (stdin as any).removeListener = mockRemoveListener; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + const selectPromise = selector.select(); + + // Simulate newline + setTimeout(() => dataCallback('\n'), 0); + + const result = await selectPromise; + + expect(result).toBe('option1'); + + stdoutWriteSpy.mockRestore(); + }); + }); + + describe('renderMenu', () => { + it('should render menu with correct formatting', () => { + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + + // Access private method for testing + (selector as any).renderMenu(); + + expect(stdoutWriteSpy).toHaveBeenCalled(); + const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); + + expect(output).toContain('Select an option:'); + expect(output).toContain('Option 1'); + expect(output).toContain('Option 2'); + expect(output).toContain('Option 3'); + expect(output).toContain('First option'); + expect(output).toContain('Second option'); + expect(output).toContain('Third option'); + expect(output).toContain('↑ ↓'); + expect(output).toContain('Enter'); + expect(output).toContain('Ctrl+C'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should highlight selected option', () => { + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(mockOptions, mockPrompt); + (selector as any).selectedIndex = 1; + (selector as any).renderMenu(); + + const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); + + // Selected option should have cyan color code + expect(output).toContain('\x1B[36m'); + + stdoutWriteSpy.mockRestore(); + }); + + it('should calculate correct total lines', () => { + const selector = new InteractiveSelector(mockOptions, mockPrompt); + + // Access private method for testing + (selector as any).calculateTotalLines(); + + // Expected: 4 (prompt + empty + empty + instructions) + 3 (options) = 7 + expect((selector as any).calculateTotalLines()).toBe(7); + }); + + it('should handle options without descriptions', () => { + const simpleOptions = [ + { value: 'a', label: 'A' }, + { value: 'b', label: 'B' }, + ]; + + const stdoutWriteSpy = vi + .spyOn(stdout, 'write') + .mockImplementation(() => true); + + const selector = new InteractiveSelector(simpleOptions, mockPrompt); + (selector as any).renderMenu(); + + const output = stdoutWriteSpy.mock.calls.map((call) => call[0]).join(''); + + expect(output).toContain('A'); + expect(output).toContain('B'); + + stdoutWriteSpy.mockRestore(); + }); + }); +}); diff --git a/packages/cli/src/commands/auth/interactiveSelector.ts b/packages/cli/src/commands/auth/interactiveSelector.ts new file mode 100644 index 000000000..84b9c9f0d --- /dev/null +++ b/packages/cli/src/commands/auth/interactiveSelector.ts @@ -0,0 +1,166 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { stdin, stdout } from 'node:process'; +import { t } from '../../i18n/index.js'; + +/** + * Represents an option in the interactive selector + */ +interface Option { + value: T; + label: string; + description?: string; +} + +/** + * Interactive selector that allows users to navigate with arrow keys + */ +export class InteractiveSelector { + private selectedIndex = 0; + private isListening = false; + + constructor( + private options: Array>, + private prompt: string = t('Select an option:'), + ) {} + + /** + * Shows the interactive menu and waits for user selection + */ + async select(): Promise { + return new Promise((resolve, reject) => { + this.isListening = true; + + // Display initial menu + this.renderMenu(); + + // Check if stdin supports raw mode + if (!stdin.setRawMode) { + // Fallback to readline if raw mode is not available (e.g., when piped) + reject( + new Error( + t('Raw mode not available. Please run in an interactive terminal.'), + ), + ); + return; + } + + const wasRaw = stdin.isRaw; + stdin.setRawMode(true); + stdin.resume(); + stdin.setEncoding('utf8'); + + const onData = (chunk: string) => { + if (!this.isListening) return; + + for (const char of chunk) { + switch (char) { + case '\x03': // Ctrl+C + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + reject(new Error('Interrupted')); + return; + case '\r': // Enter + case '\n': // Newline + stdin.removeListener('data', onData); + stdin.setRawMode(wasRaw); + resolve(this.options[this.selectedIndex].value); + return; + case '\x1B': // ESC sequence + // Next character will be [, then A, B, C, or D + break; + default: + // Handle other characters if needed + break; + } + } + + // Handle escape sequences + if (chunk.startsWith('\x1B')) { + if (chunk === '\x1B[A') { + // Arrow up + this.moveUp(); + } else if (chunk === '\x1B[B') { + // Arrow down + this.moveDown(); + } else if (chunk === '\x1B[C') { + // Arrow right + // Do nothing for now + } else if (chunk === '\x1B[D') { + // Arrow left + // Do nothing for now + } + } + }; + + stdin.on('data', onData); + }); + } + + /** + * Renders the menu to stdout + */ + private renderMenu(): void { + // Calculate how many lines we need to clear + const totalLines = this.calculateTotalLines(); + + // Clear the screen area we'll be using + if (totalLines > 0) { + stdout.write(`\x1B[${totalLines}A\x1B[J`); // Move up and clear from cursor down + } + + // Write the prompt + stdout.write(`${this.prompt}\n\n`); + + // Write each option - combine label and description on same line + this.options.forEach((option, index) => { + const isSelected = index === this.selectedIndex; + const indicator = isSelected ? '> ' : ' '; + const color = isSelected ? '\x1B[36m' : '\x1B[0m'; // Cyan for selected, default for others + const reset = '\x1B[0m'; + + // Combine label and description in one line + let line = `${indicator}${color}${option.label}`; + if (option.description) { + line += ` - ${option.description}`; + } + line += `${reset}\n`; + + stdout.write(line); + }); + + // Add instructions + stdout.write( + `\n${t('(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n')}`, + ); + } + + /** + * Calculates the total number of lines to clear + */ + private calculateTotalLines(): number { + // Lines for: prompt (1) + empty line (1) + options (each option takes 1 line) + empty line (1) + instructions (1) + return 4 + this.options.length; + } + + /** + * Moves selection up + */ + private moveUp(): void { + this.selectedIndex = + (this.selectedIndex - 1 + this.options.length) % this.options.length; + this.renderMenu(); + } + + /** + * Moves selection down + */ + private moveDown(): void { + this.selectedIndex = (this.selectedIndex + 1) % this.options.length; + this.renderMenu(); + } +} diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts new file mode 100644 index 000000000..9666d11f3 --- /dev/null +++ b/packages/cli/src/commands/auth/status.test.ts @@ -0,0 +1,287 @@ +/** + * @license + * Copyright 2025 Google LLC + * SPDX-License-Identifier: Apache-2.0 + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { showAuthStatus } from './handler.js'; +import { AuthType } from '@qwen-code/qwen-code-core'; +import { CODING_PLAN_ENV_KEY } from '../../constants/codingPlan.js'; +import type { LoadedSettings } from '../../config/settings.js'; + +vi.mock('../../config/settings.js', () => ({ + loadSettings: vi.fn(), +})); + +vi.mock('../../utils/stdioHelpers.js', () => ({ + writeStdoutLine: vi.fn(), + writeStderrLine: vi.fn(), +})); + +import { loadSettings } from '../../config/settings.js'; +import { writeStdoutLine, writeStderrLine } from '../../utils/stdioHelpers.js'; + +describe('showAuthStatus', () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + afterEach(() => { + vi.restoreAllMocks(); + delete process.env[CODING_PLAN_ENV_KEY]; + }); + + const createMockSettings = ( + merged: Record, + ): LoadedSettings => ({ + merged, + system: { settings: {}, path: '/system.json' }, + systemDefaults: { settings: {}, path: '/system-defaults.json' }, + user: { settings: {}, path: '/user.json' }, + workspace: { settings: {}, path: '/workspace.json' }, + forScope: vi.fn(), + setValue: vi.fn(), + isTrusted: true, + } as unknown as LoadedSettings); + + it('should show message when no authentication is configured', async () => { + vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('No authentication method configured'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth qwen-oauth'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth code-plan'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should show Qwen OAuth status when configured', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.QWEN_OAUTH, + }, + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Qwen OAuth'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Free tier'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('1,000 requests/day'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should show Coding Plan status when configured with API key', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'china', + version: 'abc123def456', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Alibaba Cloud Coding Plan'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('API key configured'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should show Coding Plan as incomplete when API key is missing', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'global', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Incomplete'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('API key not found'), + ); + }); + + it('should show Coding Plan region for china', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'china', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('中国 (China)'), + ); + }); + + it('should show Coding Plan region for global', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'global', + }, + model: { + name: 'qwen3-coder-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Global'), + ); + }); + + it('should show current model name', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'china', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen3.5-plus'), + ); + }); + + it('should show config version (truncated)', async () => { + process.env[CODING_PLAN_ENV_KEY] = 'test-api-key'; + + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.USE_OPENAI, + }, + }, + codingPlan: { + region: 'china', + version: 'abc123def456789', + }, + model: { + name: 'qwen3.5-plus', + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('abc123de...'), + ); + }); + + it('should show available commands at the end', async () => { + vi.mocked(loadSettings).mockReturnValue( + createMockSettings({ + security: { + auth: { + selectedType: AuthType.QWEN_OAUTH, + }, + }, + }), + ); + + await showAuthStatus(); + + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('Commands:'), + ); + expect(writeStdoutLine).toHaveBeenCalledWith( + expect.stringContaining('qwen auth status'), + ); + expect(process.exit).toHaveBeenCalledWith(0); + }); + + it('should handle errors and exit with code 1', async () => { + const error = new Error('Settings load failed'); + vi.mocked(loadSettings).mockImplementation(() => { + throw error; + }); + + await showAuthStatus(); + + expect(writeStderrLine).toHaveBeenCalledWith( + expect.stringContaining('Failed to check authentication status'), + ); + expect(process.exit).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/cli/src/config/config.ts b/packages/cli/src/config/config.ts index eab0470c6..833290609 100755 --- a/packages/cli/src/config/config.ts +++ b/packages/cli/src/config/config.ts @@ -34,6 +34,7 @@ import { } from '@qwen-code/qwen-code-core'; import { extensionsCommand } from '../commands/extensions.js'; import { hooksCommand } from '../commands/hooks.js'; +import { authCommand } from '../commands/auth.js'; import type { Settings } from './settings.js'; import { resolveCliGenerationConfig, @@ -570,6 +571,8 @@ export async function parseArguments(): Promise { .command(mcpCommand) // Register Extension subcommands .command(extensionsCommand) + // Register Auth subcommands + .command(authCommand) // Register Hooks subcommands .command(hooksCommand); From b470a965ab2f7707cfdab779a90d0e9914bdaa98 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 18:12:54 +0800 Subject: [PATCH 40/82] docs: update PR template for auth command feature Co-authored-by: Qwen-Coder --- .github/pull_request_template.md | 109 +++++++++++++++++++++++++------ 1 file changed, 88 insertions(+), 21 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 773e4cc87..66b7d4ca2 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,41 +1,108 @@ ## TLDR - +This PR adds a new `qwen auth` command with subcommands for managing authentication in Qwen Code: + +- **`qwen auth`** - Interactive authentication setup +- **`qwen auth qwen-oauth`** - Authenticate with Qwen OAuth (free tier) +- **`qwen auth code-plan`** - Authenticate with Alibaba Cloud Coding Plan +- **`qwen auth status`** - Check current authentication status + +Also includes a new `qwen-code-claw` skill for using Qwen Code as an AI code agent via ACPX. ## Dive Deeper - +### Authentication Command (`qwen auth`) + +The authentication system provides a unified way to configure and manage API credentials for Qwen Code: + +1. **Interactive Mode** (`qwen auth`) + - Presents a menu to choose between Qwen OAuth and Coding Plan + - Uses arrow keys for navigation and Enter to select + - Secure password input for API key entry + +2. **Qwen OAuth** (`qwen auth qwen-oauth`) + - Free tier authentication + - Up to 1,000 requests/day + - Access to latest Qwen models + +3. **Coding Plan** (`qwen auth code-plan [--region] [--key]`) + - Paid tier with higher limits + - Supports China and Global regions + - Can be configured via environment variable or interactively + +4. **Status Check** (`qwen auth status`) + - Displays current authentication method + - Shows configuration details (region, model, version) + - Provides helpful hints if not configured + +### Qwen Code Claw Skill + +Added a new skill (`.qwen/skills/qwen-code-claw/SKILL.md`) that enables using Qwen Code as an AI code agent through ACPX (Agent Client Protocol). The skill documentation includes: + +- When to use the skill +- Installation instructions +- Authentication setup +- ACPX integration guide +- Common workflows and examples +- Command reference and best practices + +### Technical Implementation + +- **`InteractiveSelector`** - Reusable interactive menu component for CLI +- **`handler.ts`** - Authentication logic with proper error handling +- **`status.test.ts`** - Comprehensive tests for status command (10 tests) +- **`interactiveSelector.test.ts`** - Tests for the selector component (15 tests) ## Reviewer Test Plan - +1. **Test authentication status:** + + ```bash + qwen auth status + ``` + + Should show "not configured" message if no auth exists + +2. **Test interactive auth:** + + ```bash + qwen auth + ``` + + Should display interactive menu with arrow key navigation + +3. **Test Qwen OAuth:** + + ```bash + qwen auth qwen-oauth + ``` + + Should open browser for OAuth flow + +4. **Test Coding Plan auth:** + + ```bash + qwen auth code-plan --region china --key YOUR_KEY + ``` + + Should configure without prompts + +5. **Test skill usage:** + - Read the skill documentation at `.qwen/skills/qwen-code-claw/SKILL.md` + - Verify all commands and examples are accurate ## Testing Matrix - - | | 🍏 | 🪟 | 🐧 | | -------- | --- | --- | --- | -| npm run | ❓ | ❓ | ❓ | -| npx | ❓ | ❓ | ❓ | +| npm run | ✅ | ❓ | ❓ | +| npx | ✅ | ❓ | ❓ | | Docker | ❓ | ❓ | ❓ | | Podman | ❓ | - | - | | Seatbelt | ❓ | - | - | ## Linked issues / bugs - +This PR builds on the existing authentication infrastructure and adds the missing CLI commands for user-facing authentication management. From 9ca4e1debdf87bae5fc1648d652a1e16f2a01388 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 18:15:02 +0800 Subject: [PATCH 41/82] recover template --- .github/pull_request_template.md | 109 ++++++------------------------- 1 file changed, 21 insertions(+), 88 deletions(-) diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index 66b7d4ca2..773e4cc87 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,108 +1,41 @@ ## TLDR -This PR adds a new `qwen auth` command with subcommands for managing authentication in Qwen Code: - -- **`qwen auth`** - Interactive authentication setup -- **`qwen auth qwen-oauth`** - Authenticate with Qwen OAuth (free tier) -- **`qwen auth code-plan`** - Authenticate with Alibaba Cloud Coding Plan -- **`qwen auth status`** - Check current authentication status - -Also includes a new `qwen-code-claw` skill for using Qwen Code as an AI code agent via ACPX. + ## Dive Deeper -### Authentication Command (`qwen auth`) - -The authentication system provides a unified way to configure and manage API credentials for Qwen Code: - -1. **Interactive Mode** (`qwen auth`) - - Presents a menu to choose between Qwen OAuth and Coding Plan - - Uses arrow keys for navigation and Enter to select - - Secure password input for API key entry - -2. **Qwen OAuth** (`qwen auth qwen-oauth`) - - Free tier authentication - - Up to 1,000 requests/day - - Access to latest Qwen models - -3. **Coding Plan** (`qwen auth code-plan [--region] [--key]`) - - Paid tier with higher limits - - Supports China and Global regions - - Can be configured via environment variable or interactively - -4. **Status Check** (`qwen auth status`) - - Displays current authentication method - - Shows configuration details (region, model, version) - - Provides helpful hints if not configured - -### Qwen Code Claw Skill - -Added a new skill (`.qwen/skills/qwen-code-claw/SKILL.md`) that enables using Qwen Code as an AI code agent through ACPX (Agent Client Protocol). The skill documentation includes: - -- When to use the skill -- Installation instructions -- Authentication setup -- ACPX integration guide -- Common workflows and examples -- Command reference and best practices - -### Technical Implementation - -- **`InteractiveSelector`** - Reusable interactive menu component for CLI -- **`handler.ts`** - Authentication logic with proper error handling -- **`status.test.ts`** - Comprehensive tests for status command (10 tests) -- **`interactiveSelector.test.ts`** - Tests for the selector component (15 tests) + ## Reviewer Test Plan -1. **Test authentication status:** - - ```bash - qwen auth status - ``` - - Should show "not configured" message if no auth exists - -2. **Test interactive auth:** - - ```bash - qwen auth - ``` - - Should display interactive menu with arrow key navigation - -3. **Test Qwen OAuth:** - - ```bash - qwen auth qwen-oauth - ``` - - Should open browser for OAuth flow - -4. **Test Coding Plan auth:** - - ```bash - qwen auth code-plan --region china --key YOUR_KEY - ``` - - Should configure without prompts - -5. **Test skill usage:** - - Read the skill documentation at `.qwen/skills/qwen-code-claw/SKILL.md` - - Verify all commands and examples are accurate + ## Testing Matrix + + | | 🍏 | 🪟 | 🐧 | | -------- | --- | --- | --- | -| npm run | ✅ | ❓ | ❓ | -| npx | ✅ | ❓ | ❓ | +| npm run | ❓ | ❓ | ❓ | +| npx | ❓ | ❓ | ❓ | | Docker | ❓ | ❓ | ❓ | | Podman | ❓ | - | - | | Seatbelt | ❓ | - | - | ## Linked issues / bugs -Related to: #2410 (test/simplify-sdk-integration-tests) + From 0897ddd75c46dda4436cf889e2c48a2ca3a1f944 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 18:28:32 +0800 Subject: [PATCH 42/82] i18n: add auth command translations for all 6 languages --- packages/cli/src/i18n/locales/de.js | 76 +++++++++++++++++++++++++++++ packages/cli/src/i18n/locales/en.js | 73 +++++++++++++++++++++++++++ packages/cli/src/i18n/locales/ja.js | 72 +++++++++++++++++++++++++++ packages/cli/src/i18n/locales/pt.js | 74 ++++++++++++++++++++++++++++ packages/cli/src/i18n/locales/ru.js | 73 +++++++++++++++++++++++++++ packages/cli/src/i18n/locales/zh.js | 68 ++++++++++++++++++++++++++ 6 files changed, 436 insertions(+) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 09e138670..d3eee4c49 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1655,4 +1655,80 @@ export default { '↑/↓: Navigieren | Space/Enter: Umschalten | Esc: Abbrechen', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: Navigieren | Enter: Auswählen | Esc: Abbrechen', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + 'Qwen-Authentifizierung mit Qwen-OAuth oder Alibaba Cloud Coding Plan konfigurieren', + 'Authenticate using Qwen OAuth': 'Mit Qwen OAuth authentifizieren', + 'Authenticate using Alibaba Cloud Coding Plan': + 'Mit Alibaba Cloud Coding Plan authentifizieren', + 'Region for Coding Plan (china/global)': + 'Region für Coding Plan (china/global)', + 'API key for Coding Plan': 'API-Schlüssel für Coding Plan', + 'Show current authentication status': + 'Aktuellen Authentifizierungsstatus anzeigen', + 'Authentication completed successfully.': + 'Authentifizierung erfolgreich abgeschlossen.', + 'Starting Qwen OAuth authentication...': + 'Qwen OAuth-Authentifizierung wird gestartet...', + 'Successfully authenticated with Qwen OAuth.': + 'Erfolgreich mit Qwen OAuth authentifiziert.', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Authentifizierung mit Qwen OAuth fehlgeschlagen: {{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Alibaba Cloud Coding Plan-Authentifizierung wird verarbeitet...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Erfolgreich mit Alibaba Cloud Coding Plan authentifiziert.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Authentifizierung mit Coding Plan fehlgeschlagen: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Region für Coding Plan auswählen:', + 'Enter your Coding Plan API key: ': + 'Geben Sie Ihren Coding Plan API-Schlüssel ein: ', + 'Select authentication method:': 'Authentifizierungsmethode auswählen:', + '\n=== Authentication Status ===\n': '\n=== Authentifizierungsstatus ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Keine Authentifizierungsmethode konfiguriert.\n', + 'Run one of the following commands to get started:\n': + 'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - Mit Qwen OAuth authentifizieren (kostenlos)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n', + 'Or simply run:': 'Oder einfach ausführen:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Interaktive Authentifizierungseinrichtung\n', + '✓ Authentication Method: Qwen OAuth': + '✓ Authentifizierungsmethode: Qwen OAuth', + ' Type: Free tier': ' Typ: Kostenlos', + ' Limit: Up to 1,000 requests/day': ' Limit: Bis zu 1.000 Anfragen/Tag', + ' Models: Qwen latest models\n': ' Modelle: Qwen neueste Modelle\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Authentifizierungsmethode: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Region: {{region}}', + ' Current Model: {{model}}': ' Aktuelles Modell: {{model}}', + ' Config Version: {{version}}': ' Konfigurationsversion: {{version}}', + ' Status: API key configured\n': ' Status: API-Schlüssel konfiguriert\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)', + ' Issue: API key not found in environment or settings\n': + ' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' Führen Sie `qwen auth code-plan` aus, um neu zu konfigurieren.\n', + '✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}', + ' Status: Configured\n': ' Status: Konfiguriert\n', + 'Failed to check authentication status: {{error}}': + 'Authentifizierungsstatus konnte nicht überprüft werden: {{error}}', + 'Select an option:': 'Option auswählen:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw-Modus nicht verfügbar. Bitte in einem interaktiven Terminal ausführen.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ Pfeiltasten zum Navigieren, Enter zum Auswählen, Strg+C zum Beenden)\n', }; diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 903310a6c..335229eff 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1706,4 +1706,77 @@ export default { '↑/↓: Navigate | Space/Enter: Toggle | Esc: Cancel', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: Navigate | Enter: Select | Esc: Cancel', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan', + 'Authenticate using Qwen OAuth': 'Authenticate using Qwen OAuth', + 'Authenticate using Alibaba Cloud Coding Plan': + 'Authenticate using Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Region for Coding Plan (china/global)', + 'API key for Coding Plan': 'API key for Coding Plan', + 'Show current authentication status': 'Show current authentication status', + 'Authentication completed successfully.': + 'Authentication completed successfully.', + 'Starting Qwen OAuth authentication...': + 'Starting Qwen OAuth authentication...', + 'Successfully authenticated with Qwen OAuth.': + 'Successfully authenticated with Qwen OAuth.', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Failed to authenticate with Qwen OAuth: {{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Processing Alibaba Cloud Coding Plan authentication...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Successfully authenticated with Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Failed to authenticate with Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Select region for Coding Plan:', + 'Enter your Coding Plan API key: ': 'Enter your Coding Plan API key: ', + 'Select authentication method:': 'Select authentication method:', + '\n=== Authentication Status ===\n': '\n=== Authentication Status ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ No authentication method configured.\n', + 'Run one of the following commands to get started:\n': + 'Run one of the following commands to get started:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Or simply run:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Interactive authentication setup\n', + '✓ Authentication Method: Qwen OAuth': '✓ Authentication Method: Qwen OAuth', + ' Type: Free tier': ' Type: Free tier', + ' Limit: Up to 1,000 requests/day': ' Limit: Up to 1,000 requests/day', + ' Models: Qwen latest models\n': ' Models: Qwen latest models\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Authentication Method: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Region: {{region}}', + ' Current Model: {{model}}': ' Current Model: {{model}}', + ' Config Version: {{version}}': ' Config Version: {{version}}', + ' Status: API key configured\n': ' Status: API key configured\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', + ' Issue: API key not found in environment or settings\n': + ' Issue: API key not found in environment or settings\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' Run `qwen auth code-plan` to re-configure.\n', + '✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}', + ' Status: Configured\n': ' Status: Configured\n', + 'Failed to check authentication status: {{error}}': + 'Failed to check authentication status: {{error}}', + 'Select an option:': 'Select an option:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw mode not available. Please run in an interactive terminal.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n', }; diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 4c99e4148..3e80691ab 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1159,4 +1159,76 @@ export default { '↑/↓: ナビゲート | Space/Enter: 切り替え | Esc: キャンセル', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: ナビゲート | Enter: 選択 | Esc: キャンセル', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + 'Qwen-OAuth または Alibaba Cloud Coding Plan で Qwen 認証情報を設定する', + 'Authenticate using Qwen OAuth': 'Qwen OAuth で認証する', + 'Authenticate using Alibaba Cloud Coding Plan': + 'Alibaba Cloud Coding Plan で認証する', + 'Region for Coding Plan (china/global)': + 'Coding Plan のリージョン (china/global)', + 'API key for Coding Plan': 'Coding Plan の API キー', + 'Show current authentication status': '現在の認証ステータスを表示', + 'Authentication completed successfully.': '認証が正常に完了しました。', + 'Starting Qwen OAuth authentication...': 'Qwen OAuth 認証を開始しています...', + 'Successfully authenticated with Qwen OAuth.': + 'Qwen OAuth での認証に成功しました。', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Qwen OAuth での認証に失敗しました: {{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Alibaba Cloud Coding Plan 認証を処理しています...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Alibaba Cloud Coding Plan での認証に成功しました。', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Coding Plan での認証に失敗しました: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'グローバル', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Coding Plan のリージョンを選択:', + 'Enter your Coding Plan API key: ': + 'Coding Plan の API キーを入力してください: ', + 'Select authentication method:': '認証方法を選択:', + '\n=== Authentication Status ===\n': '\n=== 認証ステータス ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ 認証方法が設定されていません。\n', + 'Run one of the following commands to get started:\n': + '以下のコマンドのいずれかを実行して開始してください:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - Qwen OAuth で認証(無料)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - Alibaba Cloud Coding Plan で認証\n', + 'Or simply run:': 'または以下を実行:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - インタラクティブ認証セットアップ\n', + '✓ Authentication Method: Qwen OAuth': '✓ 認証方法: Qwen OAuth', + ' Type: Free tier': ' タイプ: 無料プラン', + ' Limit: Up to 1,000 requests/day': ' 制限: 1日最大1,000リクエスト', + ' Models: Qwen latest models\n': ' モデル: Qwen 最新モデル\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ 認証方法: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'グローバル - Alibaba Cloud', + ' Region: {{region}}': ' リージョン: {{region}}', + ' Current Model: {{model}}': ' 現在のモデル: {{model}}', + ' Config Version: {{version}}': ' 設定バージョン: {{version}}', + ' Status: API key configured\n': ' ステータス: APIキー設定済み\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ 認証方法: Alibaba Cloud Coding Plan(不完全)', + ' Issue: API key not found in environment or settings\n': + ' 問題: 環境変数または設定にAPIキーが見つかりません\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' `qwen auth code-plan` を実行して再設定してください。\n', + '✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}', + ' Status: Configured\n': ' ステータス: 設定済み\n', + 'Failed to check authentication status: {{error}}': + '認証ステータスの確認に失敗しました: {{error}}', + 'Select an option:': 'オプションを選択:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Rawモードが利用できません。インタラクティブターミナルで実行してください。', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ 矢印キーで移動、Enter で選択、Ctrl+C で終了)\n', }; diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index d7746377d..a4f5f3300 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1650,4 +1650,78 @@ export default { '↑/↓: Navegar | Space/Enter: Alternar | Esc: Cancelar', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: Navegar | Enter: Selecionar | Esc: Cancelar', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + 'Configurar autenticação Qwen com Qwen-OAuth ou Alibaba Cloud Coding Plan', + 'Authenticate using Qwen OAuth': 'Autenticar usando Qwen OAuth', + 'Authenticate using Alibaba Cloud Coding Plan': + 'Autenticar usando Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Região para Coding Plan (china/global)', + 'API key for Coding Plan': 'Chave de API para Coding Plan', + 'Show current authentication status': 'Mostrar status atual de autenticação', + 'Authentication completed successfully.': + 'Autenticação concluída com sucesso.', + 'Starting Qwen OAuth authentication...': + 'Iniciando autenticação Qwen OAuth...', + 'Successfully authenticated with Qwen OAuth.': + 'Autenticado com sucesso via Qwen OAuth.', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Falha ao autenticar com Qwen OAuth: {{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Processando autenticação Alibaba Cloud Coding Plan...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Autenticado com sucesso via Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Falha ao autenticar com Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Global', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Selecione a região para Coding Plan:', + 'Enter your Coding Plan API key: ': + 'Insira sua chave de API do Coding Plan: ', + 'Select authentication method:': 'Selecione o método de autenticação:', + '\n=== Authentication Status ===\n': '\n=== Status de Autenticação ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Nenhum método de autenticação configurado.\n', + 'Run one of the following commands to get started:\n': + 'Execute um dos seguintes comandos para começar:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - Autenticar com Qwen OAuth (gratuito)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - Autenticar com Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Ou simplesmente execute:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Configuração interativa de autenticação\n', + '✓ Authentication Method: Qwen OAuth': '✓ Método de autenticação: Qwen OAuth', + ' Type: Free tier': ' Tipo: Gratuito', + ' Limit: Up to 1,000 requests/day': ' Limite: Até 1.000 solicitações/dia', + ' Models: Qwen latest models\n': ' Modelos: Modelos Qwen mais recentes\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Método de autenticação: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Global - Alibaba Cloud', + ' Region: {{region}}': ' Região: {{region}}', + ' Current Model: {{model}}': ' Modelo atual: {{model}}', + ' Config Version: {{version}}': ' Versão da configuração: {{version}}', + ' Status: API key configured\n': ' Status: Chave de API configurada\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)', + ' Issue: API key not found in environment or settings\n': + ' Problema: Chave de API não encontrada no ambiente ou configurações\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' Execute `qwen auth code-plan` para reconfigurar.\n', + '✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}', + ' Status: Configured\n': ' Status: Configurado\n', + 'Failed to check authentication status: {{error}}': + 'Falha ao verificar status de autenticação: {{error}}', + 'Select an option:': 'Selecione uma opção:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Modo raw não disponível. Execute em um terminal interativo.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(Use ↑ ↓ para navegar, Enter para selecionar, Ctrl+C para sair)\n', }; diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 91c1eb057..fa5e49ef6 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1662,4 +1662,77 @@ export default { '↑/↓: Навигация | Space/Enter: Переключить | Esc: Отмена', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: Навигация | Enter: Выбор | Esc: Отмена', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + 'Настроить аутентификацию Qwen через Qwen-OAuth или Alibaba Cloud Coding Plan', + 'Authenticate using Qwen OAuth': 'Аутентификация через Qwen OAuth', + 'Authenticate using Alibaba Cloud Coding Plan': + 'Аутентификация через Alibaba Cloud Coding Plan', + 'Region for Coding Plan (china/global)': + 'Регион для Coding Plan (china/global)', + 'API key for Coding Plan': 'API-ключ для Coding Plan', + 'Show current authentication status': + 'Показать текущий статус аутентификации', + 'Authentication completed successfully.': 'Аутентификация успешно завершена.', + 'Starting Qwen OAuth authentication...': + 'Запуск аутентификации Qwen OAuth...', + 'Successfully authenticated with Qwen OAuth.': + 'Успешная аутентификация через Qwen OAuth.', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Ошибка аутентификации через Qwen OAuth: {{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + 'Обработка аутентификации Alibaba Cloud Coding Plan...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + 'Успешная аутентификация через Alibaba Cloud Coding Plan.', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Ошибка аутентификации через Coding Plan: {{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: 'Глобальный', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': 'Выберите регион для Coding Plan:', + 'Enter your Coding Plan API key: ': 'Введите ваш API-ключ Coding Plan: ', + 'Select authentication method:': 'Выберите метод аутентификации:', + '\n=== Authentication Status ===\n': '\n=== Статус аутентификации ===\n', + '⚠️ No authentication method configured.\n': + '⚠️ Метод аутентификации не настроен.\n', + 'Run one of the following commands to get started:\n': + 'Выполните одну из следующих команд для начала:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - Аутентификация через Qwen OAuth (бесплатно)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - Аутентификация через Alibaba Cloud Coding Plan\n', + 'Or simply run:': 'Или просто выполните:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - Интерактивная настройка аутентификации\n', + '✓ Authentication Method: Qwen OAuth': '✓ Метод аутентификации: Qwen OAuth', + ' Type: Free tier': ' Тип: Бесплатный', + ' Limit: Up to 1,000 requests/day': ' Лимит: До 1 000 запросов/день', + ' Models: Qwen latest models\n': ' Модели: Последние модели Qwen\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ Метод аутентификации: Alibaba Cloud Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': 'Глобальный - Alibaba Cloud', + ' Region: {{region}}': ' Регион: {{region}}', + ' Current Model: {{model}}': ' Текущая модель: {{model}}', + ' Config Version: {{version}}': ' Версия конфигурации: {{version}}', + ' Status: API key configured\n': ' Статус: API-ключ настроен\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)', + ' Issue: API key not found in environment or settings\n': + ' Проблема: API-ключ не найден в окружении или настройках\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' Выполните `qwen auth code-plan` для повторной настройки.\n', + '✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}', + ' Status: Configured\n': ' Статус: Настроено\n', + 'Failed to check authentication status: {{error}}': + 'Не удалось проверить статус аутентификации: {{error}}', + 'Select an option:': 'Выберите вариант:', + 'Raw mode not available. Please run in an interactive terminal.': + 'Raw-режим недоступен. Пожалуйста, запустите в интерактивном терминале.', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(↑ ↓ стрелки для навигации, Enter для выбора, Ctrl+C для выхода)\n', }; diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 9a06554ff..653faa3a5 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1526,4 +1526,72 @@ export default { '↑/↓: 导航 | Space/Enter: 切换 | Esc: 取消', '↑/↓: Navigate | Enter: Select | Esc: Cancel': '↑/↓: 导航 | Enter: 选择 | Esc: 取消', + + // ============================================================================ + // Commands - Auth + // ============================================================================ + 'Configure Qwen authentication information with Qwen-OAuth or Alibaba Cloud Coding Plan': + '使用 Qwen OAuth 或阿里云百炼 Coding Plan 配置 Qwen 认证信息', + 'Authenticate using Qwen OAuth': '使用 Qwen OAuth 进行认证', + 'Authenticate using Alibaba Cloud Coding Plan': + '使用阿里云百炼 Coding Plan 进行认证', + 'Region for Coding Plan (china/global)': 'Coding Plan 区域 (china/global)', + 'API key for Coding Plan': 'Coding Plan 的 API 密钥', + 'Show current authentication status': '显示当前认证状态', + 'Authentication completed successfully.': '认证完成。', + 'Starting Qwen OAuth authentication...': '正在启动 Qwen OAuth 认证...', + 'Successfully authenticated with Qwen OAuth.': '已成功通过 Qwen OAuth 认证。', + 'Failed to authenticate with Qwen OAuth: {{error}}': + 'Qwen OAuth 认证失败:{{error}}', + 'Processing Alibaba Cloud Coding Plan authentication...': + '正在处理阿里云百炼 Coding Plan 认证...', + 'Successfully authenticated with Alibaba Cloud Coding Plan.': + '已成功通过阿里云百炼 Coding Plan 认证。', + 'Failed to authenticate with Coding Plan: {{error}}': + 'Coding Plan 认证失败:{{error}}', + '中国 (China)': '中国 (China)', + '阿里云百炼 (aliyun.com)': '阿里云百炼 (aliyun.com)', + Global: '全球', + 'Alibaba Cloud (alibabacloud.com)': 'Alibaba Cloud (alibabacloud.com)', + 'Select region for Coding Plan:': '选择 Coding Plan 区域:', + 'Enter your Coding Plan API key: ': '请输入您的 Coding Plan API 密钥:', + 'Select authentication method:': '选择认证方式:', + '\n=== Authentication Status ===\n': '\n=== 认证状态 ===\n', + '⚠️ No authentication method configured.\n': '⚠️ 未配置认证方式。\n', + 'Run one of the following commands to get started:\n': + '运行以下命令之一开始配置:\n', + ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': + ' qwen auth qwen-oauth - 使用 Qwen OAuth 认证(免费)', + ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth code-plan - 使用阿里云百炼 Coding Plan 认证\n', + 'Or simply run:': '或者直接运行:', + ' qwen auth - Interactive authentication setup\n': + ' qwen auth - 交互式认证配置\n', + '✓ Authentication Method: Qwen OAuth': '✓ 认证方式:Qwen OAuth', + ' Type: Free tier': ' 类型:免费版', + ' Limit: Up to 1,000 requests/day': ' 限额:每天最多 1,000 次请求', + ' Models: Qwen latest models\n': ' 模型:Qwen 最新模型\n', + '✓ Authentication Method: Alibaba Cloud Coding Plan': + '✓ 认证方式:阿里云百炼 Coding Plan', + '中国 (China) - 阿里云百炼': '中国 (China) - 阿里云百炼', + 'Global - Alibaba Cloud': '全球 - Alibaba Cloud', + ' Region: {{region}}': ' 区域:{{region}}', + ' Current Model: {{model}}': ' 当前模型:{{model}}', + ' Config Version: {{version}}': ' 配置版本:{{version}}', + ' Status: API key configured\n': ' 状态:API 密钥已配置\n', + '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)': + '⚠️ 认证方式:阿里云百炼 Coding Plan(不完整)', + ' Issue: API key not found in environment or settings\n': + ' 问题:在环境变量或设置中未找到 API 密钥\n', + ' Run `qwen auth code-plan` to re-configure.\n': + ' 运行 `qwen auth code-plan` 重新配置。\n', + '✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}', + ' Status: Configured\n': ' 状态:已配置\n', + 'Failed to check authentication status: {{error}}': + '检查认证状态失败:{{error}}', + 'Select an option:': '请选择:', + 'Raw mode not available. Please run in an interactive terminal.': + '原始模式不可用。请在交互式终端中运行。', + '(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit)\n': + '(使用 ↑ ↓ 箭头导航,Enter 选择,Ctrl+C 退出)\n', }; From 8722dc9dd6c7b39b3c0f7b9c48628d8e3cca4476 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 18:53:42 +0800 Subject: [PATCH 43/82] fix remove useless output --- packages/cli/src/commands/auth/handler.ts | 11 ----------- 1 file changed, 11 deletions(-) diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index b75f6b208..112db6949 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -486,17 +486,6 @@ export async function showAuthStatus(): Promise { ); writeStdoutLine(t(' Status: Configured\n')); } - - // Show available commands - writeStdoutLine(t('---')); - writeStdoutLine(t('Commands:')); - writeStdoutLine( - t(' qwen auth - Change authentication method'), - ); - writeStdoutLine(t(' qwen auth status - Show this status')); - writeStdoutLine(t(' qwen auth qwen-oauth - Switch to Qwen OAuth')); - writeStdoutLine(t(' qwen auth code-plan - Switch to Coding Plan\n')); - process.exit(0); } catch (error) { writeStderrLine( From 28149e0cc468b70eaecca199691f8737daf18b69 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Tue, 17 Mar 2026 19:15:58 +0800 Subject: [PATCH 44/82] fix test ci --- packages/cli/src/commands/auth/status.test.ts | 27 +++---------------- 1 file changed, 3 insertions(+), 24 deletions(-) diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index 9666d11f3..69c020a02 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -36,7 +36,8 @@ describe('showAuthStatus', () => { const createMockSettings = ( merged: Record, - ): LoadedSettings => ({ + ): LoadedSettings => + ({ merged, system: { settings: {}, path: '/system.json' }, systemDefaults: { settings: {}, path: '/system-defaults.json' }, @@ -45,7 +46,7 @@ describe('showAuthStatus', () => { forScope: vi.fn(), setValue: vi.fn(), isTrusted: true, - } as unknown as LoadedSettings); + }) as unknown as LoadedSettings; it('should show message when no authentication is configured', async () => { vi.mocked(loadSettings).mockReturnValue(createMockSettings({})); @@ -249,28 +250,6 @@ describe('showAuthStatus', () => { ); }); - it('should show available commands at the end', async () => { - vi.mocked(loadSettings).mockReturnValue( - createMockSettings({ - security: { - auth: { - selectedType: AuthType.QWEN_OAUTH, - }, - }, - }), - ); - - await showAuthStatus(); - - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('Commands:'), - ); - expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth status'), - ); - expect(process.exit).toHaveBeenCalledWith(0); - }); - it('should handle errors and exit with code 1', async () => { const error = new Error('Settings load failed'); vi.mocked(loadSettings).mockImplementation(() => { From 03e59256c411b0ecefeae2f4f7dc429e96767a46 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 17 Mar 2026 20:10:54 +0800 Subject: [PATCH 45/82] feat(ui): enhance LoadingIndicator to display token counts and improve formatting - Added candidatesTokens prop to LoadingIndicator for displaying token counts. - Updated formatting to show elapsed time and token counts inline. - Refactored tests to validate new token display functionality and formatting changes. - Introduced formatTokenCount utility for consistent token count representation. This improves user feedback during loading states by providing clearer information on token usage. --- packages/cli/src/ui/components/Composer.tsx | 11 +- .../ui/components/LoadingIndicator.test.tsx | 101 ++++++++++++++++-- .../src/ui/components/LoadingIndicator.tsx | 22 ++-- .../LoadingIndicator.test.tsx.snap | 4 +- packages/cli/src/ui/utils/formatters.test.ts | 22 ++++ packages/cli/src/ui/utils/formatters.ts | 10 ++ 6 files changed, 152 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 193549245..1310fface 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -27,7 +27,15 @@ export const Composer = () => { const uiActions = useUIActions(); const { vimEnabled } = useVimMode(); - const { showAutoAcceptIndicator } = uiState; + const { showAutoAcceptIndicator, sessionStats } = uiState; + + const tokens = Object.values(sessionStats.metrics.models).reduce( + (acc, model) => ({ + prompt: acc.prompt + model.tokens.prompt, + candidates: acc.candidates + model.tokens.candidates, + }), + { prompt: 0, candidates: 0 }, + ); // State for keyboard shortcuts display toggle const [showShortcuts, setShowShortcuts] = useState(false); @@ -64,6 +72,7 @@ export const Composer = () => { : uiState.currentLoadingPhrase } elapsedTime={uiState.elapsedTime} + candidatesTokens={tokens.candidates} /> )} diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 1d1e89ba7..4c914bd30 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -72,7 +72,8 @@ describe('', () => { const output = lastFrame(); expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Loading...'); - expect(output).toContain('(esc to cancel, 5s)'); + expect(output).toContain('5s'); + expect(output).toContain('esc to cancel'); }); it('should render spinner (static), phrase but no time/cancel when streamingState is WaitingForConfirmation', () => { @@ -88,7 +89,7 @@ describe('', () => { expect(output).toContain('⠏'); // Static char for WaitingForConfirmation expect(output).toContain('Confirm action'); expect(output).not.toContain('(esc to cancel)'); - expect(output).not.toContain(', 10s'); + expect(output).not.toContain('10s'); }); it('should display the currentLoadingPhrase correctly', () => { @@ -112,7 +113,7 @@ describe('', () => { , StreamingState.Responding, ); - expect(lastFrame()).toContain('(esc to cancel, 1m)'); + expect(lastFrame()).toContain('(1m · esc to cancel)'); }); it('should display the elapsedTime correctly in human-readable format', () => { @@ -124,7 +125,7 @@ describe('', () => { , StreamingState.Responding, ); - expect(lastFrame()).toContain('(esc to cancel, 2m 5s)'); + expect(lastFrame()).toContain('(2m 5s · esc to cancel)'); }); it('should render rightContent when provided', () => { @@ -155,7 +156,7 @@ describe('', () => { let output = lastFrame(); expect(output).toContain('MockRespondingSpinner'); expect(output).toContain('Now Responding'); - expect(output).toContain('(esc to cancel, 2s)'); + expect(output).toContain('(2s · esc to cancel)'); // Transition to WaitingForConfirmation rerender( @@ -170,7 +171,7 @@ describe('', () => { expect(output).toContain('⠏'); expect(output).toContain('Please Confirm'); expect(output).not.toContain('(esc to cancel)'); - expect(output).not.toContain(', 15s'); + expect(output).not.toContain('15s'); // Transition back to Idle rerender( @@ -262,7 +263,7 @@ describe('', () => { // Check for single line output expect(output?.includes('\n')).toBe(false); expect(output).toContain('Loading...'); - expect(output).toContain('(esc to cancel, 5s)'); + expect(output).toContain('(5s · esc to cancel)'); expect(output).toContain('Right'); }); @@ -284,8 +285,8 @@ describe('', () => { expect(lines).toHaveLength(3); if (lines) { expect(lines[0]).toContain('Loading...'); - expect(lines[0]).not.toContain('(esc to cancel, 5s)'); - expect(lines[1]).toContain('(esc to cancel, 5s)'); + expect(lines[0]).not.toContain('5s'); + expect(lines[1]).toContain('5s'); expect(lines[2]).toContain('Right'); } }); @@ -308,4 +309,86 @@ describe('', () => { expect(lastFrame()?.includes('\n')).toBe(true); }); }); + + describe('token display', () => { + it('should display output tokens inline with arrow notation', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).toContain('↓ 847 tokens'); + expect(output).not.toContain('↑'); + expect(output).toContain('5s'); + expect(output).toContain('esc to cancel'); + }); + + it('should not display tokens when output tokens is 0', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).not.toContain('↓'); + expect(output).not.toContain('tokens'); + }); + + it('should not display tokens when props are undefined', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + ); + const output = lastFrame(); + expect(output).not.toContain('↓'); + expect(output).not.toContain('tokens'); + }); + + it('should hide tokens in narrow terminal', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + 79, + ); + const output = lastFrame(); + expect(output).not.toContain('↓'); + expect(output).not.toContain('tokens'); + expect(output).toContain('esc to cancel'); + }); + + it('should show tokens in wide terminal with inline format', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + 80, + ); + const output = lastFrame(); + expect(output).toContain('↓ 5.4k tokens'); + }); + + it('should format tokens inline with time and cancel', () => { + const { lastFrame } = renderWithContext( + , + StreamingState.Responding, + 120, + ); + const output = lastFrame(); + expect(output).toContain('(5s · ↓ 5.4k tokens · esc to cancel)'); + }); + }); }); diff --git a/packages/cli/src/ui/components/LoadingIndicator.tsx b/packages/cli/src/ui/components/LoadingIndicator.tsx index 5fc2c20b4..30aad2893 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.tsx @@ -11,7 +11,7 @@ import { theme } from '../semantic-colors.js'; import { useStreamingContext } from '../contexts/StreamingContext.js'; import { StreamingState } from '../types.js'; import { GeminiRespondingSpinner } from './GeminiRespondingSpinner.js'; -import { formatDuration } from '../utils/formatters.js'; +import { formatDuration, formatTokenCount } from '../utils/formatters.js'; import { useTerminalSize } from '../hooks/useTerminalSize.js'; import { isNarrowWidth } from '../utils/isNarrowWidth.js'; import { t } from '../../i18n/index.js'; @@ -21,6 +21,7 @@ interface LoadingIndicatorProps { elapsedTime: number; rightContent?: React.ReactNode; thought?: ThoughtSummary | null; + candidatesTokens?: number; } export const LoadingIndicator: React.FC = ({ @@ -28,6 +29,7 @@ export const LoadingIndicator: React.FC = ({ elapsedTime, rightContent, thought, + candidatesTokens, }) => { const streamingState = useStreamingContext(); const { columns: terminalWidth } = useTerminalSize(); @@ -39,13 +41,21 @@ export const LoadingIndicator: React.FC = ({ const primaryText = thought?.subject || currentLoadingPhrase; + const outputTokens = candidatesTokens ?? 0; + const showTokens = !isNarrow && outputTokens > 0; + + const timeStr = + elapsedTime < 60 ? `${elapsedTime}s` : formatDuration(elapsedTime * 1000); + + const tokenStr = showTokens + ? ` · ↓ ${formatTokenCount(outputTokens)} tokens` + : ''; + const cancelAndTimerContent = streamingState !== StreamingState.WaitingForConfirmation - ? t('(esc to cancel, {{time}})', { - time: - elapsedTime < 60 - ? `${elapsedTime}s` - : formatDuration(elapsedTime * 1000), + ? t('({{time}}{{tokens}} · esc to cancel)', { + time: timeStr, + tokens: tokenStr, }) : null; diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap index 3d472f97e..46e4489c0 100644 --- a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should truncate long primary text instead of wrapping 1`] = ` -"MockResponding This is an extremely long loading phrase that should be truncated in t (esc to -Spinner cancel, 5s)" +"MockResponding This is an extremely long loading phrase that should be truncated in t (5s · esc to +Spinner cancel)" `; diff --git a/packages/cli/src/ui/utils/formatters.test.ts b/packages/cli/src/ui/utils/formatters.test.ts index 34bf67e26..09173e10e 100644 --- a/packages/cli/src/ui/utils/formatters.test.ts +++ b/packages/cli/src/ui/utils/formatters.test.ts @@ -9,6 +9,7 @@ import { formatDuration, formatMemoryUsage, formatRelativeTime, + formatTokenCount, } from './formatters.js'; describe('formatters', () => { @@ -154,4 +155,25 @@ describe('formatters', () => { expect(formatDuration(-100)).toBe('0s'); }); }); + + describe('formatTokenCount', () => { + it('should display exact number for counts less than 1000', () => { + expect(formatTokenCount(0)).toBe('0'); + expect(formatTokenCount(100)).toBe('100'); + expect(formatTokenCount(847)).toBe('847'); + expect(formatTokenCount(999)).toBe('999'); + }); + + it('should display with k suffix and one decimal for counts 1000-9999', () => { + expect(formatTokenCount(1000)).toBe('1.0k'); + expect(formatTokenCount(5400)).toBe('5.4k'); + expect(formatTokenCount(9999)).toBe('10.0k'); + }); + + it('should display with k suffix without decimal for counts 10000 and above', () => { + expect(formatTokenCount(10000)).toBe('10k'); + expect(formatTokenCount(15000)).toBe('15k'); + expect(formatTokenCount(100000)).toBe('100k'); + }); + }); }); diff --git a/packages/cli/src/ui/utils/formatters.ts b/packages/cli/src/ui/utils/formatters.ts index b65cefe18..38afaaa30 100644 --- a/packages/cli/src/ui/utils/formatters.ts +++ b/packages/cli/src/ui/utils/formatters.ts @@ -55,6 +55,16 @@ export const formatRelativeTime = (timestamp: number): string => { return 'just now'; }; +export const formatTokenCount = (count: number): string => { + if (count < 1000) { + return `${count}`; + } + if (count < 10000) { + return `${(count / 1000).toFixed(1)}k`; + } + return `${Math.floor(count / 1000)}k`; +}; + export const formatDuration = (milliseconds: number): string => { if (milliseconds <= 0) { return '0s'; From 61347577ced53c84a639d84841bc03a739b73fb6 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Tue, 17 Mar 2026 20:19:05 +0800 Subject: [PATCH 46/82] refactor(core): centralize tool output truncation logic Co-authored-by: Qwen-Coder - Add truncateToolOutput helper in truncation.ts to centralize threshold reading, file saving, and telemetry logging - Refactor shell.ts to use the new helper, removing duplicate code - Add truncation support for MCP tool output while preserving non-text content (images, audio, resources) - Refactor getDisplayFromParts to work on transformed Part[] instead of raw MCP response This reduces code duplication and ensures consistent truncation behavior across shell and MCP tools. --- packages/core/src/tools/mcp-tool.test.ts | 241 ++++++++++++++++++++++- packages/core/src/tools/mcp-tool.ts | 81 ++++---- packages/core/src/tools/shell.ts | 33 +--- packages/core/src/utils/truncation.ts | 51 +++++ 4 files changed, 331 insertions(+), 75 deletions(-) diff --git a/packages/core/src/tools/mcp-tool.test.ts b/packages/core/src/tools/mcp-tool.test.ts index 005623afe..1826ff197 100644 --- a/packages/core/src/tools/mcp-tool.test.ts +++ b/packages/core/src/tools/mcp-tool.test.ts @@ -18,6 +18,8 @@ import { ToolConfirmationOutcome } from './tools.js'; import type { CallableTool, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; +vi.mock('node:fs/promises'); + // Mock @google/genai mcpToTool and CallableTool // We only need to mock the parts of CallableTool that DiscoveredMCPTool uses. const mockCallTool = vi.fn(); @@ -147,7 +149,7 @@ describe('DiscoveredMCPTool', () => { expect(toolResult.returnDisplay).toBe(stringifiedResponseContent); }); - it('should handle empty result from getStringifiedResultForDisplay', async () => { + it('should handle empty result from getDisplayFromParts', async () => { const params = { param: 'testValue' }; const mockMcpToolResponsePartsEmpty: Part[] = []; mockCallTool.mockResolvedValue(mockMcpToolResponsePartsEmpty); @@ -155,7 +157,9 @@ describe('DiscoveredMCPTool', () => { const toolResult: ToolResult = await invocation.execute( new AbortController().signal, ); - expect(toolResult.returnDisplay).toBe('```json\n[]\n```'); + expect(toolResult.returnDisplay).toBe( + '[Error: Could not parse tool response]', + ); expect(toolResult.llmContent).toEqual([ { text: '[Error: Could not parse tool response]' }, ]); @@ -339,7 +343,9 @@ describe('DiscoveredMCPTool', () => { }, }, ]); - expect(toolResult.returnDisplay).toBe('[Audio: audio/mp3]'); + expect(toolResult.returnDisplay).toBe( + `[Tool '${serverToolName}' provided the following audio data with mime-type: audio/mp3]\n[audio/mp3]`, + ); }); it('should handle a ResourceLinkBlock response', async () => { @@ -372,7 +378,7 @@ describe('DiscoveredMCPTool', () => { }, ]); expect(toolResult.returnDisplay).toBe( - '[Link to My Resource: file:///path/to/thing]', + 'Resource Link: My Resource at file:///path/to/thing', ); }); @@ -446,7 +452,7 @@ describe('DiscoveredMCPTool', () => { }, ]); expect(toolResult.returnDisplay).toBe( - '[Embedded Resource: application/octet-stream]', + `[Tool '${serverToolName}' provided the following embedded resource with mime-type: application/octet-stream]\n[application/octet-stream]`, ); }); @@ -489,7 +495,7 @@ describe('DiscoveredMCPTool', () => { { text: 'Second part.' }, ]); expect(toolResult.returnDisplay).toBe( - 'First part.\n[Image: image/jpeg]\nSecond part.', + `First part.\n[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]\n[image/jpeg]\nSecond part.`, ); }); @@ -514,9 +520,7 @@ describe('DiscoveredMCPTool', () => { const toolResult = await invocation.execute(new AbortController().signal); expect(toolResult.llmContent).toEqual([{ text: 'Valid part.' }]); - expect(toolResult.returnDisplay).toBe( - 'Valid part.\n[Unknown content type: future_block]', - ); + expect(toolResult.returnDisplay).toBe('Valid part.'); }); it('should handle a complex mix of content block types', async () => { @@ -574,7 +578,7 @@ describe('DiscoveredMCPTool', () => { }, ]); expect(toolResult.returnDisplay).toBe( - 'Here is a resource.\n[Link to My Resource: file:///path/to/resource]\nEmbedded text content.\n[Image: image/jpeg]', + `Here is a resource.\nResource Link: My Resource at file:///path/to/resource\nEmbedded text content.\n[Tool '${serverToolName}' provided the following image data with mime-type: image/jpeg]\n[image/jpeg]`, ); }); @@ -964,6 +968,223 @@ describe('DiscoveredMCPTool', () => { }); }); + describe('output truncation for large MCP results', () => { + const THRESHOLD = 1000; + const TRUNCATE_LINES = 50; + + const mockConfigWithTruncation = { + getTruncateToolOutputThreshold: () => THRESHOLD, + getTruncateToolOutputLines: () => TRUNCATE_LINES, + getUsageStatisticsEnabled: () => false, + storage: { + getProjectTempDir: () => '/tmp/test-project', + }, + isTrustedFolder: () => true, + } as any; + + it('should truncate large text results from direct client execution', async () => { + const largeText = 'Line of text content\n'.repeat(200); // ~4200 chars, well over THRESHOLD + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: largeText }], + })), + }; + + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + true, // trust + undefined, + mockConfigWithTruncation, + mockMcpClient, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + // The text part in llmContent should be truncated + const textParts = (result.llmContent as Part[]).filter( + (p: Part) => p.text, + ); + const combinedText = textParts.map((p: Part) => p.text).join(''); + expect(combinedText.length).toBeLessThan(largeText.length); + expect(combinedText).toContain('CONTENT TRUNCATED'); + expect(result.returnDisplay).toContain('CONTENT TRUNCATED'); + }); + + it('should truncate large text results from callable tool execution', async () => { + const largeText = 'Line of text content\n'.repeat(200); + const mockMcpToolResponseParts: Part[] = [ + { + functionResponse: { + name: serverToolName, + response: { + content: [{ type: 'text', text: largeText }], + }, + }, + }, + ]; + mockCallTool.mockResolvedValue(mockMcpToolResponseParts); + + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + true, + undefined, + mockConfigWithTruncation, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + const textParts = (result.llmContent as Part[]).filter( + (p: Part) => p.text, + ); + const combinedText = textParts.map((p: Part) => p.text).join(''); + expect(combinedText.length).toBeLessThan(largeText.length); + expect(combinedText).toContain('CONTENT TRUNCATED'); + expect(result.returnDisplay).toContain('CONTENT TRUNCATED'); + }); + + it('should not truncate small text results', async () => { + const smallText = 'Small response'; + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: smallText }], + })), + }; + + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + true, + undefined, + mockConfigWithTruncation, + mockMcpClient, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + expect(result.llmContent).toEqual([{ text: smallText }]); + expect(result.returnDisplay).not.toContain('Output too long'); + }); + + it('should not truncate non-text content (images, audio)', async () => { + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [ + { + type: 'image', + data: 'x'.repeat(5000), // large base64 data + mimeType: 'image/png', + }, + ], + })), + }; + + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + true, + undefined, + mockConfigWithTruncation, + mockMcpClient, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + // Image data should not be truncated + const inlineDataParts = (result.llmContent as Part[]).filter( + (p: Part) => p.inlineData, + ); + expect(inlineDataParts[0].inlineData!.data).toBe('x'.repeat(5000)); + }); + + it('should truncate only text parts in mixed content', async () => { + const largeText = 'Line of text content\n'.repeat(200); + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [ + { type: 'text', text: largeText }, + { + type: 'image', + data: 'IMAGE_DATA', + mimeType: 'image/png', + }, + ], + })), + }; + + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + true, + undefined, + mockConfigWithTruncation, + mockMcpClient, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + const parts = result.llmContent as Part[]; + // Text should be truncated + const textPart = parts.find( + (p: Part) => p.text && !p.text.startsWith('[Tool'), + ); + expect(textPart!.text!.length).toBeLessThan(largeText.length); + expect(textPart!.text).toContain('CONTENT TRUNCATED'); + // Image should be preserved + const imagePart = parts.find((p: Part) => p.inlineData); + expect(imagePart!.inlineData!.data).toBe('IMAGE_DATA'); + }); + + it('should not truncate when config is not provided', async () => { + const largeText = 'Line of text content\n'.repeat(200); + const mockMcpClient: McpDirectClient = { + callTool: vi.fn(async () => ({ + content: [{ type: 'text', text: largeText }], + })), + }; + + // No cliConfig provided + const truncTool = new DiscoveredMCPTool( + mockCallableToolInstance, + serverName, + serverToolName, + baseDescription, + inputSchema, + undefined, + undefined, + undefined, // no config + mockMcpClient, + ); + + const invocation = truncTool.build({ param: 'test' }); + const result = await invocation.execute(new AbortController().signal); + + // Without config, should return untouched + expect(result.llmContent).toEqual([{ text: largeText }]); + }); + }); + describe('streaming progress for long-running MCP tools', () => { it('should have canUpdateOutput set to true so the scheduler creates liveOutputCallback', () => { // For long-running MCP tools (e.g., browseruse), the scheduler needs diff --git a/packages/core/src/tools/mcp-tool.ts b/packages/core/src/tools/mcp-tool.ts index 5d48b68c7..73ba1ece4 100644 --- a/packages/core/src/tools/mcp-tool.ts +++ b/packages/core/src/tools/mcp-tool.ts @@ -23,6 +23,7 @@ import { import type { CallableTool, FunctionCall, Part } from '@google/genai'; import { ToolErrorType } from './tool-error.js'; import type { Config } from '../config/config.js'; +import { truncateToolOutput } from '../utils/truncation.js'; type ToolParams = Record; @@ -263,10 +264,11 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< } const transformedParts = transformMcpContentToParts(rawResponseParts); + const truncatedParts = await this.truncateTextParts(transformedParts); return { - llmContent: transformedParts, - returnDisplay: getStringifiedResultForDisplay(rawResponseParts), + llmContent: truncatedParts, + returnDisplay: getDisplayFromParts(truncatedParts), }; } @@ -333,13 +335,39 @@ class DiscoveredMCPToolInvocation extends BaseToolInvocation< } const transformedParts = transformMcpContentToParts(rawResponseParts); + const truncatedParts = await this.truncateTextParts(transformedParts); return { - llmContent: transformedParts, - returnDisplay: getStringifiedResultForDisplay(rawResponseParts), + llmContent: truncatedParts, + returnDisplay: getDisplayFromParts(truncatedParts), }; } + /** + * Truncates text parts in the transformed result if they exceed the + * configured threshold. Non-text parts (images, audio, etc.) are preserved. + */ + private async truncateTextParts(parts: Part[]): Promise { + if (!this.cliConfig) { + return parts; + } + + const result: Part[] = []; + for (const part of parts) { + if (part.text && !part.inlineData) { + const truncated = await truncateToolOutput( + this.cliConfig, + `mcp__${this.serverName}__${this.serverToolName}`, + part.text, + ); + result.push({ text: truncated.content }); + } else { + result.push(part); + } + } + return result; + } + getDescription(): string { return safeJsonStringify(this.params); } @@ -524,43 +552,22 @@ function transformMcpContentToParts(sdkResponse: Part[]): Part[] { } /** - * Processes the raw response from the MCP tool to generate a clean, - * human-readable string for display in the CLI. It summarizes non-text - * content and presents text directly. - * - * @param rawResponse The raw Part[] array from the GenAI SDK. - * @returns A formatted string representing the tool's output. + * Builds a human-readable display string from transformed Part[]. + * Text parts are shown directly; inline data is summarized by mime type. */ -function getStringifiedResultForDisplay(rawResponse: Part[]): string { - const mcpContent = rawResponse?.[0]?.functionResponse?.response?.[ - 'content' - ] as McpContentBlock[]; - - if (!Array.isArray(mcpContent)) { - return '```json\n' + JSON.stringify(rawResponse, null, 2) + '\n```'; +function getDisplayFromParts(parts: Part[]): string { + if (parts.length === 0) { + return ''; } - const displayParts = mcpContent.map((block: McpContentBlock): string => { - switch (block.type) { - case 'text': - return block.text; - case 'image': - return `[Image: ${block.mimeType}]`; - case 'audio': - return `[Audio: ${block.mimeType}]`; - case 'resource_link': - return `[Link to ${block.title || block.name}: ${block.uri}]`; - case 'resource': - if (block.resource?.text) { - return block.resource.text; - } - return `[Embedded Resource: ${ - block.resource?.mimeType || 'unknown type' - }]`; - default: - return `[Unknown content type: ${(block as { type: string }).type}]`; + const displayParts: string[] = []; + for (const part of parts) { + if (part.text !== undefined) { + displayParts.push(part.text); + } else if (part.inlineData) { + displayParts.push(`[${part.inlineData.mimeType}]`); } - }); + } return displayParts.join('\n'); } diff --git a/packages/core/src/tools/shell.ts b/packages/core/src/tools/shell.ts index 1de48b599..54ecf30f8 100644 --- a/packages/core/src/tools/shell.ts +++ b/packages/core/src/tools/shell.ts @@ -26,9 +26,7 @@ import { Kind, } from './tools.js'; import { getErrorMessage } from '../utils/errors.js'; -import { truncateAndSaveToFile } from '../utils/truncation.js'; -import { logToolOutputTruncated } from '../telemetry/loggers.js'; -import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; +import { truncateToolOutput } from '../utils/truncation.js'; import type { ShellExecutionConfig, ShellOutputEvent, @@ -381,21 +379,11 @@ export class ShellToolInvocation extends BaseToolInvocation< } // Truncate large output and save full content to a temp file. - const truncateThreshold = this.config.getTruncateToolOutputThreshold(); - const truncateLines = this.config.getTruncateToolOutputLines(); - if ( - typeof llmContent === 'string' && - truncateThreshold > 0 && - truncateLines > 0 - ) { - const originalContentLength = llmContent.length; - const fileName = `shell_${crypto.randomBytes(6).toString('hex')}`; - const truncatedResult = await truncateAndSaveToFile( + if (typeof llmContent === 'string') { + const truncatedResult = await truncateToolOutput( + this.config, + ShellTool.Name, llmContent, - fileName, - this.config.storage.getProjectTempDir(), - truncateThreshold, - truncateLines, ); if (truncatedResult.outputFile) { @@ -403,17 +391,6 @@ export class ShellToolInvocation extends BaseToolInvocation< returnDisplayMessage += (returnDisplayMessage ? '\n' : '') + `Output too long and was saved to: ${truncatedResult.outputFile}`; - - logToolOutputTruncated( - this.config, - new ToolOutputTruncatedEvent('', { - toolName: ShellTool.Name, - originalContentLength, - truncatedContentLength: truncatedResult.content.length, - threshold: truncateThreshold, - lines: truncateLines, - }), - ); } } diff --git a/packages/core/src/utils/truncation.ts b/packages/core/src/utils/truncation.ts index 47a21ef60..6672a1f83 100644 --- a/packages/core/src/utils/truncation.ts +++ b/packages/core/src/utils/truncation.ts @@ -6,7 +6,11 @@ import * as fs from 'node:fs/promises'; import * as path from 'node:path'; +import * as crypto from 'node:crypto'; import { ReadFileTool } from '../tools/read-file.js'; +import type { Config } from '../config/config.js'; +import { logToolOutputTruncated } from '../telemetry/loggers.js'; +import { ToolOutputTruncatedEvent } from '../telemetry/types.js'; /** * Truncates large tool output and saves the full content to a temp file. @@ -100,3 +104,50 @@ ${truncatedContent}`, }; } } + +/** + * High-level truncation helper that reads thresholds from Config, + * truncates if needed, saves full output to a temp file, and logs + * telemetry. Returns the (possibly truncated) content and an optional + * output file path. + * + * Callers no longer need to duplicate config extraction, file naming, + * or telemetry logging. + */ +export async function truncateToolOutput( + config: Config, + toolName: string, + content: string, +): Promise<{ content: string; outputFile?: string }> { + const threshold = config.getTruncateToolOutputThreshold(); + const lines = config.getTruncateToolOutputLines(); + + if (threshold <= 0 || lines <= 0) { + return { content }; + } + + const originalLength = content.length; + const fileName = `${toolName}_${crypto.randomBytes(6).toString('hex')}`; + const result = await truncateAndSaveToFile( + content, + fileName, + config.storage.getProjectTempDir(), + threshold, + lines, + ); + + if (result.outputFile) { + logToolOutputTruncated( + config, + new ToolOutputTruncatedEvent('', { + toolName, + originalContentLength: originalLength, + truncatedContentLength: result.content.length, + threshold, + lines, + }), + ); + } + + return result; +} From ebeb7ed690fa78388f802eeeaf26eefb51958b75 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 17 Mar 2026 20:55:12 +0800 Subject: [PATCH 47/82] refactor(completion): enhance trigger detection logic for completion suggestions --- .../src/webview/hooks/useCompletionTrigger.ts | 56 ++++++++++--------- 1 file changed, 31 insertions(+), 25 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 6fad7cba5..67e62d2c6 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -301,38 +301,44 @@ export function useCompletionTrigger( const lastAtMatch = textBeforeCursor.lastIndexOf('@'); const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); - // Check if we're in a trigger context + // Build candidate triggers sorted by proximity (nearest first) + const candidates: Array<{ pos: number; char: '@' | '/' }> = []; + if (lastAtMatch >= 0) { + candidates.push({ pos: lastAtMatch, char: '@' }); + } + if (lastSlashMatch >= 0) { + candidates.push({ pos: lastSlashMatch, char: '/' }); + } + // Sort by position descending (nearest to cursor first) + candidates.sort((a, b) => b.pos - a.pos); + + // Find the nearest valid trigger (at word boundary) let triggerPos = -1; let triggerChar: '@' | '/' | null = null; - // Priority: @ trigger takes precedence over / trigger - // This allows path-like queries (e.g., "src/components/Button") in @ mentions - // But skip if the trigger is inside a file tag - if (lastAtMatch >= 0) { - triggerPos = lastAtMatch; - triggerChar = '@'; - } else if (lastSlashMatch >= 0) { - triggerPos = lastSlashMatch; - triggerChar = '/'; - } - - // Check if trigger is at word boundary (start of line or after space) - if (triggerPos >= 0 && triggerChar) { - const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + for (const candidate of candidates) { + const charBefore = candidate.pos > 0 ? text[candidate.pos - 1] : ' '; const isValidTrigger = - charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + charBefore === ' ' || charBefore === '\n' || candidate.pos === 0; if (isValidTrigger) { - const query = text.substring(triggerPos + 1, effectiveCursorPosition); + triggerPos = candidate.pos; + triggerChar = candidate.char; + break; + } + } - // Only show if query doesn't contain spaces (still typing the reference) - if (!query.includes(' ') && !query.includes('\n')) { - // Get precise cursor position for menu - const cursorPos = getCursorPosition(); - if (cursorPos) { - await openCompletion(triggerChar, query, cursorPos); - return; - } + // Check if we found a valid trigger + if (triggerPos >= 0 && triggerChar) { + const query = text.substring(triggerPos + 1, effectiveCursorPosition); + + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; } } } From 7a554b1226ca31c93612e0e1c05abcd51fdc2ee4 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 17 Mar 2026 21:21:53 +0800 Subject: [PATCH 48/82] refactor(file-handler): improve file watcher management and cache clearing --- .../webview/handlers/FileMessageHandler.ts | 69 +++++++++++-------- 1 file changed, 41 insertions(+), 28 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts index 7086e6080..f8708d8d4 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.ts @@ -18,6 +18,7 @@ import { FileSearchFactory, type FileSearch, } from '@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js'; +import * as crawlCache from '@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js'; import { getErrorMessage } from '../../utils/errorMessage.js'; /** @@ -31,7 +32,7 @@ export class FileMessageHandler extends BaseMessageHandler { >(); private readonly fileSearchInstances = new Map(); private readonly fileSearchInitializing = new Map>(); - private readonly fileWatchers: vscode.Disposable[] = []; + private readonly fileWatchers = new Map(); private readonly globSpecialChars = new Set([ '\\', '*', @@ -102,62 +103,74 @@ export class FileMessageHandler extends BaseMessageHandler { } } - private invalidateFileSearchCache(rootPath: string): void { + private clearFileSearchCache(rootPath: string): void { this.fileSearchInstances.delete(rootPath); this.fileSearchInitializing.delete(rootPath); + crawlCache.clear(); console.log( - '[FileMessageHandler] Invalidated file search cache for:', + '[FileMessageHandler] Cleared file search cache, trigger:', rootPath, ); } - setupFileWatchers(): vscode.Disposable { - const workspaceFolders = vscode.workspace.workspaceFolders; - if (!workspaceFolders) { - return { dispose: () => {} }; + private createWatcherForFolder(folder: vscode.WorkspaceFolder): void { + const rootPath = folder.uri.fsPath; + + // Skip if watcher already exists for this folder + if (this.fileWatchers.has(rootPath)) { + return; } - for (const folder of workspaceFolders) { - const rootPath = folder.uri.fsPath; - const watcher = vscode.workspace.createFileSystemWatcher( - new vscode.RelativePattern(folder, '**/*'), - ); + const watcher = vscode.workspace.createFileSystemWatcher( + new vscode.RelativePattern(folder, '**/*'), + ); - watcher.onDidCreate(() => { - this.invalidateFileSearchCache(rootPath); - }); + const onFileAddOrDelete = () => this.clearFileSearchCache(rootPath); + watcher.onDidCreate(onFileAddOrDelete); + watcher.onDidDelete(onFileAddOrDelete); + // Note: onDidChange is not needed - file search is based on names, not content - watcher.onDidDelete(() => { - this.invalidateFileSearchCache(rootPath); - }); + this.fileWatchers.set(rootPath, watcher); + } - watcher.onDidChange(() => { - this.invalidateFileSearchCache(rootPath); - }); + private disposeWatcherForFolder(rootPath: string): void { + const watcher = this.fileWatchers.get(rootPath); + if (watcher) { + watcher.dispose(); + this.fileWatchers.delete(rootPath); + } + } - this.fileWatchers.push(watcher); + setupFileWatchers(): vscode.Disposable { + const workspaceFolders = vscode.workspace.workspaceFolders; + if (workspaceFolders) { + for (const folder of workspaceFolders) { + this.createWatcherForFolder(folder); + } } const foldersChangeListener = vscode.workspace.onDidChangeWorkspaceFolders( (e) => { for (const folder of e.removed) { const rootPath = folder.uri.fsPath; - this.invalidateFileSearchCache(rootPath); + this.clearFileSearchCache(rootPath); + this.disposeWatcherForFolder(rootPath); } for (const folder of e.added) { - this.invalidateFileSearchCache(folder.uri.fsPath); + const rootPath = folder.uri.fsPath; + this.clearFileSearchCache(rootPath); + this.createWatcherForFolder(folder); } }, ); - this.fileWatchers.push(foldersChangeListener); - return { dispose: () => { - for (const watcher of this.fileWatchers) { + for (const watcher of this.fileWatchers.values()) { watcher.dispose(); } - this.fileWatchers.length = 0; + this.fileWatchers.clear(); + foldersChangeListener.dispose(); }, }; } From 617874f1520bb5d30a946b054e93a88eb0b4ba05 Mon Sep 17 00:00:00 2001 From: qqqys Date: Tue, 17 Mar 2026 21:37:02 +0800 Subject: [PATCH 49/82] fix(ui): handle optional metrics in Composer component --- packages/cli/src/ui/components/Composer.tsx | 6 +++--- .../cli/src/ui/components/LoadingIndicator.test.tsx | 12 ++---------- 2 files changed, 5 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 1310fface..70eb59a05 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -29,10 +29,10 @@ export const Composer = () => { const { showAutoAcceptIndicator, sessionStats } = uiState; - const tokens = Object.values(sessionStats.metrics.models).reduce( + const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce( (acc, model) => ({ - prompt: acc.prompt + model.tokens.prompt, - candidates: acc.candidates + model.tokens.candidates, + prompt: acc.prompt + (model.tokens?.prompt ?? 0), + candidates: acc.candidates + (model.tokens?.candidates ?? 0), }), { prompt: 0, candidates: 0 }, ); diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index 4c914bd30..ea9e54a34 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -313,11 +313,7 @@ describe('', () => { describe('token display', () => { it('should display output tokens inline with arrow notation', () => { const { lastFrame } = renderWithContext( - , + , StreamingState.Responding, ); const output = lastFrame(); @@ -329,11 +325,7 @@ describe('', () => { it('should not display tokens when output tokens is 0', () => { const { lastFrame } = renderWithContext( - , + , StreamingState.Responding, ); const output = lastFrame(); From 476d6bc4fcb67defdae903fc4d5b649f933fb9cb Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 00:20:11 +0800 Subject: [PATCH 50/82] test(file-handler): enhance tests for FileMessageHandler with fuzzy search and path filtering --- .../handlers/FileMessageHandler.test.ts | 88 ++++++++++++++++--- 1 file changed, 78 insertions(+), 10 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts index 8cccae79e..d6ff4c4a9 100644 --- a/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts +++ b/packages/vscode-ide-companion/src/webview/handlers/FileMessageHandler.test.ts @@ -11,6 +11,11 @@ import { FileMessageHandler } from './FileMessageHandler.js'; import * as vscode from 'vscode'; const shouldIgnoreFileMock = vi.hoisted(() => vi.fn()); +const fileSearchMock = vi.hoisted(() => ({ + initialize: vi.fn(), + search: vi.fn(), +})); + const vscodeMock = vi.hoisted(() => { class Uri { fsPath: string; @@ -20,6 +25,9 @@ const vscodeMock = vi.hoisted(() => { static file(fsPath: string) { return new Uri(fsPath); } + static joinPath(base: Uri, ...pathSegments: string[]) { + return new Uri(`${base.fsPath}/${pathSegments.join('/')}`); + } } return { @@ -28,7 +36,14 @@ const vscodeMock = vi.hoisted(() => { findFiles: vi.fn(), getWorkspaceFolder: vi.fn(), asRelativePath: vi.fn(), - workspaceFolders: [], + workspaceFolders: [] as vscode.WorkspaceFolder[], + createFileSystemWatcher: vi.fn(() => ({ + onDidCreate: vi.fn(), + onDidDelete: vi.fn(), + onDidChange: vi.fn(), + dispose: vi.fn(), + })), + onDidChangeWorkspaceFolders: vi.fn(() => ({ dispose: vi.fn() })), }, window: { activeTextEditor: undefined, @@ -50,13 +65,67 @@ vi.mock( }, }), ); +vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/fileSearch.js', () => ({ + FileSearchFactory: { + create: () => fileSearchMock, + }, +})); +vi.mock('@qwen-code/qwen-code-core/src/utils/filesearch/crawlCache.js', () => ({ + clear: vi.fn(), +})); describe('FileMessageHandler', () => { beforeEach(() => { vi.clearAllMocks(); }); - it('filters ignored paths and includes request metadata in workspace files', async () => { + it('searches files using fuzzy search when query is provided', async () => { + const rootPath = '/workspace'; + + vscodeMock.workspace.workspaceFolders = [ + { uri: vscode.Uri.file(rootPath), name: 'workspace', index: 0 }, + ]; + + fileSearchMock.initialize.mockResolvedValue(undefined); + fileSearchMock.search.mockResolvedValue([ + 'src/test.txt', + 'docs/readme.txt', + ]); + + const sendToWebView = vi.fn(); + const handler = new FileMessageHandler( + {} as QwenAgentManager, + {} as ConversationStore, + null, + sendToWebView, + ); + + await handler.handle({ + type: 'getWorkspaceFiles', + data: { query: 'txt', requestId: 7 }, + }); + + expect(fileSearchMock.search).toHaveBeenCalledWith('txt', { + maxResults: 50, + }); + + expect(sendToWebView).toHaveBeenCalledTimes(1); + const payload = sendToWebView.mock.calls[0]?.[0] as { + type: string; + data: { + files: Array<{ path: string }>; + query?: string; + requestId?: number; + }; + }; + + expect(payload.type).toBe('workspaceFiles'); + expect(payload.data.requestId).toBe(7); + expect(payload.data.query).toBe('txt'); + expect(payload.data.files).toHaveLength(2); + }); + + it('filters ignored paths in non-query mode', async () => { const rootPath = '/workspace'; const allowedPath = `${rootPath}/allowed.txt`; const ignoredPath = `${rootPath}/ignored.log`; @@ -64,6 +133,7 @@ describe('FileMessageHandler', () => { const allowedUri = vscode.Uri.file(allowedPath); const ignoredUri = vscode.Uri.file(ignoredPath); + vscodeMock.workspace.workspaceFolders = []; vscodeMock.workspace.findFiles.mockResolvedValue([allowedUri, ignoredUri]); vscodeMock.workspace.getWorkspaceFolder.mockImplementation(() => ({ uri: vscode.Uri.file(rootPath), @@ -86,21 +156,22 @@ describe('FileMessageHandler', () => { await handler.handle({ type: 'getWorkspaceFiles', - data: { query: 'txt', requestId: 7 }, + data: { requestId: 7 }, }); expect(vscodeMock.workspace.findFiles).toHaveBeenCalledWith( - '**/*[tT][xX][tT]*', + '**/*', '**/{.git,node_modules}/**', - 50, + 20, ); expect(shouldIgnoreFileMock).toHaveBeenCalledWith(ignoredPath, { respectGitIgnore: true, respectQwenIgnore: false, }); - expect(sendToWebView).toHaveBeenCalledTimes(1); - const payload = sendToWebView.mock.calls[0]?.[0] as { + const payload = sendToWebView.mock.calls[ + sendToWebView.mock.calls.length - 1 + ]?.[0] as { type: string; data: { files: Array<{ path: string }>; @@ -111,8 +182,5 @@ describe('FileMessageHandler', () => { expect(payload.type).toBe('workspaceFiles'); expect(payload.data.requestId).toBe(7); - expect(payload.data.query).toBe('txt'); - expect(payload.data.files).toHaveLength(1); - expect(payload.data.files[0]?.path).toBe(allowedPath); }); }); From 3a92be09e08a306962693436c5d19ea1914bced0 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 00:22:35 +0800 Subject: [PATCH 51/82] test(cli): remove promptTokens prop from LoadingIndicator tests --- .../cli/src/ui/components/LoadingIndicator.test.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/ui/components/LoadingIndicator.test.tsx b/packages/cli/src/ui/components/LoadingIndicator.test.tsx index ea9e54a34..c608f4a4e 100644 --- a/packages/cli/src/ui/components/LoadingIndicator.test.tsx +++ b/packages/cli/src/ui/components/LoadingIndicator.test.tsx @@ -345,11 +345,7 @@ describe('', () => { it('should hide tokens in narrow terminal', () => { const { lastFrame } = renderWithContext( - , + , StreamingState.Responding, 79, ); @@ -361,11 +357,7 @@ describe('', () => { it('should show tokens in wide terminal with inline format', () => { const { lastFrame } = renderWithContext( - , + , StreamingState.Responding, 80, ); From 40fceebbd6096ab82252a8c8454605917800e87a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 10:05:16 +0800 Subject: [PATCH 52/82] docs: add qwen auth CLI command documentation --- docs/users/configuration/auth.md | 75 ++++++++++++++++++++++++++++++++ docs/users/features/commands.md | 16 +++++++ docs/users/quickstart.md | 24 +++++++--- 3 files changed, 110 insertions(+), 5 deletions(-) diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index 3e15aa462..d4adfa493 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -6,6 +6,61 @@ Qwen Code supports three authentication methods. Pick the one that matches how y - **Alibaba Cloud Coding Plan**: use an API key from Alibaba Cloud. Paid subscription with diverse model options and higher quotas. - **API Key**: bring your own API key. Flexible to your own needs — supports OpenAI, Anthropic, Gemini, and other compatible endpoints. +## Quick setup with `qwen auth` + +The `qwen auth` CLI command lets you configure authentication directly from your terminal — no need to start an interactive session first. + +### Interactive mode + +Run `qwen auth` without arguments to get an interactive menu: + +```bash +qwen auth +``` + +You'll see a selector with arrow-key navigation: + +``` +Select authentication method: + +> Qwen OAuth - Free · Up to 1,000 requests/day · Qwen latest models + Alibaba Cloud Coding Plan - Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models + +(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit) +``` + +### Direct subcommands + +You can also skip the menu and run a specific authentication method directly: + +| Command | Description | +| -------------------------------------------------- | ------------------------------------------------- | +| `qwen auth` | Interactive authentication setup | +| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | +| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | +| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | +| `qwen auth status` | Show current authentication status | + +**Examples:** + +```bash +# Authenticate with Qwen OAuth directly +qwen auth qwen-oauth + +# Set up Coding Plan interactively (prompts for region and key) +qwen auth code-plan + +# Set up Coding Plan non-interactively (useful for CI/scripting) +qwen auth code-plan --region china --key sk-sp-xxxxxxxxx + +# Check your current auth configuration +qwen auth status +``` + +> [!tip] +> +> You can also use the `/auth` slash command within an active Qwen Code session to change authentication methods interactively. + ## Option 1: Qwen OAuth (Free) Use this if you want the simplest setup and you're using Qwen models. @@ -21,6 +76,12 @@ Start the CLI and follow the browser flow: qwen ``` +Or authenticate directly without starting a session: + +```bash +qwen auth qwen-oauth +``` + > [!note] > > In non-interactive or headless environments (e.g., CI, SSH, containers), you typically **cannot** complete the OAuth browser login flow. @@ -44,6 +105,20 @@ Alibaba Cloud Coding Plan is available in two regions: ### Interactive setup +You can set up Coding Plan authentication in two ways: + +**Option A: From the terminal (recommended for first-time setup)** + +```bash +# Interactive — prompts for region and API key +qwen auth code-plan + +# Or non-interactive — pass region and key directly +qwen auth code-plan --region china --key sk-sp-xxxxxxxxx +``` + +**Option B: Inside a Qwen Code session** + Enter `qwen` in the terminal to launch Qwen Code, then run the `/auth` command and select **Alibaba Cloud Coding Plan**. Choose your region, then enter your `sk-sp-xxxxxxxxx` key. After authentication, use the `/model` command to switch between all Alibaba Cloud Coding Plan supported models (including qwen3.5-plus, qwen3-coder-plus, qwen3-coder-next, qwen3-max, glm-4.7, and kimi-k2.5). diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index ba980db80..78148a17a 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -94,6 +94,22 @@ Commands for obtaining information and performing system settings. | `Ctrl/cmd+Z` | Undo input | Text editing | | `Ctrl/cmd+Shift+Z` | Redo input | Text editing | +### 1.7 CLI Auth Subcommands + +In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal: + +| Command | Description | +| -------------------------------------------------- | ------------------------------------------------- | +| `qwen auth` | Interactive authentication setup | +| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | +| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | +| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | +| `qwen auth status` | Show current authentication status | + +> [!tip] +> +> These commands run outside of a Qwen Code session. Use them to configure authentication before starting a session, or in scripts and CI environments. See the [Authentication](../configuration/auth) page for full details. + ## 2. @ Commands (Introducing Files) @ commands are used to quickly add local file or directory content to the conversation. diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 3c4eafcea..8d23c5042 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -54,15 +54,27 @@ brew install qwen-code ## Step 2: Log in to your account -Qwen Code requires an account to use. When you start an interactive session with the `qwen` command, you'll need to log in: +Qwen Code requires an account to use. The quickest way is to run the `qwen auth` command directly: + +```bash +# Interactive auth setup — select a method and follow the prompts +qwen auth +``` + +Or authenticate with Qwen OAuth directly: + +```bash +qwen auth qwen-oauth +``` + +Alternatively, you can start a session first and authenticate from within: ```bash -# You'll be prompted to log in on first use qwen ``` ```bash -# Follow the prompts to log in with your account +# Inside a Qwen Code session /auth ``` @@ -74,7 +86,7 @@ Select `Qwen OAuth`, log in to your account and follow the prompts to confirm. O > [!tip] > -> If you need to log in again or switch accounts, use the `/auth` command within Qwen Code. +> Use `qwen auth status` to check your current authentication configuration at any time. To switch accounts or methods, run `qwen auth` again or use the `/auth` command within a session. ## Step 3: Start your first session @@ -216,7 +228,9 @@ Here are the most important commands for daily use: | Command | What it does | Example | | --------------------- | ------------------------------------------------ | ----------------------------- | | `qwen` | start Qwen Code | `qwen` | -| `/auth` | Change authentication method | `/auth` | +| `qwen auth` | Configure authentication from the terminal | `qwen auth` | +| `qwen auth status` | Check current authentication status | `qwen auth status` | +| `/auth` | Change authentication method (in session) | `/auth` | | `/help` | Display help information for available commands | `/help` or `/?` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | From a36264936f913b946c02c09c55d3173ce81f2351 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 10:14:35 +0800 Subject: [PATCH 53/82] docs: adjust auth docs priority - keep /auth as primary, qwen auth as supplement --- docs/users/configuration/auth.md | 104 +++++++++++++++---------------- docs/users/quickstart.md | 22 ++----- 2 files changed, 54 insertions(+), 72 deletions(-) diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index d4adfa493..dee7933e0 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -6,61 +6,6 @@ Qwen Code supports three authentication methods. Pick the one that matches how y - **Alibaba Cloud Coding Plan**: use an API key from Alibaba Cloud. Paid subscription with diverse model options and higher quotas. - **API Key**: bring your own API key. Flexible to your own needs — supports OpenAI, Anthropic, Gemini, and other compatible endpoints. -## Quick setup with `qwen auth` - -The `qwen auth` CLI command lets you configure authentication directly from your terminal — no need to start an interactive session first. - -### Interactive mode - -Run `qwen auth` without arguments to get an interactive menu: - -```bash -qwen auth -``` - -You'll see a selector with arrow-key navigation: - -``` -Select authentication method: - -> Qwen OAuth - Free · Up to 1,000 requests/day · Qwen latest models - Alibaba Cloud Coding Plan - Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models - -(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit) -``` - -### Direct subcommands - -You can also skip the menu and run a specific authentication method directly: - -| Command | Description | -| -------------------------------------------------- | ------------------------------------------------- | -| `qwen auth` | Interactive authentication setup | -| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | -| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | -| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | -| `qwen auth status` | Show current authentication status | - -**Examples:** - -```bash -# Authenticate with Qwen OAuth directly -qwen auth qwen-oauth - -# Set up Coding Plan interactively (prompts for region and key) -qwen auth code-plan - -# Set up Coding Plan non-interactively (useful for CI/scripting) -qwen auth code-plan --region china --key sk-sp-xxxxxxxxx - -# Check your current auth configuration -qwen auth status -``` - -> [!tip] -> -> You can also use the `/auth` slash command within an active Qwen Code session to change authentication methods interactively. - ## Option 1: Qwen OAuth (Free) Use this if you want the simplest setup and you're using Qwen models. @@ -365,6 +310,55 @@ qwen --model "qwen3-coder-plus" qwen --model "qwen3.5-plus" ``` +## `qwen auth` CLI command + +In addition to the in-session `/auth` slash command, Qwen Code provides a standalone `qwen auth` CLI command for managing authentication directly from the terminal — without starting an interactive session first. + +### Interactive mode + +Run `qwen auth` without arguments to get an interactive menu: + +```bash +qwen auth +``` + +You'll see a selector with arrow-key navigation: + +``` +Select authentication method: + +> Qwen OAuth - Free · Up to 1,000 requests/day · Qwen latest models + Alibaba Cloud Coding Plan - Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models + +(Use ↑ ↓ arrows to navigate, Enter to select, Ctrl+C to exit) +``` + +### Subcommands + +| Command | Description | +| -------------------------------------------------- | ------------------------------------------------- | +| `qwen auth` | Interactive authentication setup | +| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | +| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | +| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | +| `qwen auth status` | Show current authentication status | + +**Examples:** + +```bash +# Authenticate with Qwen OAuth directly +qwen auth qwen-oauth + +# Set up Coding Plan interactively (prompts for region and key) +qwen auth code-plan + +# Set up Coding Plan non-interactively (useful for CI/scripting) +qwen auth code-plan --region china --key sk-sp-xxxxxxxxx + +# Check your current auth configuration +qwen auth status +``` + ## Security notes - Don't commit API keys to version control. diff --git a/docs/users/quickstart.md b/docs/users/quickstart.md index 8d23c5042..4d9e561e4 100644 --- a/docs/users/quickstart.md +++ b/docs/users/quickstart.md @@ -54,27 +54,15 @@ brew install qwen-code ## Step 2: Log in to your account -Qwen Code requires an account to use. The quickest way is to run the `qwen auth` command directly: - -```bash -# Interactive auth setup — select a method and follow the prompts -qwen auth -``` - -Or authenticate with Qwen OAuth directly: - -```bash -qwen auth qwen-oauth -``` - -Alternatively, you can start a session first and authenticate from within: +Qwen Code requires an account to use. When you start an interactive session with the `qwen` command, you'll be prompted to log in: ```bash +# You'll be prompted to log in on first use qwen ``` ```bash -# Inside a Qwen Code session +# Follow the prompts to log in with your account /auth ``` @@ -86,7 +74,7 @@ Select `Qwen OAuth`, log in to your account and follow the prompts to confirm. O > [!tip] > -> Use `qwen auth status` to check your current authentication configuration at any time. To switch accounts or methods, run `qwen auth` again or use the `/auth` command within a session. +> You can also configure authentication directly from the terminal without starting a session by running `qwen auth`. Use `qwen auth status` to check your current configuration at any time. See the [Authentication](./configuration/auth) page for details. ## Step 3: Start your first session @@ -228,9 +216,9 @@ Here are the most important commands for daily use: | Command | What it does | Example | | --------------------- | ------------------------------------------------ | ----------------------------- | | `qwen` | start Qwen Code | `qwen` | +| `/auth` | Change authentication method (in session) | `/auth` | | `qwen auth` | Configure authentication from the terminal | `qwen auth` | | `qwen auth status` | Check current authentication status | `qwen auth status` | -| `/auth` | Change authentication method (in session) | `/auth` | | `/help` | Display help information for available commands | `/help` or `/?` | | `/compress` | Replace chat history with summary to save Tokens | `/compress` | | `/clear` | Clear terminal screen content | `/clear` (shortcut: `Ctrl+L`) | From 848f7dbd4c2dc9525bbf05428298fed3775a3d94 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 15:28:22 +0800 Subject: [PATCH 54/82] fix(vscode-ide-companion): update URI handling for Windows paths --- packages/vscode-ide-companion/src/diff-manager.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/vscode-ide-companion/src/diff-manager.ts b/packages/vscode-ide-companion/src/diff-manager.ts index 9a32769c1..8367517ab 100644 --- a/packages/vscode-ide-companion/src/diff-manager.ts +++ b/packages/vscode-ide-companion/src/diff-manager.ts @@ -192,17 +192,17 @@ export class DiffManager { return; } // Left side: old content using qwen-diff scheme - const leftDocUri = vscode.Uri.from({ + // Use Uri.file() to properly handle Windows paths (e.g., C:\Users\...) + // then change the scheme to our custom diff scheme + const leftDocUri = vscode.Uri.file(normalizedPath).with({ scheme: DIFF_SCHEME, - path: normalizedPath, query: `old&rand=${Math.random()}`, }); this.diffContentProvider.setContent(leftDocUri, oldContent); // Right side: new content using qwen-diff scheme - const rightDocUri = vscode.Uri.from({ + const rightDocUri = vscode.Uri.file(normalizedPath).with({ scheme: DIFF_SCHEME, - path: normalizedPath, query: `new&rand=${Math.random()}`, }); this.diffContentProvider.setContent(rightDocUri, newContent); From 9a05f969290770f0401fe52b000225a3b35b17fd Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 18 Mar 2026 01:00:12 -0700 Subject: [PATCH 55/82] remove deplicate function and add test --- .../src/extension/claude-converter.test.ts | 137 +++++++++++++ .../core/src/extension/claude-converter.ts | 23 +-- .../src/extension/extensionManager.test.ts | 135 +++++++++++++ .../core/src/extension/extensionManager.ts | 27 +-- packages/core/src/extension/variables.test.ts | 180 +++++++++++++++++- packages/core/src/extension/variables.ts | 41 ++++ 6 files changed, 496 insertions(+), 47 deletions(-) diff --git a/packages/core/src/extension/claude-converter.test.ts b/packages/core/src/extension/claude-converter.test.ts index 502e8196e..c984b17bc 100644 --- a/packages/core/src/extension/claude-converter.test.ts +++ b/packages/core/src/extension/claude-converter.test.ts @@ -17,6 +17,7 @@ import { type ClaudeMarketplacePluginConfig, type ClaudeMarketplaceConfig, } from './claude-converter.js'; +import { HookType } from '../hooks/types.js'; describe('convertClaudeToQwenConfig', () => { it('should convert basic Claude config', () => { @@ -433,4 +434,140 @@ describe('convertClaudePluginPackage', () => { // Clean up fs.rmSync(result.convertedDir, { recursive: true, force: true }); }); + + it('should convert hooks from Claude plugin format to Qwen format with variable substitution', async () => { + // Setup: Create a plugin with hooks in Claude format + const pluginSourceDir = path.join(testDir, 'plugin-with-hooks'); + fs.mkdirSync(pluginSourceDir, { recursive: true }); + + // Create hooks directory with hooks.json in Claude format + const hooksDir = path.join(pluginSourceDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const hooksJson = { + hooks: { + PostToolUse: [ + { + matcher: 'post-install-matcher', // Part of HookDefinition + sequential: true, // Part of HookDefinition + description: 'Run after installation', + hooks: [ + // HookConfig[] array inside HookDefinition + { + type: HookType.Command, + command: '${CLAUDE_PLUGIN_ROOT}/scripts/post-install.sh', + }, + ], + }, + ], + }, + }; + + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify(hooksJson), + 'utf-8', + ); + + // Create marketplace.json + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'hooks-plugin', + version: '1.0.0', + source: './', + strict: false, + hooks: './hooks/hooks.json', // Reference hooks from file + }, + ], + }; + + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + // Execute: Convert the plugin + const result = await convertClaudePluginPackage( + pluginSourceDir, + 'hooks-plugin', + ); + + // Verify: The converted config should contain processed hooks + expect(result.config.hooks).toBeDefined(); + expect(result.config.hooks!['PostToolUse']).toHaveLength(1); + // Check that the variable was substituted + expect(result.config.hooks!['PostToolUse']![0].hooks![0].command).toBe( + `${pluginSourceDir}/scripts/post-install.sh`, + ); + + // Clean up converted directory + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); + + it('should handle hooks defined directly in marketplace config', async () => { + // Setup: Create a plugin with hooks defined directly in marketplace config + const pluginSourceDir = path.join(testDir, 'direct-hooks-plugin'); + fs.mkdirSync(pluginSourceDir, { recursive: true }); + + // Create marketplace.json with hooks defined directly + const marketplaceDir = path.join(pluginSourceDir, '.claude-plugin'); + fs.mkdirSync(marketplaceDir, { recursive: true }); + + const marketplaceConfig: ClaudeMarketplaceConfig = { + name: 'test-marketplace', + owner: { name: 'Test Owner', email: 'test@example.com' }, + plugins: [ + { + name: 'direct-hooks-plugin', + version: '1.0.0', + source: './', + strict: false, + hooks: { + PreToolUse: [ + { + matcher: '*', // Part of HookDefinition + sequential: true, // Part of HookDefinition + hooks: [ + // HookConfig[] array inside HookDefinition + { + type: HookType.Command, + command: 'npm install', + }, + ], + }, + ], + }, + }, + ], + }; + + fs.writeFileSync( + path.join(marketplaceDir, 'marketplace.json'), + JSON.stringify(marketplaceConfig, null, 2), + 'utf-8', + ); + + // Execute: Convert the plugin + const result = await convertClaudePluginPackage( + pluginSourceDir, + 'direct-hooks-plugin', + ); + + // Verify: The converted config should contain the hooks + expect(result.config.hooks).toBeDefined(); + expect(result.config.hooks!['PreToolUse']).toHaveLength(1); + expect(result.config.hooks!['PreToolUse']![0].hooks![0].command).toBe( + 'npm install', + ); + + // Clean up converted directory + fs.rmSync(result.convertedDir, { recursive: true, force: true }); + }); }); diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 1e14c4bab..1d0b65efe 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -26,6 +26,7 @@ import { } from '../utils/yaml-parser.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { normalizeContent } from '../utils/textUtils.js'; +import { substituteHookVariables } from './variables.js'; const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); @@ -498,27 +499,7 @@ export async function convertClaudePluginPackage( } // Process the hooks to substitute variables like ${CLAUDE_PLUGIN_ROOT} - // Replace ${CLAUDE_PLUGIN_ROOT} with the pluginSource path - const processedHooks = JSON.parse(JSON.stringify(hooksData)); - for (const eventName in processedHooks) { - const eventHooks = processedHooks[eventName as HookEventName]; - if (eventHooks && Array.isArray(eventHooks)) { - for (const hookDef of eventHooks) { - if (hookDef.hooks && Array.isArray(hookDef.hooks)) { - for (const hook of hookDef.hooks) { - if (hook.type === 'command' && hook.command) { - hook.command = hook.command.replace( - /\$\{CLAUDE_PLUGIN_ROOT\}/g, - pluginSource, - ); - } - } - } - } - } - } - - mergedConfig.hooks = processedHooks; + mergedConfig.hooks = substituteHookVariables(hooksData, pluginSource); } catch (error) { debugLogger.warn( `Failed to parse hooks file ${hooksPath}: ${error instanceof Error ? error.message : String(error)}`, diff --git a/packages/core/src/extension/extensionManager.test.ts b/packages/core/src/extension/extensionManager.test.ts index be94f9056..8ef27da30 100644 --- a/packages/core/src/extension/extensionManager.test.ts +++ b/packages/core/src/extension/extensionManager.test.ts @@ -757,4 +757,139 @@ describe('extension tests', () => { }); }); }); + + describe('hooks loading and processing', () => { + it('should load hooks from qwen-extension.json', async () => { + const extensionDir = path.join(userExtensionsDir, 'hooks-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create qwen-extension.json with hooks + const configWithHooks = { + name: 'hooks-extension', + version: '1.0.0', + hooks: { + PreToolUse: [ + { + description: 'Run before tool start', + hooks: [ + { + type: 'command', + command: 'echo "hello"', + }, + ], + }, + ], + }, + }; + + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(configWithHooks), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); + expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( + 'echo "hello"', + ); + }); + + it('should load hooks from hooks/hooks.json when not in main config', async () => { + const extensionDir = path.join( + userExtensionsDir, + 'hooks-from-file-extension', + ); + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create qwen-extension.json without hooks + const configWithoutHooks = { + name: 'hooks-from-file-extension', + version: '1.0.0', + }; + + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(configWithoutHooks), + ); + + // Create hooks directory and hooks.json + const hooksDir = path.join(extensionDir, 'hooks'); + fs.mkdirSync(hooksDir, { recursive: true }); + + const hooksJson = { + PostToolUse: [ + { + description: 'Run after install', + hooks: [ + { + type: 'command', + command: `echo "installed in ${extensionDir}"`, + }, + ], + }, + ], + }; + + fs.writeFileSync( + path.join(hooksDir, 'hooks.json'), + JSON.stringify(hooksJson), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PostToolUse']).toHaveLength(1); + expect(extensions[0].hooks!['PostToolUse']![0].hooks![0].command).toBe( + `echo "installed in ${extensionDir}"`, + ); + }); + + it('should substitute ${CLAUDE_PLUGIN_ROOT} variable in hooks', async () => { + const extensionDir = path.join(userExtensionsDir, 'hooks-var-extension'); + fs.mkdirSync(extensionDir, { recursive: true }); + + // Create qwen-extension.json with hooks using ${CLAUDE_PLUGIN_ROOT} + const configWithHooks = { + name: 'hooks-var-extension', + version: '1.0.0', + hooks: { + PreToolUse: [ + { + description: 'Run before start with var', + hooks: [ + { + type: 'command', + command: '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh', + }, + ], + }, + ], + }, + }; + + fs.writeFileSync( + path.join(extensionDir, EXTENSIONS_CONFIG_FILENAME), + JSON.stringify(configWithHooks), + ); + + const manager = createExtensionManager(); + await manager.refreshCache(); + const extensions = manager.getLoadedExtensions(); + + expect(extensions).toHaveLength(1); + expect(extensions[0].hooks).toBeDefined(); + expect(extensions[0].hooks!['PreToolUse']).toHaveLength(1); + expect(extensions[0].hooks!['PreToolUse']![0].hooks![0].command).toBe( + `${extensionDir}/scripts/setup.sh`, + ); + }); + }); }); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 5a61b4070..1d10bfc89 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -29,6 +29,7 @@ import { EXTENSIONS_CONFIG_FILENAME, INSTALL_METADATA_FILENAME, recursivelyHydrateStrings, + substituteHookVariables, } from './variables.js'; import { resolveEnvVarsInObject } from '../utils/envVarResolver.js'; import { @@ -730,31 +731,7 @@ export class ExtensionManager { hooks: { [K in HookEventName]?: HookDefinition[] } | undefined, extensionPath: string, ): { [K in HookEventName]?: HookDefinition[] } | undefined { - if (!hooks) return hooks; - - // Deep clone the hooks to avoid modifying the original - const clonedHooks = JSON.parse(JSON.stringify(hooks)); - - // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path in all command hooks - for (const eventName in clonedHooks) { - const eventHooks = clonedHooks[eventName as HookEventName]; - if (eventHooks && Array.isArray(eventHooks)) { - for (const hookDef of eventHooks) { - if (hookDef.hooks && Array.isArray(hookDef.hooks)) { - for (const hook of hookDef.hooks) { - if (hook.type === 'command' && hook.command) { - hook.command = hook.command.replace( - /\$\{CLAUDE_PLUGIN_ROOT\}/g, - extensionPath, - ); - } - } - } - } - } - } - - return clonedHooks; + return substituteHookVariables(hooks, extensionPath); } /** diff --git a/packages/core/src/extension/variables.test.ts b/packages/core/src/extension/variables.test.ts index d2015f4f9..e8a1db714 100644 --- a/packages/core/src/extension/variables.test.ts +++ b/packages/core/src/extension/variables.test.ts @@ -5,7 +5,8 @@ */ import { expect, describe, it } from 'vitest'; -import { hydrateString } from './variables.js'; +import { hydrateString, substituteHookVariables } from './variables.js'; +import { HookType } from '../hooks/types.js'; describe('hydrateString', () => { it('should replace a single variable', () => { @@ -16,3 +17,180 @@ describe('hydrateString', () => { expect(result).toBe('Hello, path/my-extension!'); }); }); + +describe('substituteHookVariables', () => { + it('should substitute ${CLAUDE_PLUGIN_ROOT} with the actual path in hooks', () => { + const basePath = '/path/to/plugin'; + + const hooks = { + PreToolUse: [ + { + description: 'Setup before start', + hooks: [ + { + type: HookType.Command, + command: '${CLAUDE_PLUGIN_ROOT}/scripts/setup.sh', + }, + ], + }, + ], + }; + + const result = substituteHookVariables(hooks, basePath); + + expect(result).toBeDefined(); + expect(result!['PreToolUse']).toHaveLength(1); + expect(result!['PreToolUse']![0].hooks![0].command).toBe( + '/path/to/plugin/scripts/setup.sh', + ); + }); + + it('should handle multiple hooks with variables', () => { + const basePath = '/project/plugins/my-plugin'; + + const hooks = { + PostToolUse: [ + { + description: 'Post install hook 1', + hooks: [ + { + type: HookType.Command, + command: '${CLAUDE_PLUGIN_ROOT}/bin/init.sh', + }, + ], + }, + { + description: 'Post install hook 2', + hooks: [ + { + type: HookType.Command, + command: 'chmod +x ${CLAUDE_PLUGIN_ROOT}/bin/executable.sh', + }, + ], + }, + ], + }; + + const result = substituteHookVariables(hooks, basePath); + + expect(result).toBeDefined(); + expect(result!['PostToolUse']).toHaveLength(2); + expect(result!['PostToolUse']![0].hooks![0].command).toBe( + '/project/plugins/my-plugin/bin/init.sh', + ); + expect(result!['PostToolUse']![1].hooks![0].command).toBe( + 'chmod +x /project/plugins/my-plugin/bin/executable.sh', + ); + }); + + it('should handle multiple event types with hooks', () => { + const basePath = '/home/user/.qwen/extensions/my-extension'; + + const hooks = { + PreToolUse: [ + { + matcher: 'test-matcher', // Part of HookDefinition + sequential: true, // Part of HookDefinition + hooks: [ + // HookConfig[] array inside HookDefinition + { + type: HookType.Command, // HookType.Command + command: '${CLAUDE_PLUGIN_ROOT}/scripts/pre-start.sh', + }, + ], + }, + ], + UserPromptSubmit: [ + { + matcher: 'another-matcher', // Part of HookDefinition + sequential: false, // Part of HookDefinition + hooks: [ + // HookConfig[] array inside HookDefinition + { + type: HookType.Command, // HookType.Command + command: '${CLAUDE_PLUGIN_ROOT}/setup/install.py', + }, + ], + }, + ], + }; + + const result = substituteHookVariables(hooks, basePath); + + expect(result).toBeDefined(); + expect(result!['PreToolUse']).toHaveLength(1); + expect(result!['PreToolUse']![0].hooks![0].command).toBe( + '/home/user/.qwen/extensions/my-extension/scripts/pre-start.sh', + ); + expect(result!['UserPromptSubmit']).toHaveLength(1); + expect(result!['UserPromptSubmit']![0].hooks![0].command).toBe( + '/home/user/.qwen/extensions/my-extension/setup/install.py', + ); + }); + + it('should not modify non-command hooks', () => { + const basePath = '/path/to/extension'; + + const hooks = { + SessionStart: [ + { + matcher: 'test-matcher', // This is part of HookDefinition + sequential: true, // This is part of HookDefinition + hooks: [ + // This is the HookConfig[] array inside HookDefinition + { + type: HookType.Command, // This is part of HookConfig + command: '${CLAUDE_PLUGIN_ROOT}/scripts/run.sh', // This is part of HookConfig + }, + { + type: 'non-command' as HookType.Command, // Non-command type won't be processed + command: '${CLAUDE_PLUGIN_ROOT}/not-affected', // Should not be modified + }, + ], + }, + ], + }; + + const result = substituteHookVariables(hooks, basePath); + + expect(result).toBeDefined(); + expect(result!['SessionStart']).toHaveLength(1); + expect(result!['SessionStart']![0].hooks![0].command).toBe( + '/path/to/extension/scripts/run.sh', + ); + expect(result!['SessionStart']![0].hooks![1].command).toBe( + '${CLAUDE_PLUGIN_ROOT}/not-affected', + ); // Non-command type won't be processed + }); + + it('should return undefined when hooks is undefined', () => { + const result = substituteHookVariables(undefined, '/some/path'); + expect(result).toBeUndefined(); + }); + + it('should return original hooks when no ${CLAUDE_PLUGIN_ROOT} found', () => { + const basePath = '/path/to/plugin'; + + const hooks = { + Stop: [ + { + matcher: 'test-matcher', // This is part of HookDefinition + sequential: true, // This is part of HookDefinition + hooks: [ + // This is the HookConfig[] array inside HookDefinition + { + type: HookType.Command, // This is part of CommandHookConfig + command: 'echo "hello world"', // This is part of CommandHookConfig + }, + ], + }, + ], + }; + + const result = substituteHookVariables(hooks, basePath); + + expect(result).toBeDefined(); + expect(result).toEqual(hooks); // Should be equal but not the same object (deep clone) + expect(result!['Stop']![0].hooks![0].command).toBe('echo "hello world"'); + }); +}); diff --git a/packages/core/src/extension/variables.ts b/packages/core/src/extension/variables.ts index ccac1c65f..7bdc60d13 100644 --- a/packages/core/src/extension/variables.ts +++ b/packages/core/src/extension/variables.ts @@ -7,6 +7,10 @@ import { type VariableSchema, VARIABLE_SCHEMA } from './variableSchema.js'; import path from 'node:path'; import { QWEN_DIR } from '../config/storage.js'; +import type { HookEventName, HookDefinition } from '../hooks/types.js'; + +// Re-export types for substituteHookVariables +export type { HookEventName, HookDefinition }; export const EXTENSIONS_DIRECTORY_NAME = path.join(QWEN_DIR, 'extensions'); export const EXTENSIONS_CONFIG_FILENAME = 'qwen-extension.json'; @@ -70,3 +74,40 @@ export function recursivelyHydrateStrings( } return obj; } + +/** + * Substitute variables in hook configurations, particularly ${CLAUDE_PLUGIN_ROOT} + * @param hooks - The hooks configuration object + * @param basePath - The path to substitute for ${CLAUDE_PLUGIN_ROOT} + * @returns A deep cloned hooks object with variables substituted + */ +export function substituteHookVariables( + hooks: { [K in HookEventName]?: HookDefinition[] } | undefined, + basePath: string, +): { [K in HookEventName]?: HookDefinition[] } | undefined { + if (!hooks) return hooks; + + // Deep clone the hooks to avoid modifying the original + const clonedHooks = JSON.parse(JSON.stringify(hooks)); + + // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path in all command hooks + for (const eventName in clonedHooks) { + const eventHooks = clonedHooks[eventName as HookEventName]; + if (eventHooks && Array.isArray(eventHooks)) { + for (const hookDef of eventHooks) { + if (hookDef.hooks && Array.isArray(hookDef.hooks)) { + for (const hook of hookDef.hooks) { + if (hook.type === 'command' && hook.command) { + hook.command = hook.command.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + basePath, + ); + } + } + } + } + } + } + + return clonedHooks; +} From d0923ef972b5d8297ace7d921b2b50ddd2a24177 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 18 Mar 2026 16:07:35 +0800 Subject: [PATCH 56/82] refactor(core): improve error handling and quota detection - Move getErrorStatus from retry.ts to errors.ts for better organization - Add getErrorType utility to extract error class/category names - Enhance getErrorMessage to include cause chain for better debugging - Refactor ApiErrorEvent to use options object pattern (more readable) - Rename 'error' to 'error_message' in ApiErrorEvent for clarity - Make isQwenQuotaExceededError more precise: requires status=429, code='insufficient_quota', and 'free allocated quota exceeded' message - Update all tests to match new error detection behavior This improves error telemetry and makes quota detection more reliable. Co-authored-by: Qwen-Coder --- packages/core/src/core/geminiChat.ts | 3 +- .../loggingContentGenerator.ts | 37 ++++----- packages/core/src/telemetry/loggers.ts | 4 +- .../src/telemetry/qwen-logger/qwen-logger.ts | 3 +- packages/core/src/telemetry/types.ts | 45 ++++++----- .../core/src/telemetry/uiTelemetry.test.ts | 4 +- packages/core/src/utils/errors.ts | 78 +++++++++++++++++++ .../src/utils/quotaErrorDetection.test.ts | 77 +++++++++--------- .../core/src/utils/quotaErrorDetection.ts | 37 ++++----- packages/core/src/utils/retry.test.ts | 18 +++-- packages/core/src/utils/retry.ts | 33 +------- 11 files changed, 194 insertions(+), 145 deletions(-) diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 03b78f06c..13eae7e5b 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -17,7 +17,8 @@ import type { GenerateContentResponseUsageMetadata, } from '@google/genai'; import { createUserContent } from '@google/genai'; -import { getErrorStatus, retryWithBackoff } from '../utils/retry.js'; +import { retryWithBackoff } from '../utils/retry.js'; +import { getErrorStatus } from '../utils/errors.js'; import { createDebugLogger } from '../utils/debugLogger.js'; import { parseAndFormatApiError } from '../utils/errorParsing.js'; import { isRateLimitError, type RetryInfo } from '../utils/rateLimit.js'; diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts index 1a51846c3..33242a28a 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.ts @@ -35,13 +35,13 @@ import type { ContentGenerator, ContentGeneratorConfig, } from '../contentGenerator.js'; -import { isStructuredError } from '../../utils/quotaErrorDetection.js'; import { OpenAIContentConverter } from '../openaiContentGenerator/converter.js'; import { OpenAILogger } from '../../utils/openaiLogger.js'; - -interface StructuredError { - status: number; -} +import { + getErrorMessage, + getErrorStatus, + getErrorType, +} from '../../utils/errors.js'; /** * A decorator that wraps a ContentGenerator to add logging to API calls. @@ -108,33 +108,26 @@ export class LoggingContentGenerator implements ContentGenerator { model: string, prompt_id: string, ): void { - const errorMessage = error instanceof Error ? error.message : String(error); - const errorType = - (error as { type?: string })?.type || - (error instanceof Error ? error.name : 'unknown'); + const errorMessage = getErrorMessage(error); + const errorType = getErrorType(error); const errorResponseId = (error as { requestID?: string; request_id?: string })?.requestID || (error as { requestID?: string; request_id?: string })?.request_id || responseId; - const errorStatus = - (error as { code?: string | number; status?: number })?.code ?? - (error as { status?: number })?.status ?? - (isStructuredError(error) - ? (error as StructuredError).status - : undefined); + const errorStatus = getErrorStatus(error); logApiError( this.config, - new ApiErrorEvent( - errorResponseId, + new ApiErrorEvent({ + responseId: errorResponseId, model, - errorMessage, durationMs, - prompt_id, - this.config.getAuthType(), + promptId: prompt_id, + authType: this.config.getAuthType(), + errorMessage, errorType, - errorStatus, - ), + statusCode: errorStatus, + }), ); } diff --git a/packages/core/src/telemetry/loggers.ts b/packages/core/src/telemetry/loggers.ts index 30334751a..e2bf6b1e5 100644 --- a/packages/core/src/telemetry/loggers.ts +++ b/packages/core/src/telemetry/loggers.ts @@ -375,7 +375,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { ...event, 'event.name': EVENT_API_ERROR, 'event.timestamp': new Date().toISOString(), - ['error.message']: event.error, + ['error.message']: event.error_message, model_name: event.model, duration: event.duration_ms, }; @@ -389,7 +389,7 @@ export function logApiError(config: Config, event: ApiErrorEvent): void { const logger = logs.getLogger(SERVICE_NAME); const logRecord: LogRecord = { - body: `API error for ${event.model}. Error: ${event.error}. Duration: ${event.duration_ms}ms.`, + body: `API error for ${event.model}. Error: ${event.error_message}. Duration: ${event.duration_ms}ms.`, attributes, }; logger.emit(logRecord); diff --git a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts index 0d89d6b69..81cf7efbf 100644 --- a/packages/core/src/telemetry/qwen-logger/qwen-logger.ts +++ b/packages/core/src/telemetry/qwen-logger/qwen-logger.ts @@ -642,12 +642,13 @@ export class QwenLogger { status_code: event.status_code?.toString() ?? '', duration: event.duration_ms, success: 0, - message: event.error, + message: event.error_message, trace_id: event.response_id, properties: { auth_type: event.auth_type, model: event.model, prompt_id: event.prompt_id, + error_message: event.error_message, error_type: event.error_type, }, }); diff --git a/packages/core/src/telemetry/types.ts b/packages/core/src/telemetry/types.ts index c9e6c2d53..e25e937e4 100644 --- a/packages/core/src/telemetry/types.ts +++ b/packages/core/src/telemetry/types.ts @@ -254,33 +254,36 @@ export class ApiErrorEvent implements BaseTelemetryEvent { 'event.timestamp': string; // ISO 8601 response_id?: string; model: string; - error: string; - error_type?: string; - status_code?: number | string; duration_ms: number; prompt_id: string; auth_type?: string; + // Human-readable error message (e.g. "Request failed with status 429") + error_message: string; + // Error class or category (e.g. "RateLimitError", "invalid_request_error") + error_type?: string; + // HTTP status code from the API response (e.g. 429, 500) + status_code?: number | string; - constructor( - response_id: string | undefined, - model: string, - error: string, - duration_ms: number, - prompt_id: string, - auth_type?: string, - error_type?: string, - status_code?: number | string, - ) { + constructor(opts: { + responseId?: string; + model: string; + durationMs: number; + promptId: string; + authType?: string; + errorMessage: string; + errorType?: string; + statusCode?: number | string; + }) { this['event.name'] = 'api_error'; this['event.timestamp'] = new Date().toISOString(); - this.response_id = response_id; - this.model = model; - this.error = error; - this.error_type = error_type; - this.status_code = status_code; - this.duration_ms = duration_ms; - this.prompt_id = prompt_id; - this.auth_type = auth_type; + this.response_id = opts.responseId; + this.model = opts.model; + this.duration_ms = opts.durationMs; + this.prompt_id = opts.promptId; + this.auth_type = opts.authType; + this.error_message = opts.errorMessage; + this.error_type = opts.errorType; + this.status_code = opts.statusCode; } } diff --git a/packages/core/src/telemetry/uiTelemetry.test.ts b/packages/core/src/telemetry/uiTelemetry.test.ts index e45032619..37542273a 100644 --- a/packages/core/src/telemetry/uiTelemetry.test.ts +++ b/packages/core/src/telemetry/uiTelemetry.test.ts @@ -301,7 +301,7 @@ describe('UiTelemetryService', () => { 'event.name': EVENT_API_ERROR, model: 'gemini-2.5-pro', duration_ms: 300, - error: 'Something went wrong', + error_message: 'Something went wrong', } as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; service.addEvent(event); @@ -342,7 +342,7 @@ describe('UiTelemetryService', () => { 'event.name': EVENT_API_ERROR, model: 'gemini-2.5-pro', duration_ms: 300, - error: 'Something went wrong', + error_message: 'Something went wrong', } as ApiErrorEvent & { 'event.name': typeof EVENT_API_ERROR }; service.addEvent(responseEvent); diff --git a/packages/core/src/utils/errors.ts b/packages/core/src/utils/errors.ts index b0ba031dd..790123508 100644 --- a/packages/core/src/utils/errors.ts +++ b/packages/core/src/utils/errors.ts @@ -38,6 +38,10 @@ export function isAbortError(error: unknown): boolean { export function getErrorMessage(error: unknown): string { if (error instanceof Error) { + const cause = error.cause; + if (cause instanceof Error && cause.message !== error.message) { + return `${error.message} (cause: ${cause.message})`; + } return error.message; } try { @@ -47,6 +51,80 @@ export function getErrorMessage(error: unknown): string { } } +/** + * Extracts the HTTP status code from an error object. + * + * Checks the following properties in order of priority: + * 1. `error.status` - OpenAI, Anthropic, Gemini SDK errors + * 2. `error.statusCode` - Some HTTP client libraries + * 3. `error.response.status` - Axios-style errors + * 4. `error.error.code` - Nested error objects + * + * @returns The HTTP status code (100-599), or undefined if not found. + */ +export function getErrorStatus(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null) { + return undefined; + } + + const err = error as { + status?: unknown; + statusCode?: unknown; + response?: { status?: unknown }; + error?: { code?: unknown }; + }; + + const value = + err.status ?? err.statusCode ?? err.response?.status ?? err.error?.code; + + return typeof value === 'number' && value >= 100 && value <= 599 + ? value + : undefined; +} + +/** + * Extracts a descriptive error type string from an error object. + * + * Uses the error's constructor name (e.g. "APIConnectionError", + * "APIConnectionTimeoutError") which is more specific than the generic + * `.type` field. Falls back to `.type` for SDK errors that set it, + * then to `error.name`, then "unknown". + * + * For network errors, appends the cause code (e.g. "ECONNREFUSED") + * when available. + * + * @returns A string identifying the error type. + */ +export function getErrorType(error: unknown): string { + if (typeof error !== 'object' || error === null) { + return 'unknown'; + } + + // Prefer the constructor name — SDK subclasses like APIConnectionError, + // RateLimitError etc. have meaningful names. + const constructorName = + error instanceof Error && error.constructor.name !== 'Error' + ? error.constructor.name + : undefined; + + // .type is set by OpenAI SDK (e.g. "invalid_request_error") + const sdkType = (error as { type?: string }).type; + + const baseType = + constructorName ?? + sdkType ?? + (error instanceof Error ? error.name : 'unknown'); + + // For network errors, append the cause code (e.g. ECONNREFUSED, ETIMEDOUT) + const cause = error instanceof Error ? error.cause : undefined; + const causeCode = + cause && typeof cause === 'object' && 'code' in cause + ? (cause as { code?: string }).code + : undefined; + + return causeCode ? `${baseType}:${causeCode}` : baseType; +} + export class FatalError extends Error { constructor( message: string, diff --git a/packages/core/src/utils/quotaErrorDetection.test.ts b/packages/core/src/utils/quotaErrorDetection.test.ts index 01dccec24..0da986623 100644 --- a/packages/core/src/utils/quotaErrorDetection.test.ts +++ b/packages/core/src/utils/quotaErrorDetection.test.ts @@ -16,52 +16,55 @@ import { describe('quotaErrorDetection', () => { describe('isQwenQuotaExceededError', () => { - it('should detect insufficient_quota error message', () => { - const error = new Error('insufficient_quota'); - expect(isQwenQuotaExceededError(error)).toBe(true); - }); - - it('should detect free allocated quota exceeded error message', () => { - const error = new Error('Free allocated quota exceeded.'); - expect(isQwenQuotaExceededError(error)).toBe(true); - }); - - it('should detect quota exceeded error message', () => { - const error = new Error('quota exceeded'); - expect(isQwenQuotaExceededError(error)).toBe(true); - }); - - it('should detect quota exceeded in string error', () => { - const error = 'insufficient_quota'; - expect(isQwenQuotaExceededError(error)).toBe(true); - }); - - it('should detect quota exceeded in structured error', () => { - const error = { message: 'Free allocated quota exceeded.', status: 429 }; - expect(isQwenQuotaExceededError(error)).toBe(true); - }); - - it('should detect quota exceeded in API error', () => { - const error: ApiError = { - error: { - code: 429, - message: 'insufficient_quota', - status: 'RESOURCE_EXHAUSTED', - details: [], - }, + it('should detect the Qwen insufficient_quota error', () => { + const error = { + status: 429, + code: 'insufficient_quota', + message: 'Free allocated quota exceeded.', }; expect(isQwenQuotaExceededError(error)).toBe(true); }); - it('should not detect throttling errors as quota exceeded', () => { - const error = new Error('requests throttling triggered'); + it('should not match when status is not 429', () => { + const error = { + status: 400, + code: 'insufficient_quota', + message: 'Free allocated quota exceeded.', + }; expect(isQwenQuotaExceededError(error)).toBe(false); }); - it('should not detect unrelated errors', () => { - const error = new Error('Network error'); + it('should not match temporary throttling (concurrency 429)', () => { + const error = { + status: 429, + code: 'rate_limit_exceeded', + message: 'Rate limit exceeded', + }; expect(isQwenQuotaExceededError(error)).toBe(false); }); + + it('should not match paid account quota exceeded', () => { + const error = { + status: 429, + code: 'insufficient_quota', + message: 'You exceeded your current quota.', + }; + expect(isQwenQuotaExceededError(error)).toBe(false); + }); + + it('should not match plain Error objects', () => { + const error = new Error('insufficient_quota'); + expect(isQwenQuotaExceededError(error)).toBe(false); + }); + + it('should not match string errors', () => { + expect(isQwenQuotaExceededError('insufficient_quota')).toBe(false); + }); + + it('should not match null or undefined', () => { + expect(isQwenQuotaExceededError(null)).toBe(false); + expect(isQwenQuotaExceededError(undefined)).toBe(false); + }); }); describe('isProQuotaExceededError', () => { diff --git a/packages/core/src/utils/quotaErrorDetection.ts b/packages/core/src/utils/quotaErrorDetection.ts index 1c8af9cd3..87e50aa98 100644 --- a/packages/core/src/utils/quotaErrorDetection.ts +++ b/packages/core/src/utils/quotaErrorDetection.ts @@ -100,27 +100,20 @@ export function isGenericQuotaExceededError(error: unknown): boolean { } export function isQwenQuotaExceededError(error: unknown): boolean { - // Check for Qwen insufficient quota errors (should not retry) - const checkMessage = (message: string): boolean => { - const lowerMessage = message.toLowerCase(); - return ( - lowerMessage.includes('insufficient_quota') || - lowerMessage.includes('free allocated quota exceeded') || - (lowerMessage.includes('quota') && lowerMessage.includes('exceeded')) - ); + // Match the specific Qwen free-tier quota error to distinguish it from + // temporary throttling (429 due to concurrency) or paid account quota limits. + if (typeof error !== 'object' || error === null) { + return false; + } + const { status, code, message } = error as { + status?: number; + code?: string; + message?: string; }; - - if (typeof error === 'string') { - return checkMessage(error); - } - - if (isStructuredError(error)) { - return checkMessage(error.message); - } - - if (isApiError(error)) { - return checkMessage(error.error.message); - } - - return false; + return ( + status === 429 && + code === 'insufficient_quota' && + typeof message === 'string' && + message.toLowerCase().includes('free allocated quota exceeded') + ); } diff --git a/packages/core/src/utils/retry.test.ts b/packages/core/src/utils/retry.test.ts index a628719a5..a0e269950 100644 --- a/packages/core/src/utils/retry.test.ts +++ b/packages/core/src/utils/retry.test.ts @@ -7,7 +7,8 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import type { HttpError } from './retry.js'; -import { getErrorStatus, retryWithBackoff } from './retry.js'; +import { retryWithBackoff } from './retry.js'; +import { getErrorStatus } from './errors.js'; import { setSimulate429 } from './testUtils.js'; import { AuthType } from '../core/contentGenerator.js'; @@ -312,7 +313,10 @@ describe('retryWithBackoff', () => { }); it('should throw immediately for Qwen OAuth with insufficient_quota message', async () => { - const errorWithInsufficientQuota = new Error('insufficient_quota'); + const errorWithInsufficientQuota = Object.assign( + new Error('Free allocated quota exceeded.'), + { status: 429, code: 'insufficient_quota' }, + ); const fn = vi.fn().mockRejectedValue(errorWithInsufficientQuota); @@ -330,8 +334,9 @@ describe('retryWithBackoff', () => { }); it('should throw immediately for Qwen OAuth with free allocated quota exceeded message', async () => { - const errorWithQuotaExceeded = new Error( - 'Free allocated quota exceeded.', + const errorWithQuotaExceeded = Object.assign( + new Error('Free allocated quota exceeded.'), + { status: 429, code: 'insufficient_quota' }, ); const fn = vi.fn().mockRejectedValue(errorWithQuotaExceeded); @@ -403,7 +408,10 @@ describe('retryWithBackoff', () => { }); it('should throw immediately for Qwen OAuth with quota message', async () => { - const errorWithQuota = new Error('quota exceeded'); + const errorWithQuota = Object.assign( + new Error('Free allocated quota exceeded.'), + { status: 429, code: 'insufficient_quota' }, + ); const fn = vi.fn().mockRejectedValue(errorWithQuota); diff --git a/packages/core/src/utils/retry.ts b/packages/core/src/utils/retry.ts index 5ce79f08f..e03a3d682 100644 --- a/packages/core/src/utils/retry.ts +++ b/packages/core/src/utils/retry.ts @@ -8,6 +8,7 @@ import type { GenerateContentResponse } from '@google/genai'; import { AuthType } from '../core/contentGenerator.js'; import { isQwenQuotaExceededError } from './quotaErrorDetection.js'; import { createDebugLogger } from './debugLogger.js'; +import { getErrorStatus } from './errors.js'; const debugLogger = createDebugLogger('RETRY'); @@ -151,38 +152,6 @@ export async function retryWithBackoff( throw new Error('Retry attempts exhausted'); } -/** - * Extracts the HTTP status code from an error object. - * - * Checks the following properties in order of priority: - * 1. `error.status` - OpenAI, Anthropic, Gemini SDK errors - * 2. `error.statusCode` - Some HTTP client libraries - * 3. `error.response.status` - Axios-style errors - * 4. `error.error.code` - Nested error objects - * - * @param error The error object. - * @returns The HTTP status code (100-599), or undefined if not found. - */ -export function getErrorStatus(error: unknown): number | undefined { - if (typeof error !== 'object' || error === null) { - return undefined; - } - - const err = error as { - status?: unknown; - statusCode?: unknown; - response?: { status?: unknown }; - error?: { code?: unknown }; - }; - - const value = - err.status ?? err.statusCode ?? err.response?.status ?? err.error?.code; - - return typeof value === 'number' && value >= 100 && value <= 599 - ? value - : undefined; -} - /** * Extracts the Retry-After delay from an error object's headers. * @param error The error object. From 257934f1e94ca9b7477f6e549c6eca46fa75ff04 Mon Sep 17 00:00:00 2001 From: DennisYu07 <617072224@qq.com> Date: Wed, 18 Mar 2026 01:25:11 -0700 Subject: [PATCH 57/82] resolve comment --- .../core/src/extension/claude-converter.ts | 115 ++++++++++++++++++ .../core/src/extension/extensionManager.ts | 110 ----------------- 2 files changed, 115 insertions(+), 110 deletions(-) diff --git a/packages/core/src/extension/claude-converter.ts b/packages/core/src/extension/claude-converter.ts index 1d0b65efe..ff5ba72a9 100644 --- a/packages/core/src/extension/claude-converter.ts +++ b/packages/core/src/extension/claude-converter.ts @@ -30,6 +30,117 @@ import { substituteHookVariables } from './variables.js'; const debugLogger = createDebugLogger('CLAUDE_CONVERTER'); +/** + * Perform variable replacement in all markdown and shell script files of the extension. + * This is done during the conversion phase to avoid modifying files during every extension load. + * @param extensionPath - The path to the extension directory + */ +export function performVariableReplacement(extensionPath: string): void { + // Process markdown files + const mdGlobPattern = '**/*.md'; + const mdGlobOptions = { + cwd: extensionPath, + nodir: true, + }; + + try { + const mdFiles = glob.sync(mdGlobPattern, mdGlobOptions); + + for (const file of mdFiles) { + const filePath = path.join(extensionPath, file); + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path + const updatedContent = content.replace( + /\$\{CLAUDE_PLUGIN_ROOT\}/g, + extensionPath, + ); + + // Replace Markdown shell syntax ```! ... ``` with system-recognized !{...} syntax + // This regex finds code blocks with ! language identifier and captures their content + const updatedMdContent = updatedContent.replace( + /```!(?:\s*\n)?([\s\S]*?)\n*```/g, + '!{$1}', + ); + + // Only write if content was actually changed + if (updatedMdContent !== content) { + fs.writeFileSync(filePath, updatedMdContent, 'utf8'); + debugLogger.debug( + `Updated variables and syntax in file: ${filePath}`, + ); + } + } catch (error) { + debugLogger.warn( + `Failed to process file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } catch (error) { + debugLogger.warn( + `Failed to scan markdown files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + + // Process shell script files + const scriptGlobPattern = '**/*.sh'; + const scriptGlobOptions = { + cwd: extensionPath, + nodir: true, + }; + + try { + const scriptFiles = glob.sync(scriptGlobPattern, scriptGlobOptions); + + for (const file of scriptFiles) { + const filePath = path.join(extensionPath, file); + + try { + const content = fs.readFileSync(filePath, 'utf8'); + + // Replace references to "role":"assistant" with "type":"assistant" in shell scripts + const updatedScriptContent = content.replace( + /"role":"assistant"/g, + '"type":"assistant"', + ); + + // Replace transcript parsing logic to adapt to actual transcript structure + // Change from .message.content | map(select(.type == "text")) to .message.parts | map(select(has("text"))) + const adaptedScriptContent = updatedScriptContent.replace( + /\.message\.content\s*\|\s*map\(select\(\.type\s*==\s*"text"\)\)/g, + '.message.parts | map(select(has("text")))', + ); + + // Replace references to ".claude" directory with ".qwen" in shell scripts + // Only match path references (e.g., ~/.claude/, $HOME/.claude, ./.claude/) + // Avoid matching URLs, comments, or string literals containing .claude + const finalScriptContent = adaptedScriptContent.replace( + /(\$\{?HOME\}?\/|~\/)?\.claude(\/|$)/g, + '$1.qwen$2', + ); + + // Only write if content was actually changed + if (finalScriptContent !== content) { + fs.writeFileSync(filePath, finalScriptContent, 'utf8'); + debugLogger.debug( + `Updated transcript format and replaced .claude with .qwen in shell script: ${filePath}`, + ); + } + } catch (error) { + debugLogger.warn( + `Failed to process shell script file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } + } + } catch (error) { + debugLogger.warn( + `Failed to scan shell script files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, + ); + } +} + export interface ClaudePluginConfig { name: string; version: string; @@ -512,6 +623,10 @@ export async function convertClaudePluginPackage( const agentsDestDir = path.join(tmpDir, 'agents'); await convertAgentFiles(agentsDestDir); + // Step 9.2: Perform variable replacement in markdown and shell script files + // This is done during conversion to avoid modifying files during every extension load + performVariableReplacement(tmpDir); + // Step 10: Convert to Qwen format config const qwenConfig = convertClaudeToQwenConfig(mergedConfig); diff --git a/packages/core/src/extension/extensionManager.ts b/packages/core/src/extension/extensionManager.ts index 1d10bfc89..d0382347e 100644 --- a/packages/core/src/extension/extensionManager.ts +++ b/packages/core/src/extension/extensionManager.ts @@ -710,9 +710,6 @@ export class ExtensionManager { } } - // Replace variables in all markdown files in the extension - this.performVariableReplacement(effectiveExtensionPath); - return extension; } catch (e) { debugLogger.warn( @@ -734,113 +731,6 @@ export class ExtensionManager { return substituteHookVariables(hooks, extensionPath); } - /** - * Perform variable replacement in all markdown files of the extension - */ - private performVariableReplacement(extensionPath: string): void { - // Process markdown files - const mdGlobPattern = '**/*.md'; - const mdGlobOptions = { - cwd: extensionPath, - nodir: true, - }; - - try { - const mdFiles = glob.sync(mdGlobPattern, mdGlobOptions); - - for (const file of mdFiles) { - const filePath = path.join(extensionPath, file); - - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Replace ${CLAUDE_PLUGIN_ROOT} with the actual extension path - const updatedContent = content.replace( - /\$\{CLAUDE_PLUGIN_ROOT\}/g, - extensionPath, - ); - - // Replace Markdown shell syntax ```! ... ``` with system-recognized !{...} syntax - // This regex finds code blocks with ! language identifier and captures their content - const updatedMdContent = updatedContent.replace( - /```!(?:\s*\n)?([\s\S]*?)\n*```/g, - '!{$1}', - ); - - // Only write if content was actually changed - if (updatedMdContent !== content) { - fs.writeFileSync(filePath, updatedMdContent, 'utf8'); - debugLogger.debug( - `Updated variables and syntax in file: ${filePath}`, - ); - } - } catch (error) { - debugLogger.warn( - `Failed to process file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } catch (error) { - debugLogger.warn( - `Failed to scan markdown files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - - // Process shell script files - const scriptGlobPattern = '**/*.sh'; - const scriptGlobOptions = { - cwd: extensionPath, - nodir: true, - }; - - try { - const scriptFiles = glob.sync(scriptGlobPattern, scriptGlobOptions); - - for (const file of scriptFiles) { - const filePath = path.join(extensionPath, file); - - try { - const content = fs.readFileSync(filePath, 'utf8'); - - // Replace references to "role":"assistant" with "type":"assistant" in shell scripts - const updatedScriptContent = content.replace( - /"role":"assistant"/g, - '"type":"assistant"', - ); - - // Replace transcript parsing logic to adapt to actual transcript structure - // Change from .message.content | map(select(.type == "text")) to .message.parts | map(select(has("text"))) - const adaptedScriptContent = updatedScriptContent.replace( - /\.message\.content\s*\|\s*map\(select\(\.type\s*==\s*"text"\)\)/g, - '.message.parts | map(select(has("text")))', - ); - - // Replace references to ".claude" with ".qwen" in shell scripts - const finalScriptContent = adaptedScriptContent.replace( - /\.claude/g, - '.qwen', - ); - - // Only write if content was actually changed - if (finalScriptContent !== content) { - fs.writeFileSync(filePath, finalScriptContent, 'utf8'); - debugLogger.debug( - `Updated transcript format and replaced .claude with .qwen in shell script: ${filePath}`, - ); - } - } catch (error) { - debugLogger.warn( - `Failed to process shell script file ${filePath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - } catch (error) { - debugLogger.warn( - `Failed to scan shell script files in extension directory ${extensionPath}: ${error instanceof Error ? error.message : String(error)}`, - ); - } - } - loadInstallMetadata( extensionDir: string, ): ExtensionInstallMetadata | undefined { From f67e28b4be21626839e8033f3fb05b840af5a8f1 Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 18 Mar 2026 16:26:59 +0800 Subject: [PATCH 58/82] docs(arena): add Agent Arena documentation Add comprehensive documentation for the Agent Arena feature, covering usage, configuration, best practices, troubleshooting, and limitations. Update navigation metadata to include the new page. This enables users to discover and learn about the multi-model comparison capability for competitive task execution. Co-authored-by: Qwen-Coder --- docs/users/features/_meta.ts | 1 + docs/users/features/arena.md | 218 +++++++++++++++++++++++++++++++++++ 2 files changed, 219 insertions(+) create mode 100644 docs/users/features/arena.md diff --git a/docs/users/features/_meta.ts b/docs/users/features/_meta.ts index f5218e85f..9cf6d403f 100644 --- a/docs/users/features/_meta.ts +++ b/docs/users/features/_meta.ts @@ -1,6 +1,7 @@ export default { commands: 'Commands', 'sub-agents': 'SubAgents', + arena: 'Agent Arena', skills: 'Skills', headless: 'Headless Mode', checkpointing: { diff --git a/docs/users/features/arena.md b/docs/users/features/arena.md new file mode 100644 index 000000000..7b53238c7 --- /dev/null +++ b/docs/users/features/arena.md @@ -0,0 +1,218 @@ +# Agent Arena + +> Dispatch multiple AI models simultaneously to execute the same task, compare their solutions side-by-side, and select the best result to apply to your workspace. + +> [!warning] +> Agent Arena is experimental. It has [known limitations](#limitations) around display modes and session management. + +Agent Arena lets you pit multiple AI models against each other on the same task. Each model runs as a fully independent agent in its own isolated Git worktree, so file operations never interfere. When all agents finish, you compare results and select a winner to merge back into your main workspace. + +Unlike [subagents](/users/features/sub-agents), which delegate focused subtasks within a single session, Arena agents are complete, top-level agent instances — each with its own model, context window, and full tool access. + +This page covers: + +- [When to use Agent Arena](#when-to-use-agent-arena) +- [Starting an arena session](#start-an-arena-session) +- [Interacting with agents](#interact-with-agents), including display modes and navigation +- [Comparing results and selecting a winner](#compare-results-and-select-a-winner) +- [Best practices](#best-practices) + +## When to use Agent Arena + +Agent Arena is most effective when you want to **evaluate or compare** how different models tackle the same problem. The strongest use cases are: + +- **Model benchmarking**: Evaluate different models' capabilities on real tasks in your actual codebase, not synthetic benchmarks +- **Best-of-N selection**: Get multiple independent solutions and pick the best implementation +- **Exploring approaches**: See how different models reason about and solve the same problem — useful for learning and insight +- **Risk reduction**: For critical changes, validate that multiple models converge on a similar approach before committing + +Agent Arena uses significantly more tokens than a single session (each agent has its own context window and model calls). It works best when the value of comparison justifies the cost. For routine tasks where you trust your default model, a single session is more efficient. + +## Start an arena session + +Use the `/arena` slash command to launch a session. Specify the models you want to compete and the task: + +``` +/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Refactor the authentication module to use JWT tokens" +``` + +If you omit `--models`, an interactive model selection dialog appears, letting you pick from your configured providers. + +### What happens when you start + +1. **Worktree setup**: Qwen Code creates isolated Git worktrees for each agent at `~/.qwen/arena//worktrees//`. Each worktree mirrors your current working directory state exactly — including staged changes, unstaged changes, and untracked files. +2. **Agent spawning**: Each agent starts in its own worktree with full tool access and its configured model. Agents are launched sequentially but execute in parallel. +3. **Execution**: All agents work on the task independently with no shared state or communication. You can monitor their progress and interact with any of them. +4. **Completion**: When all agents finish (or fail), you enter the result comparison phase. + +## Interact with agents + +### Display modes + +Agent Arena currently supports **in-process mode**, where all agents run asynchronously within the same terminal process. A tab bar at the bottom of the terminal lets you switch between agents. + +> [!note] +> **Split-pane display modes are planned for the future.** We intend to support tmux-based and iTerm2-based split-pane layouts, where each agent gets its own terminal pane for true side-by-side viewing. Currently, only in-process tab switching is available. + +### Navigate between agents + +In in-process mode, use keyboard shortcuts to switch between agent views: + +| Shortcut | Action | +| :------- | :-------------------------------- | +| `Right` | Switch to the next agent tab | +| `Left` | Switch to the previous agent tab | +| `Up` | Switch focus to the input box | +| `Down` | Switch focus to the agent tab bar | + +The tab bar shows each agent's current status: + +| Indicator | Meaning | +| :-------- | :--------------------- | +| `●` | Running or idle | +| `✓` | Completed successfully | +| `✗` | Failed | +| `○` | Cancelled | + +### Interact with individual agents + +When viewing an agent's tab, you can: + +- **Send messages** — type in the input area to give the agent additional instructions +- **Approve tool calls** — if an agent requests tool approval, the confirmation dialog appears in its tab +- **View full history** — scroll through the agent's complete conversation, including model output, tool calls, and results + +Each agent is a full, independent session. Anything you can do with the main agent, you can do with an arena agent. + +## Compare results and select a winner + +When all agents complete, the Arena enters the result comparison phase. You'll see: + +- **Status summary**: Which agents succeeded, failed, or were cancelled +- **Execution metrics**: Duration, rounds of reasoning, token usage, and tool call counts for each agent + +A selection dialog presents the successful agents. Choose one to apply its changes to your main workspace, or discard all results. + +### What happens when you select a winner + +1. The winning agent's changes are extracted as a diff against the baseline +2. The diff is applied to your main working directory +3. All worktrees and temporary branches are cleaned up automatically + +If you want to inspect results before deciding, each agent's full conversation history is available via the tab bar while the selection dialog is active. + +## Configuration + +Arena behavior can be customized in [settings.json](/users/configuration/settings): + +```json +{ + "arena": { + "worktreeBaseDir": "~/.qwen/arena", + "maxRoundsPerAgent": 50, + "timeoutSeconds": 600 + } +} +``` + +| Setting | Description | Default | +| :------------------------ | :--------------------------------- | :-------------- | +| `arena.worktreeBaseDir` | Base directory for arena worktrees | `~/.qwen/arena` | +| `arena.maxRoundsPerAgent` | Maximum reasoning rounds per agent | `50` | +| `arena.timeoutSeconds` | Timeout for each agent in seconds | `600` | + +## Best practices + +### Choose models that complement each other + +Arena is most valuable when you compare models with meaningfully different strengths. For example: + +``` +/arena --models qwen3.5-plus,glm-5,kimi-k2.5 "Optimize the database query layer" +``` + +Comparing three versions of the same model family yields less insight than comparing across providers. + +### Keep tasks self-contained + +Arena agents work independently with no communication. Tasks should be fully describable in the prompt without requiring back-and-forth: + +**Good**: "Refactor the payment module to use the strategy pattern. Update all tests." + +**Less effective**: "Let's discuss how to improve the payment module" — this benefits from conversation, which is better suited to a single session. + +### Limit the number of agents + +Up to 5 agents can run simultaneously. In practice, 2-3 agents provide the best balance of comparison value to resource cost. More agents means: + +- Higher token costs (each agent has its own context window) +- Longer total execution time +- More results to compare + +Start with 2-3 and scale up only when the comparison value justifies it. + +### Use Arena for high-impact decisions + +Arena shines when the stakes justify running multiple models: + +- Choosing an architecture for a new module +- Selecting an approach for a complex refactor +- Validating a critical bug fix from multiple angles + +For routine changes like renaming a variable or updating a config file, a single session is faster and cheaper. + +## Troubleshooting + +### Agents failing to start + +- Verify that each model in `--models` is properly configured with valid API credentials +- Check that your working directory is a Git repository (worktrees require Git) +- Ensure you have write access to the worktree base directory (`~/.qwen/arena/` by default) + +### Worktree creation fails + +- Run `git worktree list` to check for stale worktrees from previous sessions +- Clean up stale worktrees with `git worktree prune` +- Ensure your Git version supports worktrees (`git --version`, requires Git 2.5+) + +### Agent takes too long + +- Increase the timeout: set `arena.timeoutSeconds` in settings +- Reduce task complexity — Arena tasks should be focused and well-defined +- Lower `arena.maxRoundsPerAgent` if agents are spending too many rounds + +### Applying winner fails + +- Check for uncommitted changes in your main working directory that might conflict +- The diff is applied as a patch — merge conflicts are possible if your working directory changed during the session + +## Limitations + +Agent Arena is experimental. Current limitations: + +- **In-process mode only**: Split-pane display via tmux or iTerm2 is not yet available. All agents run within a single terminal window with tab switching. +- **No diff preview before selection**: You can view each agent's conversation history, but there is no unified diff viewer to compare solutions side-by-side before picking a winner. +- **No worktree retention**: Worktrees are always cleaned up after selection. There is no option to preserve them for further inspection. +- **No session resumption**: Arena sessions cannot be resumed after exiting. If you close the terminal mid-session, worktrees remain on disk and must be cleaned up manually via `git worktree prune`. +- **Maximum 5 agents**: The hard limit of 5 concurrent agents cannot be changed. +- **Git repository required**: Arena requires a Git repository for worktree isolation. It cannot be used in non-Git directories. + +## Comparison with other multi-agent modes + +Agent Arena is one of several planned multi-agent modes in Qwen Code. **Agent Team** and **Agent Swarm** are not yet implemented — the table below describes their intended design for reference. + +| | **Agent Arena** | **Agent Team** (planned) | **Agent Swarm** (planned) | +| :---------------- | :----------------------------------------------------- | :------------------------------------------------- | :------------------------------------------------------- | +| **Goal** | Competitive: Find the best solution to the _same_ task | Collaborative: Tackle _different_ aspects together | Batch parallel: Dynamically spawn workers for bulk tasks | +| **Agents** | Pre-configured models compete independently | Teammates collaborate with assigned roles | Workers spawned on-the-fly, destroyed on completion | +| **Communication** | No inter-agent communication | Direct peer-to-peer messaging | One-way: results aggregated by parent | +| **Isolation** | Full: separate Git worktrees | Independent sessions with shared task list | Lightweight ephemeral context per worker | +| **Output** | One selected solution applied to workspace | Synthesized results from multiple perspectives | Aggregated results from parallel processing | +| **Best for** | Benchmarking, choosing between model approaches | Research, complex collaboration, cross-layer work | Batch operations, data processing, map-reduce tasks | + +## Next steps + +Explore related approaches for parallel and delegated work: + +- **Lightweight delegation**: [Subagents](/users/features/sub-agents) handle focused subtasks within your session — better when you don't need model comparison +- **Manual parallel sessions**: Run multiple Qwen Code sessions yourself in separate terminals with [Git worktrees](https://git-scm.com/docs/git-worktree) for full manual control From 2bd3c293ffe4740031dd431d0d28e3bf470e00fe Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 17:06:25 +0800 Subject: [PATCH 59/82] refactor(completion): enhance trigger detection logic for completion suggestions --- .../src/webview/hooks/useCompletionTrigger.ts | 52 ++++++++----------- 1 file changed, 21 insertions(+), 31 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 67e62d2c6..7dcaf169e 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -301,44 +301,34 @@ export function useCompletionTrigger( const lastAtMatch = textBeforeCursor.lastIndexOf('@'); const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); - // Build candidate triggers sorted by proximity (nearest first) - const candidates: Array<{ pos: number; char: '@' | '/' }> = []; - if (lastAtMatch >= 0) { - candidates.push({ pos: lastAtMatch, char: '@' }); - } - if (lastSlashMatch >= 0) { - candidates.push({ pos: lastSlashMatch, char: '/' }); - } - // Sort by position descending (nearest to cursor first) - candidates.sort((a, b) => b.pos - a.pos); - - // Find the nearest valid trigger (at word boundary) let triggerPos = -1; let triggerChar: '@' | '/' | null = null; - for (const candidate of candidates) { - const charBefore = candidate.pos > 0 ? text[candidate.pos - 1] : ' '; - const isValidTrigger = - charBefore === ' ' || charBefore === '\n' || candidate.pos === 0; - - if (isValidTrigger) { - triggerPos = candidate.pos; - triggerChar = candidate.char; - break; - } + // Check if we're in a trigger context + if (lastAtMatch > lastSlashMatch) { + triggerPos = lastAtMatch; + triggerChar = '@'; + } else if (lastSlashMatch > lastAtMatch) { + triggerPos = lastSlashMatch; + triggerChar = '/'; } - // Check if we found a valid trigger + // Check if trigger is at word boundary (start of line or after space) if (triggerPos >= 0 && triggerChar) { - const query = text.substring(triggerPos + 1, effectiveCursorPosition); + const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; + const isValidTrigger = + charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + if (isValidTrigger) { + const query = text.substring(triggerPos + 1, effectiveCursorPosition); - // Only show if query doesn't contain spaces (still typing the reference) - if (!query.includes(' ') && !query.includes('\n')) { - // Get precise cursor position for menu - const cursorPos = getCursorPosition(); - if (cursorPos) { - await openCompletion(triggerChar, query, cursorPos); - return; + // Only show if query doesn't contain spaces (still typing the reference) + if (!query.includes(' ') && !query.includes('\n')) { + // Get precise cursor position for menu + const cursorPos = getCursorPosition(); + if (cursorPos) { + await openCompletion(triggerChar, query, cursorPos); + return; + } } } } From 8f5ecbc46c6034323bee34405aa3b7621c168028 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 17:12:46 +0800 Subject: [PATCH 60/82] refactor(completion): improve trigger detection logic for completion suggestions by prioritizing '@' over '/' and refining context checks --- .../src/webview/hooks/useCompletionTrigger.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts index 7dcaf169e..6fad7cba5 100644 --- a/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts +++ b/packages/vscode-ide-companion/src/webview/hooks/useCompletionTrigger.ts @@ -301,14 +301,17 @@ export function useCompletionTrigger( const lastAtMatch = textBeforeCursor.lastIndexOf('@'); const lastSlashMatch = textBeforeCursor.lastIndexOf('/'); + // Check if we're in a trigger context let triggerPos = -1; let triggerChar: '@' | '/' | null = null; - // Check if we're in a trigger context - if (lastAtMatch > lastSlashMatch) { + // Priority: @ trigger takes precedence over / trigger + // This allows path-like queries (e.g., "src/components/Button") in @ mentions + // But skip if the trigger is inside a file tag + if (lastAtMatch >= 0) { triggerPos = lastAtMatch; triggerChar = '@'; - } else if (lastSlashMatch > lastAtMatch) { + } else if (lastSlashMatch >= 0) { triggerPos = lastSlashMatch; triggerChar = '/'; } @@ -318,6 +321,7 @@ export function useCompletionTrigger( const charBefore = triggerPos > 0 ? text[triggerPos - 1] : ' '; const isValidTrigger = charBefore === ' ' || charBefore === '\n' || triggerPos === 0; + if (isValidTrigger) { const query = text.substring(triggerPos + 1, effectiveCursorPosition); From 40485c59ac50956dfcf6db67aa77fc9bdc118115 Mon Sep 17 00:00:00 2001 From: qqqys Date: Wed, 18 Mar 2026 17:35:37 +0800 Subject: [PATCH 61/82] feat(ui): implement per-task token tracking in LoadingIndicator --- packages/cli/src/ui/AppContainer.tsx | 18 ++- .../cli/src/ui/components/Composer.test.tsx | 1 + packages/cli/src/ui/components/Composer.tsx | 6 +- .../cli/src/ui/contexts/UIStateContext.tsx | 2 + .../src/ui/hooks/useLoadingIndicator.test.ts | 115 ++++++++++++++++++ .../cli/src/ui/hooks/useLoadingIndicator.ts | 20 ++- 6 files changed, 150 insertions(+), 12 deletions(-) diff --git a/packages/cli/src/ui/AppContainer.tsx b/packages/cli/src/ui/AppContainer.tsx index c6bfa67c3..5767d40cc 100644 --- a/packages/cli/src/ui/AppContainer.tsx +++ b/packages/cli/src/ui/AppContainer.tsx @@ -1022,10 +1022,16 @@ export const AppContainer = (props: AppContainerProps) => { [historyManager, setShowCommandMigrationNudge, config.storage], ); - const { elapsedTime, currentLoadingPhrase } = useLoadingIndicator( - streamingState, - settings.merged.ui?.customWittyPhrases, - ); + const currentCandidatesTokens = Object.values( + sessionStats.metrics?.models ?? {}, + ).reduce((acc, model) => acc + (model.tokens?.candidates ?? 0), 0); + + const { elapsedTime, currentLoadingPhrase, taskStartTokens } = + useLoadingIndicator( + streamingState, + settings.merged.ui?.customWittyPhrases, + currentCandidatesTokens, + ); useAttentionNotifications({ isFocused, @@ -1430,6 +1436,8 @@ export const AppContainer = (props: AppContainerProps) => { isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, + // Per-task token tracking + taskStartTokens, }), [ isThemeDialogOpen, @@ -1524,6 +1532,8 @@ export const AppContainer = (props: AppContainerProps) => { isMcpDialogOpen, // Feedback dialog isFeedbackDialogOpen, + // Per-task token tracking + taskStartTokens, ], ); diff --git a/packages/cli/src/ui/components/Composer.test.tsx b/packages/cli/src/ui/components/Composer.test.tsx index 67d992dbe..5d969de5c 100644 --- a/packages/cli/src/ui/components/Composer.test.tsx +++ b/packages/cli/src/ui/components/Composer.test.tsx @@ -111,6 +111,7 @@ const createMockUIState = (overrides: Partial = {}): UIState => debugMessage: '', nightly: false, isTrustedFolder: true, + taskStartTokens: 0, ...overrides, }) as UIState; diff --git a/packages/cli/src/ui/components/Composer.tsx b/packages/cli/src/ui/components/Composer.tsx index 70eb59a05..e1a0bac0b 100644 --- a/packages/cli/src/ui/components/Composer.tsx +++ b/packages/cli/src/ui/components/Composer.tsx @@ -27,7 +27,7 @@ export const Composer = () => { const uiActions = useUIActions(); const { vimEnabled } = useVimMode(); - const { showAutoAcceptIndicator, sessionStats } = uiState; + const { showAutoAcceptIndicator, sessionStats, taskStartTokens } = uiState; const tokens = Object.values(sessionStats.metrics?.models ?? {}).reduce( (acc, model) => ({ @@ -37,6 +37,8 @@ export const Composer = () => { { prompt: 0, candidates: 0 }, ); + const taskTokens = tokens.candidates - taskStartTokens; + // State for keyboard shortcuts display toggle const [showShortcuts, setShowShortcuts] = useState(false); const handleToggleShortcuts = useCallback(() => { @@ -72,7 +74,7 @@ export const Composer = () => { : uiState.currentLoadingPhrase } elapsedTime={uiState.elapsedTime} - candidatesTokens={tokens.candidates} + candidatesTokens={taskTokens} /> )} diff --git a/packages/cli/src/ui/contexts/UIStateContext.tsx b/packages/cli/src/ui/contexts/UIStateContext.tsx index 0d461e70c..3a65aa6ce 100644 --- a/packages/cli/src/ui/contexts/UIStateContext.tsx +++ b/packages/cli/src/ui/contexts/UIStateContext.tsx @@ -131,6 +131,8 @@ export interface UIState { isMcpDialogOpen: boolean; // Feedback dialog isFeedbackDialogOpen: boolean; + // Per-task token tracking + taskStartTokens: number; } export const UIStateContext = createContext(null); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts index 0845658ed..25e3bfe10 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.test.ts @@ -133,4 +133,119 @@ describe('useLoadingIndicator', () => { }); expect(result.current.elapsedTime).toBe(0); }); + + describe('token tracking', () => { + it('should capture token snapshot when task starts', () => { + const { result, rerender } = renderHook( + ({ streamingState, currentCandidatesTokens }) => + useLoadingIndicator( + streamingState, + undefined, + currentCandidatesTokens, + ), + { + initialProps: { + streamingState: StreamingState.Idle, + currentCandidatesTokens: 100, + }, + }, + ); + + expect(result.current.taskStartTokens).toBe(0); + + act(() => { + rerender({ + streamingState: StreamingState.Responding, + currentCandidatesTokens: 100, + }); + }); + + expect(result.current.taskStartTokens).toBe(100); + }); + + it('should reset token snapshot when transitioning from Responding to Idle', async () => { + const { result, rerender } = renderHook( + ({ streamingState, currentCandidatesTokens }) => + useLoadingIndicator( + streamingState, + undefined, + currentCandidatesTokens, + ), + { + initialProps: { + streamingState: StreamingState.Idle, + currentCandidatesTokens: 0, + }, + }, + ); + + act(() => { + rerender({ + streamingState: StreamingState.Responding, + currentCandidatesTokens: 0, + }); + }); + expect(result.current.taskStartTokens).toBe(0); + + await act(async () => { + await vi.advanceTimersByTimeAsync(1000); + rerender({ + streamingState: StreamingState.Responding, + currentCandidatesTokens: 500, + }); + }); + + act(() => { + rerender({ + streamingState: StreamingState.Idle, + currentCandidatesTokens: 500, + }); + }); + + expect(result.current.taskStartTokens).toBe(0); + }); + + it('should reset token snapshot when transitioning from WaitingForConfirmation to Responding', async () => { + const { result, rerender } = renderHook( + ({ streamingState, currentCandidatesTokens }) => + useLoadingIndicator( + streamingState, + undefined, + currentCandidatesTokens, + ), + { + initialProps: { + streamingState: StreamingState.Responding, + currentCandidatesTokens: 100, + }, + }, + ); + + expect(result.current.taskStartTokens).toBe(100); + + await act(async () => { + await vi.advanceTimersByTimeAsync(5000); + rerender({ + streamingState: StreamingState.Responding, + currentCandidatesTokens: 500, + }); + }); + + act(() => { + rerender({ + streamingState: StreamingState.WaitingForConfirmation, + currentCandidatesTokens: 500, + }); + }); + + act(() => { + rerender({ + streamingState: StreamingState.Responding, + currentCandidatesTokens: 500, + }); + }); + + expect(result.current.taskStartTokens).toBe(500); + }); + }); }); diff --git a/packages/cli/src/ui/hooks/useLoadingIndicator.ts b/packages/cli/src/ui/hooks/useLoadingIndicator.ts index d69df1706..63cab5711 100644 --- a/packages/cli/src/ui/hooks/useLoadingIndicator.ts +++ b/packages/cli/src/ui/hooks/useLoadingIndicator.ts @@ -7,11 +7,12 @@ import { StreamingState } from '../types.js'; import { useTimer } from './useTimer.js'; import { usePhraseCycler } from './usePhraseCycler.js'; -import { useState, useEffect, useRef } from 'react'; // Added useRef +import { useState, useEffect, useRef } from 'react'; export const useLoadingIndicator = ( streamingState: StreamingState, customWittyPhrases?: string[], + currentCandidatesTokens?: number, ) => { const [timerResetKey, setTimerResetKey] = useState(0); const isTimerActive = streamingState === StreamingState.Responding; @@ -27,6 +28,7 @@ export const useLoadingIndicator = ( ); const [retainedElapsedTime, setRetainedElapsedTime] = useState(0); + const [taskStartTokens, setTaskStartTokens] = useState(0); const prevStreamingStateRef = useRef(null); useEffect(() => { @@ -35,21 +37,26 @@ export const useLoadingIndicator = ( streamingState === StreamingState.Responding ) { setTimerResetKey((prevKey) => prevKey + 1); - setRetainedElapsedTime(0); // Clear retained time when going back to responding + setRetainedElapsedTime(0); + setTaskStartTokens(currentCandidatesTokens ?? 0); } else if ( streamingState === StreamingState.Idle && prevStreamingStateRef.current === StreamingState.Responding ) { - setTimerResetKey((prevKey) => prevKey + 1); // Reset timer when becoming idle from responding + setTimerResetKey((prevKey) => prevKey + 1); setRetainedElapsedTime(0); + setTaskStartTokens(0); + } else if ( + streamingState === StreamingState.Responding && + prevStreamingStateRef.current !== StreamingState.Responding + ) { + setTaskStartTokens(currentCandidatesTokens ?? 0); } else if (streamingState === StreamingState.WaitingForConfirmation) { - // Capture the time when entering WaitingForConfirmation - // elapsedTimeFromTimer will hold the last value from when isTimerActive was true. setRetainedElapsedTime(elapsedTimeFromTimer); } prevStreamingStateRef.current = streamingState; - }, [streamingState, elapsedTimeFromTimer]); + }, [streamingState, elapsedTimeFromTimer, currentCandidatesTokens]); return { elapsedTime: @@ -57,5 +64,6 @@ export const useLoadingIndicator = ( ? retainedElapsedTime : elapsedTimeFromTimer, currentLoadingPhrase, + taskStartTokens, }; }; From 3bfe34a1dc949c2b3cfba4e372522b435a67a086 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 18 Mar 2026 17:51:50 +0800 Subject: [PATCH 62/82] telemetry: track cached content tokens for accurate context calculation - Add cachedContentTokenCount tracking in uiTelemetry service - Collect cached_content_token_count from streaming usage metadata - Use cached tokens instead of estimated overhead when available - Fix messages token calculation to avoid 'messages = 0' issue This improves context window display accuracy when using providers that support prefix caching (e.g., DashScope). Co-authored-by: Qwen-Coder --- packages/cli/src/ui/commands/contextCommand.ts | 15 ++++++++++++++- packages/core/src/core/geminiChat.ts | 9 ++++++++- packages/core/src/telemetry/uiTelemetry.ts | 10 ++++++++++ 3 files changed, 32 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/commands/contextCommand.ts b/packages/cli/src/ui/commands/contextCommand.ts index b4b7f4f04..c693606a9 100644 --- a/packages/cli/src/ui/commands/contextCommand.ts +++ b/packages/cli/src/ui/commands/contextCommand.ts @@ -120,6 +120,10 @@ export const contextCommand: SlashCommand = { // Total prompt token count from API (most accurate) const apiTotalTokens = uiTelemetryService.getLastPromptTokenCount(); + // Cached content token count — when available (e.g. DashScope prefix caching), + // represents the cached overhead (system prompt + tools). Using this gives a much + // more accurate "Messages" count: promptTokens - cachedTokens = actual history tokens. + const apiCachedTokens = uiTelemetryService.getLastCachedContentTokenCount(); // 1. System prompt tokens (without memory, as memory is counted separately) const systemPromptText = getCoreSystemPrompt(undefined, modelName); @@ -302,7 +306,16 @@ export const contextCommand: SlashCommand = { scaledAllTools + displayMemoryFiles + Math.round(loadedBodiesTokens * overheadScale); - messagesTokens = Math.max(0, totalTokens - scaledOverhead); + + // When the API reports cached content tokens (e.g. DashScope prefix caching), + // use them as the actual overhead indicator for a more accurate messages count. + // cachedTokens ≈ system prompt + tools tokens actually served from cache. + // This avoids the "messages = 0" problem caused by estimation overshoot. + if (apiCachedTokens > 0) { + messagesTokens = Math.max(0, totalTokens - apiCachedTokens); + } else { + messagesTokens = Math.max(0, totalTokens - scaledOverhead); + } freeSpace = Math.max( 0, diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 03b78f06c..1d1cb064f 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -649,11 +649,18 @@ export class GeminiChat { // Collect token usage for consolidated recording if (chunk.usageMetadata) { usageMetadata = chunk.usageMetadata; + // Use || instead of ?? so that totalTokenCount=0 falls back to promptTokenCount. + // Some providers omit total_tokens or return 0 in streaming usage chunks. const lastPromptTokenCount = - usageMetadata.totalTokenCount ?? usageMetadata.promptTokenCount; + usageMetadata.totalTokenCount || usageMetadata.promptTokenCount; if (lastPromptTokenCount) { uiTelemetryService.setLastPromptTokenCount(lastPromptTokenCount); } + if (usageMetadata.cachedContentTokenCount) { + uiTelemetryService.setLastCachedContentTokenCount( + usageMetadata.cachedContentTokenCount, + ); + } } yield chunk; // Yield every chunk to the UI immediately. diff --git a/packages/core/src/telemetry/uiTelemetry.ts b/packages/core/src/telemetry/uiTelemetry.ts index 0f8f2146c..a7361f038 100644 --- a/packages/core/src/telemetry/uiTelemetry.ts +++ b/packages/core/src/telemetry/uiTelemetry.ts @@ -119,6 +119,7 @@ const createInitialMetrics = (): SessionMetrics => ({ export class UiTelemetryService extends EventEmitter { #metrics: SessionMetrics = createInitialMetrics(); #lastPromptTokenCount = 0; + #lastCachedContentTokenCount = 0; addEvent(event: UiEvent) { switch (event['event.name']) { @@ -158,12 +159,21 @@ export class UiTelemetryService extends EventEmitter { }); } + getLastCachedContentTokenCount(): number { + return this.#lastCachedContentTokenCount; + } + + setLastCachedContentTokenCount(count: number): void { + this.#lastCachedContentTokenCount = count; + } + /** * Resets metrics to the initial state (used when resuming a session). */ reset(): void { this.#metrics = createInitialMetrics(); this.#lastPromptTokenCount = 0; + this.#lastCachedContentTokenCount = 0; this.emit('update', { metrics: this.#metrics, lastPromptTokenCount: this.#lastPromptTokenCount, From 4e08c2009d83d426a895ce0fffd6fef6c0abba5a Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 18:01:40 +0800 Subject: [PATCH 63/82] fix remove other dirs --- packages/core/src/config/storage.ts | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/core/src/config/storage.ts b/packages/core/src/config/storage.ts index 0272b5b8c..b8711ef46 100644 --- a/packages/core/src/config/storage.ts +++ b/packages/core/src/config/storage.ts @@ -12,13 +12,7 @@ import { getProjectHash, sanitizeCwd } from '../utils/paths.js'; export const QWEN_DIR = '.qwen'; export const GOOGLE_ACCOUNTS_FILENAME = 'google_accounts.json'; export const OAUTH_FILE = 'oauth_creds.json'; -export const SKILL_PROVIDER_CONFIG_DIRS = [ - '.qwen', - '.agent', - '.claude', - '.cursor', - '.codex', -]; +export const SKILL_PROVIDER_CONFIG_DIRS = ['.qwen', '.agent']; const TMP_DIR_NAME = 'tmp'; const BIN_DIR_NAME = 'bin'; const PROJECT_DIR_NAME = 'projects'; From eea92fc8dbc7f4a36026d088b1f96994d1695d47 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 17 Mar 2026 23:33:37 +0800 Subject: [PATCH 64/82] fix: ensure message_start and message_stop are always paired in SDK stream events Co-authored-by: Qwen-Coder --- .../message-event-pairing.test.ts | 440 ++++++++++++++++++ .../io/StreamJsonOutputAdapter.ts | 30 +- 2 files changed, 464 insertions(+), 6 deletions(-) create mode 100644 integration-tests/sdk-typescript/message-event-pairing.test.ts diff --git a/integration-tests/sdk-typescript/message-event-pairing.test.ts b/integration-tests/sdk-typescript/message-event-pairing.test.ts new file mode 100644 index 000000000..32b81b21b --- /dev/null +++ b/integration-tests/sdk-typescript/message-event-pairing.test.ts @@ -0,0 +1,440 @@ +/** + * E2E tests for message_start and message_stop event pairing + * Ensures that message_start and message_stop events are always paired correctly + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { + query, + isSDKPartialAssistantMessage, + isSDKAssistantMessage, + type SDKPartialAssistantMessage, + type TextBlock, +} from '@qwen-code/sdk'; +import { SDKTestHelper, createSharedTestOptions } from './test-helper.js'; + +const SHARED_TEST_OPTIONS = createSharedTestOptions(); + +describe('Message Start/Stop Event Pairing (E2E)', () => { + let helper: SDKTestHelper; + let testDir: string; + + beforeEach(async () => { + helper = new SDKTestHelper(); + testDir = await helper.setup('message-event-pairing'); + }); + + afterEach(async () => { + await helper.cleanup(); + }); + + describe('Basic Message Event Pairing', () => { + it('should emit paired message_start and message_stop for single turn', async () => { + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start and message_stop are paired + expect(messageStartEvents.length).toBeGreaterThan(0); + expect(messageStopEvents.length).toBe(messageStartEvents.length); + }); + + it('should emit message_start before message_stop', async () => { + const events: Array<{ type: string; timestamp: number }> = []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if ( + message.event.type === 'message_start' || + message.event.type === 'message_stop' + ) { + events.push({ + type: message.event.type, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start comes before message_stop + expect(events.length).toBeGreaterThanOrEqual(2); + expect(events[0].type).toBe('message_start'); + expect(events[events.length - 1].type).toBe('message_stop'); + }); + + it('should have matching session_id for paired events', async () => { + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify session_id matches between paired events + expect(messageStartEvents.length).toBeGreaterThan(0); + expect(messageStopEvents.length).toBe(messageStartEvents.length); + expect(messageStartEvents[0].session_id).toBe( + messageStopEvents[0].session_id, + ); + }); + }); + + describe('Multi-turn Message Event Pairing', () => { + it('should emit paired events for each turn in multi-turn conversation', async () => { + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + const assistantMessages: string[] = []; + + const sessionId = crypto.randomUUID(); + + const q = query({ + prompt: (async function* () { + // First turn + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Say "first"', + }, + parent_tool_use_id: null, + }; + + // Wait a bit for processing + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Second turn + yield { + type: 'user', + session_id: sessionId, + message: { + role: 'user', + content: 'Say "second"', + }, + parent_tool_use_id: null, + }; + })(), + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } else if (isSDKAssistantMessage(message)) { + const text = message.message.content + .filter((block): block is TextBlock => block.type === 'text') + .map((block) => block.text) + .join(''); + assistantMessages.push(text); + } + } + } finally { + await q.close(); + } + + // Verify we have paired events for each assistant message + expect(messageStartEvents.length).toBeGreaterThanOrEqual(1); + expect(messageStopEvents.length).toBe(messageStartEvents.length); + }); + }); + + describe('Message Event Pairing with Tool Calls', () => { + it('should emit paired events when tool is used', async () => { + await helper.createFile('test.txt', 'Hello World'); + + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Read the content of test.txt', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + coreTools: ['read_file'], + permissionMode: 'default', + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start and message_stop are paired even with tool usage + expect(messageStartEvents.length).toBeGreaterThan(0); + expect(messageStopEvents.length).toBe(messageStartEvents.length); + }); + + it('should maintain event pairing through multiple tool calls', async () => { + await helper.createFile('file1.txt', 'Content 1'); + await helper.createFile('file2.txt', 'Content 2'); + + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Read file1.txt and file2.txt and summarize their contents', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + coreTools: ['read_file'], + permissionMode: 'default', + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify events are paired + expect(messageStartEvents.length).toBeGreaterThan(0); + expect(messageStopEvents.length).toBe(messageStartEvents.length); + }); + }); + + describe('Message Event Structure Validation', () => { + it('should have correct message_start event structure', async () => { + const messageStartEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'message_start' + ) { + messageStartEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(messageStartEvents.length).toBeGreaterThan(0); + const startEvent = messageStartEvents[0].event; + expect(startEvent.type).toBe('message_start'); + if (startEvent.type === 'message_start') { + expect(startEvent.message).toBeDefined(); + expect(startEvent.message.id).toBeDefined(); + expect(startEvent.message.role).toBe('assistant'); + expect(startEvent.message.model).toBeDefined(); + } + }); + + it('should have correct message_stop event structure', async () => { + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'message_stop' + ) { + messageStopEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(messageStopEvents.length).toBeGreaterThan(0); + const event = messageStopEvents[0].event; + expect(event.type).toBe('message_stop'); + }); + + it('should have message_start and message_stop paired by message_id', async () => { + const startEvents: SDKPartialAssistantMessage[] = []; + const stopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + startEvents.push(message); + } else if (message.event.type === 'message_stop') { + stopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start and message_stop are paired (same count) + expect(startEvents.length).toBeGreaterThan(0); + expect(stopEvents.length).toBe(startEvents.length); + + // Verify each message_start has a corresponding message_stop with the same message_id + const startMessageIds = new Set( + startEvents.map((e) => (e.event as { message_id?: string }).message_id), + ); + const stopMessageIds = new Set( + stopEvents.map((e) => (e.event as { message_id?: string }).message_id), + ); + + // Each message_stop should have the same message_id as a message_start + startMessageIds.forEach((messageId) => { + expect(stopMessageIds.has(messageId)).toBe(true); + }); + }); + }); + + describe('Error Scenarios', () => { + it('should still emit message_stop even when query errors', async () => { + const messageStartEvents: SDKPartialAssistantMessage[] = []; + const messageStopEvents: SDKPartialAssistantMessage[] = []; + + // Use an invalid tool to trigger an error scenario + const q = query({ + prompt: 'Use a non-existent tool', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + coreTools: [], // No tools available + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'message_start') { + messageStartEvents.push(message); + } else if (message.event.type === 'message_stop') { + messageStopEvents.push(message); + } + } + } + } catch { + // Expected to potentially have errors + } finally { + await q.close(); + } + + // Even in error scenarios, if message_start was emitted, message_stop should also be emitted + if (messageStartEvents.length > 0) { + expect(messageStopEvents.length).toBe(messageStartEvents.length); + } + }); + }); +}); diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index bf76d025c..346c4b072 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -69,26 +69,44 @@ export class StreamJsonOutputAdapter } finalizeAssistantMessage(): CLIAssistantMessage { - const state = this.mainAgentMessageState; + return this.finalizeAssistantMessageInternal( + this.mainAgentMessageState, + null, + ); + } + + /** + * Overrides base class to emit message_stop event when message is finalized. + * This ensures message_start and message_stop are always paired. + */ + protected override finalizeAssistantMessageInternal( + state: MessageState, + parentToolUseId: string | null, + ): CLIAssistantMessage { if (state.finalized) { - return this.buildMessage(null); + return this.buildMessage(parentToolUseId); } state.finalized = true; - this.finalizePendingBlocks(state, null); + this.finalizePendingBlocks(state, parentToolUseId); const orderedOpenBlocks = Array.from(state.openBlocks).sort( (a, b) => a - b, ); for (const index of orderedOpenBlocks) { - this.onBlockClosed(state, index, null); + this.onBlockClosed(state, index, parentToolUseId); this.closeBlock(state, index); } - if (state.messageStarted && this.includePartialMessages) { + // Emit message_stop for main agent when message was started and partial messages are enabled + if ( + state.messageStarted && + this.includePartialMessages && + parentToolUseId === null + ) { this.emitStreamEventIfEnabled({ type: 'message_stop' }, null); } - const message = this.buildMessage(null); + const message = this.buildMessage(parentToolUseId); this.updateLastAssistantMessage(message); this.emitMessageImpl(message); return message; From 79083ffd50d84e7b7399f79692b82cb0159cc0b6 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 18 Mar 2026 18:08:56 +0800 Subject: [PATCH 65/82] Fix SDK message event pairing and improve content block handling Co-authored-by: Qwen-Coder --- .../message-event-pairing.test.ts | 458 +++++++++++++++++- package.json | 4 +- .../io/BaseJsonOutputAdapter.ts | 23 +- .../nonInteractive/io/JsonOutputAdapter.ts | 4 +- .../io/StreamJsonOutputAdapter.test.ts | 76 ++- .../io/StreamJsonOutputAdapter.ts | 71 +-- packages/cli/src/nonInteractive/types.ts | 1 + packages/cli/src/nonInteractiveCli.ts | 10 + 8 files changed, 545 insertions(+), 102 deletions(-) diff --git a/integration-tests/sdk-typescript/message-event-pairing.test.ts b/integration-tests/sdk-typescript/message-event-pairing.test.ts index 32b81b21b..b439ec276 100644 --- a/integration-tests/sdk-typescript/message-event-pairing.test.ts +++ b/integration-tests/sdk-typescript/message-event-pairing.test.ts @@ -351,7 +351,7 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { expect(event.type).toBe('message_stop'); }); - it('should have message_start and message_stop paired by message_id', async () => { + it('should have message_start and message_stop paired by count', async () => { const startEvents: SDKPartialAssistantMessage[] = []; const stopEvents: SDKPartialAssistantMessage[] = []; @@ -379,22 +379,19 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { await q.close(); } - // Verify message_start and message_stop are paired (same count) + // Verify message_start and message_stop appear in pairs (same count) expect(startEvents.length).toBeGreaterThan(0); expect(stopEvents.length).toBe(startEvents.length); - // Verify each message_start has a corresponding message_stop with the same message_id - const startMessageIds = new Set( - startEvents.map((e) => (e.event as { message_id?: string }).message_id), - ); - const stopMessageIds = new Set( - stopEvents.map((e) => (e.event as { message_id?: string }).message_id), - ); - - // Each message_stop should have the same message_id as a message_start - startMessageIds.forEach((messageId) => { - expect(stopMessageIds.has(messageId)).toBe(true); - }); + // Verify message_start carries the message id via its nested message.id field + for (const e of startEvents) { + const event = e.event as { + type: 'message_start'; + message: { id: string }; + }; + expect(typeof event.message.id).toBe('string'); + expect(event.message.id.length).toBeGreaterThan(0); + } }); }); @@ -437,4 +434,437 @@ describe('Message Start/Stop Event Pairing (E2E)', () => { } }); }); + + describe('Content Block Event Pairing', () => { + it('should emit paired content_block_start and content_block_stop for each content block', async () => { + const contentBlockStartEvents: SDKPartialAssistantMessage[] = []; + const contentBlockStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'content_block_start') { + contentBlockStartEvents.push(message); + } else if (message.event.type === 'content_block_stop') { + contentBlockStopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify content_block_start and content_block_stop are paired + expect(contentBlockStartEvents.length).toBeGreaterThan(0); + expect(contentBlockStopEvents.length).toBe( + contentBlockStartEvents.length, + ); + }); + + it('should emit content_block_start before content_block_stop', async () => { + const events: Array<{ type: string; index: number; timestamp: number }> = + []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if ( + message.event.type === 'content_block_start' || + message.event.type === 'content_block_stop' + ) { + events.push({ + type: message.event.type, + index: message.event.index, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify events exist + expect(events.length).toBeGreaterThanOrEqual(2); + + // Group events by index + const eventsByIndex = new Map(); + for (const event of events) { + if (!eventsByIndex.has(event.index)) { + eventsByIndex.set(event.index, []); + } + eventsByIndex.get(event.index)!.push(event); + } + + // For each index, verify content_block_start comes before content_block_stop + eventsByIndex.forEach((indexEvents) => { + const startIndex = indexEvents.findIndex( + (e) => e.type === 'content_block_start', + ); + const stopIndex = indexEvents.findIndex( + (e) => e.type === 'content_block_stop', + ); + expect(startIndex).toBeGreaterThanOrEqual(0); + expect(stopIndex).toBeGreaterThanOrEqual(0); + expect(startIndex).toBeLessThan(stopIndex); + }); + }); + + it('should have correct content_block_start event structure', async () => { + const contentBlockStartEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'content_block_start' + ) { + contentBlockStartEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(contentBlockStartEvents.length).toBeGreaterThan(0); + + // Verify each content_block_start has correct structure + for (const message of contentBlockStartEvents) { + const event = message.event as { + type: 'content_block_start'; + index: number; + content_block: unknown; + }; + expect(event.type).toBe('content_block_start'); + expect(event).toHaveProperty('index'); + expect(typeof event.index).toBe('number'); + expect(event.index).toBeGreaterThanOrEqual(0); + expect(event).toHaveProperty('content_block'); + expect(event.content_block).toBeDefined(); + } + }); + + it('should have correct content_block_stop event structure', async () => { + const contentBlockStopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if ( + isSDKPartialAssistantMessage(message) && + message.event.type === 'content_block_stop' + ) { + contentBlockStopEvents.push(message); + } + } + } finally { + await q.close(); + } + + expect(contentBlockStopEvents.length).toBeGreaterThan(0); + + // Verify each content_block_stop has correct structure + for (const message of contentBlockStopEvents) { + const event = message.event as { + type: 'content_block_stop'; + index: number; + }; + expect(event.type).toBe('content_block_stop'); + expect(event).toHaveProperty('index'); + expect(typeof event.index).toBe('number'); + expect(event.index).toBeGreaterThanOrEqual(0); + } + }); + + it('should have matching index for paired content_block_start and content_block_stop', async () => { + const startEvents: SDKPartialAssistantMessage[] = []; + const stopEvents: SDKPartialAssistantMessage[] = []; + + const q = query({ + prompt: 'Say hello world', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + if (message.event.type === 'content_block_start') { + startEvents.push(message); + } else if (message.event.type === 'content_block_stop') { + stopEvents.push(message); + } + } + } + } finally { + await q.close(); + } + + // Verify events exist and are paired + expect(startEvents.length).toBeGreaterThan(0); + expect(stopEvents.length).toBe(startEvents.length); + + // Extract indices from start and stop events + const startIndices = startEvents.map( + (e) => (e.event as { index: number }).index, + ); + const stopIndices = stopEvents.map( + (e) => (e.event as { index: number }).index, + ); + + // Verify each start index has a matching stop index + expect(new Set(stopIndices)).toEqual(new Set(startIndices)); + + // Verify each index appears the same number of times in both start and stop events + const startIndexCounts = new Map(); + const stopIndexCounts = new Map(); + + for (const idx of startIndices) { + startIndexCounts.set(idx, (startIndexCounts.get(idx) || 0) + 1); + } + for (const idx of stopIndices) { + stopIndexCounts.set(idx, (stopIndexCounts.get(idx) || 0) + 1); + } + + startIndexCounts.forEach((count, idx) => { + expect(stopIndexCounts.get(idx)).toBe(count); + }); + }); + + it('should follow correct event flow: content_block_start -> content_block_delta -> content_block_stop', async () => { + const events: Array<{ + type: string; + index: number; + position: number; + }> = []; + + const q = query({ + prompt: 'Write a short story about a cat', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + let pos = 0; + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'content_block_start' || + eventType === 'content_block_delta' || + eventType === 'content_block_stop' + ) { + events.push({ + type: eventType, + index: (message.event as { index: number }).index, + position: pos++, + }); + } + } + } + } finally { + await q.close(); + } + + expect(events.length).toBeGreaterThanOrEqual(2); + + // Pair content_block_start/stop sequentially (not by index, since + // block-type transitions reset the blocks array and reuse index 0). + // Each start is matched with the next stop that follows it. + const starts = events.filter((e) => e.type === 'content_block_start'); + const stops = events.filter((e) => e.type === 'content_block_stop'); + expect(starts.length).toBe(stops.length); + + for (let i = 0; i < starts.length; i++) { + const start = starts[i]; + const stop = stops[i]; + + // start must come before the paired stop + expect(start.position).toBeLessThan(stop.position); + + // All deltas between this pair must sit between start and stop + const deltas = events.filter( + (e) => + e.type === 'content_block_delta' && + e.position > start.position && + e.position < stop.position, + ); + for (const delta of deltas) { + expect(delta.position).toBeGreaterThan(start.position); + expect(delta.position).toBeLessThan(stop.position); + } + } + }); + + it('should have content_block_start after message_start and before message_stop', async () => { + const events: Array<{ + type: string; + timestamp: number; + }> = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'message_start' || + eventType === 'message_stop' || + eventType === 'content_block_start' + ) { + events.push({ + type: eventType, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start exists + const messageStartIndex = events.findIndex( + (e) => e.type === 'message_start', + ); + expect(messageStartIndex).toBeGreaterThanOrEqual(0); + + // Verify message_stop exists + const messageStopIndex = events.findIndex( + (e) => e.type === 'message_stop', + ); + expect(messageStopIndex).toBeGreaterThanOrEqual(0); + + // Verify content_block_start exists + const firstContentBlockStartIndex = events.findIndex( + (e) => e.type === 'content_block_start', + ); + expect(firstContentBlockStartIndex).toBeGreaterThanOrEqual(0); + + // content_block_start should be after message_start + expect(firstContentBlockStartIndex).toBeGreaterThan(messageStartIndex); + + // content_block_start should be before message_stop + expect(firstContentBlockStartIndex).toBeLessThan(messageStopIndex); + }); + + it('should have content_block_stop after message_start and before message_stop', async () => { + const events: Array<{ + type: string; + timestamp: number; + }> = []; + + const q = query({ + prompt: 'Say hello', + options: { + ...SHARED_TEST_OPTIONS, + includePartialMessages: true, + cwd: testDir, + debug: false, + }, + }); + + try { + for await (const message of q) { + if (isSDKPartialAssistantMessage(message)) { + const eventType = message.event.type; + if ( + eventType === 'message_start' || + eventType === 'message_stop' || + eventType === 'content_block_stop' + ) { + events.push({ + type: eventType, + timestamp: Date.now(), + }); + } + } + } + } finally { + await q.close(); + } + + // Verify message_start exists + const messageStartIndex = events.findIndex( + (e) => e.type === 'message_start', + ); + expect(messageStartIndex).toBeGreaterThanOrEqual(0); + + // Verify message_stop exists + const messageStopIndex = events.findIndex( + (e) => e.type === 'message_stop', + ); + expect(messageStopIndex).toBeGreaterThanOrEqual(0); + + // Verify content_block_stop exists (use reverse find for ES compatibility) + const lastContentBlockStopIndex = + events + .map((e, i) => ({ ...e, originalIndex: i })) + .reverse() + .find((e) => e.type === 'content_block_stop')?.originalIndex ?? -1; + expect(lastContentBlockStopIndex).toBeGreaterThanOrEqual(0); + + // content_block_stop should be after message_start + expect(lastContentBlockStopIndex).toBeGreaterThan(messageStartIndex); + + // content_block_stop should be before message_stop + expect(lastContentBlockStopIndex).toBeLessThan(messageStopIndex); + }); + }); }); diff --git a/package.json b/package.json index a49760350..c1dfa2448 100644 --- a/package.json +++ b/package.json @@ -36,8 +36,8 @@ "test:integration:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests", "test:integration:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests", "test:integration:sandbox:podman": "cross-env QWEN_SANDBOX=podman vitest run --root ./integration-tests", - "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests sdk-typescript", - "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests sdk-typescript", + "test:integration:sdk:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", + "test:integration:sdk:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --poolOptions.threads.maxThreads 2 sdk-typescript", "test:integration:cli:sandbox:none": "cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:integration:cli:sandbox:docker": "cross-env QWEN_SANDBOX=docker npm run build:sandbox && QWEN_SANDBOX=docker vitest run --root ./integration-tests --exclude '**/sdk-typescript/**'", "test:terminal-bench": "cross-env VERBOSE=true KEEP_OUTPUT=true vitest run --config ./vitest.terminal-bench.config.ts --root ./integration-tests", diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index b0d6736a5..dc62f9ae2 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -282,12 +282,12 @@ export abstract class BaseJsonOutputAdapter { return; } - if (lastBlock.type === 'text') { - const index = state.blocks.length - 1; - this.onBlockClosed(state, index, actualParentToolUseId); - this.closeBlock(state, index); - } else if (lastBlock.type === 'thinking') { - const index = state.blocks.length - 1; + const index = state.blocks.length - 1; + if (!state.openBlocks.has(index)) { + return; + } + + if (lastBlock.type === 'text' || lastBlock.type === 'thinking') { this.onBlockClosed(state, index, actualParentToolUseId); this.closeBlock(state, index); } @@ -392,7 +392,9 @@ export abstract class BaseJsonOutputAdapter { } const message = this.buildMessage(parentToolUseId); - this.emitMessageImpl(message); + if (state.messageStarted) { + this.emitMessageImpl(message); + } return message; } @@ -656,12 +658,7 @@ export abstract class BaseJsonOutputAdapter { parentToolUseId: string, ): CLIAssistantMessage { const state = this.getMessageState(parentToolUseId); - const message = this.finalizeAssistantMessageInternal( - state, - parentToolUseId, - ); - this.updateLastAssistantMessage(message); - return message; + return this.finalizeAssistantMessageInternal(state, parentToolUseId); } /** diff --git a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts index a76de53a8..68633675b 100644 --- a/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/JsonOutputAdapter.ts @@ -52,12 +52,10 @@ export class JsonOutputAdapter } finalizeAssistantMessage(): CLIAssistantMessage { - const message = this.finalizeAssistantMessageInternal( + return this.finalizeAssistantMessageInternal( this.mainAgentMessageState, null, ); - this.updateLastAssistantMessage(message); - return message; } emitResult(options: ResultOptions): void { diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts index 96977d5b0..64448c8a6 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.test.ts @@ -654,6 +654,24 @@ describe('StreamJsonOutputAdapter', () => { 'Message not started', ); }); + + it('should not emit empty assistant message when started but no content processed', () => { + stdoutWriteSpy.mockClear(); + adapter.finalizeAssistantMessage(); + + const assistantCalls = stdoutWriteSpy.mock.calls.filter( + (call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return parsed.type === 'assistant'; + } catch { + return false; + } + }, + ); + + expect(assistantCalls).toHaveLength(0); + }); }); describe('emitResult', () => { @@ -1007,56 +1025,68 @@ describe('StreamJsonOutputAdapter', () => { }); }); - describe('message_id in stream events', () => { + describe('content_block event identification', () => { beforeEach(() => { adapter = new StreamJsonOutputAdapter(mockConfig, true); adapter.startAssistantMessage(); }); - it('should include message_id in stream events after message starts', () => { + it('should not include message_id in content_block events', () => { adapter.processEvent({ type: GeminiEventType.Content, value: 'Text', }); - // Process another event to ensure messageStarted is true adapter.processEvent({ type: GeminiEventType.Content, value: 'More', }); const calls = stdoutWriteSpy.mock.calls; - // Find all delta events - const deltaCalls = calls.filter((call: unknown[]) => { + const contentBlockCalls = calls.filter((call: unknown[]) => { try { const parsed = JSON.parse(call[0] as string); return ( parsed.type === 'stream_event' && - parsed.event.type === 'content_block_delta' + (parsed.event.type === 'content_block_start' || + parsed.event.type === 'content_block_delta' || + parsed.event.type === 'content_block_stop') ); } catch { return false; } }); - expect(deltaCalls.length).toBeGreaterThan(0); - // The second delta event should have message_id (after messageStarted becomes true) - // message_id is added to the event object, so check parsed.event.message_id - if (deltaCalls.length > 1) { - const secondDelta = JSON.parse( - (deltaCalls[1] as unknown[])[0] as string, - ); - // message_id is on the enriched event object - expect( - secondDelta.event.message_id || secondDelta.message_id, - ).toBeTruthy(); - } else { - // If only one delta, check if message_id exists - const delta = JSON.parse((deltaCalls[0] as unknown[])[0] as string); - // message_id is added when messageStarted is true - // First event may or may not have it, but subsequent ones should - expect(delta.event.message_id || delta.message_id).toBeTruthy(); + expect(contentBlockCalls.length).toBeGreaterThan(0); + for (const call of contentBlockCalls) { + const parsed = JSON.parse((call as unknown[])[0] as string); + expect(parsed.event.message_id).toBeUndefined(); } }); + + it('should identify content_block events by session_id and index', () => { + adapter.processEvent({ + type: GeminiEventType.Content, + value: 'Text', + }); + + const calls = stdoutWriteSpy.mock.calls; + const blockStartCall = calls.find((call: unknown[]) => { + try { + const parsed = JSON.parse(call[0] as string); + return ( + parsed.type === 'stream_event' && + parsed.event.type === 'content_block_start' + ); + } catch { + return false; + } + }); + + expect(blockStartCall).toBeDefined(); + const parsed = JSON.parse((blockStartCall as unknown[])[0] as string); + expect(parsed.session_id).toBe('test-session-id'); + expect(typeof parsed.event.index).toBe('number'); + }); }); describe('multiple text blocks', () => { diff --git a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts index 346c4b072..c67190e6a 100644 --- a/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/StreamJsonOutputAdapter.ts @@ -36,6 +36,8 @@ export class StreamJsonOutputAdapter extends BaseJsonOutputAdapter implements JsonOutputAdapterInterface { + private mainTurnMessageStartEmitted = false; + constructor( config: Config, private readonly includePartialMessages: boolean, @@ -68,47 +70,27 @@ export class StreamJsonOutputAdapter return this.includePartialMessages; } + override startAssistantMessage(): void { + this.mainTurnMessageStartEmitted = false; + super.startAssistantMessage(); + } + finalizeAssistantMessage(): CLIAssistantMessage { - return this.finalizeAssistantMessageInternal( + const message = this.finalizeAssistantMessageInternal( this.mainAgentMessageState, null, ); - } - - /** - * Overrides base class to emit message_stop event when message is finalized. - * This ensures message_start and message_stop are always paired. - */ - protected override finalizeAssistantMessageInternal( - state: MessageState, - parentToolUseId: string | null, - ): CLIAssistantMessage { - if (state.finalized) { - return this.buildMessage(parentToolUseId); + if (this.mainTurnMessageStartEmitted && this.includePartialMessages) { + const partial: CLIPartialAssistantMessage = { + type: 'stream_event', + uuid: randomUUID(), + session_id: this.getSessionId(), + parent_tool_use_id: null, + event: { type: 'message_stop' }, + }; + this.emitMessageImpl(partial); } - state.finalized = true; - - this.finalizePendingBlocks(state, parentToolUseId); - const orderedOpenBlocks = Array.from(state.openBlocks).sort( - (a, b) => a - b, - ); - for (const index of orderedOpenBlocks) { - this.onBlockClosed(state, index, parentToolUseId); - this.closeBlock(state, index); - } - - // Emit message_stop for main agent when message was started and partial messages are enabled - if ( - state.messageStarted && - this.includePartialMessages && - parentToolUseId === null - ) { - this.emitStreamEventIfEnabled({ type: 'message_stop' }, null); - } - - const message = this.buildMessage(parentToolUseId); - this.updateLastAssistantMessage(message); - this.emitMessageImpl(message); + this.mainTurnMessageStartEmitted = false; return message; } @@ -267,14 +249,15 @@ export class StreamJsonOutputAdapter /** * Overrides base class hook to emit message_start event when message is started. - * Only emits for main agent, not for subagents. + * Only emits once per turn for the main agent (guarded by mainTurnMessageStartEmitted), + * so block-type transitions inside a single turn do not produce spurious message_start events. */ protected override onEnsureMessageStarted( state: MessageState, parentToolUseId: string | null, ): void { - // Only emit message_start for main agent, not for subagents - if (parentToolUseId === null) { + if (parentToolUseId === null && !this.mainTurnMessageStartEmitted) { + this.mainTurnMessageStartEmitted = true; this.emitStreamEventIfEnabled( { type: 'message_start', @@ -282,6 +265,7 @@ export class StreamJsonOutputAdapter id: state.messageId!, role: 'assistant', model: this.config.getModel(), + content: [], }, }, null, @@ -329,19 +313,12 @@ export class StreamJsonOutputAdapter return; } - const state = this.getMessageState(parentToolUseId); - const enrichedEvent = state.messageStarted - ? ({ ...event, message_id: state.messageId } as StreamEvent & { - message_id: string; - }) - : event; - const partial: CLIPartialAssistantMessage = { type: 'stream_event', uuid: randomUUID(), session_id: this.getSessionId(), parent_tool_use_id: parentToolUseId, - event: enrichedEvent, + event, }; this.emitMessageImpl(partial); } diff --git a/packages/cli/src/nonInteractive/types.ts b/packages/cli/src/nonInteractive/types.ts index 84c2d0ff7..69eaa1dcd 100644 --- a/packages/cli/src/nonInteractive/types.ts +++ b/packages/cli/src/nonInteractive/types.ts @@ -201,6 +201,7 @@ export interface MessageStartStreamEvent { id: string; role: 'assistant'; model: string; + content: []; }; } diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index e4c22cebb..bf29f8f0e 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -390,6 +390,16 @@ export async function runNonInteractive( } } } catch (error) { + // Ensure message_start / message_stop (and content_block events) are + // properly paired even when an error aborts the turn mid-stream. + // The call is safe when no message was started (throws → caught) or + // when already finalized (idempotent guard inside the adapter). + try { + adapter.finalizeAssistantMessage(); + } catch { + // Expected when no message was started or already finalized + } + // For JSON and STREAM_JSON modes, compute usage from metrics const message = error instanceof Error ? error.message : String(error); const metrics = uiTelemetryService.getMetrics(); From ddee359003128a390181084d6137144b6e70199e Mon Sep 17 00:00:00 2001 From: tanzhenxin Date: Wed, 18 Mar 2026 19:00:44 +0800 Subject: [PATCH 66/82] fix(core): correct error property in test from code to status This aligns the test with the updated error handling that uses `status` instead of `code` for HTTP status codes. Co-authored-by: Qwen-Coder --- .../loggingContentGenerator/loggingContentGenerator.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts index 156b75a01..abf129268 100644 --- a/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts +++ b/packages/core/src/core/loggingContentGenerator/loggingContentGenerator.test.ts @@ -225,7 +225,7 @@ describe('LoggingContentGenerator', () => { it('logs errors with status code and request id, then rethrows', async () => { const error = Object.assign(new Error('boom'), { - code: 429, + status: 429, request_id: 'req-99', type: 'rate_limit', }); From 770b2ade92929e958c211670b0f4c2d7bf836023 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 19:40:13 +0800 Subject: [PATCH 67/82] fix ci test --- packages/core/src/skills/skill-manager.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 78c8f36d4..730653f93 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -595,7 +595,7 @@ Skill 3 content`); it('should return all project-level base dirs', () => { const baseDirs = manager.getSkillsBaseDirs('project'); - expect(baseDirs).toHaveLength(5); + expect(baseDirs).toHaveLength(2); expect(baseDirs).toContain(path.join('/test/project', '.qwen', 'skills')); expect(baseDirs).toContain( path.join('/test/project', '.agent', 'skills'), @@ -614,7 +614,7 @@ Skill 3 content`); it('should return all user-level base dirs', () => { const baseDirs = manager.getSkillsBaseDirs('user'); - expect(baseDirs).toHaveLength(5); + expect(baseDirs).toHaveLength(2); expect(baseDirs).toContain(path.join('/home/user', '.qwen', 'skills')); expect(baseDirs).toContain(path.join('/home/user', '.agent', 'skills')); expect(baseDirs).toContain(path.join('/home/user', '.cursor', 'skills')); @@ -623,13 +623,13 @@ Skill 3 content`); }); it('should return bundled-level base dir', () => { - const baseDir = manager.getSkillsBaseDir('bundled'); + const baseDirs = manager.getSkillsBaseDirs('bundled'); - expect(baseDir).toMatch(/skills[/\\]bundled$/); + expect(baseDirs[0]).toMatch(/skills[/\\]bundled$/); }); it('should throw for extension level', () => { - expect(() => manager.getSkillsBaseDir('extension')).toThrow( + expect(() => manager.getSkillsBaseDirs('extension')).toThrow( 'Extension skills do not have a base directory', ); }); From 620807b1ee47b56f14e6c9eb7d9d1502d54cfcf3 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 19:57:11 +0800 Subject: [PATCH 68/82] fix: code-plan to coding-plan --- .qwen/skills/qwen-code-claw/SKILL.md | 4 ++-- docs/users/configuration/auth.md | 22 +++++++++---------- docs/users/features/commands.md | 14 ++++++------ packages/cli/src/commands/auth.ts | 9 ++++---- packages/cli/src/commands/auth/handler.ts | 14 ++++++------ packages/cli/src/commands/auth/status.test.ts | 2 +- packages/cli/src/i18n/locales/de.js | 8 +++---- packages/cli/src/i18n/locales/en.js | 8 +++---- packages/cli/src/i18n/locales/ja.js | 8 +++---- packages/cli/src/i18n/locales/pt.js | 8 +++---- packages/cli/src/i18n/locales/ru.js | 8 +++---- packages/cli/src/i18n/locales/zh.js | 8 +++---- 12 files changed, 56 insertions(+), 57 deletions(-) diff --git a/.qwen/skills/qwen-code-claw/SKILL.md b/.qwen/skills/qwen-code-claw/SKILL.md index 9c080f332..f9a7b6a17 100644 --- a/.qwen/skills/qwen-code-claw/SKILL.md +++ b/.qwen/skills/qwen-code-claw/SKILL.md @@ -41,10 +41,10 @@ echo $BAILIAN_CODING_PLAN_API_KEY **If `BAILIAN_CODING_PLAN_API_KEY` exists**, authenticate directly: ```bash -qwen auth code-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY +qwen auth coding-plan --region china --key $BAILIAN_CODING_PLAN_API_KEY ``` -**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `code-plan`: +**If the environment variable does not exist**, interrupt and prompt the user to authenticate via `qwen-oauth` or `coding-plan`: ```bash qwen auth diff --git a/docs/users/configuration/auth.md b/docs/users/configuration/auth.md index dee7933e0..445e42bc5 100644 --- a/docs/users/configuration/auth.md +++ b/docs/users/configuration/auth.md @@ -56,10 +56,10 @@ You can set up Coding Plan authentication in two ways: ```bash # Interactive — prompts for region and API key -qwen auth code-plan +qwen auth coding-plan # Or non-interactive — pass region and key directly -qwen auth code-plan --region china --key sk-sp-xxxxxxxxx +qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx ``` **Option B: Inside a Qwen Code session** @@ -335,13 +335,13 @@ Select authentication method: ### Subcommands -| Command | Description | -| -------------------------------------------------- | ------------------------------------------------- | -| `qwen auth` | Interactive authentication setup | -| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | -| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | -| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | -| `qwen auth status` | Show current authentication status | +| Command | Description | +| ---------------------------------------------------- | ------------------------------------------------- | +| `qwen auth` | Interactive authentication setup | +| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | +| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan | +| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | +| `qwen auth status` | Show current authentication status | **Examples:** @@ -350,10 +350,10 @@ Select authentication method: qwen auth qwen-oauth # Set up Coding Plan interactively (prompts for region and key) -qwen auth code-plan +qwen auth coding-plan # Set up Coding Plan non-interactively (useful for CI/scripting) -qwen auth code-plan --region china --key sk-sp-xxxxxxxxx +qwen auth coding-plan --region china --key sk-sp-xxxxxxxxx # Check your current auth configuration qwen auth status diff --git a/docs/users/features/commands.md b/docs/users/features/commands.md index 78148a17a..c5ca44e45 100644 --- a/docs/users/features/commands.md +++ b/docs/users/features/commands.md @@ -98,13 +98,13 @@ Commands for obtaining information and performing system settings. In addition to the in-session `/auth` slash command, Qwen Code provides standalone CLI subcommands for managing authentication directly from the terminal: -| Command | Description | -| -------------------------------------------------- | ------------------------------------------------- | -| `qwen auth` | Interactive authentication setup | -| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | -| `qwen auth code-plan` | Authenticate with Alibaba Cloud Coding Plan | -| `qwen auth code-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | -| `qwen auth status` | Show current authentication status | +| Command | Description | +| ---------------------------------------------------- | ------------------------------------------------- | +| `qwen auth` | Interactive authentication setup | +| `qwen auth qwen-oauth` | Authenticate with Qwen OAuth | +| `qwen auth coding-plan` | Authenticate with Alibaba Cloud Coding Plan | +| `qwen auth coding-plan --region china --key sk-sp-…` | Non-interactive Coding Plan setup (for scripting) | +| `qwen auth status` | Show current authentication status | > [!tip] > diff --git a/packages/cli/src/commands/auth.ts b/packages/cli/src/commands/auth.ts index 0e6cfcb80..b90795bc7 100644 --- a/packages/cli/src/commands/auth.ts +++ b/packages/cli/src/commands/auth.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { CommandModule , Argv } from 'yargs'; +import type { CommandModule, Argv } from 'yargs'; import { handleQwenAuth, runInteractiveAuth, @@ -12,7 +12,6 @@ import { } from './auth/handler.js'; import { t } from '../i18n/index.js'; - // Define subcommands separately const qwenOauthCommand = { command: 'qwen-oauth', @@ -23,7 +22,7 @@ const qwenOauthCommand = { }; const codePlanCommand = { - command: 'code-plan', + command: 'coding-plan', describe: t('Authenticate using Alibaba Cloud Coding Plan'), builder: (yargs: Argv) => yargs @@ -43,10 +42,10 @@ const codePlanCommand = { // If region and key are provided, use them directly if (region && key) { - await handleQwenAuth('code-plan', { region, key }); + await handleQwenAuth('coding-plan', { region, key }); } else { // Otherwise, prompt interactively - await handleQwenAuth('code-plan', {}); + await handleQwenAuth('coding-plan', {}); } }, }; diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 112db6949..0c0ad2a88 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -53,7 +53,7 @@ interface MergedSettingsWithCodingPlan { * Handles the authentication process based on the specified command and options */ export async function handleQwenAuth( - command: 'qwen-oauth' | 'code-plan', + command: 'qwen-oauth' | 'coding-plan', options: QwenAuthOptions, ) { try { @@ -120,7 +120,7 @@ export async function handleQwenAuth( if (command === 'qwen-oauth') { await handleQwenOAuth(config, settings); - } else if (command === 'code-plan') { + } else if (command === 'coding-plan') { await handleCodePlanAuth(config, settings, options); } @@ -372,7 +372,7 @@ export async function runInteractiveAuth() { description: t('Free · Up to 1,000 requests/day · Qwen latest models'), }, { - value: 'code-plan' as const, + value: 'coding-plan' as const, label: t('Alibaba Cloud Coding Plan'), description: t( 'Paid · Up to 6,000 requests/5 hrs · All Alibaba Cloud Coding Plan Models', @@ -384,8 +384,8 @@ export async function runInteractiveAuth() { const choice = await selector.select(); - if (choice === 'code-plan') { - await handleQwenAuth('code-plan', {}); + if (choice === 'coding-plan') { + await handleQwenAuth('coding-plan', {}); } else { await handleQwenAuth('qwen-oauth', {}); } @@ -414,7 +414,7 @@ export async function showAuthStatus(): Promise { ); writeStdoutLine( t( - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n', ), ); writeStdoutLine(t('Or simply run:')); @@ -478,7 +478,7 @@ export async function showAuthStatus(): Promise { writeStdoutLine( t(' Issue: API key not found in environment or settings\n'), ); - writeStdoutLine(t(' Run `qwen auth code-plan` to re-configure.\n')); + writeStdoutLine(t(' Run `qwen auth coding-plan` to re-configure.\n')); } } else { writeStdoutLine( diff --git a/packages/cli/src/commands/auth/status.test.ts b/packages/cli/src/commands/auth/status.test.ts index 69c020a02..b0f2be210 100644 --- a/packages/cli/src/commands/auth/status.test.ts +++ b/packages/cli/src/commands/auth/status.test.ts @@ -60,7 +60,7 @@ describe('showAuthStatus', () => { expect.stringContaining('qwen auth qwen-oauth'), ); expect(writeStdoutLine).toHaveBeenCalledWith( - expect.stringContaining('qwen auth code-plan'), + expect.stringContaining('qwen auth coding-plan'), ); expect(process.exit).toHaveBeenCalledWith(0); }); diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index d3eee4c49..95a33bbf1 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1698,8 +1698,8 @@ export default { 'Führen Sie einen der folgenden Befehle aus, um zu beginnen:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - Mit Qwen OAuth authentifizieren (kostenlos)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Mit Alibaba Cloud Coding Plan authentifizieren\n', 'Or simply run:': 'Oder einfach ausführen:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - Interaktive Authentifizierungseinrichtung\n', @@ -1720,8 +1720,8 @@ export default { '⚠️ Authentifizierungsmethode: Alibaba Cloud Coding Plan (Unvollständig)', ' Issue: API key not found in environment or settings\n': ' Problem: API-Schlüssel nicht in Umgebung oder Einstellungen gefunden\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' Führen Sie `qwen auth code-plan` aus, um neu zu konfigurieren.\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Führen Sie `qwen auth coding-plan` aus, um neu zu konfigurieren.\n', '✓ Authentication Method: {{type}}': '✓ Authentifizierungsmethode: {{type}}', ' Status: Configured\n': ' Status: Konfiguriert\n', 'Failed to check authentication status: {{error}}': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index 335229eff..d74b78693 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1747,8 +1747,8 @@ export default { 'Run one of the following commands to get started:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n', 'Or simply run:': 'Or simply run:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - Interactive authentication setup\n', @@ -1768,8 +1768,8 @@ export default { '⚠️ Authentication Method: Alibaba Cloud Coding Plan (Incomplete)', ' Issue: API key not found in environment or settings\n': ' Issue: API key not found in environment or settings\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' Run `qwen auth code-plan` to re-configure.\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Run `qwen auth coding-plan` to re-configure.\n', '✓ Authentication Method: {{type}}': '✓ Authentication Method: {{type}}', ' Status: Configured\n': ' Status: Configured\n', 'Failed to check authentication status: {{error}}': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 3e80691ab..e102bca60 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1199,8 +1199,8 @@ export default { '以下のコマンドのいずれかを実行して開始してください:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - Qwen OAuth で認証(無料)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - Alibaba Cloud Coding Plan で認証\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Alibaba Cloud Coding Plan で認証\n', 'Or simply run:': 'または以下を実行:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - インタラクティブ認証セットアップ\n', @@ -1220,8 +1220,8 @@ export default { '⚠️ 認証方法: Alibaba Cloud Coding Plan(不完全)', ' Issue: API key not found in environment or settings\n': ' 問題: 環境変数または設定にAPIキーが見つかりません\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' `qwen auth code-plan` を実行して再設定してください。\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' `qwen auth coding-plan` を実行して再設定してください。\n', '✓ Authentication Method: {{type}}': '✓ 認証方法: {{type}}', ' Status: Configured\n': ' ステータス: 設定済み\n', 'Failed to check authentication status: {{error}}': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index a4f5f3300..630be8d39 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1692,8 +1692,8 @@ export default { 'Execute um dos seguintes comandos para começar:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - Autenticar com Qwen OAuth (gratuito)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - Autenticar com Alibaba Cloud Coding Plan\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Autenticar com Alibaba Cloud Coding Plan\n', 'Or simply run:': 'Ou simplesmente execute:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - Configuração interativa de autenticação\n', @@ -1713,8 +1713,8 @@ export default { '⚠️ Método de autenticação: Alibaba Cloud Coding Plan (Incompleto)', ' Issue: API key not found in environment or settings\n': ' Problema: Chave de API não encontrada no ambiente ou configurações\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' Execute `qwen auth code-plan` para reconfigurar.\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Execute `qwen auth coding-plan` para reconfigurar.\n', '✓ Authentication Method: {{type}}': '✓ Método de autenticação: {{type}}', ' Status: Configured\n': ' Status: Configurado\n', 'Failed to check authentication status: {{error}}': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index fa5e49ef6..cff3b0316 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1703,8 +1703,8 @@ export default { 'Выполните одну из следующих команд для начала:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - Аутентификация через Qwen OAuth (бесплатно)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - Аутентификация через Alibaba Cloud Coding Plan\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - Аутентификация через Alibaba Cloud Coding Plan\n', 'Or simply run:': 'Или просто выполните:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - Интерактивная настройка аутентификации\n', @@ -1724,8 +1724,8 @@ export default { '⚠️ Метод аутентификации: Alibaba Cloud Coding Plan (Не завершён)', ' Issue: API key not found in environment or settings\n': ' Проблема: API-ключ не найден в окружении или настройках\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' Выполните `qwen auth code-plan` для повторной настройки.\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' Выполните `qwen auth coding-plan` для повторной настройки.\n', '✓ Authentication Method: {{type}}': '✓ Метод аутентификации: {{type}}', ' Status: Configured\n': ' Статус: Настроено\n', 'Failed to check authentication status: {{error}}': diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 653faa3a5..c7ba39488 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1562,8 +1562,8 @@ export default { '运行以下命令之一开始配置:\n', ' qwen auth qwen-oauth - Authenticate with Qwen OAuth (free tier)': ' qwen auth qwen-oauth - 使用 Qwen OAuth 认证(免费)', - ' qwen auth code-plan - Authenticate with Alibaba Cloud Coding Plan\n': - ' qwen auth code-plan - 使用阿里云百炼 Coding Plan 认证\n', + ' qwen auth coding-plan - Authenticate with Alibaba Cloud Coding Plan\n': + ' qwen auth coding-plan - 使用阿里云百炼 Coding Plan 认证\n', 'Or simply run:': '或者直接运行:', ' qwen auth - Interactive authentication setup\n': ' qwen auth - 交互式认证配置\n', @@ -1583,8 +1583,8 @@ export default { '⚠️ 认证方式:阿里云百炼 Coding Plan(不完整)', ' Issue: API key not found in environment or settings\n': ' 问题:在环境变量或设置中未找到 API 密钥\n', - ' Run `qwen auth code-plan` to re-configure.\n': - ' 运行 `qwen auth code-plan` 重新配置。\n', + ' Run `qwen auth coding-plan` to re-configure.\n': + ' 运行 `qwen auth coding-plan` 重新配置。\n', '✓ Authentication Method: {{type}}': '✓ 认证方式:{{type}}', ' Status: Configured\n': ' 状态:已配置\n', 'Failed to check authentication status: {{error}}': From 0d8f352aec52ecdc2ec871d52070ad537478a8f6 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Wed, 18 Mar 2026 20:10:31 +0800 Subject: [PATCH 69/82] fix ci test --- packages/core/src/skills/skill-manager.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/core/src/skills/skill-manager.test.ts b/packages/core/src/skills/skill-manager.test.ts index 730653f93..639234577 100644 --- a/packages/core/src/skills/skill-manager.test.ts +++ b/packages/core/src/skills/skill-manager.test.ts @@ -600,15 +600,6 @@ Skill 3 content`); expect(baseDirs).toContain( path.join('/test/project', '.agent', 'skills'), ); - expect(baseDirs).toContain( - path.join('/test/project', '.cursor', 'skills'), - ); - expect(baseDirs).toContain( - path.join('/test/project', '.codex', 'skills'), - ); - expect(baseDirs).toContain( - path.join('/test/project', '.claude', 'skills'), - ); }); it('should return all user-level base dirs', () => { @@ -617,9 +608,6 @@ Skill 3 content`); expect(baseDirs).toHaveLength(2); expect(baseDirs).toContain(path.join('/home/user', '.qwen', 'skills')); expect(baseDirs).toContain(path.join('/home/user', '.agent', 'skills')); - expect(baseDirs).toContain(path.join('/home/user', '.cursor', 'skills')); - expect(baseDirs).toContain(path.join('/home/user', '.codex', 'skills')); - expect(baseDirs).toContain(path.join('/home/user', '.claude', 'skills')); }); it('should return bundled-level base dir', () => { From 8a03c0261bd867e1821d48e3cfa377b515b1f64e Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 18 Mar 2026 21:20:23 +0800 Subject: [PATCH 70/82] fix(i18n): add missing translation keys for /context command Co-authored-by: Qwen-Coder --- packages/cli/src/i18n/locales/de.js | 5 +++++ packages/cli/src/i18n/locales/en.js | 4 ++++ packages/cli/src/i18n/locales/ja.js | 5 +++++ packages/cli/src/i18n/locales/pt.js | 5 +++++ packages/cli/src/i18n/locales/ru.js | 5 +++++ packages/cli/src/i18n/locales/zh.js | 4 ++++ 6 files changed, 28 insertions(+) diff --git a/packages/cli/src/i18n/locales/de.js b/packages/cli/src/i18n/locales/de.js index 66290f246..32085f696 100644 --- a/packages/cli/src/i18n/locales/de.js +++ b/packages/cli/src/i18n/locales/de.js @@ -1645,6 +1645,11 @@ export default { Messages: 'Nachrichten', 'Show context window usage breakdown.': 'Zeigt die Aufschlüsselung der Kontextfenster-Nutzung an.', + 'Run /context detail for per-item breakdown.': + 'Führen Sie /context detail für eine Aufschlüsselung nach Elementen aus.', + active: 'aktiv', + 'body loaded': 'Inhalt geladen', + memory: 'Speicher', '{{region}} configuration updated successfully.': '{{region}}-Konfiguration erfolgreich aktualisiert.', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': diff --git a/packages/cli/src/i18n/locales/en.js b/packages/cli/src/i18n/locales/en.js index fe1dd306c..619ab9e11 100644 --- a/packages/cli/src/i18n/locales/en.js +++ b/packages/cli/src/i18n/locales/en.js @@ -1696,6 +1696,10 @@ export default { Messages: 'Messages', 'Show context window usage breakdown.': 'Show context window usage breakdown.', + 'Run /context detail for per-item breakdown.': + 'Run /context detail for per-item breakdown.', + 'body loaded': 'body loaded', + memory: 'memory', '{{region}} configuration updated successfully.': '{{region}} configuration updated successfully.', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': diff --git a/packages/cli/src/i18n/locales/ja.js b/packages/cli/src/i18n/locales/ja.js index 8702e4e19..cd9884072 100644 --- a/packages/cli/src/i18n/locales/ja.js +++ b/packages/cli/src/i18n/locales/ja.js @@ -1150,6 +1150,11 @@ export default { Messages: 'メッセージ', 'Show context window usage breakdown.': 'コンテキストウィンドウの使用状況を表示します。', + 'Run /context detail for per-item breakdown.': + '/context detail を実行すると項目ごとの内訳を表示します。', + active: '有効', + 'body loaded': '本文読み込み済み', + memory: 'メモリ', '{{region}} configuration updated successfully.': '{{region}} の設定が正常に更新されました。', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': diff --git a/packages/cli/src/i18n/locales/pt.js b/packages/cli/src/i18n/locales/pt.js index c5fe8ab30..88c91170a 100644 --- a/packages/cli/src/i18n/locales/pt.js +++ b/packages/cli/src/i18n/locales/pt.js @@ -1639,6 +1639,11 @@ export default { Messages: 'Mensagens', 'Show context window usage breakdown.': 'Exibe a divisão de uso da janela de contexto.', + 'Run /context detail for per-item breakdown.': + 'Execute /context detail para detalhamento por item.', + active: 'ativo', + 'body loaded': 'conteúdo carregado', + memory: 'memória', '{{region}} configuration updated successfully.': 'Configuração do {{region}} atualizada com sucesso.', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': diff --git a/packages/cli/src/i18n/locales/ru.js b/packages/cli/src/i18n/locales/ru.js index 2b9c548ad..d3b51e9b0 100644 --- a/packages/cli/src/i18n/locales/ru.js +++ b/packages/cli/src/i18n/locales/ru.js @@ -1574,6 +1574,11 @@ export default { Messages: 'Сообщения', 'Show context window usage breakdown.': 'Показать разбивку использования контекстного окна.', + 'Run /context detail for per-item breakdown.': + 'Выполните /context detail для детализации по элементам.', + active: 'активно', + 'body loaded': 'содержимое загружено', + memory: 'память', // MCP Management Dialog // ============================================================================ 'MCP Management': 'Управление MCP', diff --git a/packages/cli/src/i18n/locales/zh.js b/packages/cli/src/i18n/locales/zh.js index 1072572cf..fb06c2792 100644 --- a/packages/cli/src/i18n/locales/zh.js +++ b/packages/cli/src/i18n/locales/zh.js @@ -1519,6 +1519,10 @@ export default { 'No API response yet. Send a message to see actual usage.': '暂无 API 响应。发送消息以查看实际使用情况。', 'Show context window usage breakdown.': '显示上下文窗口使用情况分解。', + 'Run /context detail for per-item breakdown.': + '运行 /context detail 查看详细分解。', + 'body loaded': '内容已加载', + memory: '记忆', '{{region}} configuration updated successfully.': '{{region}} 配置更新成功。', 'Authenticated successfully with {{region}}. API key and model configs saved to settings.json.': '成功通过 {{region}} 认证。API Key 和模型配置已保存至 settings.json。', From bb99755b21d674bf3f4f282e1915ea59e3220ec4 Mon Sep 17 00:00:00 2001 From: pomelo-nwu Date: Wed, 18 Mar 2026 21:34:51 +0800 Subject: [PATCH 71/82] fix: resolve TypeScript errors in geminiChat and HistoryItemDisplay Co-authored-by: Qwen-Coder --- .../cli/src/ui/components/HistoryItemDisplay.tsx | 2 ++ packages/core/src/core/geminiChat.ts | 14 +++++++++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/cli/src/ui/components/HistoryItemDisplay.tsx b/packages/cli/src/ui/components/HistoryItemDisplay.tsx index ab804d202..b52a2b9bf 100644 --- a/packages/cli/src/ui/components/HistoryItemDisplay.tsx +++ b/packages/cli/src/ui/components/HistoryItemDisplay.tsx @@ -209,6 +209,8 @@ const HistoryItemDisplayComponent: React.FC = ({ skills={itemForDisplay.skills} isEstimated={itemForDisplay.isEstimated} showDetails={itemForDisplay.showDetails} + /> + )} {itemForDisplay.type === 'arena_agent_complete' && ( )} diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 1d7036709..74e15deba 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -34,7 +34,11 @@ import { ContentRetryEvent, ContentRetryFailureEvent, } from '../telemetry/types.js'; -import type { UiTelemetryService } from '../telemetry/uiTelemetry.js'; +import type { + UiTelemetryService} from '../telemetry/uiTelemetry.js'; +import { + uiTelemetryService, +} from '../telemetry/uiTelemetry.js'; const debugLogger = createDebugLogger('QWEN_CODE_CHAT'); @@ -659,10 +663,14 @@ export class GeminiChat { const lastPromptTokenCount = usageMetadata.totalTokenCount || usageMetadata.promptTokenCount; if (lastPromptTokenCount) { - uiTelemetryService.setLastPromptTokenCount(lastPromptTokenCount); + (this.telemetryService ?? uiTelemetryService).setLastPromptTokenCount( + lastPromptTokenCount, + ); } if (usageMetadata.cachedContentTokenCount) { - uiTelemetryService.setLastCachedContentTokenCount( + ( + this.telemetryService ?? uiTelemetryService + ).setLastCachedContentTokenCount( usageMetadata.cachedContentTokenCount, ); } From ef640ba69858fbd0b72d855e31724b772ad9516c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Wed, 18 Mar 2026 21:45:11 +0800 Subject: [PATCH 72/82] feat(vscode-ide-companion): add Tab key fill-only behavior for completions (#2431) * feat(vscode-ide-companion): add Tab key fill-only behavior for completions - Separate Tab and Enter key handling in CompletionMenu - Tab now inserts completion text without executing (useful for slash commands) - Enter/click continues to select and execute immediately - Allow users to append arguments after Tab-filling slash commands * feat(vscode-ide-companion): add Tab key fill-only behavior for completions - Separate Tab and Enter key handling in CompletionMenu - Tab now inserts completion text without executing (useful for slash commands) - Enter/click continues to select and execute immediately - Allow users to append arguments after Tab-filling slash commands Co-authored-by: Mingholy <14246397+Mingholy@users.noreply.github.com> * feat: add command selection behavior logic and tests Co-developed-by: Aone Copilot * feat(vscode-ide-companion): add Tab key completion fill behavior with tests - Add onCompletionFill prop to InputForm for Tab key handling - Distinguish Tab (fill) and Enter (select) completion behaviors - Add keyboard handling tests for completion items - Remove 'skills' command from non-interactive CLI allowed list Co-authored-by: Qwen-Coder * refactor: add itemId variable for command handling in App component Co-developed-by: Aone Copilot * refactor: remove unused command selection behavior utils and tests --------- Co-authored-by: Mingholy <14246397+Mingholy@users.noreply.github.com> Co-authored-by: Qwen-Coder --- .../vscode-ide-companion/src/webview/App.tsx | 17 +- .../components/layout/InputForm.test.tsx | 155 ++++++++++++++++++ .../webview/components/layout/InputForm.tsx | 5 +- .../src/components/layout/CompletionMenu.tsx | 14 +- .../webui/src/components/layout/InputForm.tsx | 6 +- 5 files changed, 186 insertions(+), 11 deletions(-) create mode 100644 packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx diff --git a/packages/vscode-ide-companion/src/webview/App.tsx b/packages/vscode-ide-companion/src/webview/App.tsx index 65d38b96e..c569c1557 100644 --- a/packages/vscode-ide-companion/src/webview/App.tsx +++ b/packages/vscode-ide-companion/src/webview/App.tsx @@ -182,6 +182,7 @@ export const App: React.FC = () => { description: cmd.description, type: 'command' as const, group: 'Slash Commands', + value: cmd.name, }), ); @@ -511,9 +512,11 @@ export const App: React.FC = () => { setAskUserQuestionRequest(null); }, [vscode]); - // Handle completion selection + // Handle completion selection. + // When fillOnly is true (Tab), slash commands are inserted into the input + // instead of being sent immediately, so users can append arguments. const handleCompletionSelect = useCallback( - (item: CompletionItem) => { + (item: CompletionItem, fillOnly?: boolean) => { // Handle completion selection by inserting the value into the input field const inputElement = inputFieldRef.current; if (!inputElement) { @@ -586,13 +589,13 @@ export const App: React.FC = () => { } }; - // Handle special commands by id if (itemId === 'login') { clearTriggerText(); vscode.postMessage({ type: 'login', data: {} }); completion.closeCompletion(); return; } + if (itemId === 'model') { clearTriggerText(); setShowModelSelector(true); @@ -600,10 +603,11 @@ export const App: React.FC = () => { return; } - // Handle server-provided slash commands by sending them as messages - // CLI will detect slash commands in session/prompt and execute them + // Handle server-provided slash commands by sending them as messages. + // Skip when fillOnly (Tab) — let the generic insertion path fill the + // command text so the user can keep typing arguments. const serverCmd = availableCommands.find((c) => c.name === itemId); - if (serverCmd) { + if (serverCmd && !fillOnly) { // Clear the trigger text since we're sending the command clearTriggerText(); // Send the slash command as a user message @@ -1026,6 +1030,7 @@ export const App: React.FC = () => { completionIsOpen={completion.isOpen} completionItems={completion.items} onCompletionSelect={handleCompletionSelect} + onCompletionFill={(item) => handleCompletionSelect(item, true)} onCompletionClose={completion.closeCompletion} showModelSelector={showModelSelector} availableModels={availableModels} diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx new file mode 100644 index 000000000..8bf5ea26f --- /dev/null +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.test.tsx @@ -0,0 +1,155 @@ +/** + * @license + * Copyright 2025 Qwen Team + * SPDX-License-Identifier: Apache-2.0 + */ + +/** @vitest-environment jsdom */ + +import type React from 'react'; +import { act, createRef } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createRoot, type Root } from 'react-dom/client'; +import { ApprovalMode } from '../../../types/acpTypes.js'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; +import { InputForm } from './InputForm.js'; + +vi.mock('@qwen-code/webui', async () => { + const actual = await vi.importActual( + '../../../../../webui/src/components/layout/InputForm.tsx', + ); + + return { + InputForm: actual.InputForm, + getEditModeIcon: actual.getEditModeIcon, + }; +}); + +const completionItem: CompletionItem = { + id: 'create-issue', + label: '/create-issue', + type: 'command', + value: 'create-issue', +}; + +function renderInputForm(props?: { + onCompletionSelect?: (item: CompletionItem) => void; + onCompletionFill?: (item: CompletionItem) => void; +}) { + const container = document.createElement('div'); + document.body.appendChild(container); + + const root = createRoot(container); + const inputFieldRef = + createRef() as unknown as React.RefObject; + const onCompletionSelect = props?.onCompletionSelect ?? vi.fn(); + const onCompletionFill = props?.onCompletionFill ?? vi.fn(); + + act(() => { + root.render( + , + ); + }); + + return { + container, + root, + onCompletionSelect, + onCompletionFill, + }; +} + +describe('InputForm completion keyboard handling', () => { + let root: Root | null = null; + let container: HTMLDivElement | null = null; + + beforeEach(() => { + vi.clearAllMocks(); + ( + globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean } + ).IS_REACT_ACT_ENVIRONMENT = true; + Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', { + configurable: true, + value: vi.fn(), + }); + }); + + afterEach(() => { + if (root) { + act(() => { + root?.unmount(); + }); + root = null; + } + if (container) { + container.remove(); + container = null; + } + }); + + it('uses onCompletionFill for Tab without triggering onCompletionSelect', () => { + const rendered = renderInputForm(); + root = rendered.root; + container = rendered.container; + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Tab', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(rendered.onCompletionFill).toHaveBeenCalledWith(completionItem); + expect(rendered.onCompletionSelect).not.toHaveBeenCalled(); + }); + + it('keeps Enter mapped to onCompletionSelect', () => { + const rendered = renderInputForm(); + root = rendered.root; + container = rendered.container; + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'Enter', + bubbles: true, + cancelable: true, + }), + ); + }); + + expect(rendered.onCompletionSelect).toHaveBeenCalledWith(completionItem); + expect(rendered.onCompletionFill).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx index cb747aff3..809f80dbc 100644 --- a/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx +++ b/packages/vscode-ide-companion/src/webview/components/layout/InputForm.tsx @@ -13,6 +13,7 @@ import type { InputFormProps as BaseInputFormProps, EditModeInfo, } from '@qwen-code/webui'; +import type { CompletionItem } from '../../../types/completionItemTypes.js'; import { getApprovalModeInfoFromString } from '../../../types/acpTypes.js'; import type { ApprovalModeValue } from '../../../types/approvalModeValueTypes.js'; import type { ModelInfo } from '@agentclientprotocol/sdk'; @@ -22,9 +23,11 @@ import { ModelSelector } from './ModelSelector.js'; * Extended props that accept ApprovalModeValue and ModelSelector */ export interface InputFormProps - extends Omit { + extends Omit { /** Edit mode value (local type) */ editMode: ApprovalModeValue; + /** Completion fill callback (Tab or equivalent) */ + onCompletionFill?: (item: CompletionItem) => void; /** Whether to show model selector */ showModelSelector?: boolean; /** Available models for selection */ diff --git a/packages/webui/src/components/layout/CompletionMenu.tsx b/packages/webui/src/components/layout/CompletionMenu.tsx index 06727f7ee..eeefd6da7 100644 --- a/packages/webui/src/components/layout/CompletionMenu.tsx +++ b/packages/webui/src/components/layout/CompletionMenu.tsx @@ -17,8 +17,10 @@ import type { CompletionItem } from '../../types/completion.js'; export interface CompletionMenuProps { /** List of completion items to display */ items: CompletionItem[]; - /** Callback when an item is selected */ + /** Callback when an item is selected (Enter / click) */ onSelect: (item: CompletionItem) => void; + /** Optional callback for Tab selection (fill without executing). Falls back to onSelect. */ + onFill?: (item: CompletionItem) => void; /** Callback when menu should close */ onClose: () => void; /** Optional section title */ @@ -75,6 +77,7 @@ const groupItems = ( export const CompletionMenu: FC = ({ items, onSelect, + onFill, onClose, title, selectedIndex = 0, @@ -123,12 +126,17 @@ export const CompletionMenu: FC = ({ setSelected((prev) => Math.max(prev - 1, 0)); break; case 'Enter': - case 'Tab': event.preventDefault(); if (items[selected]) { onSelect(items[selected]); } break; + case 'Tab': + event.preventDefault(); + if (items[selected]) { + (onFill ?? onSelect)(items[selected]); + } + break; case 'Escape': event.preventDefault(); onClose(); @@ -144,7 +152,7 @@ export const CompletionMenu: FC = ({ document.removeEventListener('mousedown', handleClickOutside); document.removeEventListener('keydown', handleKeyDown); }; - }, [items, selected, onSelect, onClose]); + }, [items, selected, onSelect, onFill, onClose]); useEffect(() => { // Only scroll into view for keyboard navigation, not mouse hover diff --git a/packages/webui/src/components/layout/InputForm.tsx b/packages/webui/src/components/layout/InputForm.tsx index e77f57e24..7edfac03b 100644 --- a/packages/webui/src/components/layout/InputForm.tsx +++ b/packages/webui/src/components/layout/InputForm.tsx @@ -111,8 +111,10 @@ export interface InputFormProps { completionIsOpen: boolean; /** Completion items */ completionItems?: CompletionItem[]; - /** Completion select callback */ + /** Completion select callback (Enter / click) */ onCompletionSelect?: (item: CompletionItem) => void; + /** Completion fill callback (Tab — fill without executing). Falls back to onCompletionSelect. */ + onCompletionFill?: (item: CompletionItem) => void; /** Completion close callback */ onCompletionClose?: () => void; /** Placeholder text */ @@ -170,6 +172,7 @@ export const InputForm: FC = ({ completionIsOpen, completionItems, onCompletionSelect, + onCompletionFill, onCompletionClose, placeholder = 'Ask Qwen Code …', }) => { @@ -242,6 +245,7 @@ export const InputForm: FC = ({ From 200a29832e411ba515b5328984a775df1f455342 Mon Sep 17 00:00:00 2001 From: qqqys Date: Thu, 19 Mar 2026 10:09:16 +0800 Subject: [PATCH 73/82] fix(test): fix loadingindicator test case --- .../components/__snapshots__/LoadingIndicator.test.tsx.snap | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap index 46e4489c0..f9236b52a 100644 --- a/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap +++ b/packages/cli/src/ui/components/__snapshots__/LoadingIndicator.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html exports[` > should truncate long primary text instead of wrapping 1`] = ` -"MockResponding This is an extremely long loading phrase that should be truncated in t (5s · esc to -Spinner cancel)" +" MockResponding This is an extremely long loading phrase that should be truncated in (5s · esc to + Spinner cancel)" `; From 4b67e60e639d161459e1512bec04f3eb6c5bbd17 Mon Sep 17 00:00:00 2001 From: LaZzyMan Date: Thu, 19 Mar 2026 10:47:30 +0800 Subject: [PATCH 74/82] fix lint --- packages/cli/src/commands/auth/handler.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/cli/src/commands/auth/handler.ts b/packages/cli/src/commands/auth/handler.ts index 0c0ad2a88..1d03e9860 100644 --- a/packages/cli/src/commands/auth/handler.ts +++ b/packages/cli/src/commands/auth/handler.ts @@ -108,6 +108,8 @@ export async function handleQwenAuth( excludeTools: undefined, authType: undefined, channel: undefined, + systemPrompt: undefined, + appendSystemPrompt: undefined, }; // Create a minimal config to access settings and storage From d59e668729bfd3fb30a179037f1bdb402e917215 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 12 Mar 2026 21:37:05 +0800 Subject: [PATCH 75/82] feat(export): add metadata and statistics to export data - Add ExportMetadata type with session info, token stats, file operation stats - Track response_id from LLM API for telemetry correlation - Collect usageMetadata from assistant messages - Calculate file stats (files read/written, lines added/removed) - Calculate token stats (total tokens, context usage percentage) - Add metadata sidebar to HTML export template - Support metadata in JSONL and Markdown formatters - Update chatRecordingService to record response_id Co-authored-by: Qwen-Coder --- packages/cli/src/ui/utils/export/collect.ts | 260 +++++++++++++++++- .../src/ui/utils/export/formatters/html.ts | 1 + .../src/ui/utils/export/formatters/jsonl.ts | 19 +- .../ui/utils/export/formatters/markdown.ts | 11 + packages/cli/src/ui/utils/export/normalize.ts | 33 +++ packages/cli/src/ui/utils/export/types.ts | 48 ++++ packages/core/src/core/geminiChat.ts | 7 + .../core/src/services/chatRecordingService.ts | 8 + .../src/export-html/src/main.tsx | 225 ++++++++++++++- .../src/export-html/src/styles.css | 186 ++++++++++++- .../MarkdownRenderer/MarkdownRenderer.css | 9 +- 11 files changed, 776 insertions(+), 31 deletions(-) diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index 112f38c7f..ca297200b 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -6,10 +6,211 @@ import { randomUUID } from 'node:crypto'; import type { Config, ChatRecord } from '@qwen-code/qwen-code-core'; +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; import type { SessionContext } from '../../../acp-integration/session/types.js'; import type { SessionUpdate, ToolCall } from '@agentclientprotocol/sdk'; import { HistoryReplayer } from '../../../acp-integration/session/HistoryReplayer.js'; -import type { ExportMessage, ExportSessionData } from './types.js'; +import type { + ExportMessage, + ExportSessionData, + ExportMetadata, +} from './types.js'; + +/** + * File operation statistics extracted from tool calls. + */ +interface FileOperationStats { + filesRead: number; + filesWritten: number; + linesAdded: number; + linesRemoved: number; + uniqueFiles: Set; +} + +/** + * Calculate file operation statistics from ChatRecords. + * Uses toolCallResult from tool_result records for accurate statistics. + */ +function calculateFileStats(records: ChatRecord[]): FileOperationStats { + const stats: FileOperationStats = { + filesRead: 0, + filesWritten: 0, + linesAdded: 0, + linesRemoved: 0, + uniqueFiles: new Set(), + }; + + for (const record of records) { + if (record.type !== 'tool_result' || !record.toolCallResult) continue; + + const { resultDisplay } = record.toolCallResult; + + // Track file locations from resultDisplay + if ( + resultDisplay && + typeof resultDisplay === 'object' && + 'fileName' in resultDisplay + ) { + const display = resultDisplay as { + fileName: string; + originalContent?: string | null; + newContent?: string; + diffStat?: { model_added_lines?: number; model_removed_lines?: number }; + }; + + // Track unique files + if (typeof display.fileName === 'string') { + stats.uniqueFiles.add(display.fileName); + } + + // Determine operation type based on content fields + const hasOriginalContent = 'originalContent' in display; + const hasNewContent = 'newContent' in display; + + if (hasOriginalContent || hasNewContent) { + // This is a write/edit operation + stats.filesWritten++; + + // Calculate line changes + if (display.diffStat) { + // Use diffStat if available for accurate counts + stats.linesAdded += display.diffStat.model_added_lines ?? 0; + stats.linesRemoved += display.diffStat.model_removed_lines ?? 0; + } else { + // Fallback: count lines in content + const oldText = String(display.originalContent ?? ''); + const newText = String(display.newContent ?? ''); + + // Count non-empty lines + const oldLines = oldText + .split('\n') + .filter((line) => line.length > 0).length; + const newLines = newText + .split('\n') + .filter((line) => line.length > 0).length; + + stats.linesAdded += newLines; + stats.linesRemoved += oldLines; + } + } else { + // This is likely a read operation (no content changes) + stats.filesRead++; + } + } + } + + return stats; +} + +/** + * Calculate token statistics from ChatRecords. + * Aggregates usageMetadata from assistant records to get total token usage. + */ +function calculateTokenStats( + records: ChatRecord[], + contextWindowSize?: number, +): { totalTokens: number; promptTokens: number; contextUsagePercent?: number } { + let totalTokens = 0; + let lastPromptTokens = 0; + + // Aggregate usageMetadata from all assistant records + // Use last available promptTokenCount for context usage calculation + for (const record of records) { + if (record.type === 'assistant' && record.usageMetadata) { + totalTokens += record.usageMetadata.totalTokenCount ?? 0; + // Use the last available promptTokenCount (represents current context usage) + if (record.usageMetadata.promptTokenCount !== undefined) { + lastPromptTokens = record.usageMetadata.promptTokenCount; + } + } + } + + // Use promptTokens (input tokens) for context usage calculation + // This represents how much of the context window is being used + if (contextWindowSize && lastPromptTokens > 0) { + const percent = (lastPromptTokens / contextWindowSize) * 100; + return { + totalTokens, + promptTokens: lastPromptTokens, + contextUsagePercent: Math.round(percent * 10) / 10, + }; + } + + return { totalTokens, promptTokens: lastPromptTokens }; +} + +/** + * Extract session metadata from ChatRecords. + */ +function extractMetadata( + conversation: { + sessionId: string; + startTime: string; + messages: ChatRecord[]; + }, + config: Config, +): ExportMetadata { + const { sessionId, startTime, messages } = conversation; + + // Extract basic info from the first record + const firstRecord = messages[0]; + const cwd = firstRecord?.cwd ?? ''; + const gitBranch = firstRecord?.gitBranch; + + // Try to get model from assistant messages + let model: string | undefined; + for (const record of messages) { + if (record.type === 'assistant' && record.model) { + model = record.model; + break; + } + } + + // Get channel from config + const channel = config.getChannel?.(); + + // Count user prompts + const promptCount = messages.filter((m) => m.type === 'user').length; + + // Get context window size + const contentGenConfig = config.getContentGeneratorConfig?.(); + const contextWindowSize = contentGenConfig?.contextWindowSize; + + // Calculate file stats from original ChatRecords + const fileStats = calculateFileStats(messages); + + // Calculate token stats from original ChatRecords + const tokenStats = calculateTokenStats(messages, contextWindowSize); + + // Extract the last response_id from assistant records (for request tracking) + let requestId: string | undefined; + for (let i = messages.length - 1; i >= 0; i--) { + const record = messages[i]; + if (record.type === 'assistant' && record.response_id) { + requestId = record.response_id; + break; + } + } + + return { + sessionId, + startTime, + exportTime: new Date().toISOString(), + cwd, + gitBranch, + model, + channel, + promptCount, + contextUsagePercent: tokenStats.contextUsagePercent, + totalTokens: tokenStats.totalTokens, + filesRead: fileStats.filesRead, + filesWritten: fileStats.filesWritten, + linesAdded: fileStats.linesAdded, + linesRemoved: fileStats.linesRemoved, + uniqueFiles: Array.from(fileStats.uniqueFiles), + requestId, + }; +} /** * Export session context that captures session updates into export messages. @@ -24,6 +225,7 @@ class ExportSessionContext implements SessionContext { role: 'user' | 'assistant' | 'thinking'; parts: Array<{ text: string }>; timestamp: number; + usageMetadata?: GenerateContentResponseUsageMetadata; } | null = null; private activeRecordId: string | null = null; private activeRecordTimestamp: string | null = null; @@ -39,9 +241,37 @@ class ExportSessionContext implements SessionContext { case 'user_message_chunk': this.handleMessageChunk('user', update.content); break; - case 'agent_message_chunk': - this.handleMessageChunk('assistant', update.content); + case 'agent_message_chunk': { + // Extract usageMetadata from _meta if available + const usageMeta = update._meta as + | { + usage?: { + inputTokens?: number; + outputTokens?: number; + totalTokens?: number; + thoughtTokens?: number; + cachedReadTokens?: number; + }; + } + | undefined; + const usageMetadata: GenerateContentResponseUsageMetadata | undefined = + usageMeta?.usage + ? { + promptTokenCount: usageMeta.usage.inputTokens, + candidatesTokenCount: usageMeta.usage.outputTokens, + totalTokenCount: usageMeta.usage.totalTokens, + thoughtsTokenCount: usageMeta.usage.thoughtTokens, + cachedContentTokenCount: usageMeta.usage.cachedReadTokens, + } + : undefined; + this.handleMessageChunk( + 'assistant', + update.content, + 'assistant', + usageMetadata, + ); break; + } case 'agent_thought_chunk': this.handleMessageChunk('assistant', update.content, 'thinking'); break; @@ -79,6 +309,7 @@ class ExportSessionContext implements SessionContext { role: 'user' | 'assistant', content: { type: string; text?: string }, messageRole: 'user' | 'assistant' | 'thinking' = role, + usageMetadata?: GenerateContentResponseUsageMetadata, ): void { if (content.type !== 'text' || !content.text) return; @@ -98,12 +329,17 @@ class ExportSessionContext implements SessionContext { this.currentMessage.role === messageRole ) { this.currentMessage.parts.push({ text: content.text }); + // Merge usageMetadata if provided (for assistant messages) + if (usageMetadata && role === 'assistant') { + this.currentMessage.usageMetadata = usageMetadata; + } } else { this.currentMessage = { type: role, role: messageRole, parts: [{ text: content.text }], timestamp: Date.now(), + ...(usageMetadata && role === 'assistant' ? { usageMetadata } : {}), }; } } @@ -205,7 +441,7 @@ class ExportSessionContext implements SessionContext { if (!this.currentMessage) return; const uuid = this.getMessageUuid(); - this.messages.push({ + const exportMessage: ExportMessage = { uuid, sessionId: this.sessionId, timestamp: this.getMessageTimestamp(), @@ -214,7 +450,17 @@ class ExportSessionContext implements SessionContext { role: this.currentMessage.role, parts: this.currentMessage.parts, }, - }); + }; + + // Add usageMetadata for assistant messages + if ( + this.currentMessage.type === 'assistant' && + this.currentMessage.usageMetadata + ) { + exportMessage.usageMetadata = this.currentMessage.usageMetadata; + } + + this.messages.push(exportMessage); this.currentMessage = null; } @@ -258,9 +504,13 @@ export async function collectSessionData( // Get the export messages const messages = exportContext.getMessages(); + // Extract metadata from conversation + const metadata = extractMetadata(conversation, config); + return { sessionId: conversation.sessionId, startTime: conversation.startTime, messages, + metadata, }; } diff --git a/packages/cli/src/ui/utils/export/formatters/html.ts b/packages/cli/src/ui/utils/export/formatters/html.ts index b4b72fb39..3fb4b9914 100644 --- a/packages/cli/src/ui/utils/export/formatters/html.ts +++ b/packages/cli/src/ui/utils/export/formatters/html.ts @@ -36,6 +36,7 @@ export function injectDataIntoHtmlTemplate( sessionId: string; startTime: string; messages: unknown[]; + metadata?: unknown; }, ): string { const jsonData = JSON.stringify(data, null, 2); diff --git a/packages/cli/src/ui/utils/export/formatters/jsonl.ts b/packages/cli/src/ui/utils/export/formatters/jsonl.ts index 57dcfeb8b..10854ba90 100644 --- a/packages/cli/src/ui/utils/export/formatters/jsonl.ts +++ b/packages/cli/src/ui/utils/export/formatters/jsonl.ts @@ -14,13 +14,18 @@ export function toJsonl(sessionData: ExportSessionData): string { const lines: string[] = []; // Add session metadata as the first line - lines.push( - JSON.stringify({ - type: 'session_metadata', - sessionId: sessionData.sessionId, - startTime: sessionData.startTime, - }), - ); + const metadata: Record = { + type: 'session_metadata', + sessionId: sessionData.sessionId, + startTime: sessionData.startTime, + }; + + // Add requestId if available + if (sessionData.metadata?.requestId) { + metadata['requestId'] = sessionData.metadata.requestId; + } + + lines.push(JSON.stringify(metadata)); // Add each message as a separate line for (const message of sessionData.messages) { diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index deb520cad..2a79be8ff 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -16,6 +16,14 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push('# Chat Session Export\n'); lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``); lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`); + + // Add requestId if available + if (sessionData.metadata?.requestId) { + lines.push( + `- **Request ID**: \`${sanitizeText(sessionData.metadata.requestId)}\``, + ); + } + lines.push(`- **Exported**: ${new Date().toISOString()}`); lines.push('\n---\n'); @@ -26,6 +34,9 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push(formatMessageContent(message)); } else if (message.type === 'assistant') { lines.push('## Assistant\n'); + if (message.response_id) { + lines.push(`*Response ID: \`${sanitizeText(message.response_id)}\`*\n`); + } lines.push(formatMessageContent(message)); } else if (message.type === 'tool_call') { lines.push(formatToolCall(message)); diff --git a/packages/cli/src/ui/utils/export/normalize.ts b/packages/cli/src/ui/utils/export/normalize.ts index c2236dd3c..ae22f2cb5 100644 --- a/packages/cli/src/ui/utils/export/normalize.ts +++ b/packages/cli/src/ui/utils/export/normalize.ts @@ -28,6 +28,14 @@ export function normalizeSessionData( } }); + // Build index of assistant messages by uuid for response_id mapping + const assistantMessageIndexByUuid = new Map(); + normalized.forEach((message, index) => { + if (message.type === 'assistant') { + assistantMessageIndexByUuid.set(message.uuid, index); + } + }); + // Merge tool result information into tool call messages for (const record of originalRecords) { if (record.type !== 'tool_result') continue; @@ -58,6 +66,31 @@ export function normalizeSessionData( mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall); } + // Merge response_id from assistant records + for (const record of originalRecords) { + if (record.type !== 'assistant') continue; + if (!record.response_id) continue; + + const existingIndex = assistantMessageIndexByUuid.get(record.uuid); + if (existingIndex !== undefined) { + normalized[existingIndex].response_id = record.response_id; + } + } + + // Merge usageMetadata from assistant records + for (const record of originalRecords) { + if (record.type !== 'assistant') continue; + if (!record.usageMetadata) continue; + + const existingIndex = assistantMessageIndexByUuid.get(record.uuid); + if (existingIndex !== undefined) { + // Only set if not already present from collect phase + if (!normalized[existingIndex].usageMetadata) { + normalized[existingIndex].usageMetadata = record.usageMetadata; + } + } + } + return { ...sessionData, messages: normalized, diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts index e71612615..3ff0a7352 100644 --- a/packages/cli/src/ui/utils/export/types.ts +++ b/packages/cli/src/ui/utils/export/types.ts @@ -4,6 +4,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import type { GenerateContentResponseUsageMetadata } from '@google/genai'; + /** * Universal export message format - SSOT for all export formats. * This is format-agnostic and contains all information needed for any export type. @@ -25,6 +27,12 @@ export interface ExportMessage { /** Model used for assistant messages */ model?: string; + /** Response ID from the LLM API for telemetry/tracing correlation */ + response_id?: string; + + /** Token usage for this message (mainly for assistant messages) */ + usageMetadata?: GenerateContentResponseUsageMetadata; + /** For tool_call messages */ toolCall?: { toolCallId: string; @@ -44,6 +52,44 @@ export interface ExportMessage { }; } +/** + * Metadata for export session - contains aggregated statistics and session context. + */ +export interface ExportMetadata { + /** Session ID */ + sessionId: string; + /** ISO timestamp when session started */ + startTime: string; + /** Export timestamp */ + exportTime: string; + /** Current working directory */ + cwd: string; + /** Git branch name, if available */ + gitBranch?: string; + /** Model used in the session */ + model?: string; + /** Channel/source identifier */ + channel?: string; + /** Number of user prompts in the session */ + promptCount: number; + /** Context window utilization percentage (0-100) */ + contextUsagePercent?: number; + /** Total tokens used (prompt + completion) */ + totalTokens?: number; + /** Number of files read */ + filesRead?: number; + /** Number of files written/edited */ + filesWritten?: number; + /** Lines of code added */ + linesAdded?: number; + /** Lines of code removed */ + linesRemoved?: number; + /** Unique files referenced in the session */ + uniqueFiles: string[]; + /** Last response ID from the LLM API (request ID) */ + requestId?: string; +} + /** * Complete export session data - the single source of truth. */ @@ -51,4 +97,6 @@ export interface ExportSessionData { sessionId: string; startTime: string; messages: ExportMessage[]; + /** Session metadata and statistics */ + metadata?: ExportMetadata; } diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 74e15deba..979cca0a1 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -633,6 +633,7 @@ export class GeminiChat { // Collect ALL parts from the model response (including thoughts for recording) const allModelParts: Part[] = []; let usageMetadata: GenerateContentResponseUsageMetadata | undefined; + let responseId: string | undefined; let hasToolCall = false; let hasFinishReason = false; @@ -653,6 +654,11 @@ export class GeminiChat { // Collect all parts for recording allModelParts.push(...content.parts); } + + // Collect response ID for telemetry/tracing correlation + if (chunk.responseId) { + responseId = chunk.responseId; + } } // Collect token usage for consolidated recording @@ -736,6 +742,7 @@ export class GeminiChat { : []), ], tokens: usageMetadata, + responseId, }); } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 795ac1fe5..9ae4064a2 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -81,6 +81,8 @@ export interface ChatRecord { usageMetadata?: GenerateContentResponseUsageMetadata; /** Model used for this response */ model?: string; + /** Response ID from the LLM API for telemetry/tracing correlation */ + response_id?: string; /** * Tool call metadata for UI recovery. * Contains enriched info (displayName, status, result, etc.) not in API format. @@ -299,12 +301,14 @@ export class ChatRecordingService { * @param data.message The raw PartListUnion object from the model response * @param data.model The model name * @param data.tokens Token usage statistics + * @param data.responseId Response ID from the LLM API * @param data.toolCallsMetadata Enriched tool call info for UI recovery */ recordAssistantTurn(data: { model: string; message?: PartListUnion; tokens?: GenerateContentResponseUsageMetadata; + responseId?: string; }): void { try { const record: ChatRecord = { @@ -320,6 +324,10 @@ export class ChatRecordingService { record.usageMetadata = data.tokens; } + if (data.responseId) { + record.response_id = data.responseId; + } + this.appendRecord(record); } catch (error) { debugLogger.error('Error saving assistant turn:', error); diff --git a/packages/web-templates/src/export-html/src/main.tsx b/packages/web-templates/src/export-html/src/main.tsx index a0d7468ba..874894903 100644 --- a/packages/web-templates/src/export-html/src/main.tsx +++ b/packages/web-templates/src/export-html/src/main.tsx @@ -29,6 +29,27 @@ type ChatData = { messages?: unknown[]; sessionId?: string; startTime?: string; + metadata?: ExportMetadata; +}; + +type ExportMetadata = { + sessionId: string; + startTime: string; + relativeTime: string; + exportTime: string; + cwd: string; + gitBranch?: string; + model?: string; + channel?: string; + promptCount: number; + contextUsagePercent?: number; + totalTokens?: number; + filesRead?: number; + filesWritten?: number; + linesAdded?: number; + linesRemoved?: number; + uniqueFiles: string[]; + requestId?: string; }; type PlatformContextValue = { @@ -132,6 +153,198 @@ const formatSessionDate = (startTime?: string | null) => { } }; +const formatExportTime = (exportTime?: string | null) => { + if (!exportTime) { + return '-'; + } + + try { + const date = new Date(exportTime); + return date.toLocaleString(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return exportTime; + } +}; + +const formatPath = (path: string, maxLength: number = 40) => { + if (!path || path.length <= maxLength) return path; + const parts = path.split('/'); + if (parts.length <= 2) return '...' + path.slice(-maxLength + 3); + return '...' + path.slice(-maxLength + 3); +}; + +const CopyButton = ({ text }: { text: string }) => { + const [copied, setCopied] = React.useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + ); +}; + +const MetadataItem = ({ + label, + value, + valueClass, +}: { + label: string; + value?: string | number; + valueClass?: string; +}) => { + if (value === undefined || value === null || value === '') { + return null; + } + return ( +
+
+ {label} + + {value} + +
+
+ ); +}; + +const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => { + const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0; + + return ( + + ); +}; + const App = () => { const chatData = parseChatData(); const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; @@ -140,6 +353,7 @@ const App = () => { .filter((record) => record.type !== 'system'); const sessionId = chatData.sessionId ?? '-'; const sessionDate = formatSessionDate(chatData.startTime); + const metadata = chatData.metadata; const { platformContext, modalState, closeModal } = usePlatformContext(); return ( @@ -168,10 +382,13 @@ const App = () => { -
- - - +
+
+ + + +
+ {metadata && }
diff --git a/packages/web-templates/src/export-html/src/styles.css b/packages/web-templates/src/export-html/src/styles.css index e8286b2c5..eff5bc2c8 100644 --- a/packages/web-templates/src/export-html/src/styles.css +++ b/packages/web-templates/src/export-html/src/styles.css @@ -144,14 +144,6 @@ body { color: #71717a; } -.chat-container { - width: 100%; - max-width: 900px; - padding: 40px 20px; - box-sizing: border-box; - flex: 1; -} - ::-webkit-scrollbar { width: 10px; height: 10px; @@ -201,3 +193,181 @@ body { padding: 16px 12px; } } + +/* Main layout - sidebar on right, messages on left */ +.content-wrapper { + display: flex; + width: 100%; + max-width: 1600px; + height: calc(100vh - 73px); +} + +.chat-container { + flex: 1; + min-width: 0; + overflow-y: auto; + padding: 24px; + box-sizing: border-box; +} + +/* Metadata Sidebar - fixed on right */ +.metadata-sidebar { + width: 280px; + min-width: 280px; + padding: 12px; + border-right: 1px solid var(--border-color); + background-color: var(--bg-secondary); + display: flex; + flex-direction: column; + gap: 12px; + overflow-y: auto; + height: 100%; + box-sizing: border-box; +} + +.metadata-section { + display: flex; + flex-direction: column; + gap: 8px; +} + +.metadata-section-title { + font-size: 10px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0; + padding-bottom: 4px; + border-bottom: 1px solid var(--border-color); +} + +.metadata-section-small { + margin-top: auto; + padding-top: 12px; + border-top: 1px solid var(--border-color); +} + +.metadata-item { + display: flex; + flex-direction: column; + gap: 2px; +} + +.metadata-content { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.metadata-content .metadata-label { + font-size: 10px; + color: #71717a; +} + +.metadata-content .metadata-value { + font-size: 11px; + color: var(--text-primary); + word-break: break-all; + line-height: 1.3; + cursor: pointer; +} + +.metadata-content .metadata-value.text-green { + color: #22c55e; +} + +.metadata-content .metadata-value.text-red { + color: #ef4444; +} + +.metadata-value-with-copy { + display: flex; + align-items: center; + gap: 8px; +} + +.metadata-value-with-copy .metadata-value { + flex: 1; + min-width: 0; +} + +.copy-button { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 4px; + background: transparent; + border: 1px solid var(--border-color, #3f3f46); + border-radius: 4px; + color: var(--text-secondary, #a1a1aa); + cursor: pointer; + transition: all 0.15s ease; + flex-shrink: 0; +} + +.copy-button:hover { + background: var(--bg-hover, #27272a); + color: var(--text-primary, #f4f4f5); + border-color: var(--border-hover, #52525b); +} + +.copy-button:active { + transform: scale(0.95); +} + +/* Responsive adjustments */ +@media (max-width: 1024px) { + .metadata-sidebar { + width: 260px; + min-width: 260px; + padding: 10px; + } +} + +@media (max-width: 768px) { + .content-wrapper { + flex-direction: column; + height: auto; + } + + .chat-container { + height: auto; + min-height: 50vh; + } + + .metadata-sidebar { + width: 100%; + min-width: 100%; + height: auto; + max-height: none; + border-right: none; + border-top: 1px solid var(--border-color); + padding: 12px; + gap: 12px; + } + + .metadata-section { + flex-direction: row; + flex-wrap: wrap; + gap: 12px; + } + + .metadata-section-title { + width: 100%; + border-bottom: none; + padding-bottom: 0; + } + + .metadata-item { + flex: 1; + min-width: 140px; + } + + .metadata-section-small { + margin-top: 0; + padding-top: 0; + border-top: none; + } +} diff --git a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css index c53725e49..45f16499c 100644 --- a/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css +++ b/packages/webui/src/components/messages/MarkdownRenderer/MarkdownRenderer.css @@ -182,14 +182,9 @@ monospace ); font-size: 0.95em; - color: var(--app-link-foreground, #007acc); - text-decoration: underline; + color: inherit; + text-decoration: none; cursor: pointer; - transition: color 0.1s ease; -} - -.markdown-content .file-path-link:hover { - color: var(--app-link-active-foreground, #005a9e); } .markdown-content hr { From ccecc472dc15eb2cfc6b54eadd2f0fbcdfdf6115 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 17 Mar 2026 21:12:42 +0800 Subject: [PATCH 76/82] feat(export): refactor HTML export components and improve metadata Co-authored-by: Qwen-Coder --- packages/cli/src/ui/utils/export/collect.ts | 38 ++- packages/cli/src/ui/utils/export/types.ts | 4 + packages/core/src/utils/gitUtils.ts | 58 ++++ .../export-html/src/components/CopyButton.tsx | 53 +++ .../src/components/MetadataItem.tsx | 28 ++ .../src/components/MetadataSidebar.tsx | 110 ++++++ .../src/export-html/src/components/hooks.ts | 38 +++ .../src/export-html/src/components/types.ts | 48 +++ .../src/export-html/src/components/utils.ts | 135 ++++++++ .../src/export-html/src/main.tsx | 317 +----------------- .../src/export-html/src/styles.css | 10 +- 11 files changed, 511 insertions(+), 328 deletions(-) create mode 100644 packages/web-templates/src/export-html/src/components/CopyButton.tsx create mode 100644 packages/web-templates/src/export-html/src/components/MetadataItem.tsx create mode 100644 packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx create mode 100644 packages/web-templates/src/export-html/src/components/hooks.ts create mode 100644 packages/web-templates/src/export-html/src/components/types.ts create mode 100644 packages/web-templates/src/export-html/src/components/utils.ts diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index ca297200b..c4de5ee75 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -109,47 +109,46 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { function calculateTokenStats( records: ChatRecord[], contextWindowSize?: number, -): { totalTokens: number; promptTokens: number; contextUsagePercent?: number } { +): { totalTokens: number; contextUsagePercent?: number } { let totalTokens = 0; - let lastPromptTokens = 0; + let lastTotalTokens = 0; // Aggregate usageMetadata from all assistant records - // Use last available promptTokenCount for context usage calculation + // Use last available totalTokenCount for context usage calculation for (const record of records) { if (record.type === 'assistant' && record.usageMetadata) { totalTokens += record.usageMetadata.totalTokenCount ?? 0; - // Use the last available promptTokenCount (represents current context usage) - if (record.usageMetadata.promptTokenCount !== undefined) { - lastPromptTokens = record.usageMetadata.promptTokenCount; + // Use the last available totalTokenCount for context usage calculation + if (record.usageMetadata.totalTokenCount !== undefined) { + lastTotalTokens = record.usageMetadata.totalTokenCount; } } } - // Use promptTokens (input tokens) for context usage calculation - // This represents how much of the context window is being used - if (contextWindowSize && lastPromptTokens > 0) { - const percent = (lastPromptTokens / contextWindowSize) * 100; + // Use last totalTokenCount for context usage calculation + // This represents how much of the context window is being used by the total tokens + if (contextWindowSize && lastTotalTokens > 0) { + const percent = (lastTotalTokens / contextWindowSize) * 100; return { totalTokens, - promptTokens: lastPromptTokens, contextUsagePercent: Math.round(percent * 10) / 10, }; } - return { totalTokens, promptTokens: lastPromptTokens }; + return { totalTokens }; } /** * Extract session metadata from ChatRecords. */ -function extractMetadata( +async function extractMetadata( conversation: { sessionId: string; startTime: string; messages: ChatRecord[]; }, config: Config, -): ExportMetadata { +): Promise { const { sessionId, startTime, messages } = conversation; // Extract basic info from the first record @@ -157,6 +156,13 @@ function extractMetadata( const cwd = firstRecord?.cwd ?? ''; const gitBranch = firstRecord?.gitBranch; + // Get git repository name + let gitRepo: string | undefined; + if (cwd) { + const { getGitRepoName } = await import('@qwen-code/qwen-code-core'); + gitRepo = getGitRepoName(cwd); + } + // Try to get model from assistant messages let model: string | undefined; for (const record of messages) { @@ -197,11 +203,13 @@ function extractMetadata( startTime, exportTime: new Date().toISOString(), cwd, + gitRepo, gitBranch, model, channel, promptCount, contextUsagePercent: tokenStats.contextUsagePercent, + contextWindowSize, totalTokens: tokenStats.totalTokens, filesRead: fileStats.filesRead, filesWritten: fileStats.filesWritten, @@ -505,7 +513,7 @@ export async function collectSessionData( const messages = exportContext.getMessages(); // Extract metadata from conversation - const metadata = extractMetadata(conversation, config); + const metadata = await extractMetadata(conversation, config); return { sessionId: conversation.sessionId, diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts index 3ff0a7352..e73e0fefa 100644 --- a/packages/cli/src/ui/utils/export/types.ts +++ b/packages/cli/src/ui/utils/export/types.ts @@ -64,6 +64,8 @@ export interface ExportMetadata { exportTime: string; /** Current working directory */ cwd: string; + /** Git repository name, if available */ + gitRepo?: string; /** Git branch name, if available */ gitBranch?: string; /** Model used in the session */ @@ -74,6 +76,8 @@ export interface ExportMetadata { promptCount: number; /** Context window utilization percentage (0-100) */ contextUsagePercent?: number; + /** Context window size in tokens (used for calculating percentage) */ + contextWindowSize?: number; /** Total tokens used (prompt + completion) */ totalTokens?: number; /** Number of files read */ diff --git a/packages/core/src/utils/gitUtils.ts b/packages/core/src/utils/gitUtils.ts index e63b6bebd..493c89bd6 100644 --- a/packages/core/src/utils/gitUtils.ts +++ b/packages/core/src/utils/gitUtils.ts @@ -88,3 +88,61 @@ export const getGitBranch = (cwd: string): string | undefined => { return undefined; } }; + +/** + * Gets the git repository full name (owner/repo), if in a git repository. + * Tries to get the name from the remote URL first, then falls back to the directory name. + */ +export const getGitRepoName = (cwd: string): string | undefined => { + try { + // Try to get the repository name from the remote URL + const remoteUrl = execSync('git remote get-url origin', { + cwd, + encoding: 'utf8', + stdio: ['pipe', 'pipe', 'pipe'], + }).trim(); + + if (remoteUrl) { + // Extract owner/repo from various URL formats: + // - https://github.com/owner/repo.git -> owner/repo + // - git@github.com:owner/repo.git -> owner/repo + // - https://gitlab.com/owner/repo -> owner/repo + // - https://github.com/owner/repo/extra -> owner/repo (ignore extra path) + + // Handle SSH format: git@host.com:owner/repo.git + let normalizedUrl = remoteUrl; + if (remoteUrl.startsWith('git@')) { + normalizedUrl = remoteUrl.replace(/^git@[^:]+:/, 'https://host.com/'); + } + + try { + const url = new URL(normalizedUrl); + // Remove .git suffix and split path + const pathParts = url.pathname + .replace(/\.git$/, '') + .split('/') + .filter(Boolean); + if (pathParts.length >= 2) { + // Return owner/repo format + return `${pathParts[0]}/${pathParts[1]}`; + } + } catch { + // URL parsing failed, try regex fallback + const match = remoteUrl.match(/[:/]([^/]+)\/([^/]+?)(?:\.git)?$/); + if (match && match[1] && match[2]) { + return `${match[1]}/${match[2]}`; + } + } + } + } catch { + // Fall back to directory name if remote URL is not available + } + + // Fallback: use the directory name of the git root + const gitRoot = findGitRoot(cwd); + if (gitRoot) { + return path.basename(gitRoot); + } + + return undefined; +}; diff --git a/packages/web-templates/src/export-html/src/components/CopyButton.tsx b/packages/web-templates/src/export-html/src/components/CopyButton.tsx new file mode 100644 index 000000000..4a390d50b --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/CopyButton.tsx @@ -0,0 +1,53 @@ +const React = window.React; + +export type CopyButtonProps = { + text: string; +}; + +export const CopyButton = ({ text }: CopyButtonProps) => { + const [copied, setCopied] = React.useState(false); + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(text); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch (err) { + console.error('Failed to copy:', err); + } + }; + + return ( + + ); +}; diff --git a/packages/web-templates/src/export-html/src/components/MetadataItem.tsx b/packages/web-templates/src/export-html/src/components/MetadataItem.tsx new file mode 100644 index 000000000..476ab7fe3 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/MetadataItem.tsx @@ -0,0 +1,28 @@ +export type MetadataItemProps = { + label: string; + value?: string | number; + valueClass?: string; +}; + +export const MetadataItem = ({ + label, + value, + valueClass, +}: MetadataItemProps) => { + if (value === undefined || value === null || value === '') { + return null; + } + return ( +
+
+ {label} + + {value} + +
+
+ ); +}; diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx new file mode 100644 index 000000000..7593f6d0e --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -0,0 +1,110 @@ +import type { ExportMetadata } from './types.js'; +import { MetadataItem } from './MetadataItem.js'; +import { CopyButton } from './CopyButton.js'; +import { + formatRelativeTime, + formatExportTime, + formatPath, + formatTokenLimit, +} from './utils.js'; + +export type MetadataSidebarProps = { + metadata: ExportMetadata; +}; + +export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => { + const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0; + + return ( + + ); +}; diff --git a/packages/web-templates/src/export-html/src/components/hooks.ts b/packages/web-templates/src/export-html/src/components/hooks.ts new file mode 100644 index 000000000..f4dcd7be0 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/hooks.ts @@ -0,0 +1,38 @@ +import type { PlatformContextValue } from './types.js'; +import { useModalState } from './TempFileModal.js'; + +const React = window.React; + +/** + * Hook to provide platform context for the export HTML viewer + */ +export const usePlatformContext = () => { + const { modalState, openModal, closeModal } = useModalState(); + + const platformContext = React.useMemo( + () => + ({ + platform: 'web' as PlatformContextValue['platform'], + postMessage: (message: unknown) => { + console.log('Posted message:', message); + }, + onMessage: (handler: (event: MessageEvent) => void) => { + window.addEventListener('message', handler); + return () => window.removeEventListener('message', handler); + }, + openFile: (path: string) => { + console.log('Opening file:', path); + }, + openTempFile: openModal, + getResourceUrl: () => undefined, + features: { + canOpenFile: false, + canOpenTempFile: true, + canCopy: true, + }, + }) satisfies PlatformContextValue, + [openModal], + ); + + return { platformContext, modalState, closeModal }; +}; diff --git a/packages/web-templates/src/export-html/src/components/types.ts b/packages/web-templates/src/export-html/src/components/types.ts new file mode 100644 index 000000000..94069c607 --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/types.ts @@ -0,0 +1,48 @@ +/** + * Type definitions for export-html + */ + +export type ChatData = { + messages?: unknown[]; + sessionId?: string; + startTime?: string; + metadata?: ExportMetadata; +}; + +export type ExportMetadata = { + sessionId: string; + startTime: string; + relativeTime: string; + exportTime: string; + cwd: string; + gitRepo?: string; + gitBranch?: string; + model?: string; + channel?: string; + promptCount: number; + contextUsagePercent?: number; + contextWindowSize?: number; + totalTokens?: number; + filesRead?: number; + filesWritten?: number; + linesAdded?: number; + linesRemoved?: number; + uniqueFiles: string[]; + requestId?: string; +}; + +export type PlatformContextValue = { + platform: 'web'; + postMessage: (message: unknown) => void; + onMessage: (handler: (event: MessageEvent) => void) => () => void; + openFile: (path: string) => void; + openTempFile?: (content: string, fileName?: string) => void; + getResourceUrl: () => string | undefined; + features: { + canOpenFile: boolean; + canOpenTempFile?: boolean; + canCopy: boolean; + }; +}; + +export type ChatViewerMessage = { type?: string } & Record; diff --git a/packages/web-templates/src/export-html/src/components/utils.ts b/packages/web-templates/src/export-html/src/components/utils.ts new file mode 100644 index 000000000..a72fa369b --- /dev/null +++ b/packages/web-templates/src/export-html/src/components/utils.ts @@ -0,0 +1,135 @@ +import type { ChatData, ChatViewerMessage } from './types.js'; + +/** + * Type guard for ChatViewerMessage + */ +export const isChatViewerMessage = ( + value: unknown, +): value is ChatViewerMessage => Boolean(value) && typeof value === 'object'; + +/** + * Parse chat data from the embedded script tag + */ +export const parseChatData = (): ChatData => { + const chatDataElement = document.getElementById('chat-data'); + if (!chatDataElement?.textContent) { + return {}; + } + + try { + const parsed = JSON.parse(chatDataElement.textContent) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as ChatData; + } + return {}; + } catch (error) { + console.error('Failed to parse chat data.', error); + return {}; + } +}; + +/** + * Format session date for display + */ +export const formatSessionDate = (startTime?: string | null) => { + if (!startTime) { + return '-'; + } + + try { + const date = new Date(startTime); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return startTime; + } +}; + +/** + * Format export time for display + */ +export const formatExportTime = (exportTime?: string | null) => { + if (!exportTime) { + return '-'; + } + + try { + const date = new Date(exportTime); + return date.toLocaleString('en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); + } catch { + return exportTime; + } +}; + +/** + * Format relative time (e.g., "5 minutes ago") + */ +export const formatRelativeTime = (startTime?: string | null) => { + if (!startTime) { + return '-'; + } + + try { + const date = new Date(startTime); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffSeconds = Math.floor(diffMs / 1000); + const diffMinutes = Math.floor(diffSeconds / 60); + const diffHours = Math.floor(diffMinutes / 60); + const diffDays = Math.floor(diffHours / 24); + const diffWeeks = Math.floor(diffDays / 7); + const diffMonths = Math.floor(diffDays / 30); + const diffYears = Math.floor(diffDays / 365); + + if (diffSeconds < 60) { + return 'just now'; + } else if (diffMinutes < 60) { + return `${diffMinutes} minute${diffMinutes === 1 ? '' : 's'} ago`; + } else if (diffHours < 24) { + return `${diffHours} hour${diffHours === 1 ? '' : 's'} ago`; + } else if (diffDays < 7) { + return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`; + } else if (diffWeeks < 4) { + return `${diffWeeks} week${diffWeeks === 1 ? '' : 's'} ago`; + } else if (diffMonths < 12) { + return `${diffMonths} month${diffMonths === 1 ? '' : 's'} ago`; + } else { + return `${diffYears} year${diffYears === 1 ? '' : 's'} ago`; + } + } catch { + return '-'; + } +}; + +/** + * Format path with truncation + */ +export const formatPath = (path: string, maxLength: number = 40) => { + if (!path || path.length <= maxLength) return path; + return '...' + path.slice(-maxLength + 3); +}; + +/** + * Format token limit for display (e.g., 128k, 200k, 1m) + */ +export const formatTokenLimit = (tokens?: number): string => { + if (tokens === undefined || tokens === null) return '128k'; + if (tokens >= 1000000) { + return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}m`; + } + if (tokens >= 1000) { + return `${(tokens / 1000).toFixed(tokens % 1000 === 0 ? 0 : 1)}k`; + } + return tokens.toString(); +}; diff --git a/packages/web-templates/src/export-html/src/main.tsx b/packages/web-templates/src/export-html/src/main.tsx index 874894903..f9031fc62 100644 --- a/packages/web-templates/src/export-html/src/main.tsx +++ b/packages/web-templates/src/export-html/src/main.tsx @@ -1,6 +1,13 @@ import './styles.css'; import logoSvg from './favicon.svg'; -import { TempFileModal, useModalState } from './components/TempFileModal'; +import { TempFileModal } from './components/TempFileModal.js'; +import { usePlatformContext } from './components/hooks.js'; +import { MetadataSidebar } from './components/MetadataSidebar.js'; +import { + parseChatData, + isChatViewerMessage, + formatSessionDate, +} from './components/utils.js'; declare global { interface Window { @@ -10,6 +17,7 @@ declare global { } const ReactDOM = window.ReactDOM; +const React = window.React; declare const QwenCodeWebUI: { ChatViewer: (props: { @@ -25,48 +33,6 @@ declare const QwenCodeWebUI: { const { ChatViewer, PlatformProvider } = QwenCodeWebUI; -type ChatData = { - messages?: unknown[]; - sessionId?: string; - startTime?: string; - metadata?: ExportMetadata; -}; - -type ExportMetadata = { - sessionId: string; - startTime: string; - relativeTime: string; - exportTime: string; - cwd: string; - gitBranch?: string; - model?: string; - channel?: string; - promptCount: number; - contextUsagePercent?: number; - totalTokens?: number; - filesRead?: number; - filesWritten?: number; - linesAdded?: number; - linesRemoved?: number; - uniqueFiles: string[]; - requestId?: string; -}; - -type PlatformContextValue = { - platform: 'web'; - postMessage: (message: unknown) => void; - onMessage: (handler: (event: MessageEvent) => void) => () => void; - openFile: (path: string) => void; - openTempFile?: (content: string, fileName?: string) => void; - getResourceUrl: () => string | undefined; - features: { - canOpenFile: boolean; - canOpenTempFile?: boolean; - canCopy: boolean; - }; -}; -type ChatViewerMessage = { type?: string } & Record; - const logoSvgWithGradient = (() => { if (!logoSvg) { return logoSvg; @@ -80,271 +46,6 @@ const logoSvgWithGradient = (() => { return withDefs.replace(/fill="[^"]*"/, 'fill="url(#qwen-logo-gradient)"'); })(); -const React = window.React; - -const usePlatformContext = () => { - const { modalState, openModal, closeModal } = useModalState(); - - const platformContext = React.useMemo( - () => - ({ - platform: 'web' as PlatformContextValue['platform'], - postMessage: (message: unknown) => { - console.log('Posted message:', message); - }, - onMessage: (handler: (event: MessageEvent) => void) => { - window.addEventListener('message', handler); - return () => window.removeEventListener('message', handler); - }, - openFile: (path: string) => { - console.log('Opening file:', path); - }, - openTempFile: openModal, - getResourceUrl: () => undefined, - features: { - canOpenFile: false, - canOpenTempFile: true, - canCopy: true, - }, - }) satisfies PlatformContextValue, - [openModal], - ); - - return { platformContext, modalState, closeModal }; -}; - -const isChatViewerMessage = (value: unknown): value is ChatViewerMessage => - Boolean(value) && typeof value === 'object'; - -const parseChatData = (): ChatData => { - const chatDataElement = document.getElementById('chat-data'); - if (!chatDataElement?.textContent) { - return {}; - } - - try { - const parsed = JSON.parse(chatDataElement.textContent) as unknown; - if (parsed && typeof parsed === 'object') { - return parsed as ChatData; - } - return {}; - } catch (error) { - console.error('Failed to parse chat data.', error); - return {}; - } -}; - -const formatSessionDate = (startTime?: string | null) => { - if (!startTime) { - return '-'; - } - - try { - const date = new Date(startTime); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return startTime; - } -}; - -const formatExportTime = (exportTime?: string | null) => { - if (!exportTime) { - return '-'; - } - - try { - const date = new Date(exportTime); - return date.toLocaleString(undefined, { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); - } catch { - return exportTime; - } -}; - -const formatPath = (path: string, maxLength: number = 40) => { - if (!path || path.length <= maxLength) return path; - const parts = path.split('/'); - if (parts.length <= 2) return '...' + path.slice(-maxLength + 3); - return '...' + path.slice(-maxLength + 3); -}; - -const CopyButton = ({ text }: { text: string }) => { - const [copied, setCopied] = React.useState(false); - - const handleCopy = async () => { - try { - await navigator.clipboard.writeText(text); - setCopied(true); - setTimeout(() => setCopied(false), 2000); - } catch (err) { - console.error('Failed to copy:', err); - } - }; - - return ( - - ); -}; - -const MetadataItem = ({ - label, - value, - valueClass, -}: { - label: string; - value?: string | number; - valueClass?: string; -}) => { - if (value === undefined || value === null || value === '') { - return null; - } - return ( -
-
- {label} - - {value} - -
-
- ); -}; - -const MetadataSidebar = ({ metadata }: { metadata: ExportMetadata }) => { - const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0; - - return ( - - ); -}; - const App = () => { const chatData = parseChatData(); const rawMessages = Array.isArray(chatData.messages) ? chatData.messages : []; diff --git a/packages/web-templates/src/export-html/src/styles.css b/packages/web-templates/src/export-html/src/styles.css index eff5bc2c8..f161b5392 100644 --- a/packages/web-templates/src/export-html/src/styles.css +++ b/packages/web-templates/src/export-html/src/styles.css @@ -212,8 +212,8 @@ body { /* Metadata Sidebar - fixed on right */ .metadata-sidebar { - width: 280px; - min-width: 280px; + width: 320px; + min-width: 320px; padding: 12px; border-right: 1px solid var(--border-color); background-color: var(--bg-secondary); @@ -267,7 +267,7 @@ body { } .metadata-content .metadata-value { - font-size: 11px; + font-size: 12px; color: var(--text-primary); word-break: break-all; line-height: 1.3; @@ -320,8 +320,8 @@ body { /* Responsive adjustments */ @media (max-width: 1024px) { .metadata-sidebar { - width: 260px; - min-width: 260px; + width: 320px; + min-width: 320px; padding: 10px; } } From 186103fe4e41f69ec128b7363a8b581514781a37 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Tue, 17 Mar 2026 21:28:08 +0800 Subject: [PATCH 77/82] feat(export): enhance JSONL and Markdown formatters with comprehensive metadata Co-authored-by: Qwen-Coder --- .../src/ui/utils/export/formatters/jsonl.ts | 52 +++++++++++- .../ui/utils/export/formatters/markdown.ts | 84 +++++++++++++++++-- 2 files changed, 127 insertions(+), 9 deletions(-) diff --git a/packages/cli/src/ui/utils/export/formatters/jsonl.ts b/packages/cli/src/ui/utils/export/formatters/jsonl.ts index 10854ba90..9b84b2d6f 100644 --- a/packages/cli/src/ui/utils/export/formatters/jsonl.ts +++ b/packages/cli/src/ui/utils/export/formatters/jsonl.ts @@ -12,6 +12,7 @@ import type { ExportSessionData } from '../types.js'; */ export function toJsonl(sessionData: ExportSessionData): string { const lines: string[] = []; + const sourceMetadata = sessionData.metadata; // Add session metadata as the first line const metadata: Record = { @@ -20,9 +21,54 @@ export function toJsonl(sessionData: ExportSessionData): string { startTime: sessionData.startTime, }; - // Add requestId if available - if (sessionData.metadata?.requestId) { - metadata['requestId'] = sessionData.metadata.requestId; + // Add all metadata fields if available + if (sourceMetadata?.exportTime) { + metadata['exportTime'] = sourceMetadata.exportTime; + } + if (sourceMetadata?.cwd) { + metadata['cwd'] = sourceMetadata.cwd; + } + if (sourceMetadata?.gitRepo) { + metadata['gitRepo'] = sourceMetadata.gitRepo; + } + if (sourceMetadata?.gitBranch) { + metadata['gitBranch'] = sourceMetadata.gitBranch; + } + if (sourceMetadata?.model) { + metadata['model'] = sourceMetadata.model; + } + if (sourceMetadata?.channel) { + metadata['channel'] = sourceMetadata.channel; + } + if (sourceMetadata?.promptCount !== undefined) { + metadata['promptCount'] = sourceMetadata.promptCount; + } + if (sourceMetadata?.contextUsagePercent !== undefined) { + metadata['contextUsagePercent'] = sourceMetadata.contextUsagePercent; + } + if (sourceMetadata?.contextWindowSize !== undefined) { + metadata['contextWindowSize'] = sourceMetadata.contextWindowSize; + } + if (sourceMetadata?.totalTokens !== undefined) { + metadata['totalTokens'] = sourceMetadata.totalTokens; + } + if (sourceMetadata?.filesRead !== undefined) { + metadata['filesRead'] = sourceMetadata.filesRead; + } + if (sourceMetadata?.filesWritten !== undefined) { + metadata['filesWritten'] = sourceMetadata.filesWritten; + } + if (sourceMetadata?.linesAdded !== undefined) { + metadata['linesAdded'] = sourceMetadata.linesAdded; + } + if (sourceMetadata?.linesRemoved !== undefined) { + metadata['linesRemoved'] = sourceMetadata.linesRemoved; + } + if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) { + metadata['uniqueFiles'] = sourceMetadata.uniqueFiles; + } + if (sourceMetadata?.requestId) { + metadata['requestId'] = sourceMetadata.requestId; } lines.push(JSON.stringify(metadata)); diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index 2a79be8ff..00250dd16 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -11,20 +11,92 @@ import type { ExportSessionData, ExportMessage } from '../types.js'; */ export function toMarkdown(sessionData: ExportSessionData): string { const lines: string[] = []; + const metadata = sessionData.metadata; // Add header with metadata lines.push('# Chat Session Export\n'); lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``); lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`); - // Add requestId if available - if (sessionData.metadata?.requestId) { - lines.push( - `- **Request ID**: \`${sanitizeText(sessionData.metadata.requestId)}\``, - ); + // Add exportTime if available + if (metadata?.exportTime) { + lines.push(`- **Exported**: ${sanitizeText(metadata.exportTime)}`); + } + + // Add requestId if available + if (metadata?.requestId) { + lines.push(`- **Request ID**: \`${sanitizeText(metadata.requestId)}\``); + } + + lines.push(''); + + // Add context info + if (metadata?.cwd) { + lines.push(`- **Working Directory**: \`${sanitizeText(metadata.cwd)}\``); + } + if (metadata?.gitRepo) { + lines.push(`- **Git Repository**: ${sanitizeText(metadata.gitRepo)}`); + } + if (metadata?.gitBranch) { + lines.push(`- **Git Branch**: \`${sanitizeText(metadata.gitBranch)}\``); + } + + lines.push(''); + + // Add model info + if (metadata?.model) { + lines.push(`- **Model**: ${sanitizeText(metadata.model)}`); + } + if (metadata?.channel) { + lines.push(`- **Channel**: ${sanitizeText(metadata.channel)}`); + } + if (metadata?.promptCount !== undefined) { + lines.push(`- **Prompt Count**: ${metadata.promptCount}`); + } + + lines.push(''); + + // Add token stats + if (metadata?.totalTokens !== undefined) { + lines.push(`- **Total Tokens**: ${metadata.totalTokens}`); + } + if (metadata?.contextWindowSize !== undefined) { + lines.push(`- **Context Window Size**: ${metadata.contextWindowSize}`); + } + if (metadata?.contextUsagePercent !== undefined) { + lines.push(`- **Context Usage**: ${metadata.contextUsagePercent}%`); + } + + lines.push(''); + + // Add file operation stats + if (metadata?.filesRead !== undefined) { + lines.push(`- **Files Read**: ${metadata.filesRead}`); + } + if (metadata?.filesWritten !== undefined) { + lines.push(`- **Files Written**: ${metadata.filesWritten}`); + } + if (metadata?.linesAdded !== undefined) { + lines.push(`- **Lines Added**: ${metadata.linesAdded}`); + } + if (metadata?.linesRemoved !== undefined) { + lines.push(`- **Lines Removed**: ${metadata.linesRemoved}`); + } + + // Add unique files list if available + if (metadata?.uniqueFiles && metadata.uniqueFiles.length > 0) { + lines.push(''); + lines.push('
'); + lines.push( + `Unique Files Referenced (${metadata.uniqueFiles.length})`, + ); + lines.push(''); + for (const file of metadata.uniqueFiles) { + lines.push(`- \`${sanitizeText(file)}\``); + } + lines.push('
'); } - lines.push(`- **Exported**: ${new Date().toISOString()}`); lines.push('\n---\n'); // Process each message From a24400ccfc32d782569ac5c897927d43a97abe6b Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 18 Mar 2026 13:46:25 +0800 Subject: [PATCH 78/82] fix(export): correct export metadata accuracy issues Fix four accuracy bugs in export metadata/sidebar feature: 1. File read counting: Now properly counts read_file operations by checking functionResponse.name and args.absolute_path, instead of relying on resultDisplay which returns string for reads. 2. Unique file tracking: Uses full file path from args.file_path or args.absolute_path instead of basename-only fileName, preventing collision between same-named files in different directories. 3. TaskTool token aggregation: Includes tokens from TaskTool executionSummary in total token count, fixing under-reporting when subagents are used. 4. Context window display: Removes hardcoded '128k' fallback in HTML sidebar, now only displays context usage when contextWindowSize is actually defined. Also fixes lint errors (Array type annotations) and applies formatting. Co-authored-by: Qwen-Coder --- packages/cli/src/ui/utils/export/collect.ts | 193 +++++++++++++++++- .../ui/utils/export/formatters/markdown.ts | 8 +- .../src/components/MetadataSidebar.tsx | 13 +- .../src/export-html/src/components/utils.ts | 11 +- 4 files changed, 202 insertions(+), 23 deletions(-) diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index c4de5ee75..cbad97abb 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -27,11 +27,111 @@ interface FileOperationStats { uniqueFiles: Set; } +/** + * Tool call arguments index for matching tool_result records. + */ +interface ToolCallArgsIndex { + byId: Map>; + byName: Map>>; +} + +/** + * Extracts tool name from a ChatRecord's function response. + */ +function extractToolNameFromRecord(record: ChatRecord): string | undefined { + if (!record.message?.parts) { + return undefined; + } + + for (const part of record.message.parts) { + if ('functionResponse' in part && part.functionResponse?.name) { + return part.functionResponse.name; + } + } + + return undefined; +} + +/** + * Extracts call ID from a ChatRecord's function response. + */ +function extractFunctionResponseId(record: ChatRecord): string | undefined { + if (!record.message?.parts) { + return undefined; + } + + for (const part of record.message.parts) { + if ('functionResponse' in part && part.functionResponse?.id) { + return part.functionResponse.id; + } + } + + return undefined; +} + +/** + * Normalizes function call args into a plain object. + */ +function normalizeFunctionCallArgs( + args: unknown, +): Record | undefined { + if (args && typeof args === 'object') { + return args as Record; + } + if (typeof args === 'string') { + try { + const parsed = JSON.parse(args) as unknown; + if (parsed && typeof parsed === 'object') { + return parsed as Record; + } + } catch { + // Ignore parse errors and treat as unavailable args + } + } + return undefined; +} + +/** + * Builds an index of assistant tool calls for later tool_result arg resolution. + */ +function buildToolCallArgsIndex(records: ChatRecord[]): ToolCallArgsIndex { + const byId = new Map>(); + const byName = new Map>>(); + + for (const record of records) { + if (record.type !== 'assistant' || !record.message?.parts) continue; + + for (const part of record.message.parts) { + if (!('functionCall' in part) || !part.functionCall?.name) continue; + + const normalizedArgs = normalizeFunctionCallArgs(part.functionCall.args); + if (!normalizedArgs) continue; + + const toolName = part.functionCall.name; + const callId = + typeof part.functionCall.id === 'string' ? part.functionCall.id : null; + + if (callId) { + byId.set(callId, normalizedArgs); + } + + const queue = byName.get(toolName) ?? []; + queue.push(normalizedArgs); + byName.set(toolName, queue); + } + } + + return { byId, byName }; +} + /** * Calculate file operation statistics from ChatRecords. * Uses toolCallResult from tool_result records for accurate statistics. */ function calculateFileStats(records: ChatRecord[]): FileOperationStats { + const argsIndex = buildToolCallArgsIndex(records); + const byNameCursor = new Map(); + const stats: FileOperationStats = { filesRead: 0, filesWritten: 0, @@ -43,8 +143,35 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { for (const record of records) { if (record.type !== 'tool_result' || !record.toolCallResult) continue; + const toolName = extractToolNameFromRecord(record); + const callId = + record.toolCallResult.callId ?? extractFunctionResponseId(record); + const argsFromId = + callId && argsIndex.byId.has(callId) + ? argsIndex.byId.get(callId) + : undefined; + let args = argsFromId; + if (!args && toolName) { + const queue = argsIndex.byName.get(toolName); + if (queue && queue.length > 0) { + const cursor = byNameCursor.get(toolName) ?? 0; + args = queue[cursor]; + byNameCursor.set(toolName, cursor + 1); + } + } const { resultDisplay } = record.toolCallResult; + // Handle read_file operations + if ( + toolName === 'read_file' && + (args?.['absolute_path'] || args?.['file_path']) + ) { + const filePath = String(args['absolute_path'] ?? args['file_path']); + stats.filesRead++; + stats.uniqueFiles.add(filePath); + continue; + } + // Track file locations from resultDisplay if ( resultDisplay && @@ -53,20 +180,27 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { ) { const display = resultDisplay as { fileName: string; + fileDiff?: string; originalContent?: string | null; newContent?: string; diffStat?: { model_added_lines?: number; model_removed_lines?: number }; }; - // Track unique files - if (typeof display.fileName === 'string') { - stats.uniqueFiles.add(display.fileName); - } - // Determine operation type based on content fields const hasOriginalContent = 'originalContent' in display; const hasNewContent = 'newContent' in display; + // For write/edit operations, use full path from args if available + let filePath: string; + if (typeof display.fileName === 'string') { + // Prefer args.file_path for full path, fallback to fileName (which may be basename) + filePath = + (args?.['file_path'] as string) || + (args?.['absolute_path'] as string) || + display.fileName; + stats.uniqueFiles.add(filePath); + } + if (hasOriginalContent || hasNewContent) { // This is a write/edit operation stats.filesWritten++; @@ -92,9 +226,6 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { stats.linesAdded += newLines; stats.linesRemoved += oldLines; } - } else { - // This is likely a read operation (no content changes) - stats.filesRead++; } } } @@ -102,9 +233,47 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { return stats; } +/** + * Extracts token usage from TaskResultDisplay executionSummary. + */ +function extractTaskToolTokens(record: ChatRecord): number { + if (record.type !== 'tool_result' || !record.toolCallResult?.resultDisplay) { + return 0; + } + + const { resultDisplay } = record.toolCallResult; + if ( + typeof resultDisplay === 'object' && + 'type' in resultDisplay && + resultDisplay.type === 'task_execution' && + 'executionSummary' in resultDisplay + ) { + const summary = resultDisplay.executionSummary as { + totalTokens?: number; + inputTokens?: number; + outputTokens?: number; + thoughtTokens?: number; + cachedTokens?: number; + }; + // Use totalTokens if available, otherwise sum individual token counts + if (typeof summary.totalTokens === 'number') { + return summary.totalTokens; + } + // Fallback: sum available token counts + return ( + (summary.inputTokens ?? 0) + + (summary.outputTokens ?? 0) + + (summary.thoughtTokens ?? 0) + + (summary.cachedTokens ?? 0) + ); + } + + return 0; +} + /** * Calculate token statistics from ChatRecords. - * Aggregates usageMetadata from assistant records to get total token usage. + * Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage. */ function calculateTokenStats( records: ChatRecord[], @@ -123,6 +292,12 @@ function calculateTokenStats( lastTotalTokens = record.usageMetadata.totalTokenCount; } } + + // Include TaskTool token usage from executionSummary + const taskTokens = extractTaskToolTokens(record); + if (taskTokens > 0) { + totalTokens += taskTokens; + } } // Use last totalTokenCount for context usage calculation diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index 00250dd16..9267f8bd3 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -17,11 +17,9 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push('# Chat Session Export\n'); lines.push(`- **Session ID**: \`${sanitizeText(sessionData.sessionId)}\``); lines.push(`- **Start Time**: ${sanitizeText(sessionData.startTime)}`); - - // Add exportTime if available - if (metadata?.exportTime) { - lines.push(`- **Exported**: ${sanitizeText(metadata.exportTime)}`); - } + lines.push( + `- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`, + ); // Add requestId if available if (metadata?.requestId) { diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx index 7593f6d0e..17f6c4264 100644 --- a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -41,12 +41,13 @@ export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => {

Statistics

- {metadata.contextUsagePercent !== undefined && ( - - )} + {metadata.contextUsagePercent !== undefined && + metadata.contextWindowSize !== undefined && ( + + )} {metadata.totalTokens !== undefined && ( { try { const date = new Date(startTime); + const startTimestamp = date.getTime(); + if (Number.isNaN(startTimestamp)) { + return '-'; + } const now = new Date(); - const diffMs = now.getTime() - date.getTime(); + const diffMs = Math.max(0, now.getTime() - startTimestamp); const diffSeconds = Math.floor(diffMs / 1000); const diffMinutes = Math.floor(diffSeconds / 60); const diffHours = Math.floor(diffMinutes / 60); @@ -122,9 +126,10 @@ export const formatPath = (path: string, maxLength: number = 40) => { /** * Format token limit for display (e.g., 128k, 200k, 1m) + * Returns undefined if tokens is not provided. */ -export const formatTokenLimit = (tokens?: number): string => { - if (tokens === undefined || tokens === null) return '128k'; +export const formatTokenLimit = (tokens?: number): string | undefined => { + if (tokens === undefined || tokens === null) return undefined; if (tokens >= 1000000) { return `${(tokens / 1000000).toFixed(tokens % 1000000 === 0 ? 0 : 1)}m`; } From 8e221a3606c60255ba8213809a07aec4097eb509 Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Wed, 18 Mar 2026 21:17:37 +0800 Subject: [PATCH 79/82] feat: optimize export data structure and UI display - Simplify export data by removing filesRead stat, keep only written files count and paths - Restore lines-related statistics (linesAdded and linesRemoved) - Update HTML display to show only file operation stats instead of total files count - Change 'Written' label to 'Files modified' - Remove distinction between requestId and sessionId, always display sessionId - Remove Session ID and Export Time from Header (already shown in MetadataSidebar) - Display Project field with raw value and support multiline display - Fix filesWritten calculation to count unique files instead of operations Co-authored-by: Qwen-Coder --- packages/cli/src/ui/utils/export/collect.ts | 27 ++++-------- .../src/ui/utils/export/formatters/jsonl.ts | 3 -- .../ui/utils/export/formatters/markdown.ts | 3 -- packages/cli/src/ui/utils/export/types.ts | 4 +- .../src/components/MetadataSidebar.tsx | 44 +++++-------------- .../src/export-html/src/main.tsx | 18 +------- .../src/export-html/src/styles.css | 4 ++ 7 files changed, 26 insertions(+), 77 deletions(-) diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index cbad97abb..b0ea963f6 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -20,11 +20,10 @@ import type { * File operation statistics extracted from tool calls. */ interface FileOperationStats { - filesRead: number; filesWritten: number; linesAdded: number; linesRemoved: number; - uniqueFiles: Set; + writtenFilePaths: Set; } /** @@ -133,11 +132,10 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { const byNameCursor = new Map(); const stats: FileOperationStats = { - filesRead: 0, filesWritten: 0, linesAdded: 0, linesRemoved: 0, - uniqueFiles: new Set(), + writtenFilePaths: new Set(), }; for (const record of records) { @@ -161,17 +159,6 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { } const { resultDisplay } = record.toolCallResult; - // Handle read_file operations - if ( - toolName === 'read_file' && - (args?.['absolute_path'] || args?.['file_path']) - ) { - const filePath = String(args['absolute_path'] ?? args['file_path']); - stats.filesRead++; - stats.uniqueFiles.add(filePath); - continue; - } - // Track file locations from resultDisplay if ( resultDisplay && @@ -198,12 +185,15 @@ function calculateFileStats(records: ChatRecord[]): FileOperationStats { (args?.['file_path'] as string) || (args?.['absolute_path'] as string) || display.fileName; - stats.uniqueFiles.add(filePath); + } else { + // Fallback if fileName is not a string + filePath = 'unknown'; } if (hasOriginalContent || hasNewContent) { // This is a write/edit operation stats.filesWritten++; + stats.writtenFilePaths.add(filePath); // Calculate line changes if (display.diffStat) { @@ -386,11 +376,10 @@ async function extractMetadata( contextUsagePercent: tokenStats.contextUsagePercent, contextWindowSize, totalTokens: tokenStats.totalTokens, - filesRead: fileStats.filesRead, - filesWritten: fileStats.filesWritten, + filesWritten: fileStats.writtenFilePaths.size, linesAdded: fileStats.linesAdded, linesRemoved: fileStats.linesRemoved, - uniqueFiles: Array.from(fileStats.uniqueFiles), + uniqueFiles: Array.from(fileStats.writtenFilePaths), requestId, }; } diff --git a/packages/cli/src/ui/utils/export/formatters/jsonl.ts b/packages/cli/src/ui/utils/export/formatters/jsonl.ts index 9b84b2d6f..e1d6939ba 100644 --- a/packages/cli/src/ui/utils/export/formatters/jsonl.ts +++ b/packages/cli/src/ui/utils/export/formatters/jsonl.ts @@ -52,9 +52,6 @@ export function toJsonl(sessionData: ExportSessionData): string { if (sourceMetadata?.totalTokens !== undefined) { metadata['totalTokens'] = sourceMetadata.totalTokens; } - if (sourceMetadata?.filesRead !== undefined) { - metadata['filesRead'] = sourceMetadata.filesRead; - } if (sourceMetadata?.filesWritten !== undefined) { metadata['filesWritten'] = sourceMetadata.filesWritten; } diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index 9267f8bd3..443199f21 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -68,9 +68,6 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push(''); // Add file operation stats - if (metadata?.filesRead !== undefined) { - lines.push(`- **Files Read**: ${metadata.filesRead}`); - } if (metadata?.filesWritten !== undefined) { lines.push(`- **Files Written**: ${metadata.filesWritten}`); } diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts index e73e0fefa..03d4100b1 100644 --- a/packages/cli/src/ui/utils/export/types.ts +++ b/packages/cli/src/ui/utils/export/types.ts @@ -80,15 +80,13 @@ export interface ExportMetadata { contextWindowSize?: number; /** Total tokens used (prompt + completion) */ totalTokens?: number; - /** Number of files read */ - filesRead?: number; /** Number of files written/edited */ filesWritten?: number; /** Lines of code added */ linesAdded?: number; /** Lines of code removed */ linesRemoved?: number; - /** Unique files referenced in the session */ + /** Unique files referenced in the session (written files only) */ uniqueFiles: string[]; /** Last response ID from the LLM API (request ID) */ requestId?: string; diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx index 17f6c4264..4b2d56086 100644 --- a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -1,10 +1,8 @@ import type { ExportMetadata } from './types.js'; import { MetadataItem } from './MetadataItem.js'; -import { CopyButton } from './CopyButton.js'; import { formatRelativeTime, formatExportTime, - formatPath, formatTokenLimit, } from './utils.js'; @@ -12,10 +10,7 @@ export type MetadataSidebarProps = { metadata: ExportMetadata; }; -export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => { - const uniqueFilesCount = metadata.uniqueFiles?.length ?? 0; - - return ( +export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => ( ); -}; diff --git a/packages/web-templates/src/export-html/src/main.tsx b/packages/web-templates/src/export-html/src/main.tsx index f9031fc62..8c7c19115 100644 --- a/packages/web-templates/src/export-html/src/main.tsx +++ b/packages/web-templates/src/export-html/src/main.tsx @@ -3,11 +3,7 @@ import logoSvg from './favicon.svg'; import { TempFileModal } from './components/TempFileModal.js'; import { usePlatformContext } from './components/hooks.js'; import { MetadataSidebar } from './components/MetadataSidebar.js'; -import { - parseChatData, - isChatViewerMessage, - formatSessionDate, -} from './components/utils.js'; +import { parseChatData, isChatViewerMessage } from './components/utils.js'; declare global { interface Window { @@ -52,8 +48,6 @@ const App = () => { const messages = rawMessages .filter(isChatViewerMessage) .filter((record) => record.type !== 'system'); - const sessionId = chatData.sessionId ?? '-'; - const sessionDate = formatSessionDate(chatData.startTime); const metadata = chatData.metadata; const { platformContext, modalState, closeModal } = usePlatformContext(); @@ -72,16 +66,6 @@ const App = () => {
-
-
- Session Id - {sessionId} -
-
- Export Time - {sessionDate} -
-
diff --git a/packages/web-templates/src/export-html/src/styles.css b/packages/web-templates/src/export-html/src/styles.css index f161b5392..6d66dcf12 100644 --- a/packages/web-templates/src/export-html/src/styles.css +++ b/packages/web-templates/src/export-html/src/styles.css @@ -274,6 +274,10 @@ body { cursor: pointer; } +.metadata-content .metadata-value.multiline { + white-space: pre-wrap; +} + .metadata-content .metadata-value.text-green { color: #22c55e; } From 9060663f602f12dabfba6f0e945c52d64e9523ba Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 19 Mar 2026 14:02:42 +0800 Subject: [PATCH 80/82] refactor(export): clean up unnecessary fields and simplify data structure Co-authored-by: Qwen-Coder --- packages/cli/src/ui/utils/export/collect.ts | 76 +++++---- .../src/ui/utils/export/formatters/jsonl.ts | 3 - .../ui/utils/export/formatters/markdown.ts | 8 - packages/cli/src/ui/utils/export/normalize.ts | 13 +- packages/cli/src/ui/utils/export/types.ts | 5 - packages/core/src/core/geminiChat.ts | 10 +- .../core/src/services/chatRecordingService.ts | 12 +- .../src/components/MetadataSidebar.tsx | 144 +++++++++--------- .../src/export-html/src/components/types.ts | 2 - .../src/export-html/src/styles.css | 7 + 10 files changed, 135 insertions(+), 145 deletions(-) diff --git a/packages/cli/src/ui/utils/export/collect.ts b/packages/cli/src/ui/utils/export/collect.ts index b0ea963f6..cd203da95 100644 --- a/packages/cli/src/ui/utils/export/collect.ts +++ b/packages/cli/src/ui/utils/export/collect.ts @@ -264,22 +264,36 @@ function extractTaskToolTokens(record: ChatRecord): number { /** * Calculate token statistics from ChatRecords. * Aggregates usageMetadata from assistant records and TaskTool executionSummary to get total token usage. + * Uses the last assistant record that has both totalTokenCount and contextWindowSize for calculating context usage percent. */ -function calculateTokenStats( - records: ChatRecord[], - contextWindowSize?: number, -): { totalTokens: number; contextUsagePercent?: number } { +function calculateTokenStats(records: ChatRecord[]): { + totalTokens: number; + contextUsagePercent?: number; + contextWindowSize?: number; +} { let totalTokens = 0; - let lastTotalTokens = 0; + // Track the last assistant record that has BOTH totalTokenCount and contextWindowSize + // to ensure the percentage calculation uses values from the same record + let lastValidRecord: { + totalTokenCount: number; + contextWindowSize: number; + } | null = null; // Aggregate usageMetadata from all assistant records - // Use last available totalTokenCount for context usage calculation for (const record of records) { - if (record.type === 'assistant' && record.usageMetadata) { - totalTokens += record.usageMetadata.totalTokenCount ?? 0; - // Use the last available totalTokenCount for context usage calculation - if (record.usageMetadata.totalTokenCount !== undefined) { - lastTotalTokens = record.usageMetadata.totalTokenCount; + if (record.type === 'assistant') { + if (record.usageMetadata) { + totalTokens += record.usageMetadata.totalTokenCount ?? 0; + } + // Only update lastValidRecord when BOTH values are present in the same record + if ( + record.usageMetadata?.totalTokenCount !== undefined && + record.contextWindowSize !== undefined + ) { + lastValidRecord = { + totalTokenCount: record.usageMetadata.totalTokenCount, + contextWindowSize: record.contextWindowSize, + }; } } @@ -290,17 +304,29 @@ function calculateTokenStats( } } - // Use last totalTokenCount for context usage calculation + // Use last valid record's values for context usage calculation // This represents how much of the context window is being used by the total tokens - if (contextWindowSize && lastTotalTokens > 0) { - const percent = (lastTotalTokens / contextWindowSize) * 100; + if (lastValidRecord) { + const percent = + (lastValidRecord.totalTokenCount / lastValidRecord.contextWindowSize) * + 100; return { totalTokens, contextUsagePercent: Math.round(percent * 10) / 10, + contextWindowSize: lastValidRecord.contextWindowSize, }; } - return { totalTokens }; + // Fallback: return the contextWindowSize from the last assistant record even if no valid pair found + // (for display purposes only, without percentage) + const lastAssistantRecord = [...records] + .reverse() + .find((r) => r.type === 'assistant' && r.contextWindowSize !== undefined); + + return { + totalTokens, + contextWindowSize: lastAssistantRecord?.contextWindowSize, + }; } /** @@ -343,25 +369,12 @@ async function extractMetadata( // Count user prompts const promptCount = messages.filter((m) => m.type === 'user').length; - // Get context window size - const contentGenConfig = config.getContentGeneratorConfig?.(); - const contextWindowSize = contentGenConfig?.contextWindowSize; - // Calculate file stats from original ChatRecords const fileStats = calculateFileStats(messages); // Calculate token stats from original ChatRecords - const tokenStats = calculateTokenStats(messages, contextWindowSize); - - // Extract the last response_id from assistant records (for request tracking) - let requestId: string | undefined; - for (let i = messages.length - 1; i >= 0; i--) { - const record = messages[i]; - if (record.type === 'assistant' && record.response_id) { - requestId = record.response_id; - break; - } - } + // contextWindowSize is retrieved from the last assistant record for accuracy + const tokenStats = calculateTokenStats(messages); return { sessionId, @@ -374,13 +387,12 @@ async function extractMetadata( channel, promptCount, contextUsagePercent: tokenStats.contextUsagePercent, - contextWindowSize, + contextWindowSize: tokenStats.contextWindowSize, totalTokens: tokenStats.totalTokens, filesWritten: fileStats.writtenFilePaths.size, linesAdded: fileStats.linesAdded, linesRemoved: fileStats.linesRemoved, uniqueFiles: Array.from(fileStats.writtenFilePaths), - requestId, }; } diff --git a/packages/cli/src/ui/utils/export/formatters/jsonl.ts b/packages/cli/src/ui/utils/export/formatters/jsonl.ts index e1d6939ba..4de132bb1 100644 --- a/packages/cli/src/ui/utils/export/formatters/jsonl.ts +++ b/packages/cli/src/ui/utils/export/formatters/jsonl.ts @@ -64,9 +64,6 @@ export function toJsonl(sessionData: ExportSessionData): string { if (sourceMetadata?.uniqueFiles && sourceMetadata.uniqueFiles.length > 0) { metadata['uniqueFiles'] = sourceMetadata.uniqueFiles; } - if (sourceMetadata?.requestId) { - metadata['requestId'] = sourceMetadata.requestId; - } lines.push(JSON.stringify(metadata)); diff --git a/packages/cli/src/ui/utils/export/formatters/markdown.ts b/packages/cli/src/ui/utils/export/formatters/markdown.ts index 443199f21..6ee18a754 100644 --- a/packages/cli/src/ui/utils/export/formatters/markdown.ts +++ b/packages/cli/src/ui/utils/export/formatters/markdown.ts @@ -21,11 +21,6 @@ export function toMarkdown(sessionData: ExportSessionData): string { `- **Exported**: ${sanitizeText(metadata?.exportTime ?? new Date().toISOString())}`, ); - // Add requestId if available - if (metadata?.requestId) { - lines.push(`- **Request ID**: \`${sanitizeText(metadata.requestId)}\``); - } - lines.push(''); // Add context info @@ -101,9 +96,6 @@ export function toMarkdown(sessionData: ExportSessionData): string { lines.push(formatMessageContent(message)); } else if (message.type === 'assistant') { lines.push('## Assistant\n'); - if (message.response_id) { - lines.push(`*Response ID: \`${sanitizeText(message.response_id)}\`*\n`); - } lines.push(formatMessageContent(message)); } else if (message.type === 'tool_call') { lines.push(formatToolCall(message)); diff --git a/packages/cli/src/ui/utils/export/normalize.ts b/packages/cli/src/ui/utils/export/normalize.ts index ae22f2cb5..cf9f80cdc 100644 --- a/packages/cli/src/ui/utils/export/normalize.ts +++ b/packages/cli/src/ui/utils/export/normalize.ts @@ -28,7 +28,7 @@ export function normalizeSessionData( } }); - // Build index of assistant messages by uuid for response_id mapping + // Build index of assistant messages by uuid for usageMetadata merging const assistantMessageIndexByUuid = new Map(); normalized.forEach((message, index) => { if (message.type === 'assistant') { @@ -66,17 +66,6 @@ export function normalizeSessionData( mergeToolCallData(existingMessage.toolCall, toolCallMessage.toolCall); } - // Merge response_id from assistant records - for (const record of originalRecords) { - if (record.type !== 'assistant') continue; - if (!record.response_id) continue; - - const existingIndex = assistantMessageIndexByUuid.get(record.uuid); - if (existingIndex !== undefined) { - normalized[existingIndex].response_id = record.response_id; - } - } - // Merge usageMetadata from assistant records for (const record of originalRecords) { if (record.type !== 'assistant') continue; diff --git a/packages/cli/src/ui/utils/export/types.ts b/packages/cli/src/ui/utils/export/types.ts index 03d4100b1..3148fb386 100644 --- a/packages/cli/src/ui/utils/export/types.ts +++ b/packages/cli/src/ui/utils/export/types.ts @@ -27,9 +27,6 @@ export interface ExportMessage { /** Model used for assistant messages */ model?: string; - /** Response ID from the LLM API for telemetry/tracing correlation */ - response_id?: string; - /** Token usage for this message (mainly for assistant messages) */ usageMetadata?: GenerateContentResponseUsageMetadata; @@ -88,8 +85,6 @@ export interface ExportMetadata { linesRemoved?: number; /** Unique files referenced in the session (written files only) */ uniqueFiles: string[]; - /** Last response ID from the LLM API (request ID) */ - requestId?: string; } /** diff --git a/packages/core/src/core/geminiChat.ts b/packages/core/src/core/geminiChat.ts index 979cca0a1..2d1cb5748 100644 --- a/packages/core/src/core/geminiChat.ts +++ b/packages/core/src/core/geminiChat.ts @@ -633,7 +633,6 @@ export class GeminiChat { // Collect ALL parts from the model response (including thoughts for recording) const allModelParts: Part[] = []; let usageMetadata: GenerateContentResponseUsageMetadata | undefined; - let responseId: string | undefined; let hasToolCall = false; let hasFinishReason = false; @@ -654,11 +653,6 @@ export class GeminiChat { // Collect all parts for recording allModelParts.push(...content.parts); } - - // Collect response ID for telemetry/tracing correlation - if (chunk.responseId) { - responseId = chunk.responseId; - } } // Collect token usage for consolidated recording @@ -730,6 +724,8 @@ export class GeminiChat { // Record assistant turn with raw Content and metadata if (thoughtContentPart || contentText || hasToolCall || usageMetadata) { + const contextWindowSize = + this.config.getContentGeneratorConfig()?.contextWindowSize; this.chatRecordingService?.recordAssistantTurn({ model, message: [ @@ -742,7 +738,7 @@ export class GeminiChat { : []), ], tokens: usageMetadata, - responseId, + contextWindowSize, }); } diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index 9ae4064a2..14f2f5ba7 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -81,8 +81,8 @@ export interface ChatRecord { usageMetadata?: GenerateContentResponseUsageMetadata; /** Model used for this response */ model?: string; - /** Response ID from the LLM API for telemetry/tracing correlation */ - response_id?: string; + /** Context window size of the model used for this response */ + contextWindowSize?: number; /** * Tool call metadata for UI recovery. * Contains enriched info (displayName, status, result, etc.) not in API format. @@ -301,14 +301,14 @@ export class ChatRecordingService { * @param data.message The raw PartListUnion object from the model response * @param data.model The model name * @param data.tokens Token usage statistics - * @param data.responseId Response ID from the LLM API + * @param data.contextWindowSize Context window size of the model * @param data.toolCallsMetadata Enriched tool call info for UI recovery */ recordAssistantTurn(data: { model: string; message?: PartListUnion; tokens?: GenerateContentResponseUsageMetadata; - responseId?: string; + contextWindowSize?: number; }): void { try { const record: ChatRecord = { @@ -324,8 +324,8 @@ export class ChatRecordingService { record.usageMetadata = data.tokens; } - if (data.responseId) { - record.response_id = data.responseId; + if (data.contextWindowSize !== undefined) { + record.contextWindowSize = data.contextWindowSize; } this.appendRecord(record); diff --git a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx index 4b2d56086..ae5c5bd0c 100644 --- a/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx +++ b/packages/web-templates/src/export-html/src/components/MetadataSidebar.tsx @@ -11,81 +11,85 @@ export type MetadataSidebarProps = { }; export const MetadataSidebar = ({ metadata }: MetadataSidebarProps) => ( -
+ +
+ + +
+ +); diff --git a/packages/web-templates/src/export-html/src/components/types.ts b/packages/web-templates/src/export-html/src/components/types.ts index 94069c607..3fb562ad3 100644 --- a/packages/web-templates/src/export-html/src/components/types.ts +++ b/packages/web-templates/src/export-html/src/components/types.ts @@ -12,7 +12,6 @@ export type ChatData = { export type ExportMetadata = { sessionId: string; startTime: string; - relativeTime: string; exportTime: string; cwd: string; gitRepo?: string; @@ -28,7 +27,6 @@ export type ExportMetadata = { linesAdded?: number; linesRemoved?: number; uniqueFiles: string[]; - requestId?: string; }; export type PlatformContextValue = { diff --git a/packages/web-templates/src/export-html/src/styles.css b/packages/web-templates/src/export-html/src/styles.css index 6d66dcf12..df0f157e6 100644 --- a/packages/web-templates/src/export-html/src/styles.css +++ b/packages/web-templates/src/export-html/src/styles.css @@ -254,6 +254,13 @@ body { gap: 2px; } +.metadata-item-empty { + font-size: 12px; + color: #71717a; + margin: 0; + padding: 4px 0; +} + .metadata-content { display: flex; flex-direction: column; From 699bf4a0a5d4263be44689a796747fe797784c5d Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 19 Mar 2026 10:16:04 +0800 Subject: [PATCH 81/82] fix: correct MiniMax-M2.5 contextWindowSize from 1000000 to 196608 Co-authored-by: Qwen-Coder --- packages/cli/src/constants/codingPlan.ts | 4 ++-- packages/core/src/core/tokenLimits.test.ts | 4 ++-- packages/core/src/core/tokenLimits.ts | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/cli/src/constants/codingPlan.ts b/packages/cli/src/constants/codingPlan.ts index bc28a781a..87be46542 100644 --- a/packages/cli/src/constants/codingPlan.ts +++ b/packages/cli/src/constants/codingPlan.ts @@ -97,7 +97,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, - contextWindowSize: 1000000, + contextWindowSize: 196608, }, }, { @@ -222,7 +222,7 @@ export function generateCodingPlanTemplate( extra_body: { enable_thinking: true, }, - contextWindowSize: 1000000, + contextWindowSize: 196608, }, }, { diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index bc59a6332..730907ef6 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -192,8 +192,8 @@ describe('tokenLimit', () => { }); describe('MiniMax', () => { - it('should return 1M for MiniMax-M2.5 (latest)', () => { - expect(tokenLimit('MiniMax-M2.5')).toBe(1000000); + it('should return 196608 for MiniMax-M2.5 (latest)', () => { + expect(tokenLimit('MiniMax-M2.5')).toBe(196608); }); it('should return 200K for MiniMax fallback', () => { diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 2e923ab73..41e7dc6a9 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -21,6 +21,7 @@ const LIMITS = { '32k': 32_768, '64k': 65_536, '128k': 131_072, + '192k': 196_608, // MiniMax-M2.5 context window '200k': 200_000, // vendor-declared decimal, used by OpenAI, Anthropic, etc. '256k': 262_144, '272k': 272_000, // vendor-declared decimal, GPT-5.x input (400K total - 128K output) @@ -128,7 +129,7 @@ const PATTERNS: Array<[RegExp, TokenCount]> = [ // ------------------- // MiniMax // ------------------- - [/^minimax-m2\.5/i, LIMITS['1m']], // MiniMax-M2.5: 1,000,000 + [/^minimax-m2\.5/i, LIMITS['192k']], // MiniMax-M2.5: 196,608 [/^minimax-/i, LIMITS['200k']], // MiniMax fallback: 200K // ------------------- From 7d52c74a338ed8e53b800ee82388fd3a99bd877a Mon Sep 17 00:00:00 2001 From: "mingholy.lmh" Date: Thu, 19 Mar 2026 10:18:42 +0800 Subject: [PATCH 82/82] fix: correct GLM output token limit from 128k to 16k per ref.json Co-authored-by: Qwen-Coder --- packages/core/src/core/tokenLimits.test.ts | 4 ++-- packages/core/src/core/tokenLimits.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/core/src/core/tokenLimits.test.ts b/packages/core/src/core/tokenLimits.test.ts index 730907ef6..4c79cfe71 100644 --- a/packages/core/src/core/tokenLimits.test.ts +++ b/packages/core/src/core/tokenLimits.test.ts @@ -290,8 +290,8 @@ describe('tokenLimit with output type', () => { }); it('should return correct output limits for GLM', () => { - expect(tokenLimit('glm-5', 'output')).toBe(131072); - expect(tokenLimit('glm-4.7', 'output')).toBe(131072); + expect(tokenLimit('glm-5', 'output')).toBe(16384); + expect(tokenLimit('glm-4.7', 'output')).toBe(16384); }); it('should return correct output limits for MiniMax', () => { diff --git a/packages/core/src/core/tokenLimits.ts b/packages/core/src/core/tokenLimits.ts index 41e7dc6a9..e890d0cab 100644 --- a/packages/core/src/core/tokenLimits.ts +++ b/packages/core/src/core/tokenLimits.ts @@ -175,8 +175,8 @@ const OUTPUT_PATTERNS: Array<[RegExp, TokenCount]> = [ [/^deepseek-chat/, LIMITS['8k']], // Zhipu GLM - [/^glm-5/, LIMITS['128k']], - [/^glm-4\.7/, LIMITS['128k']], + [/^glm-5/, LIMITS['16k']], + [/^glm-4\.7/, LIMITS['16k']], // MiniMax [/^minimax-m2\.5/i, LIMITS['64k']],