diff --git a/.ade/.gitignore b/.ade/.gitignore index 6d2c67a58..19685b6ff 100644 --- a/.ade/.gitignore +++ b/.ade/.gitignore @@ -4,7 +4,6 @@ local.secret.yaml ade.db ade.db-* ade.db-wal -*.bak embeddings.db mcp.sock artifacts/ diff --git a/.ade/cto/identity.yaml b/.ade/cto/identity.yaml index f87dc6cc5..ad9417718 100644 --- a/.ade/cto/identity.yaml +++ b/.ade/cto/identity.yaml @@ -1,14 +1,7 @@ name: CTO -version: 2 -persona: >- - You are the CTO for this project inside ADE. - - You are the persistent technical lead who owns architecture, execution - quality, engineering continuity, and team direction. - - Use ADE's tools and project context to help the team move forward with clear, - concrete decisions. -personality: strategic +version: 3 +persona: Persistent project CTO with collaborative personality. +personality: casual modelPreferences: provider: claude model: sonnet @@ -29,6 +22,7 @@ openclawContextPolicy: - token - system_prompt onboardingState: - completedSteps: [] - dismissedAt: 2026-04-01T23:35:05.209Z -updatedAt: 2026-04-01T23:35:05.211Z + completedSteps: + - identity + completedAt: 2026-04-02T14:20:19.124Z +updatedAt: 2026-04-02T14:20:19.127Z diff --git a/CHANGELOG.md b/CHANGELOG.md index 0276938ba..f797c3a74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to ADE will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **Packed session grid** — resizable tile layout for the Work view with per-session column/row spans, drag-handle resizing on all edges and corners, and a bin-packing algorithm for compact arrangement (`PackedSessionGrid`, `packedSessionGridMath`) +- **Multi-select agent questions** — `AgentQuestionModal` now supports toggling multiple predefined options per question, with a Markdown/HTML preview pane for selected option descriptions (via `ReactMarkdown` + `rehype-sanitize`) +- **New Chat quick-create** — faster optimistic session opening from the Work view with immediate tab activation before the backend session is ready +- **Turn recap** — `chatTranscriptRows` emits a `turn_recap` summary row at the end of each turn, aggregating tool invocation counts and status +- **Claude tool-use tracking** — per-invocation lifecycle tracking via `toolUseID`; `tool_use_start` and `tool_use_complete` events enable per-tool status indicators in the work log +- **MCP initialize probe** — Claude runtime pre-checks MCP server availability before starting a session + +### Changed + +- **Terminal renderer fallback** — simplified from three tiers (WebGL/canvas/DOM) to two (WebGL-first with DOM fallback); added fit recovery with retry on invalid dimensions and `fitRecoveries` health counter +- **Work log headings** — human-readable labels (e.g. "Read utils.ts", "Run shell", "Write index.ts") replace generic tool identifiers; default visible entries increased from 1 to 4 +- **Model catalog filtering** — `UnifiedModelSelector` accepts `catalogMode: "available-only"` to restrict the picker to models available via configured providers +- **Git stash actions** — stash pop, drop, and clear now refresh workspace metadata after completion +- **Composer sizing** — new compact and grid-tile sizing modes in `ChatComposerShell` + ## [1.0.2] - 2026-03-15 ### Added diff --git a/ai-tools/claude-code.mdx b/ai-tools/claude-code.mdx index cc64c6927..c01786a2d 100644 --- a/ai-tools/claude-code.mdx +++ b/ai-tools/claude-code.mdx @@ -1,6 +1,6 @@ --- title: "Claude Code" -description: "Use Claude Code as a CLI coding agent inside ADE lanes." +description: "Use Claude Code as a CLI coding agent inside ADE lanes — launch sessions, connect MCP tools, configure permissions, and track work across agent chat, missions, and CTO delegations." icon: "terminal" --- @@ -8,45 +8,105 @@ icon: "terminal" ADE integrates with [Claude Code](https://docs.anthropic.com/en/docs/claude-code), Anthropic's CLI agent for coding tasks. When Claude Code is installed on your machine, ADE can launch it as a managed session inside any lane. +Claude Code is one of ADE's deepest integrations — it serves as both a chat provider (for Agent Chat) and an execution backend (for missions, CTO workers, and automations). ADE manages the Claude Code process lifecycle, injects MCP tools, forwards environment variables, and tracks every session in the History view. + +--- + ## Requirements -- Claude Code installed (`npm install -g @anthropic-ai/claude-code`) -- An active Anthropic API key or Claude subscription +| Requirement | Details | +|------------|---------| +| **Claude Code installed** | `npm install -g @anthropic-ai/claude-code` | +| **Authentication** | An active Anthropic API key or Claude subscription. ADE probes the Claude Code runtime at startup to verify authentication. If auth fails, the AI settings indicator shows amber with instructions. | + +ADE auto-detects the Claude Code executable from standard paths and PATH. If detection fails, check **Settings > AI Providers** for the status and diagnostic message. + +--- -## How ADE uses Claude Code +## How ADE Uses Claude Code -ADE launches Claude Code as a subprocess inside a lane's worktree. The session inherits the lane's environment variables, working directory, and port assignments. +ADE launches Claude Code as a subprocess inside a lane's worktree. The session inherits the lane's environment variables, working directory, port assignments, and MCP tool configuration. Claude Code sessions are used in several places: -- **Agent Chat** -- when you select Claude Code as the executor in a lane chat session -- **Missions** -- as a worker executor (`claude_cli`) during mission execution phases -- **CTO workers** -- when the CTO delegates a task to an employee agent using Claude Code -- **Automations** -- as the execution backend for automation rules +| Surface | How Claude Code is used | +|---------|------------------------| +| **Agent Chat** | Select Claude as the provider in the model selector. ADE starts a Claude Code session with MCP tools bridged in. | +| **Missions** | Used as a worker executor (`claude_cli`) during mission execution phases. Each worker gets its own Claude Code process. | +| **CTO Workers** | When the CTO delegates a task to an employee agent, it can use Claude Code as the execution backend. | +| **Automations** | Automation rules with `agent-session` execution kind can use Claude Code as the underlying agent. | + +--- ## Launching Claude Code from ADE - Navigate to the lane where you want to run Claude Code. + Navigate to the lane where you want to run Claude Code. The agent will work in this lane's worktree. Click the **Run** tab or use the AI tool launcher in Project Home. - Click the **Claude Code** launcher. ADE starts a managed session with the lane's context pack injected as initial context. + Click the **Claude Code** launcher. ADE starts a managed session with: + - The lane's context pack injected as initial context + - MCP tools from ADE's server available to the agent + - Environment variables from the lane's overlay applied + - The working directory set to the lane's worktree path -## Permission modes +--- + +## MCP Tool Bridging + +When ADE launches Claude Code, it bridges ADE's MCP tools into the Claude Code session. This means the Claude Code agent can use ADE-specific tools (file operations, git operations, PR management, lane management) alongside its built-in capabilities. + +ADE runs a pre-session **MCP initialize probe** to verify that the MCP server is reachable before starting the Claude Code session. If the probe fails, ADE reports the error in the chat UI instead of starting a broken session. + +--- + +## Permission Modes ADE supports several permission modes for Claude Code sessions: | Mode | Behavior | |------|----------| -| `default` | Standard Claude Code permissions | -| `plan` | Planning only, no file writes | -| `acceptEdits` | Auto-accept file edits | -| `bypassPermissions` | Skip all permission prompts | +| `default` | Standard Claude Code permissions — the agent asks before making changes | +| `plan` | Planning only — the agent can read files and reason but cannot write or execute | +| `acceptEdits` | Auto-accept file edits — the agent can write files without asking, but still asks for terminal commands | +| `bypassPermissions` | Skip all permission prompts — full autonomy. Use with caution. | + +Configure the default permission mode in **Settings > AI Providers** or per-mission in the mission creation dialog. For CTO-delegated workers, the CTO sets the permission mode based on the task's risk level. + +--- + +## Connection Status + +ADE shows Claude Code's connection status in **Settings > AI Providers**: + +| Status | Meaning | +|--------|---------| +| **Ready** (green) | Claude Code executable found, authenticated, and runtime probe passed | +| **Auth failed** (amber) | Claude Code is installed but not authenticated. Run `claude auth login` in a terminal or use `/login` in chat. | +| **Not found** (gray) | Claude Code CLI not detected. Install it with `npm install -g @anthropic-ai/claude-code`. | +| **Runtime failed** (red) | Claude Code is installed but the runtime probe failed. Check ADE's developer logs for details. | + +--- + +## Troubleshooting -Configure the default permission mode in **Settings > AI Providers** or per-mission in the mission creation dialog. + + + ADE checks standard installation paths and PATH for the Claude Code executable. If installed in a non-standard location, ensure `claude` is available in your PATH. Verify with `which claude` in a terminal. + + + Run `claude auth login` in a terminal outside ADE, then restart ADE or click **Refresh** in Settings > AI Providers. ADE caches the runtime probe result for 30 seconds, so the status updates shortly after re-authentication. + + + Check that the MCP initialize probe passed (visible in the chat session's startup messages). If the probe failed, verify that ADE's MCP server is running — check **Settings > MCP** for server status. + + + The runtime probe has a 20-second timeout. If your network is slow or the Anthropic API is under load, the probe may fail. Check your internet connection and try again. If sessions consistently hang, check the developer logs for detailed error information. + + diff --git a/ai-tools/windsurf.mdx b/ai-tools/windsurf.mdx index 003423105..5d1543229 100644 --- a/ai-tools/windsurf.mdx +++ b/ai-tools/windsurf.mdx @@ -1,19 +1,104 @@ --- title: "Windsurf" -description: "Open your project in Windsurf from ADE and track the session." +description: "Launch Windsurf from ADE to edit code in a lane's worktree with session tracking and environment inheritance." icon: "water" --- ## Overview -ADE can launch [Windsurf](https://codeium.com/windsurf) for your project directly from the Run tab. When launched through ADE, the session is tracked as a managed session in the lane for visibility in the History view. +ADE can launch [Windsurf](https://codeium.com/windsurf) (by Codeium) as an external editor for any lane. When you open Windsurf through ADE, the session is tracked in the lane's history, the correct worktree directory is opened, and any file changes you make in Windsurf appear in the lane's diff view as soon as they are saved. + +This is a one-way integration — ADE launches Windsurf and tracks the session, but does not route AI agent requests through Windsurf or manage Windsurf's internal AI features. You use Windsurf's built-in AI capabilities directly within the Windsurf editor. + +--- ## Requirements -- Windsurf installed on your machine +| Requirement | Details | +|------------|---------| +| **Windsurf installed** | The Windsurf desktop app must be installed on your machine | +| **CLI available** | The `windsurf` command must be available in your PATH. Windsurf typically adds this during installation. Verify with `which windsurf` in a terminal. | + +--- ## Launching Windsurf from ADE -Open the **Run** tab or **Project Home** and click the **Windsurf** launcher (if configured). ADE opens the current project (or the selected lane's worktree) in Windsurf as a separate application window. + + + Navigate to the lane you want to work in. Windsurf will open the lane's worktree directory — not the primary checkout. + + + Click the **Run** tab in the lane's toolbar, or open **Project Home** from the sidebar. + + + If Windsurf is detected or configured, a **Windsurf** button appears alongside other external tool launchers (Cursor, VS Code, Claude Code, etc.). Click it. + + + ADE launches Windsurf pointed at the lane's worktree path. The session is registered in ADE's history system. + + + +--- + +## What ADE Tracks + +When you launch Windsurf through ADE: + +- **Session registration** — the launch is recorded as a managed session in the lane, visible in the History view +- **File change detection** — ADE watches the lane's worktree for filesystem changes. Edits made in Windsurf appear in the lane's diff view in real time. +- **Environment inheritance** — Windsurf inherits the lane's environment variable overlays and port assignments + +ADE does not monitor Windsurf's internal AI usage, chat history, or token consumption. Those are managed entirely within Windsurf. + +--- + +## Configuring a Custom Launcher + +If Windsurf does not appear in the Run tab by default, you can add it as a custom CLI launcher: + + + + Navigate to **Settings > Project Home > Custom CLI Launchers**. + + + Click **Add Launcher** and configure: + - **Name:** `Windsurf` + - **Command:** `windsurf` (or the full path to the Windsurf executable if it is not in PATH) + - **Arguments:** `{worktreePath}` — ADE substitutes the lane's worktree directory at launch time + + + Save the launcher. It now appears in the Run tab for all lanes. + + + +--- + +## Windsurf vs. Cursor vs. Claude Code + +ADE supports multiple external AI tools. Here is how Windsurf compares: + +| Capability | Windsurf | Cursor | Claude Code | +|-----------|----------|--------|-------------| +| **Launch from ADE** | Yes | Yes | Yes | +| **Session tracking** | Yes | Yes | Yes | +| **Agent provider in ADE chat** | No | Yes (via ACP) | Yes (via CLI) | +| **MCP tool bridging** | No | Yes | Yes | +| **Model routing through ADE** | No | Yes | Yes | + +Windsurf is a good choice when you want to use Codeium's AI features directly in the editor while keeping the session tracked in ADE. For deeper integration where ADE manages the AI agent lifecycle, use Cursor (via ACP) or Claude Code (via CLI). + +--- + +## Troubleshooting -Configure custom CLI launchers in **Settings > Project Home > Custom CLI Launchers** if Windsurf does not appear by default. + + + ADE may not detect Windsurf automatically. Add it as a custom CLI launcher in **Settings > Project Home > Custom CLI Launchers** using the `windsurf` command. + + + Verify you selected the correct lane before launching. If Windsurf opens the primary checkout instead of the lane worktree, check that the lane was created as a Worktree Lane (not the primary lane). + + + ADE watches the lane's worktree for filesystem changes. If changes are not appearing, check that you are editing files inside the lane's worktree path (visible in the lane detail header), not in a different directory. + + diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 321ed127a..023ff5b51 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -26,6 +26,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "ai": "^6.0.141", @@ -7480,6 +7481,12 @@ "integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==", "license": "MIT" }, + "node_modules/@xterm/addon-webgl": { + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.19.0.tgz", + "integrity": "sha512-b3fMOsyLVuCeNJWxolACEUED0vm7qC0cy4wRvf3oURSzDTYVQiGPhTnhWZwIHdvC48Y+oLhvYXnY4XDXPoJo6A==", + "license": "MIT" + }, "node_modules/@xterm/xterm": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz", diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 5e15e5d55..00f60dc05 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -56,6 +56,7 @@ "@radix-ui/react-tabs": "^1.1.13", "@tanstack/react-virtual": "^3.13.21", "@xterm/addon-fit": "^0.11.0", + "@xterm/addon-webgl": "^0.19.0", "@xterm/xterm": "^6.0.0", "@xyflow/react": "^12.5.0", "ai": "^6.0.141", diff --git a/apps/desktop/scripts/validate-mac-artifacts.mjs b/apps/desktop/scripts/validate-mac-artifacts.mjs index f56fa3995..8dc1d5b68 100644 --- a/apps/desktop/scripts/validate-mac-artifacts.mjs +++ b/apps/desktop/scripts/validate-mac-artifacts.mjs @@ -226,6 +226,23 @@ async function validatePackagedRuntime(appPath, description) { if (!payload?.proxyProbe?.ok) { throw new Error("[release:mac] Packaged smoke failed to launch the bundled ADE MCP proxy in probe mode"); } + // Do not rely on probe mode alone here. The regression we fixed still let + // the packaged proxy start, but chat MCP failed once Claude/Codex attempted + // the first initialize handshake through that launch path. + if (!payload?.proxyInitialize?.ok) { + throw new Error( + `[release:mac] Packaged smoke failed to complete MCP initialize through the bundled ADE proxy: ${ + String(payload?.proxyInitialize?.error || payload?.proxyInitialize?.stderr || "unknown error") + }` + ); + } + if (payload?.proxyInitialize?.response?.result?.serverInfo?.name !== "ade-mcp-server") { + throw new Error( + `[release:mac] Packaged smoke expected ADE MCP initialize to report ade-mcp-server, got ${ + JSON.stringify(payload?.proxyInitialize?.response ?? null) + }` + ); + } console.log(`[release:mac] Packaged runtime smoke passed for ${description}: ${path.relative(appPath, nodePtyAddon)}`); } diff --git a/apps/desktop/src/main/adeMcpProxy.ts b/apps/desktop/src/main/adeMcpProxy.ts index 069e2428c..b9d7ae18c 100644 --- a/apps/desktop/src/main/adeMcpProxy.ts +++ b/apps/desktop/src/main/adeMcpProxy.ts @@ -45,13 +45,37 @@ function resolveRuntimeRoots(): RuntimeRoots { return { projectRoot, workspaceRoot }; } +function asBoolFlag(value: string | undefined): boolean | null { + const trimmed = value?.trim() ?? ""; + return trimmed === "1" ? true : trimmed === "0" ? false : null; +} + function resolveProxyIdentityFromEnv(): ProxyIdentity { + const computerUseMode = asTrimmed(process.env.ADE_COMPUTER_USE_MODE); + const allowLocalFallback = asBoolFlag(process.env.ADE_COMPUTER_USE_ALLOW_LOCAL_FALLBACK); + const retainArtifacts = asBoolFlag(process.env.ADE_COMPUTER_USE_RETAIN_ARTIFACTS); + const preferredBackend = asTrimmed(process.env.ADE_COMPUTER_USE_PREFERRED_BACKEND); + const hasComputerUsePolicy = + computerUseMode + || typeof allowLocalFallback === "boolean" + || typeof retainArtifacts === "boolean" + || preferredBackend; return { + chatSessionId: asTrimmed(process.env.ADE_CHAT_SESSION_ID), missionId: asTrimmed(process.env.ADE_MISSION_ID), runId: asTrimmed(process.env.ADE_RUN_ID), stepId: asTrimmed(process.env.ADE_STEP_ID), attemptId: asTrimmed(process.env.ADE_ATTEMPT_ID), + ownerId: asTrimmed(process.env.ADE_OWNER_ID), role: asTrimmed(process.env.ADE_DEFAULT_ROLE), + computerUsePolicy: hasComputerUsePolicy + ? { + mode: computerUseMode, + allowLocalFallback, + retainArtifacts, + preferredBackend, + } + : null, }; } diff --git a/apps/desktop/src/main/adeMcpProxyUtils.test.ts b/apps/desktop/src/main/adeMcpProxyUtils.test.ts index f13444f5d..59fe94f18 100644 --- a/apps/desktop/src/main/adeMcpProxyUtils.test.ts +++ b/apps/desktop/src/main/adeMcpProxyUtils.test.ts @@ -12,11 +12,14 @@ import { } from "./adeMcpProxyUtils"; const NULL_IDENTITY: ProxyIdentity = { + chatSessionId: null, missionId: null, runId: null, stepId: null, attemptId: null, + ownerId: null, role: null, + computerUsePolicy: null, }; describe("asTrimmed", () => { @@ -85,6 +88,10 @@ describe("hasProxyIdentity", () => { expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1" })).toBe(true); }); + it("returns true when chatSessionId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, chatSessionId: "chat-1" })).toBe(true); + }); + it("returns true when runId is set", () => { expect(hasProxyIdentity({ ...NULL_IDENTITY, runId: "r-1" })).toBe(true); }); @@ -101,6 +108,22 @@ describe("hasProxyIdentity", () => { expect(hasProxyIdentity({ ...NULL_IDENTITY, role: "coder" })).toBe(true); }); + it("returns true when ownerId is set", () => { + expect(hasProxyIdentity({ ...NULL_IDENTITY, ownerId: "agent-1" })).toBe(true); + }); + + it("returns true when computerUsePolicy is set", () => { + expect(hasProxyIdentity({ + ...NULL_IDENTITY, + computerUsePolicy: { + mode: "enabled", + allowLocalFallback: null, + retainArtifacts: null, + preferredBackend: null, + }, + })).toBe(true); + }); + it("returns true when multiple fields are set", () => { expect(hasProxyIdentity({ ...NULL_IDENTITY, missionId: "m-1", role: "coder" })).toBe(true); }); @@ -261,11 +284,19 @@ describe("takeNextInboundMessage", () => { describe("injectIdentityIntoInitializePayload", () => { const identity: ProxyIdentity = { + chatSessionId: "chat-1", missionId: "m-1", runId: "r-1", stepId: "s-1", attemptId: "a-1", + ownerId: "agent-1", role: "coder", + computerUsePolicy: { + mode: "enabled", + allowLocalFallback: true, + retainArtifacts: false, + preferredBackend: "vnc", + }, }; it("injects identity into initialize method", () => { @@ -277,11 +308,19 @@ describe("injectIdentityIntoInitializePayload", () => { }); const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); expect(result.params.identity).toEqual({ + chatSessionId: "chat-1", missionId: "m-1", runId: "r-1", stepId: "s-1", attemptId: "a-1", + ownerId: "agent-1", role: "coder", + computerUsePolicy: { + mode: "enabled", + allowLocalFallback: true, + retainArtifacts: false, + preferredBackend: "vnc", + }, }); }); @@ -303,17 +342,30 @@ describe("injectIdentityIntoInitializePayload", () => { id: 1, params: { identity: { + chatSessionId: "existing-chat", missionId: "existing-mission", + ownerId: "existing-owner", role: "existing-role", + computerUsePolicy: { + mode: "off", + }, }, }, }); const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity.chatSessionId).toBe("existing-chat"); expect(result.params.identity.missionId).toBe("existing-mission"); + expect(result.params.identity.ownerId).toBe("existing-owner"); expect(result.params.identity.role).toBe("existing-role"); expect(result.params.identity.runId).toBe("r-1"); expect(result.params.identity.stepId).toBe("s-1"); expect(result.params.identity.attemptId).toBe("a-1"); + expect(result.params.identity.computerUsePolicy).toEqual({ + mode: "off", + allowLocalFallback: true, + retainArtifacts: false, + preferredBackend: "vnc", + }); }); it("overwrites existing identity fields that are empty strings", () => { @@ -323,13 +375,17 @@ describe("injectIdentityIntoInitializePayload", () => { id: 1, params: { identity: { + chatSessionId: " ", missionId: " ", + ownerId: "", role: "", }, }, }); const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); + expect(result.params.identity.chatSessionId).toBe("chat-1"); expect(result.params.identity.missionId).toBe("m-1"); + expect(result.params.identity.ownerId).toBe("agent-1"); expect(result.params.identity.role).toBe("coder"); }); @@ -360,11 +416,19 @@ describe("injectIdentityIntoInitializePayload", () => { }); const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); expect(result.params.identity).toEqual({ + chatSessionId: "chat-1", missionId: "m-1", runId: "r-1", stepId: "s-1", attemptId: "a-1", + ownerId: "agent-1", role: "coder", + computerUsePolicy: { + mode: "enabled", + allowLocalFallback: true, + retainArtifacts: false, + preferredBackend: "vnc", + }, }); }); @@ -379,6 +443,7 @@ describe("injectIdentityIntoInitializePayload", () => { }); const result = JSON.parse(injectIdentityIntoInitializePayload(payload, identity)); expect(result.params.capabilities).toEqual({ tools: true }); + expect(result.params.identity.chatSessionId).toBe("chat-1"); expect(result.params.identity.missionId).toBe("m-1"); }); }); diff --git a/apps/desktop/src/main/adeMcpProxyUtils.ts b/apps/desktop/src/main/adeMcpProxyUtils.ts index 06f1f8fe8..470ad5e66 100644 --- a/apps/desktop/src/main/adeMcpProxyUtils.ts +++ b/apps/desktop/src/main/adeMcpProxyUtils.ts @@ -1,11 +1,19 @@ import type { Buffer } from "node:buffer"; export type ProxyIdentity = { + chatSessionId: string | null; missionId: string | null; runId: string | null; stepId: string | null; attemptId: string | null; + ownerId: string | null; role: string | null; + computerUsePolicy: { + mode: string | null; + allowLocalFallback: boolean | null; + retainArtifacts: boolean | null; + preferredBackend: string | null; + } | null; }; export type ParsedInboundMessage = { @@ -25,7 +33,25 @@ export function isRecord(value: unknown): value is Record { } export function hasProxyIdentity(identity: ProxyIdentity): boolean { - return Boolean(identity.missionId || identity.runId || identity.stepId || identity.attemptId || identity.role); + const hasComputerUsePolicy = Boolean( + identity.computerUsePolicy + && ( + identity.computerUsePolicy.mode + || typeof identity.computerUsePolicy.allowLocalFallback === "boolean" + || typeof identity.computerUsePolicy.retainArtifacts === "boolean" + || identity.computerUsePolicy.preferredBackend + ), + ); + return Boolean( + identity.chatSessionId + || identity.missionId + || identity.runId + || identity.stepId + || identity.attemptId + || identity.ownerId + || identity.role + || hasComputerUsePolicy, + ); } export function findHeaderBoundary(buffer: Buffer): { index: number; delimiterLength: number } | null { @@ -97,7 +123,7 @@ export function injectIdentityIntoInitializePayload(payloadText: string, identit const existingIdentity = isRecord(params.identity) ? { ...params.identity } : {}; const mergedIdentity: Record = { ...existingIdentity }; - const identityKeys = ["missionId", "runId", "stepId", "attemptId", "role"] as const; + const identityKeys = ["chatSessionId", "missionId", "runId", "stepId", "attemptId", "ownerId", "role"] as const; for (const key of identityKeys) { if (!identity[key]) continue; const existing = existingIdentity[key]; @@ -105,6 +131,37 @@ export function injectIdentityIntoInitializePayload(payloadText: string, identit mergedIdentity[key] = identity[key]; } + const proxyComputerUsePolicy = identity.computerUsePolicy; + if (proxyComputerUsePolicy) { + const existingComputerUsePolicy = isRecord(existingIdentity.computerUsePolicy) + ? { ...existingIdentity.computerUsePolicy } + : {}; + let shouldWriteComputerUsePolicy = false; + + const hasExistingString = (key: string): boolean => + typeof existingComputerUsePolicy[key] === "string" && (existingComputerUsePolicy[key] as string).trim().length > 0; + const mergeString = (key: "mode" | "preferredBackend"): void => { + if (proxyComputerUsePolicy[key] && !hasExistingString(key)) { + existingComputerUsePolicy[key] = proxyComputerUsePolicy[key]; + shouldWriteComputerUsePolicy = true; + } + }; + const mergeBool = (key: "allowLocalFallback" | "retainArtifacts"): void => { + if (typeof proxyComputerUsePolicy[key] === "boolean" && typeof existingComputerUsePolicy[key] !== "boolean") { + existingComputerUsePolicy[key] = proxyComputerUsePolicy[key]; + shouldWriteComputerUsePolicy = true; + } + }; + mergeString("mode"); + mergeBool("allowLocalFallback"); + mergeBool("retainArtifacts"); + mergeString("preferredBackend"); + + if (shouldWriteComputerUsePolicy) { + mergedIdentity.computerUsePolicy = existingComputerUsePolicy; + } + } + return JSON.stringify({ ...payload, params: { diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 4b885bb95..162752d69 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -679,6 +679,8 @@ app.whenReady().then(async () => { let prServiceRef: ReturnType | null = null; let prPollingServiceRef: ReturnType | null = null; let testServiceRef: ReturnType | null = null; + let gitServiceRef: ReturnType | null = null; + let missionBudgetServiceRef: ReturnType | null = null; const lastHeadByLaneId = new Map(); @@ -759,7 +761,10 @@ app.whenReady().then(async () => { }); const sessionService = createSessionService({ db }); - const reconciledSessions = sessionService.reconcileStaleRunningSessions({ status: "disposed" }); + const reconciledSessions = sessionService.reconcileStaleRunningSessions({ + status: "disposed", + excludeToolTypes: ["claude-chat", "codex-chat", "ai-chat", "cursor"], + }); if (reconciledSessions > 0) { logger.warn("sessions.reconciled_stale_running", { count: reconciledSessions }); } @@ -1380,6 +1385,11 @@ app.whenReady().then(async () => { getTestService: () => testServiceRef, ptyService, getAutomationService: () => automationService, + getGitService: () => gitServiceRef, + conflictService, + contextDocService, + getWorkerBudgetService: () => workerBudgetService, + getMissionBudgetService: () => missionBudgetServiceRef, episodicSummaryService, laneService, sessionService, @@ -1473,6 +1483,7 @@ app.whenReady().then(async () => { } }); testServiceRef = testService; + gitServiceRef = gitService; automationService = createAutomationService({ db, @@ -1559,6 +1570,7 @@ app.whenReady().then(async () => { aiIntegrationService, projectConfigService, }); + missionBudgetServiceRef = missionBudgetService; let missionPreflightService: ReturnType; const deferredProjectStartCancels = new Set<() => void>(); const scheduleDeferredProjectStart = ( diff --git a/apps/desktop/src/main/packagedRuntimeSmoke.ts b/apps/desktop/src/main/packagedRuntimeSmoke.ts index 21d1055ed..3717614bb 100644 --- a/apps/desktop/src/main/packagedRuntimeSmoke.ts +++ b/apps/desktop/src/main/packagedRuntimeSmoke.ts @@ -1,6 +1,6 @@ import fs from "node:fs"; import path from "node:path"; -import { execFile } from "node:child_process"; +import { execFile, spawnSync } from "node:child_process"; import { createRequire } from "node:module"; import { promisify } from "node:util"; import { resolveDesktopAdeMcpLaunch } from "./services/runtime/adeMcpLaunch"; @@ -101,6 +101,64 @@ async function probeClaudeStartup( } } +function probeMcpInitialize(args: { + command: string; + cmdArgs: string[]; + cwd: string; + env: NodeJS.ProcessEnv; +}): { + ok: boolean; + response: unknown | null; + stderr: string | null; + error: string | null; +} { + // Keep this as a real MCP initialize round-trip instead of another cheap + // "--probe" check. We regressed packaged chats by launching the proxy + // successfully but routing chat MCP through the wrong path, which only + // showed up once the client attempted the first initialize handshake. + const payload = JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "initialize", + params: { + protocolVersion: "2025-06-18", + clientInfo: { + name: "packaged-runtime-smoke", + version: "1.0.0", + }, + capabilities: {}, + }, + }); + + const result = spawnSync(args.command, args.cmdArgs, { + cwd: args.cwd, + env: args.env, + input: `${payload}\n`, + encoding: "utf8", + timeout: 5_000, + }); + + const stdout = (result.stdout ?? "").trim(); + const stderr = (result.stderr ?? "").trim(); + const error = result.error ? String(result.error.message ?? result.error) : null; + + try { + return { + ok: result.status === 0, + response: stdout ? JSON.parse(stdout) : null, + stderr: stderr || null, + error, + }; + } catch (parseError) { + return { + ok: false, + response: stdout || null, + stderr: stderr || null, + error: parseError instanceof Error ? parseError.message : String(parseError), + }; + } +} + async function main(): Promise { const pty = await import("node-pty"); const claude = await import("@anthropic-ai/claude-agent-sdk"); @@ -135,6 +193,16 @@ async function main(): Promise { proxyProbeResult = proxyProbeStdout; } + const proxyInitialize = probeMcpInitialize({ + command: launch.command, + cmdArgs: launch.cmdArgs, + cwd, + env: { + ...process.env, + ...launch.env, + }, + }); + process.stdout.write(JSON.stringify({ ok: true, nodePty: typeof pty.spawn, @@ -149,6 +217,7 @@ async function main(): Promise { launchEntryPath: launch.entryPath, launchSocketPath: launch.socketPath, proxyProbe: proxyProbeResult, + proxyInitialize, })); } diff --git a/apps/desktop/src/main/services/ai/authDetector.test.ts b/apps/desktop/src/main/services/ai/authDetector.test.ts index 26416a9ef..d18ecdc4a 100644 --- a/apps/desktop/src/main/services/ai/authDetector.test.ts +++ b/apps/desktop/src/main/services/ai/authDetector.test.ts @@ -260,38 +260,54 @@ describe("authDetector", () => { it("finds codex through an npm-global prefix when PATH lookup fails", async () => { tempHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "ade-auth-detector-")); const prefixDir = path.join(tempHomeDir, ".npm-global"); + const preferredCodexPath = path.join(prefixDir, "bin", "codex"); fs.mkdirSync(path.join(prefixDir, "bin"), { recursive: true }); fs.writeFileSync(path.join(tempHomeDir, ".npmrc"), "prefix=~/.npm-global\n", "utf8"); - fs.writeFileSync(path.join(prefixDir, "bin", "codex"), "#!/bin/sh\nexit 0\n", "utf8"); - fs.chmodSync(path.join(prefixDir, "bin", "codex"), 0o755); + fs.writeFileSync(preferredCodexPath, "#!/bin/sh\nexit 0\n", "utf8"); + fs.chmodSync(preferredCodexPath, 0o755); process.env.HOME = tempHomeDir; process.env.PATH = "/usr/bin:/bin"; - spawnMock.mockImplementation((command: string, args: string[] = []) => { - if (args[0] === "--version") { - if (command === "codex") return fakeError(); - if (command === path.join(prefixDir, "bin", "codex")) return fakeChild({ status: 0, stdout: "0.105.0\n" }); - return fakeError(); + const realStatSync = fs.statSync.bind(fs); + const statSpy = vi.spyOn(fs, "statSync").mockImplementation(((candidatePath: fs.PathLike, options?: fs.StatOptions) => { + const resolved = String(candidatePath); + if (resolved.endsWith("/codex") && resolved !== preferredCodexPath) { + const error = new Error(`ENOENT: no such file or directory, stat '${resolved}'`) as NodeJS.ErrnoException; + error.code = "ENOENT"; + throw error; } - if (command === "which") { + return realStatSync(candidatePath, options as fs.StatOptions | undefined); + }) as typeof fs.statSync); + + try { + spawnMock.mockImplementation((command: string, args: string[] = []) => { + if (args[0] === "--version") { + if (command === "codex") return fakeError(); + if (command === preferredCodexPath) return fakeChild({ status: 0, stdout: "0.105.0\n" }); + return fakeError(); + } + if (command === "which") { + return fakeChild({ status: 1 }); + } + if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { + return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); + } return fakeChild({ status: 1 }); - } - if ((command === "codex" || command.endsWith("/codex")) && args[0] === "login" && args[1] === "status") { - return fakeChild({ status: 0, stdout: "Authenticated as test-user\n" }); - } - return fakeChild({ status: 1 }); - }); + }); - const statuses = await detectCliAuthStatuses(); - const codex = statuses.find((entry) => entry.cli === "codex"); + const statuses = await detectCliAuthStatuses(); + const codex = statuses.find((entry) => entry.cli === "codex"); - expect(codex).toEqual({ - cli: "codex", - installed: true, - path: path.join(prefixDir, "bin", "codex"), - authenticated: true, - verified: true, - }); + expect(codex).toEqual({ + cli: "codex", + installed: true, + path: preferredCodexPath, + authenticated: true, + verified: true, + }); + } finally { + statSpy.mockRestore(); + } }); it("repairs PATH from the interactive shell during a forced refresh", async () => { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index 6cfe1a982..758ec81d5 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -46,7 +46,8 @@ import type { createPrService } from "../../prs/prService"; import { isNoisyIssueComment, mapPermissionMode } from "../../prs/resolverUtils"; import type { createProcessService } from "../../processes/processService"; import type { createSessionService } from "../../sessions/sessionService"; -import { getErrorMessage, nowIso } from "../../shared/utils"; +import type { createCtoStateService } from "../../cto/ctoStateService"; +import { getErrorMessage, nowIso, parseIsoToEpoch } from "../../shared/utils"; export interface CtoOperatorToolDeps { currentSessionId: string; @@ -86,7 +87,55 @@ export interface CtoOperatorToolDeps { triggerManually: (args: { id: string; dryRun?: boolean }) => Promise; listRuns: (args?: AutomationRunListArgs) => AutomationRun[]; } | null; + gitService?: { + getSyncStatus: (args: { laneId: string }) => Promise; + commit: (args: any) => Promise; + push: (args: any) => Promise; + pull: (args: { laneId: string }) => Promise; + fetch: (args: { laneId: string }) => Promise; + listRecentCommits: (args: { laneId: string; limit?: number }) => Promise; + listBranches: (args: any) => Promise; + checkoutBranch: (args: any) => Promise; + stashPush: (args: any) => Promise; + stashPop: (args: any) => Promise; + listStashes: (args: { laneId: string }) => Promise; + getConflictState: (args: { laneId: string }) => Promise; + rebaseContinue: (args: { laneId: string }) => Promise; + rebaseAbort: (args: { laneId: string }) => Promise; + mergeAbort: (args: { laneId: string }) => Promise; + } | null; + conflictService?: { + getLaneStatus: (args: any) => Promise; + getRiskMatrix: () => Promise; + simulateMerge: (args: any) => Promise; + runPrediction: (args?: any) => Promise; + listProposals: (args: { laneId: string }) => Promise; + requestProposal: (args: any) => Promise; + applyProposal: (args: any) => Promise; + undoProposal: (args: any) => Promise; + } | null; + contextDocService?: { + getStatus: () => any; + generateDocs: (args: any) => Promise; + } | null; + steerChat?: (args: { sessionId: string; instruction: string }) => Promise; + cancelSteer?: (args: { sessionId: string }) => Promise; + handoffChat?: (args: { sessionId: string; targetIdentityKey?: string; reason?: string }) => Promise; + listSubagents?: (args: { sessionId: string }) => Promise; + approveToolUse?: (args: { sessionId: string; toolUseId: string; decision: "accept" | "accept_for_session" | "decline" | "cancel" }) => Promise; + computerUseArtifactBrokerService?: { + listArtifacts: (args?: any) => any[]; + updateArtifactReview: (args: any) => any; + } | null; + workerBudgetService?: { + getBudgetSnapshot: (args: { monthKey?: string }) => any; + listCostEvents: (args: { agentId: string; monthKey?: string; limit?: number }) => any[]; + } | null; + missionBudgetService?: { + getMissionBudgetStatus: (args: { missionId: string }) => Promise; + } | null; issueTracker?: IssueTracker | null; + ctoStateService?: Pick, "getSessionLogs" | "getSubordinateActivityLogs"> | null; listChats: (laneId?: string, options?: { includeIdentity?: boolean; includeAutomation?: boolean }) => Promise; getChatStatus: (sessionId: string) => Promise; getChatTranscript: (args: { @@ -2066,6 +2115,112 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + let prFiles = await deps.prService.getFiles(prId); + if (filterFiles && filterFiles.length > 0) { + const allowed = new Set(filterFiles); + prFiles = prFiles.filter((f) => allowed.has(f.filename)); + } + // Build bounded output + let totalChars = 0; + let truncated = false; + const patches: Array<{ + filename: string; + status: string; + additions: number; + deletions: number; + patch: string | null; + }> = []; + for (const f of prFiles) { + const patchLen = f.patch?.length ?? 0; + if (totalChars + patchLen > maxChars && patches.length > 0) { + truncated = true; + break; + } + patches.push({ + filename: f.filename, + status: f.status, + additions: f.additions, + deletions: f.deletions, + patch: f.patch, + }); + totalChars += patchLen; + } + return { + success: true, + prId, + fileCount: prFiles.length, + returnedCount: patches.length, + truncated, + patches, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.approvePullRequest = tool({ + description: "Submit an APPROVE review on an ADE-managed pull request.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + body: z + .string() + .optional() + .default("") + .describe("Optional approval comment body."), + }), + execute: async ({ prId, body }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.submitReview({ prId, event: "APPROVE", body }); + return { success: true, prId, event: "APPROVE" }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.requestPrChanges = tool({ + description: + "Submit a REQUEST_CHANGES review on an ADE-managed pull request with a comment explaining what needs to change.", + inputSchema: z.object({ + prId: z.string().trim().min(1), + body: z.string().trim().min(1).describe("Review comment explaining the requested changes."), + }), + execute: async ({ prId, body }) => { + if (!deps.prService) return { success: false, error: "PR service is not available." }; + try { + await deps.prService.submitReview({ prId, event: "REQUEST_CHANGES", body }); + return { success: true, prId, event: "REQUEST_CHANGES" }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + // --------------------------------------------------------------------------- // Lane Management // --------------------------------------------------------------------------- @@ -2384,5 +2539,865 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record laneId?.trim() || deps.defaultLaneId; + + const gitGuard = async (fn: () => Promise): Promise<{ success: true } & T | { success: false; error: string }> => { + if (!deps.gitService) return { success: false, error: "Git service is not available." }; + try { + return { success: true, ...(await fn()) } as { success: true } & T; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }; + + tools.gitStatus = tool({ + description: "Get the git sync status for a lane (branch, ahead/behind, dirty state).", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.getSyncStatus({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitCommit = tool({ + description: "Create a git commit in a lane.", + inputSchema: z.object({ laneId: z.string().optional(), message: z.string().min(1), stageAll: z.boolean().optional().default(true) }), + execute: ({ laneId, message, stageAll }) => gitGuard(() => deps.gitService!.commit({ laneId: resolveLaneId(laneId), message, stageAll })), + }); + + tools.gitPush = tool({ + description: "Push commits to the remote for a lane.", + inputSchema: z.object({ laneId: z.string().optional(), force: z.boolean().optional().default(false) }), + execute: ({ laneId, force }) => gitGuard(() => deps.gitService!.push({ laneId: resolveLaneId(laneId), force })), + }); + + tools.gitPull = tool({ + description: "Pull from the remote for a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.pull({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitFetch = tool({ + description: "Fetch remote refs for a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.fetch({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitListRecentCommits = tool({ + description: "List recent commits in a lane.", + inputSchema: z.object({ laneId: z.string().optional(), limit: z.number().int().positive().max(100).optional().default(20) }), + execute: ({ laneId, limit }) => gitGuard(async () => { + const commits = await deps.gitService!.listRecentCommits({ laneId: resolveLaneId(laneId), limit }); + return { count: commits.length, commits }; + }), + }); + + tools.gitListBranches = tool({ + description: "List git branches for a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(async () => { + const branches = await deps.gitService!.listBranches({ laneId: resolveLaneId(laneId) }); + return { count: branches.length, branches }; + }), + }); + + tools.gitCheckoutBranch = tool({ + description: "Switch to or create a git branch in a lane.", + inputSchema: z.object({ laneId: z.string().optional(), branch: z.string().min(1), create: z.boolean().optional().default(false) }), + execute: ({ laneId, branch, create }) => gitGuard(() => deps.gitService!.checkoutBranch({ laneId: resolveLaneId(laneId), branch, create })), + }); + + tools.gitStashPush = tool({ + description: "Stash working changes in a lane.", + inputSchema: z.object({ laneId: z.string().optional(), message: z.string().optional() }), + execute: ({ laneId, message }) => gitGuard(() => deps.gitService!.stashPush({ laneId: resolveLaneId(laneId), ...(message?.trim() ? { message: message.trim() } : {}) })), + }); + + tools.gitStashPop = tool({ + description: "Pop the latest stash in a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.stashPop({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitStashList = tool({ + description: "List stashes in a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(async () => { + const stashes = await deps.gitService!.listStashes({ laneId: resolveLaneId(laneId) }); + return { count: stashes.length, stashes }; + }), + }); + + tools.gitGetConflictState = tool({ + description: "Check if a lane has merge or rebase conflicts in progress.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.getConflictState({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitRebaseContinue = tool({ + description: "Continue a rebase after resolving conflicts.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseContinue({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitRebaseAbort = tool({ + description: "Abort an in-progress rebase.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.rebaseAbort({ laneId: resolveLaneId(laneId) })), + }); + + tools.gitMergeAbort = tool({ + description: "Abort an in-progress merge.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => gitGuard(() => deps.gitService!.mergeAbort({ laneId: resolveLaneId(laneId) })), + }); + + // --------------------------------------------------------------------------- + // Conflict Resolution + // --------------------------------------------------------------------------- + + const conflictGuard = async (fn: () => Promise): Promise<{ success: true } & T | { success: false; error: string }> => { + if (!deps.conflictService) return { success: false, error: "Conflict service is not available." }; + try { + return { success: true, ...(await fn()) } as { success: true } & T; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }; + + tools.getConflictStatus = tool({ + description: "Check merge conflict status for a lane.", + inputSchema: z.object({ laneId: z.string().optional() }), + execute: ({ laneId }) => conflictGuard(() => deps.conflictService!.getLaneStatus({ laneId: resolveLaneId(laneId) })), + }); + + tools.getConflictRiskMatrix = tool({ + description: "Get the conflict risk matrix across all lanes.", + inputSchema: z.object({}), + execute: () => conflictGuard(async () => { + const matrix = await deps.conflictService!.getRiskMatrix(); + return { count: matrix.length, entries: matrix }; + }), + }); + + tools.simulateMerge = tool({ + description: "Dry-run merge between two lanes to predict conflicts.", + inputSchema: z.object({ sourceLaneId: z.string().min(1), targetLaneId: z.string().optional() }), + execute: ({ sourceLaneId, targetLaneId }) => conflictGuard(() => deps.conflictService!.simulateMerge({ sourceLaneId, targetLaneId: targetLaneId?.trim() || undefined })), + }); + + tools.runConflictPrediction = tool({ + description: "Run batch conflict prediction across all lanes.", + inputSchema: z.object({}), + execute: () => conflictGuard(() => deps.conflictService!.runPrediction()), + }); + + tools.listConflictProposals = tool({ + description: "List AI-generated conflict resolution proposals for a lane.", + inputSchema: z.object({ laneId: z.string().min(1) }), + execute: ({ laneId }) => conflictGuard(async () => { + const proposals = await deps.conflictService!.listProposals({ laneId }); + return { count: proposals.length, proposals }; + }), + }); + + tools.requestConflictProposal = tool({ + description: "Request an AI-generated resolution for a specific conflict.", + inputSchema: z.object({ laneId: z.string().min(1), filePath: z.string().optional() }), + execute: ({ laneId, filePath }) => conflictGuard(() => deps.conflictService!.requestProposal({ laneId, filePath: filePath?.trim() || undefined })), + }); + + tools.applyConflictProposal = tool({ + description: "Apply an AI-generated conflict resolution proposal.", + inputSchema: z.object({ laneId: z.string().min(1), proposalId: z.string().min(1) }), + execute: ({ laneId, proposalId }) => conflictGuard(() => deps.conflictService!.applyProposal({ laneId, proposalId })), + }); + + tools.undoConflictProposal = tool({ + description: "Undo an applied conflict resolution proposal.", + inputSchema: z.object({ laneId: z.string().min(1), proposalId: z.string().min(1) }), + execute: ({ laneId, proposalId }) => conflictGuard(() => deps.conflictService!.undoProposal({ laneId, proposalId })), + }); + + // --------------------------------------------------------------------------- + // Context Pack Export + // --------------------------------------------------------------------------- + + tools.getContextStatus = tool({ + description: "Check what ADE context docs exist and whether they are stale.", + inputSchema: z.object({}), + execute: async () => { + if (!deps.contextDocService) return { success: false, error: "Context doc service is not available." }; + try { + return { success: true, ...deps.contextDocService.getStatus() }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.generateContextDocs = tool({ + description: "Generate bounded context packs for bootstrapping workers or exporting project state.", + inputSchema: z.object({ + scope: z.string().optional(), + categories: z.array(z.string()).optional(), + }), + execute: async ({ scope, categories }) => { + if (!deps.contextDocService) return { success: false, error: "Context doc service is not available." }; + try { + return { success: true, ...(await deps.contextDocService.generateDocs({ scope, categories })) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Agent Chat Steering + // --------------------------------------------------------------------------- + + tools.steerChat = tool({ + description: "Inject a steering instruction into an active chat session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + instruction: z.string().min(1), + }), + execute: async ({ sessionId, instruction }) => { + if (!deps.steerChat) return { success: false, error: "Chat steering is not available." }; + try { + await deps.steerChat({ sessionId, instruction }); + return { success: true, sessionId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.cancelSteer = tool({ + description: "Cancel a pending steer instruction on a chat session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + }), + execute: async ({ sessionId }) => { + if (!deps.cancelSteer) return { success: false, error: "Chat steering is not available." }; + try { + await deps.cancelSteer({ sessionId }); + return { success: true, sessionId }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.handoffChat = tool({ + description: "Hand off a chat session to a different agent identity.", + inputSchema: z.object({ + sessionId: z.string().min(1), + targetIdentityKey: z.string().optional(), + reason: z.string().optional(), + }), + execute: async ({ sessionId, targetIdentityKey, reason }) => { + if (!deps.handoffChat) return { success: false, error: "Chat handoff is not available." }; + try { + return { success: true, ...(await deps.handoffChat({ sessionId, targetIdentityKey, reason })) }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.listSubagents = tool({ + description: "List sub-agents spawned by a chat session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + }), + execute: async ({ sessionId }) => { + if (!deps.listSubagents) return { success: false, error: "Sub-agent listing is not available." }; + try { + const subagents = await deps.listSubagents({ sessionId }); + return { success: true, count: subagents.length, subagents }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.approveToolUse = tool({ + description: "Approve or deny a pending tool use in a chat session.", + inputSchema: z.object({ + sessionId: z.string().min(1), + toolUseId: z.string().min(1), + decision: z.enum(["accept", "accept_for_session", "decline", "cancel"]), + }), + execute: async ({ sessionId, toolUseId, decision }) => { + if (!deps.approveToolUse) return { success: false, error: "Tool use approval is not available." }; + try { + await deps.approveToolUse({ sessionId, toolUseId, decision }); + return { success: true, sessionId, toolUseId, decision }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Unified Event Feed + // --------------------------------------------------------------------------- + + type RecentEvent = { + type: string; + timestamp: string; + summary: string; + ids: Record; + }; + + tools.getRecentEvents = tool({ + description: + "Surface a unified feed of recent project events: CTO session completions, worker activity, " + + "test completions/failures, PR review activity, mission state transitions, and chat session events. " + + "Use this to stay aware of what happened while you were idle or to brief the user on recent activity.", + inputSchema: z.object({ + since: z + .string() + .optional() + .describe("ISO 8601 timestamp. Only events after this time are returned. Defaults to 24 hours ago."), + limit: z + .number() + .int() + .positive() + .max(200) + .optional() + .default(50) + .describe("Maximum number of events to return."), + }), + execute: async ({ since, limit }) => { + const sinceEpoch = since + ? parseIsoToEpoch(since) + : Date.now() - 24 * 60 * 60 * 1000; + const safeLimit = Math.max(1, Math.min(200, limit)); + const events: RecentEvent[] = []; + + const afterCutoff = (ts: string | null | undefined): boolean => { + if (!ts) return false; + const epoch = parseIsoToEpoch(ts); + return Number.isFinite(epoch) && epoch >= sinceEpoch; + }; + + // 1. CTO session logs + if (deps.ctoStateService) { + try { + const logs = deps.ctoStateService.getSessionLogs(200); + for (const log of logs) { + if (!afterCutoff(log.createdAt)) continue; + events.push({ + type: "cto_session", + timestamp: log.createdAt, + summary: log.summary, + ids: { sessionId: log.sessionId, logId: log.id }, + }); + } + } catch { + // CTO state service may not be fully initialized + } + } + + // 2. Subordinate (worker) activity from CTO state + if (deps.ctoStateService) { + try { + const activities = deps.ctoStateService.getSubordinateActivityLogs(200); + for (const activity of activities) { + if (!afterCutoff(activity.createdAt)) continue; + events.push({ + type: `worker_${activity.activityType}`, + timestamp: activity.createdAt, + summary: `[${activity.agentName}] ${activity.summary}`, + ids: { + agentId: activity.agentId, + sessionId: activity.sessionId ?? null, + taskKey: activity.taskKey ?? null, + issueKey: activity.issueKey ?? null, + }, + }); + } + } catch { + // ignore + } + } + + // 3. Worker runs from heartbeat service + if (deps.workerHeartbeatService) { + try { + const runs = deps.workerHeartbeatService.listRuns({ limit: 100 }); + for (const run of runs) { + const ts = run.finishedAt ?? run.startedAt ?? run.createdAt; + if (!afterCutoff(ts)) continue; + events.push({ + type: "worker_run", + timestamp: ts, + summary: `Worker run ${run.status}${run.taskKey ? ` (task: ${run.taskKey})` : ""}${run.errorMessage ? ` — ${run.errorMessage}` : ""}`, + ids: { + runId: run.id, + agentId: run.agentId, + taskKey: run.taskKey ?? null, + issueKey: run.issueKey ?? null, + }, + }); + } + } catch { + // ignore + } + } + + // 4. Test runs + if (deps.testService) { + try { + const runs = deps.testService.listRuns({ limit: 100 }); + for (const run of runs) { + const ts = run.endedAt ?? run.startedAt; + if (!afterCutoff(ts)) continue; + const duration = run.durationMs != null ? ` (${Math.round(run.durationMs / 1000)}s)` : ""; + events.push({ + type: "test_run", + timestamp: ts, + summary: `${run.suiteName}: ${run.status}${duration}`, + ids: { + runId: run.id, + suiteId: run.suiteId, + laneId: run.laneId, + }, + }); + } + } catch { + // ignore + } + } + + // 5. Mission state transitions + if (deps.missionService) { + try { + const missions = deps.missionService.list({ limit: 50 }); + for (const mission of missions) { + const ts = mission.completedAt ?? mission.updatedAt; + if (!afterCutoff(ts)) continue; + events.push({ + type: "mission_update", + timestamp: ts, + summary: `Mission "${mission.title}": ${mission.status}${mission.outcomeSummary ? ` — ${mission.outcomeSummary}` : ""}`, + ids: { + missionId: mission.id, + laneId: mission.laneId, + }, + }); + } + } catch { + // ignore + } + } + + // 6. PR review activity (recent events from all tracked PRs) + if (deps.prService) { + try { + const prs = deps.prService.listAll(); + // Fetch activity for the most recently updated PRs to avoid excessive API calls + const recentPrs = prs + .filter((pr) => afterCutoff(pr.updatedAt)) + .slice(0, 5); + for (const pr of recentPrs) { + try { + const activity = await deps.prService.getActivity(pr.id); + for (const ev of activity) { + if (!afterCutoff(ev.timestamp)) continue; + events.push({ + type: `pr_${ev.type}`, + timestamp: ev.timestamp, + summary: `PR #${pr.githubPrNumber} "${pr.title}": ${ev.body ?? ev.type}${ev.author ? ` (${ev.author})` : ""}`, + ids: { + prId: pr.id, + prNumber: String(pr.githubPrNumber), + laneId: pr.laneId, + }, + }); + } + } catch { + // individual PR activity fetch may fail + } + } + } catch { + // ignore + } + } + + // 7. Chat session events + try { + const chats = await deps.listChats(undefined, { includeIdentity: false, includeAutomation: true }); + for (const chat of chats) { + const ts = chat.endedAt ?? chat.lastActivityAt; + if (!afterCutoff(ts)) continue; + events.push({ + type: "chat_session", + timestamp: ts, + summary: `Chat "${chat.title ?? chat.sessionId}": ${chat.status}${chat.summary ? ` — ${chat.summary}` : ""}`, + ids: { + sessionId: chat.sessionId, + laneId: chat.laneId, + }, + }); + } + } catch { + // ignore + } + + // Sort descending by timestamp and apply limit + events.sort((a, b) => parseIsoToEpoch(b.timestamp) - parseIsoToEpoch(a.timestamp)); + const sliced = events.slice(0, safeLimit); + + return { + success: true, + count: sliced.length, + totalBeforeLimit: events.length, + since: since ?? new Date(sinceEpoch).toISOString(), + events: sliced, + }; + }, + }); + + // --------------------------------------------------------------------------- + // Project Health Dashboard + // --------------------------------------------------------------------------- + + tools.getProjectHealthSummary = tool({ + description: + "Aggregate project health into a single snapshot: mission counts by status, worker utilization, test pass rates, PR status distribution, active lanes, and weekly budget burn.", + inputSchema: z.object({ + testRunLimit: z + .number() + .int() + .positive() + .max(200) + .optional() + .default(50), + }), + execute: async ({ testRunLimit }) => { + let missions: { + byStatus: Record; + total: number; + activeCount: number; + openInterventions: number; + weekly: { missions: number; successRate: number; avgDurationMs: number; totalCostUsd: number } | null; + } | null = null; + if (deps.missionService) { + const all = deps.missionService.list({ includeArchived: false, limit: 500 }); + const byStatus: Record = {}; + let openInterventions = 0; + for (const m of all) { + byStatus[m.status] = (byStatus[m.status] ?? 0) + 1; + openInterventions += m.openInterventions; + } + const activeCount = all.filter( + (m) => m.status === "queued" || m.status === "planning" || m.status === "in_progress" || m.status === "intervention_required", + ).length; + let weekly: { missions: number; successRate: number; avgDurationMs: number; totalCostUsd: number } | null = null; + try { + const dashboard = (deps.missionService as any).getDashboard?.(); + if (dashboard?.weekly) weekly = dashboard.weekly; + } catch { /* non-fatal */ } + missions = { byStatus, total: all.length, activeCount, openInterventions, weekly }; + } + + let workers: { + total: number; + byStatus: Record; + totalBudgetMonthlyCents: number; + totalSpentMonthlyCents: number; + budgetUtilizationPct: number; + } | null = null; + if (deps.workerAgentService) { + const agents = deps.workerAgentService.listAgents(); + const byStatus: Record = {}; + let totalBudget = 0; + let totalSpent = 0; + for (const a of agents) { + byStatus[a.status] = (byStatus[a.status] ?? 0) + 1; + totalBudget += (a as any).budgetMonthlyCents ?? 0; + totalSpent += (a as any).spentMonthlyCents ?? 0; + } + workers = { + total: agents.length, + byStatus, + totalBudgetMonthlyCents: totalBudget, + totalSpentMonthlyCents: totalSpent, + budgetUtilizationPct: totalBudget > 0 ? Math.round((totalSpent / totalBudget) * 10000) / 100 : 0, + }; + } + + let tests: { + suiteCount: number; + recentRuns: number; + byStatus: Record; + passRate: number; + } | null = null; + if (deps.testService) { + const suites = deps.testService.listSuites(); + const runs = deps.testService.listRuns({ limit: testRunLimit }); + const byStatus: Record = {}; + for (const r of runs) { + byStatus[r.status] = (byStatus[r.status] ?? 0) + 1; + } + const terminal = runs.filter((r) => r.status !== "running"); + const passed = terminal.filter((r) => r.status === "passed").length; + tests = { + suiteCount: suites.length, + recentRuns: runs.length, + byStatus, + passRate: terminal.length > 0 ? Math.round((passed / terminal.length) * 10000) / 100 : 0, + }; + } + + let prs: { + total: number; + byState: Record; + byChecksStatus: Record; + byReviewStatus: Record; + } | null = null; + if (deps.prService) { + const all = (deps.prService as any).listAll?.() ?? []; + const byState: Record = {}; + const byChecksStatus: Record = {}; + const byReviewStatus: Record = {}; + for (const pr of all) { + byState[pr.state] = (byState[pr.state] ?? 0) + 1; + if (pr.checksStatus) byChecksStatus[pr.checksStatus] = (byChecksStatus[pr.checksStatus] ?? 0) + 1; + if (pr.reviewStatus) byReviewStatus[pr.reviewStatus] = (byReviewStatus[pr.reviewStatus] ?? 0) + 1; + } + prs = { total: all.length, byState, byChecksStatus, byReviewStatus }; + } + + const allLanes = await deps.laneService.list({ includeArchived: false }); + const activeLanes = allLanes.filter((l) => l.laneType !== "primary"); + const lanes = { + total: allLanes.length, + active: activeLanes.length, + withMission: activeLanes.filter((l) => (l as any).missionId).length, + }; + + return { + success: true, + generatedAt: nowIso(), + missions, + workers, + tests, + prs, + lanes, + }; + }, + }); + + // --------------------------------------------------------------------------- + // Computer Use Artifact Oversight + // --------------------------------------------------------------------------- + + tools.listComputerUseArtifacts = tool({ + description: + "List computer-use artifacts (screenshots, videos, browser traces, console logs) across the project.", + inputSchema: z.object({ + kind: z + .enum(["screenshot", "video_recording", "browser_trace", "browser_verification", "console_logs"]) + .optional(), + ownerKind: z + .enum(["lane", "mission", "orchestrator_run", "orchestrator_step", "orchestrator_attempt", "chat_session", "automation_run", "github_pr", "linear_issue"]) + .optional(), + ownerId: z.string().optional(), + limit: z.number().int().min(1).max(200).optional().default(50), + }), + execute: async ({ kind, ownerKind, ownerId, limit }) => { + if (!deps.computerUseArtifactBrokerService) { + return { success: false, error: "Computer-use artifact broker is not available." }; + } + try { + const artifacts = deps.computerUseArtifactBrokerService.listArtifacts({ + kind: kind ?? null, + ownerKind: ownerKind ?? undefined, + ownerId: ownerId ?? undefined, + limit, + }); + return { + success: true, + count: artifacts.length, + artifacts: artifacts.map((a: any) => ({ + id: a.id, + kind: a.kind, + title: a.title, + description: a.description, + uri: a.uri, + reviewState: a.reviewState, + workflowState: a.workflowState, + reviewNote: a.reviewNote, + createdAt: a.createdAt, + owners: (a.links ?? []).map((l: any) => ({ kind: l.ownerKind, id: l.ownerId, relation: l.relation })), + })), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.getArtifactPreview = tool({ + description: "Get full details for a specific computer-use artifact by ID.", + inputSchema: z.object({ + artifactId: z.string().min(1), + }), + execute: async ({ artifactId }) => { + if (!deps.computerUseArtifactBrokerService) { + return { success: false, error: "Computer-use artifact broker is not available." }; + } + try { + const results = deps.computerUseArtifactBrokerService.listArtifacts({ artifactId }); + if (results.length === 0) return { success: false, error: `Artifact not found: ${artifactId}` }; + const a = results[0] as any; + return { + success: true, + artifact: { + id: a.id, kind: a.kind, title: a.title, description: a.description, + uri: a.uri, mimeType: a.mimeType, reviewState: a.reviewState, + workflowState: a.workflowState, reviewNote: a.reviewNote, + metadata: a.metadata, createdAt: a.createdAt, + links: (a.links ?? []).map((l: any) => ({ + ownerKind: l.ownerKind, ownerId: l.ownerId, relation: l.relation, + })), + }, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.reviewArtifact = tool({ + description: "Mark a computer-use artifact as approved, rejected, or needing more evidence.", + inputSchema: z.object({ + artifactId: z.string().min(1), + reviewState: z.enum(["pending", "accepted", "needs_more", "dismissed"]), + workflowState: z.enum(["evidence_only", "promoted", "published", "dismissed"]).optional(), + reviewNote: z.string().max(2000).optional(), + }), + execute: async ({ artifactId, reviewState, workflowState, reviewNote }) => { + if (!deps.computerUseArtifactBrokerService) { + return { success: false, error: "Computer-use artifact broker is not available." }; + } + try { + const updated = deps.computerUseArtifactBrokerService.updateArtifactReview({ + artifactId, + reviewState, + workflowState: workflowState ?? null, + reviewNote: reviewNote ?? null, + }); + return { + success: true, + artifact: { id: updated.id, kind: updated.kind, reviewState: updated.reviewState, workflowState: updated.workflowState, reviewNote: updated.reviewNote }, + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + // --------------------------------------------------------------------------- + // Budget / Cost Visibility + // --------------------------------------------------------------------------- + + tools.getProjectBudgetStatus = tool({ + description: "Get project-wide budget status: total spend, budget remaining, per-worker summaries, and optional per-mission deep dive.", + inputSchema: z.object({ + monthKey: z.string().optional().describe("YYYY-MM format. Defaults to current month."), + missionId: z.string().optional().describe("Include detailed budget for this mission."), + }), + execute: async ({ monthKey, missionId }) => { + if (!deps.workerBudgetService) return { success: false, error: "Worker budget service is not available." }; + try { + const snapshot = deps.workerBudgetService.getBudgetSnapshot({ monthKey: monthKey?.trim() || undefined }); + let missionBudget: any = null; + if (missionId?.trim() && deps.missionBudgetService) { + try { + missionBudget = await deps.missionBudgetService.getMissionBudgetStatus({ missionId: missionId.trim() }); + } catch { /* non-fatal */ } + } + return { + success: true, + monthKey: snapshot.monthKey, + computedAt: snapshot.computedAt, + company: { + budgetMonthlyCents: snapshot.companyBudgetMonthlyCents, + spentMonthlyCents: snapshot.companySpentMonthlyCents, + remainingCents: snapshot.companyRemainingCents, + }, + workerCount: snapshot.workers.length, + workerSummaries: snapshot.workers.map((w: any) => ({ + agentId: w.agentId, name: w.name, budgetMonthlyCents: w.budgetMonthlyCents, + spentMonthlyCents: w.spentMonthlyCents, remainingCents: w.remainingCents, status: w.status, + })), + ...(missionBudget ? { missionBudget } : {}), + }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + + tools.getWorkerCostBreakdown = tool({ + description: "Get per-worker monthly spend breakdown with token usage and model detail.", + inputSchema: z.object({ + agentId: z.string().optional(), + monthKey: z.string().optional(), + limit: z.number().int().positive().optional().default(100), + }), + execute: async ({ agentId, monthKey, limit }) => { + if (!deps.workerBudgetService) return { success: false, error: "Worker budget service is not available." }; + try { + const snapshot = deps.workerBudgetService.getBudgetSnapshot({ monthKey: monthKey?.trim() || undefined }); + const targetWorkers = agentId?.trim() + ? snapshot.workers.filter((w: any) => w.agentId === agentId.trim()) + : snapshot.workers; + if (agentId?.trim() && targetWorkers.length === 0) { + return { success: false, error: `Worker not found: ${agentId}` }; + } + const breakdowns = targetWorkers.map((worker: any) => { + const events = deps.workerBudgetService!.listCostEvents({ + agentId: worker.agentId, + monthKey: monthKey?.trim() || undefined, + limit, + }); + const modelMap = new Map(); + for (const event of events) { + const key = `${event.provider}::${event.modelId ?? "unknown"}`; + const existing = modelMap.get(key); + if (existing) { + existing.totalCostCents += event.costCents; + existing.totalInputTokens += event.inputTokens ?? 0; + existing.totalOutputTokens += event.outputTokens ?? 0; + existing.eventCount += 1; + } else { + modelMap.set(key, { + provider: event.provider, modelId: event.modelId ?? "unknown", + totalCostCents: event.costCents, totalInputTokens: event.inputTokens ?? 0, + totalOutputTokens: event.outputTokens ?? 0, eventCount: 1, + }); + } + } + return { + agentId: worker.agentId, name: worker.name, status: worker.status, + budgetMonthlyCents: worker.budgetMonthlyCents, spentMonthlyCents: worker.spentMonthlyCents, + remainingCents: worker.remainingCents, + modelBreakdown: Array.from(modelMap.values()), + }; + }); + return { success: true, monthKey: snapshot.monthKey, workerCount: breakdowns.length, breakdowns }; + } catch (error) { + return { success: false, error: getErrorMessage(error) }; + } + }, + }); + return tools; } diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 905f67047..7f038978f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -15,6 +15,7 @@ const mockState = vi.hoisted(() => ({ codexTurnCounter: 0, cursorSessionCounter: 0, codexRequestPayloads: [] as Array>, + codexCollaborationModes: [{ mode: "default" }, { mode: "plan" }] as Array | string>, codexLineHandler: null as ((line: string) => void) | null, cursorAcquireCalls: [] as Array>, cursorNewSessionCalls: [] as Array>, @@ -57,6 +58,10 @@ vi.mock("node:child_process", () => ({ } else if (payload.method === "turn/start" || payload.method === "review/start") { mockState.codexTurnCounter += 1; result = { turn: { id: `turn-${mockState.codexTurnCounter}` } }; + } else if (payload.method === "collaborationMode/list") { + result = { + collaborationModes: mockState.codexCollaborationModes, + }; } else if (payload.method === "skills/list") { result = { skills: [] }; } else if (payload.method === "account/rateLimits/read") { @@ -584,6 +589,18 @@ async function waitForEvent( throw new Error("Timed out waiting for agent chat event."); } +function expectResolvedMcpLaunchesToUseStandardProxyFlow(): void { + const calls = vi.mocked(resolveAdeMcpServerLaunch).mock.calls; + expect(calls.length).toBeGreaterThan(0); + for (const [args] of calls) { + // Regression guard: packaged chat surfaces must not force the direct + // headless MCP path. A previous refactor set preferBundledProxy=false, + // which bypassed the working ADE proxy path and broke Claude/Codex chat + // MCP initialization before the first turn could start. + expect((args as { preferBundledProxy?: boolean }).preferBundledProxy).toBeUndefined(); + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -599,6 +616,7 @@ beforeEach(() => { mockState.codexTurnCounter = 0; mockState.cursorSessionCounter = 0; mockState.codexRequestPayloads = []; + mockState.codexCollaborationModes = [{ mode: "default" }, { mode: "plan" }]; mockState.codexLineHandler = null; mockState.cursorAcquireCalls = []; mockState.cursorNewSessionCalls = []; @@ -806,6 +824,7 @@ describe("createAgentChatService", () => { it("pre-approves ADE MCP tools for Claude SDK sessions", async () => { vi.mocked(providerResolver.normalizeCliMcpServers).mockImplementation((_provider, servers) => servers ?? {}); + vi.mocked(resolveAdeMcpServerLaunch).mockClear(); vi.mocked(unstable_v2_createSession).mockReturnValue({ send: vi.fn(), stream: vi.fn(async function* () { @@ -832,6 +851,36 @@ describe("createAgentChatService", () => { } | undefined; expect(opts?.mcpServers).toHaveProperty("ade"); expect(opts?.allowedTools).toContain("mcp__ade__*"); + // This explicitly protects the Claude chat surface, which shares the + // same MCP launch helper as the other chat providers. + expectResolvedMcpLaunchesToUseStandardProxyFlow(); + }); + + it("requests markdown previews for Claude AskUserQuestion by default", async () => { + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send: vi.fn(), + stream: vi.fn(async function* () { + return; + }), + close: vi.fn(), + sessionId: "sdk-session-ask-user-preview", + } as any); + + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await vi.waitFor(() => { + expect(unstable_v2_createSession).toHaveBeenCalled(); + }); + + const opts = vi.mocked(unstable_v2_createSession).mock.calls[0]?.[0] as { + toolConfig?: { askUserQuestion?: { previewFormat?: string } }; + } | undefined; + expect(opts?.toolConfig?.askUserQuestion?.previewFormat).toBe("markdown"); }); it("attaches ADE MCP servers through the Claude V2 query controls", async () => { @@ -1391,6 +1440,10 @@ describe("createAgentChatService", () => { expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); }); + // Codex app-server was the first place this surfaced in production, so + // keep a dedicated assertion on the actual Codex chat path too. + expectResolvedMcpLaunchesToUseStandardProxyFlow(); + const workspaceRoots = vi.mocked(resolveAdeMcpServerLaunch).mock.calls .map(([args]) => (args as { workspaceRoot?: string }).workspaceRoot) .filter((value): value is string => typeof value === "string"); @@ -1422,6 +1475,7 @@ describe("createAgentChatService", () => { vi.mocked(createUniversalToolSet).mockClear(); vi.mocked(createWorkflowTools).mockClear(); vi.mocked(buildCodingAgentSystemPrompt).mockClear(); + vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const selectedLaneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(selectedLaneRootPath, { recursive: true }); @@ -1447,6 +1501,12 @@ describe("createAgentChatService", () => { expect(vi.mocked(buildCodingAgentSystemPrompt)).toHaveBeenCalledWith( expect.objectContaining({ cwd: selectedLaneRoot }), ); + await vi.waitFor(() => { + expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); + }); + // Unified/API-backed chats also inject ADE MCP through the same launch + // resolver, so guard them here as well. + expectResolvedMcpLaunchesToUseStandardProxyFlow(); const firstMessages = Array.isArray(streamCalls[0]?.messages) ? (streamCalls[0]!.messages as Array<{ role: string; content: unknown }>) @@ -1663,7 +1723,7 @@ describe("createAgentChatService", () => { expect(session.permissionMode).toBe("plan"); }); - it("does not reuse a foreign-lane identity session and retires it during migration", async () => { + it("does not reuse a foreign-lane identity session or auto-close it during migration", async () => { const { service, sessionService } = createService(); const legacy = await service.createSession({ @@ -1681,7 +1741,7 @@ describe("createAgentChatService", () => { expect(canonical.id).not.toBe(legacy.id); expect(canonical.laneId).toBe("lane-1"); - expect(sessionService.get(legacy.id)?.status).toBe("ended"); + expect(sessionService.get(legacy.id)?.status).not.toBe("ended"); const reused = await service.ensureIdentitySession({ identityKey: "cto", @@ -3345,6 +3405,114 @@ describe("createAgentChatService", () => { expect(session.interactionMode).toBe("plan"); expect(session.claudePermissionMode).toBe("default"); }); + + it("sends Codex plan collaboration mode on turn start for plan sessions", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + expect(session.permissionMode).toBe("plan"); + + await service.sendMessage({ + sessionId: session.id, + text: "Ask one planning question before coding.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "collaborationMode/list")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const params = turnStartRequest?.params as { collaborationMode?: Record } | undefined; + const collaborationMode = params?.collaborationMode as + | { mode?: unknown; settings?: { model?: unknown; reasoning_effort?: unknown; developer_instructions?: unknown } } + | undefined; + + expect(collaborationMode?.mode).toBe("plan"); + expect(collaborationMode?.settings?.model).toBe("gpt-5.4"); + expect(collaborationMode?.settings?.reasoning_effort).toBe("medium"); + expect(collaborationMode?.settings?.developer_instructions).toBeNull(); + }); + + it("sends Codex default collaboration mode on turn start outside plan mode", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Inspect the repo.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const params = turnStartRequest?.params as { collaborationMode?: Record } | undefined; + const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined; + + expect(collaborationMode?.mode).toBe("default"); + }); + + it("does not auto-upgrade default Codex chats into plan mode", async () => { + mockState.codexCollaborationModes = [{ mode: "plan" }]; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Inspect the repo.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const params = turnStartRequest?.params as { collaborationMode?: Record } | undefined; + expect(params?.collaborationMode).toBeUndefined(); + }); + + it("falls back to default collaboration mode when plan is not advertised", async () => { + mockState.codexCollaborationModes = [{ mode: "default" }]; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Ask one planning question before coding.", + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "collaborationMode/list")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const params = turnStartRequest?.params as { collaborationMode?: Record } | undefined; + const collaborationMode = params?.collaborationMode as { mode?: unknown } | undefined; + + expect(collaborationMode?.mode).toBe("default"); + }); }); // -------------------------------------------------------------------------- @@ -3374,6 +3542,227 @@ describe("createAgentChatService", () => { service.resumeSession({ sessionId: "unknown-session-id" }), ).rejects.toThrow(/not found/i); }); + + it("preserves Claude V2 session continuity after an idle timeout", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let primaryStreamCall = 0; + let primaryClosed = false; + const primarySend = vi.fn().mockResolvedValue(undefined); + const resumedSend = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + + const primarySession = { + send: primarySend, + stream: vi.fn(() => (async function* () { + primaryStreamCall += 1; + if (primaryStreamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "assistant", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "Partial answer" }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + + while (!primaryClosed) { + await new Promise((resolve) => setTimeout(resolve, 1_000)); + } + + throw new Error("aborted by user"); + })()), + close: vi.fn(() => { + primaryClosed = true; + }), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + const resumedSession = { + send: resumedSend, + stream: vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-1", + slash_commands: [], + }; + yield { + type: "assistant", + session_id: "sdk-session-1", + message: { + content: [{ type: "text", text: "You were asking about the new chat buttons." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-1", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(primarySession as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(resumedSession as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "Add the new chat button", + timeoutMs: 120_000, + }); + await vi.advanceTimersByTimeAsync(76_000); + await firstTurn; + + const persistedAfterTimeout = readPersistedChatState(session.id); + expect(persistedAfterTimeout.sdkSessionId).toBe("sdk-session-1"); + expect(events).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "error", + message: expect.stringContaining("chat stayed open so you can retry"), + }), + }), + expect.objectContaining({ + event: expect.objectContaining({ + type: "status", + turnStatus: "failed", + }), + }), + ]), + ); + + events.length = 0; + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "what happened?", + timeoutMs: 15_000, + }); + + expect(unstable_v2_resumeSession).toHaveBeenCalledWith( + "sdk-session-1", + expect.objectContaining({ model: "sonnet" }), + ); + expect(resumedSend).toHaveBeenCalledTimes(1); + expect(followUp.outputText).toContain("new chat buttons"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not abort Claude turns solely because they run longer than five minutes", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const sessionHandle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-long-running", + slash_commands: [], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + for (let index = 0; index < 6; index += 1) { + yield { + type: "assistant", + session_id: "sdk-session-long-running", + message: { + content: [{ type: "text", text: `Chunk ${index + 1}. ` }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + await new Promise((resolve) => setTimeout(resolve, 60_000)); + } + + yield { + type: "assistant", + session_id: "sdk-session-long-running", + message: { + content: [{ type: "text", text: "Finished after a long run." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()), + close: vi.fn(), + sessionId: "sdk-session-long-running", + setPermissionMode, + }; + + vi.mocked(unstable_v2_createSession).mockReturnValue(sessionHandle as any); + vi.mocked(unstable_v2_resumeSession).mockReturnValue(sessionHandle as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const turn = service.runSessionTurn({ + sessionId: session.id, + text: "Keep working until the implementation is done.", + timeoutMs: 500_000, + }); + + await vi.advanceTimersByTimeAsync(361_000); + const result = await turn; + + expect(result.outputText).toContain("Finished after a long run."); + expect(events.find((event) => event.event.type === "status" && event.event.turnStatus === "failed")).toBeUndefined(); + expect(events.find((event) => event.event.type === "status" && event.event.turnStatus === "interrupted")).toBeUndefined(); + } finally { + vi.useRealTimers(); + } + }); }); // -------------------------------------------------------------------------- @@ -4246,8 +4635,616 @@ describe("createAgentChatService", () => { await sendPromise; }); + it("emits completed Claude tool_result rows when tool_use_summary arrives", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-tool-summary", + slash_commands: [], + }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-use-1", + name: "Read", + input: { file_path: "apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx" }, + }, + }, + }; + yield { + type: "tool_use_summary", + summary: "Checked the shared chat renderer", + preceding_tool_use_ids: ["tool-use-1"], + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-tool-summary", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the shared chat renderer.", + }); + + const completedToolResults = events.filter((event) => + event.event.type === "tool_result" + && event.event.itemId === "tool-use-1" + && event.event.status === "completed" + ); + + expect(completedToolResults).toHaveLength(1); + expect(completedToolResults[0]!.event.type).toBe("tool_result"); + if (completedToolResults[0]!.event.type !== "tool_result") { + throw new Error("Expected tool_result"); + } + expect(completedToolResults[0]!.event.result).toMatchObject({ + synthetic: true, + source: "claude_tool_use_summary", + summary: "Checked the shared chat renderer", + }); + }); + + it("emits completed Claude tool_result rows for open tools when the turn ends without a tool summary", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-tool-fallback", + slash_commands: [], + }; + return; + } + + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-use-2", + name: "Read", + input: { file_path: "apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx" }, + }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-tool-fallback", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Inspect the grouped work log renderer.", + }); + + const completedToolResults = events.filter((event) => + event.event.type === "tool_result" + && event.event.itemId === "tool-use-2" + && event.event.status === "completed" + ); + + expect(completedToolResults).toHaveLength(1); + expect(completedToolResults[0]!.event.type).toBe("tool_result"); + if (completedToolResults[0]!.event.type !== "tool_result") { + throw new Error("Expected tool_result"); + } + expect(completedToolResults[0]!.event.result).toMatchObject({ + synthetic: true, + source: "claude_turn_finalization", + finalTurnStatus: "completed", + }); + }); + + it("bridges Claude AskUserQuestion through ADE's question UI", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let permissionResult: Record | null = null; + + const askInput = { + questions: [ + { + question: "What should we do about the two task list views?", + header: "Task views", + options: [ + { + label: "Remove the TurnSummaryCard tasks", + description: "Keep only the inline task list.", + preview: "
Inline only

Compact stream, no bottom summary card.

", + }, + { + label: "Keep both, improve summary", + description: "Keep both task views, but make the summary less intrusive.", + preview: "
Hybrid

Inline progress plus a compact summary card.

", + }, + ], + multiSelect: false, + }, + { + question: "Should the inline task list pin while tasks are active?", + header: "Inline pinning", + options: [ + { label: "Yes, pin while active" }, + { label: "No, let it scroll" }, + ], + multiSelect: false, + }, + ], + }; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-session-ask-user", + slash_commands: [], + }; + return; + } + + const sessionOpts = vi.mocked(unstable_v2_createSession).mock.calls.at(-1)?.[0] as any; + permissionResult = await sessionOpts.canUseTool("AskUserQuestion", askInput, { + signal: new AbortController().signal, + toolUseID: "tool-ask-user-1", + }); + + yield { + type: "assistant", + message: { + content: [{ type: "text", text: "Thanks, I can continue now." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + + vi.mocked(unstable_v2_createSession).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-ask-user", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-4-6", + modelId: "anthropic/claude-sonnet-4-6", + permissionMode: "plan", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Figure out the task list UX and ask any clarifying questions you need.", + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && typeof (event.event.detail as { request?: { providerMetadata?: { tool?: string } } } | undefined)?.request?.providerMetadata?.tool === "string" + && ((event.event.detail as { request?: { providerMetadata?: { tool?: string } } }).request?.providerMetadata?.tool === "AskUserQuestion"), + ); + + const request = (approvalEvent.event.detail as { + request: { + kind: string; + questions: Array<{ + id: string; + question: string; + options?: Array<{ preview?: string; previewFormat?: string }>; + }>; + }; + }).request; + expect(request.kind).toBe("structured_question"); + expect(request.questions.map((question) => question.question)).toEqual([ + "What should we do about the two task list views?", + "Should the inline task list pin while tasks are active?", + ]); + expect(request.questions[0]?.options?.[0]).toMatchObject({ + preview: "
Inline only

Compact stream, no bottom summary card.

", + previewFormat: "markdown", + }); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + answers: { + question_1: "Keep both, improve summary", + question_2: "Yes, pin while active", + }, + }); + + await sendPromise; + + expect(permissionResult).toMatchObject({ + behavior: "allow", + updatedInput: { + answers: { + "What should we do about the two task list views?": "Keep both, improve summary", + "Should the inline task list pin while tasks are active?": "Yes, pin while active", + }, + }, + }); + }); + + it("keeps standalone ask_user declines explicit without emitting a fake cleanup tool_result", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const requestPromise = service.requestChatInput({ + chatSessionId: session.id, + title: "Planning question", + body: "Which part of the planning UI should we test first?", + questions: [{ + id: "answer", + header: "Question 1", + question: "Which part of the planning UI should we test first?", + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + allowsFreeform: true, + }], + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => { + const detail = event.event.type === "approval_request" + ? (event.event.detail as { request?: { title?: string } } | undefined) + : undefined; + return event.event.type === "approval_request" && detail?.request?.title === "Planning question"; + }, + ); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "decline", + }); + + const result = await requestPromise; + expect(result.decision).toBe("decline"); + expect(events.filter((event) => event.event.type === "tool_result")).toHaveLength(0); + }); + + it("maps freeform replies to the single pending question when only one answer is needed", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const requestPromise = service.requestChatInput({ + chatSessionId: session.id, + title: "Single question", + body: "Which area should we test first?", + questions: [{ + id: "answer", + header: "Question 1", + question: "Which area should we test first?", + allowsFreeform: true, + }], + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => { + const detail = event.event.type === "approval_request" + ? (event.event.detail as { request?: { title?: string } } | undefined) + : undefined; + return event.event.type === "approval_request" && detail?.request?.title === "Single question"; + }, + ); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + responseText: "Question flow", + }); + + await expect(requestPromise).resolves.toMatchObject({ + decision: "accept", + answers: { answer: ["Question flow"] }, + responseText: "Question flow", + }); + }); + + it("does not fan a single freeform reply out across multiple structured questions", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + const requestPromise = service.requestChatInput({ + chatSessionId: session.id, + title: "Multiple questions", + body: "Tell me which plan we should use and whether to pin tasks.", + questions: [ + { + id: "plan_focus", + header: "Plan focus", + question: "What kind of planning scenario should I use?", + allowsFreeform: true, + }, + { + id: "task_pinning", + header: "Task pinning", + question: "Should the inline task list stay pinned?", + allowsFreeform: true, + }, + ], + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => { + const detail = event.event.type === "approval_request" + ? (event.event.detail as { request?: { title?: string } } | undefined) + : undefined; + return event.event.type === "approval_request" && detail?.request?.title === "Multiple questions"; + }, + ); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + responseText: "Start with the UI planning case.", + }); + + await expect(requestPromise).resolves.toMatchObject({ + decision: "accept", + answers: { response: ["Start with the UI planning case."] }, + responseText: "Start with the UI planning case.", + }); + }); + + it("responds to native Codex requestUserInput declines with empty answers instead of interrupting the turn", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + codexApprovalPolicy: "untrusted", + codexSandbox: "read-only", + codexConfigSource: "flags", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Ask one planning question before coding.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "native-request-1", + method: "item/tool/requestUserInput", + params: { + itemId: "codex-question-1", + threadId: "thread-1", + turnId: "turn-1", + questions: [ + { + id: "plan_focus", + header: "Plan focus", + question: "What kind of planning scenario should I use?", + isOther: true, + options: [ + { label: "UI planning" }, + { label: "Bug fix planning" }, + ], + }, + ], + }, + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && event.event.itemId === "codex-question-1", + ); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "cancel", + }); + + expect( + mockState.codexRequestPayloads.some((payload) => payload.method === "turn/interrupt"), + ).toBe(false); + expect( + mockState.codexRequestPayloads.find((payload) => payload.id === "native-request-1"), + ).toMatchObject({ + jsonrpc: "2.0", + id: "native-request-1", + result: { + answers: {}, + }, + }); + }); + + it("responds to Codex MCP elicitations with action/content payloads", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Wait for a structured MCP question.", + }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "elicitation-1", + method: "mcpServer/elicitation/request", + params: { + serverName: "ade", + message: "Confirm whether we should continue.", + turnId: "turn-1", + requestedSchema: { + type: "object", + properties: { + confirmed: { + type: "boolean", + description: "Should ADE continue?", + }, + }, + }, + }, + }); + + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.event.type === "approval_request" + && ( + (event.event.detail as { request?: { title?: string } } | undefined)?.request?.title === "Question from ade" + ), + ); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + answers: { + confirmed: "true", + }, + }); + + const elicitationResponse = mockState.codexRequestPayloads.find((payload) => payload.id === "elicitation-1"); + expect(elicitationResponse?.result).toEqual({ + action: "accept", + content: { + confirmed: true, + }, + }); + }); + it("initializes the Cursor runtime before validating the first turn", async () => { const events: AgentChatEventEnvelope[] = []; + vi.mocked(resolveAdeMcpServerLaunch).mockClear(); const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), @@ -4276,6 +5273,13 @@ describe("createAgentChatService", () => { expect(vi.mocked(acquireCursorAcpConnection)).toHaveBeenCalledTimes(1); expect(mockState.cursorNewSessionCalls).toHaveLength(1); expect(mockState.cursorPromptCalls).toHaveLength(1); + await vi.waitFor(() => { + expect(vi.mocked(resolveAdeMcpServerLaunch)).toHaveBeenCalled(); + }); + // Cursor chat used the same shared MCP launch path, so we keep a separate + // assertion to ensure future chat refactors do not regress just one + // surface while leaving the others green. + expectResolvedMcpLaunchesToUseStandardProxyFlow(); expect( events.some((event) => event.event.type === "error" && event.event.message.includes("No runtime initialized")), ).toBe(false); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index bbe2bbe39..c18c3d12d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -134,7 +134,7 @@ import { buildCodexAppServerMcpConfigOverrides } from "../ai/codexAppServerConfi import { createUniversalToolSet, type PermissionMode } from "../ai/tools/universalTools"; import { createWorkflowTools } from "../ai/tools/workflowTools"; import { createLinearTools } from "../ai/tools/linearTools"; -import { createCtoOperatorTools } from "../ai/tools/ctoOperatorTools"; +import { createCtoOperatorTools, type CtoOperatorToolDeps } from "../ai/tools/ctoOperatorTools"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { resolveClaudeCliModel } from "../ai/claudeModelUtils"; import { @@ -262,6 +262,7 @@ type PendingCodexApproval = { kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval"; request?: PendingInputRequest; permissions?: Record | null; + questionResponseKind?: "native_request_user_input" | "mcp_elicitation"; }; type PendingClaudeApproval = { @@ -296,6 +297,9 @@ type CodexRuntime = { sendError: (id: string | number, message: string) => void; slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; rateLimits: { remaining: number | null; limit: number | null; resetAt: string | null } | null; + collaborationModes: Set | null; + collaborationModesReady: Promise | null; + planModeFallbackNotified: boolean; }; type ClaudeRuntime = { @@ -325,6 +329,12 @@ type ClaudeRuntime = { approvalOverrides: Set; /** Pending MCP elicitation resolvers keyed by elicitation_id. */ pendingElicitations: Map void>; + /** SDK tool_use IDs resolved by canUseTool (e.g. answered AskUserQuestion). */ + resolvedToolUseIds: Set; + /** Suspend the active-turn idle watchdog while ADE is waiting on human input. */ + pauseIdleWatchdog?: (() => void) | null; + /** Resume the active-turn idle watchdog after the blocking wait finishes. */ + resumeIdleWatchdog?: (() => void) | null; }; type PendingUnifiedApproval = { @@ -757,7 +767,7 @@ type SessionTurnCollector = { cacheCreationTokens?: number | null; }; lastError: string | null; - timeout: NodeJS.Timeout; + timeout: NodeJS.Timeout | null; }; type PreparedSendMessage = { @@ -816,7 +826,8 @@ const MAX_TRANSCRIPT_READ_CHARS = 40_000; const AUTO_TITLE_MAX_CHARS = 48; const REASONING_ACTIVITY_DETAIL = "Thinking through the answer"; const WORKING_ACTIVITY_DETAIL = "Preparing response"; -const TURN_TIMEOUT_MS = 300_000; // 5 minutes – overall turn-level timeout +const DEFAULT_RUN_SESSION_TURN_TIMEOUT_MS = 300_000; +const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; const CLAUDE_STREAM_IDLE_TIMEOUT_MS = 75_000; const AUTO_TITLE_SYSTEM_PROMPT = `You title software development chat sessions. Return only the title text. @@ -1824,6 +1835,147 @@ function resolveSessionCodexConfigSource( ?? "flags"; } +type CodexCollaborationModePayload = { + mode: "default" | "plan"; + settings: { + model: string; + reasoning_effort: string | null; + developer_instructions: null; + }; +}; + +function buildCodexCollaborationMode( + session: Pick< + AgentChatSession, + "provider" | "permissionMode" | "interactionMode" | "model" | "reasoningEffort" | "codexConfigSource" + >, + supportedModes: Set | null, +): CodexCollaborationModePayload | null { + if (session.provider !== "codex") return null; + if (resolveSessionCodexConfigSource(session) === "config-toml") return null; + const requestedMode = session.interactionMode === "plan" || session.permissionMode === "plan" + ? "plan" + : "default"; + const mode = (() => { + if (!supportedModes || supportedModes.size === 0) return requestedMode; + if (supportedModes.has(requestedMode)) return requestedMode; + if (requestedMode === "plan" && supportedModes.has("default")) return "default"; + return null; + })(); + if (!mode) return null; + return { + mode, + settings: { + model: session.model, + reasoning_effort: session.reasoningEffort ?? DEFAULT_REASONING_EFFORT, + developer_instructions: null, + }, + }; +} + +function resolveRequestedCodexCollaborationMode( + session: Pick< + AgentChatSession, + "provider" | "permissionMode" | "interactionMode" | "codexConfigSource" + >, +): "default" | "plan" | null { + if (session.provider !== "codex") return null; + if (resolveSessionCodexConfigSource(session) === "config-toml") return null; + return session.interactionMode === "plan" || session.permissionMode === "plan" + ? "plan" + : "default"; +} + +function coerceCodexMcpElicitationContent( + request: PendingInputRequest | undefined, + normalizedAnswers: Record, +): Record { + const content: Record = {}; + const providerMetadata = request?.providerMetadata && typeof request.providerMetadata === "object" + ? request.providerMetadata as Record + : null; + const requestedSchema = providerMetadata?.requestedSchema && typeof providerMetadata.requestedSchema === "object" + ? providerMetadata.requestedSchema as Record + : null; + const schemaProperties = requestedSchema?.properties && typeof requestedSchema.properties === "object" + ? requestedSchema.properties as Record + : null; + + for (const [questionId, values] of Object.entries(normalizedAnswers)) { + if (!values.length) continue; + const property = schemaProperties?.[questionId] && typeof schemaProperties[questionId] === "object" + ? schemaProperties[questionId] as Record + : null; + const propertyType = typeof property?.type === "string" ? property.type : null; + + if (propertyType === "array") { + content[questionId] = values; + continue; + } + + const [firstValue] = values; + if (!firstValue) continue; + + if (propertyType === "boolean") { + const normalized = firstValue.trim().toLowerCase(); + content[questionId] = normalized === "true" || normalized === "yes"; + continue; + } + + if (propertyType === "number" || propertyType === "integer") { + const parsed = Number(firstValue); + if (Number.isFinite(parsed)) { + content[questionId] = propertyType === "integer" ? Math.trunc(parsed) : parsed; + continue; + } + } + + content[questionId] = firstValue; + } + + return content; +} + +function parseCodexCollaborationModes(value: unknown): Set | null { + const normalized = new Set(); + const pushMode = (candidate: unknown): void => { + if (typeof candidate === "string") { + const trimmed = candidate.trim().toLowerCase(); + if (trimmed.length) normalized.add(trimmed); + return; + } + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) return; + const record = candidate as Record; + const nested = [record.mode, record.name, record.kind]; + for (const entry of nested) { + if (typeof entry === "string" && entry.trim().length) { + normalized.add(entry.trim().toLowerCase()); + return; + } + } + }; + + if (Array.isArray(value)) { + value.forEach(pushMode); + } else if (value && typeof value === "object") { + const record = value as Record; + const candidates = [ + record.collaborationModes, + record.collaboration_modes, + record.modes, + record.presets, + record.items, + ]; + for (const candidate of candidates) { + if (Array.isArray(candidate)) { + candidate.forEach(pushMode); + } + } + } + + return normalized.size > 0 ? normalized : null; +} + function resolveSessionUnifiedPermissionMode( session: Pick, fallback: AgentChatUnifiedPermissionMode, @@ -2120,6 +2272,11 @@ export function createAgentChatService(args: { getTestService?: () => { listSuites: () => any[]; run: (args: any) => Promise; stop: (args: any) => void; listRuns: (args?: any) => any[]; getLogTail: (args: any) => string } | null; ptyService?: { create: (args: any) => Promise<{ ptyId: string; sessionId: string }> } | null; getAutomationService?: () => { list: () => any[]; triggerManually: (args: any) => Promise; listRuns: (args?: any) => any[] } | null; + getGitService?: () => CtoOperatorToolDeps["gitService"]; + conflictService?: CtoOperatorToolDeps["conflictService"]; + contextDocService?: CtoOperatorToolDeps["contextDocService"]; + getWorkerBudgetService?: () => CtoOperatorToolDeps["workerBudgetService"]; + getMissionBudgetService?: () => CtoOperatorToolDeps["missionBudgetService"]; computerUseArtifactBrokerService?: ComputerUseArtifactBrokerService | null; laneService: ReturnType; sessionService: ReturnType; @@ -2154,6 +2311,11 @@ export function createAgentChatService(args: { getTestService, ptyService, getAutomationService, + getGitService, + conflictService, + contextDocService, + getWorkerBudgetService, + getMissionBudgetService, computerUseArtifactBrokerService, laneService, sessionService, @@ -2493,6 +2655,145 @@ export function createAgentChatService(args: { return headline; }; + const hasClaudeAskUserAnswers = (input: Record): boolean => { + const questions = Array.isArray(input.questions) ? input.questions : []; + const answers = asRecord(input.answers); + if (!answers || questions.length === 0) return false; + const hasAnswerValue = (value: unknown): boolean => { + if (typeof value === "string") return value.trim().length > 0; + if (Array.isArray(value)) return value.some((item) => hasAnswerValue(item)); + if (value && typeof value === "object") { + return Object.values(value as Record).some((item) => hasAnswerValue(item)); + } + return value != null && value !== false; + }; + return questions.every((q) => { + const key = typeof q === "object" && q !== null && typeof (q as Record).id === "string" + ? (q as Record).id as string + : typeof q === "object" && q !== null && typeof (q as Record).question === "string" + ? (q as Record).question as string + : null; + if (!key) return false; + // Check both the question id and the question text as answer keys + return hasAnswerValue(answers[key]) + || (typeof (q as Record).question === "string" && hasAnswerValue(answers[(q as Record).question as string])); + }); + }; + + const buildClaudeAskUserPendingRequest = ( + runtime: ClaudeRuntime, + input: Record, + sdkOptions?: { toolUseID?: string }, + ): PendingInputRequest | null => { + const rawQuestions = Array.isArray(input.questions) ? input.questions : []; + const questions: PendingInputQuestion[] = []; + + for (const [index, rawQuestion] of rawQuestions.entries()) { + const questionRecord = asRecord(rawQuestion); + if (!questionRecord) continue; + + const question = typeof questionRecord.question === "string" ? questionRecord.question.trim() : ""; + if (!question.length) continue; + const questionId = typeof questionRecord.id === "string" && questionRecord.id.trim().length > 0 + ? questionRecord.id.trim() + : `question_${index + 1}`; + + const header = typeof questionRecord.header === "string" ? questionRecord.header.trim() : ""; + const isMultiSelect = questionRecord.multiSelect === true; + const options = Array.isArray(questionRecord.options) + ? questionRecord.options + .map((rawOption) => { + const optionRecord = asRecord(rawOption); + if (!optionRecord) return null; + const label = typeof optionRecord.label === "string" ? optionRecord.label.trim() : ""; + if (!label.length) return null; + const description = typeof optionRecord.description === "string" ? optionRecord.description.trim() : ""; + const preview = typeof optionRecord.preview === "string" ? optionRecord.preview : ""; + const previewFormat: "markdown" | "html" = + optionRecord.previewFormat === "html" || optionRecord.previewFormat === "markdown" + ? optionRecord.previewFormat + : "markdown"; + return { + label, + value: label, + ...(description.length ? { description } : {}), + ...(label.endsWith("(Recommended)") ? { recommended: true } : {}), + ...(preview.trim().length ? { preview, previewFormat } : {}), + }; + }) + .filter((option): option is NonNullable => option != null) + : []; + + questions.push({ + id: questionId, + question, + ...(header.length ? { header } : {}), + ...(options.length ? { options } : {}), + ...(isMultiSelect ? { multiSelect: true } : {}), + allowsFreeform: true, + ...(isMultiSelect + ? { + impact: + "This question allows multiple selections. If you want more than one option, type them as a comma-separated answer.", + } + : {}), + }); + } + + if (questions.length === 0) return null; + + const firstQuestion = questions[0]; + const hasStructuredChoices = questions.length > 1 || questions.some((question) => (question.options?.length ?? 0) > 0); + const itemId = randomUUID(); + return { + requestId: itemId, + itemId, + source: "claude", + kind: hasStructuredChoices ? "structured_question" : "question", + title: questions.length === 1 ? "Question from Claude" : "Questions from Claude", + description: questions.length === 1 + ? firstQuestion?.question ?? "Claude needs an answer before it can continue." + : "Claude needs a few answers before it can continue.", + questions, + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + tool: "AskUserQuestion", + questionCount: questions.length, + ...(sdkOptions?.toolUseID ? { toolUseID: sdkOptions.toolUseID } : {}), + }, + turnId: runtime.activeTurnId ?? null, + }; + }; + + const buildClaudeAskUserUpdatedInput = ( + input: Record, + request: PendingInputRequest, + response: { answers?: Record; responseText?: string | null }, + ): Record => { + const normalizedAnswers = normalizePendingInputAnswers(request, response.answers, response.responseText); + const mappedAnswers = Object.fromEntries( + Object.entries(normalizedAnswers) + .map(([questionId, values]) => { + // Map internal question ID back to the original question text + // so Claude's SDK receives answers keyed the way it expects. + const question = request.questions.find((q) => q.id === questionId); + const originalKey = question?.question ?? questionId; + // Preserve array structure for multi-select questions + const answer: string | string[] = question?.multiSelect ? values : values.join(", ").trim(); + return [originalKey, answer] as const; + }) + .filter(([, answer]) => (typeof answer === "string" ? answer.length > 0 : answer.length > 0)), + ); + + const existingAnswers = asRecord(input.answers) ?? {}; + return { + ...input, + answers: { ...existingAnswers, ...mappedAnswers }, + }; + }; + const buildClaudeCanUseTool = ( runtime: ClaudeRuntime, managed: ManagedChatSession, @@ -2560,14 +2861,29 @@ export function createAgentChatService(args: { // Block until the user responds via the approval UI. let response: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; try { + runtime.pauseIdleWatchdog?.(); response = await new Promise((resolve) => { runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); }); } finally { runtime.approvals.delete(approvalItemId); + runtime.resumeIdleWatchdog?.(); } + // Emit tool_result so derivePendingInputRequests clears this entry. const approved = response.decision === "accept" || response.decision === "accept_for_session"; + emitChatEvent(managed, { + type: "tool_result", + tool: "ExitPlanMode", + result: { approved }, + itemId: approvalItemId, + turnId: runtime.activeTurnId ?? undefined, + status: approved ? "completed" : "failed", + }); + if (sdkOptions?.toolUseID) { + runtime.resolvedToolUseIds.add(String(sdkOptions.toolUseID)); + } + if (approved) { // Switch session out of plan mode so the UI reflects the transition. if (managed.session.permissionMode === "plan" || managed.session.interactionMode === "plan") { @@ -2590,6 +2906,77 @@ export function createAgentChatService(args: { }; } + if (toolName === "AskUserQuestion") { + if (hasClaudeAskUserAnswers(input)) { + return { behavior: "allow" }; + } + + const request = buildClaudeAskUserPendingRequest(runtime, input, sdkOptions); + if (!request) { + return { behavior: "allow" }; + } + + const approvalItemId = request.itemId ?? request.requestId; + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? "Claude needs input before it can continue.", + detail: { + tool: "AskUserQuestion", + questionCount: request.questions.length, + ...(sdkOptions?.toolUseID ? { toolUseID: sdkOptions.toolUseID } : {}), + }, + }); + + let response: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; + try { + runtime.pauseIdleWatchdog?.(); + response = await new Promise((resolve) => { + runtime.approvals.set(approvalItemId, { kind: "question", resolve, request }); + }); + } finally { + runtime.approvals.delete(approvalItemId); + runtime.resumeIdleWatchdog?.(); + } + + // Emit a tool_result so derivePendingInputRequests clears this entry + // and the question UI doesn't reappear on the next event flush. + const answered = response.decision !== "cancel" && response.decision !== "decline"; + emitChatEvent(managed, { + type: "tool_result", + tool: "AskUserQuestion", + result: { answered, decision: response.decision ?? "none" }, + itemId: approvalItemId, + turnId: runtime.activeTurnId ?? undefined, + status: answered ? "completed" : "failed", + }); + + // Track the SDK tool_use ID so flushOpenClaudeToolUses skips it + // (prevents the synthetic "Completed AskUserQuestion when turn ended" noise). + if (sdkOptions?.toolUseID) { + runtime.resolvedToolUseIds.add(String(sdkOptions.toolUseID)); + } + + if (response.decision === "cancel" || response.decision === "decline") { + return { + behavior: "deny", + message: "The user declined to answer the questions.", + }; + } + + const updatedInput = buildClaudeAskUserUpdatedInput(input, request, response); + if (!hasClaudeAskUserAnswers(updatedInput)) { + return { + behavior: "deny", + message: "The user did not provide answers to the questions.", + }; + } + + return { + behavior: "allow", + updatedInput, + }; + } + // ── Memory orientation guard ── const state = runtime.turnMemoryPolicyState; if (isMemorySearchToolName(toolName) && state) { @@ -2656,11 +3043,13 @@ export function createAgentChatService(args: { let response: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; try { + runtime.pauseIdleWatchdog?.(); response = await new Promise((resolve) => { runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); }); } finally { runtime.approvals.delete(approvalItemId); + runtime.resumeIdleWatchdog?.(); } const approved = response.decision === "accept" || response.decision === "accept_for_session"; @@ -2796,7 +3185,19 @@ export function createAgentChatService(args: { testService: getTestService?.() ?? null, ptyService: ptyService ?? null, automationService: getAutomationService?.() ?? null, + gitService: getGitService?.() ?? null, + conflictService: conflictService ?? null, + contextDocService: contextDocService ?? null, + computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? null, + workerBudgetService: getWorkerBudgetService?.() ?? null, + missionBudgetService: getMissionBudgetService?.() ?? null, + steerChat: undefined, + cancelSteer: undefined, + handoffChat: undefined, + listSubagents: undefined, + approveToolUse: undefined, issueTracker: linearIssueTracker ?? null, + ctoStateService: ctoStateService ?? null, listChats: listSessions, getChatStatus: getSessionSummary, getChatTranscript, @@ -2842,9 +3243,8 @@ export function createAgentChatService(args: { chatSessionId?: string | null, computerUsePolicy?: ComputerUsePolicy | null, ): Record> => { - // CLI providers (claude/codex) spawn the MCP server as a plain Node child - // process via stdio. Skip the Electron bundled-proxy so the command - // resolves to `node dist/index.cjs` which the CLI can manage as stdio. + // Chat surfaces should use ADE's standard MCP launch resolution so both + // packaged and dev builds can route through the proxy when needed. const launch = resolveAdeMcpServerLaunch({ projectRoot, workspaceRoot, @@ -2853,7 +3253,6 @@ export function createAgentChatService(args: { ownerId: ownerId ?? undefined, chatSessionId: chatSessionId ?? undefined, computerUsePolicy: normalizeComputerUsePolicy(computerUsePolicy, createDefaultComputerUsePolicy()), - preferBundledProxy: false, }); return providerResolver.normalizeCliMcpServers(provider, { ade: { @@ -2986,7 +3385,6 @@ export function createAgentChatService(args: { ownerId: ownerId ?? undefined, chatSessionId: chatSessionId ?? undefined, computerUsePolicy: normalizeComputerUsePolicy(computerUsePolicy, createDefaultComputerUsePolicy()), - preferBundledProxy: false, }); const mergedEnv: Record = {}; @@ -4511,7 +4909,9 @@ export function createAgentChatService(args: { if (event.type !== "done") return; collector.usage = event.usage; - clearTimeout(collector.timeout); + if (collector.timeout) { + clearTimeout(collector.timeout); + } sessionTurnCollectors.delete(managed.session.id); collector.resolve({ sessionId: managed.session.id, @@ -4686,6 +5086,27 @@ export function createAgentChatService(args: { }); }; + const emitPendingInputResolved = ( + managed: ManagedChatSession, + args: { + itemId: string; + decision: AgentChatApprovalDecision; + turnId?: string | null; + }, + ): void => { + emitChatEvent(managed, { + type: "pending_input_resolved", + itemId: args.itemId, + resolution: + args.decision === "cancel" + ? "cancelled" + : args.decision === "decline" + ? "declined" + : "accepted", + ...(typeof args.turnId === "string" && args.turnId.trim().length ? { turnId: args.turnId.trim() } : {}), + }); + }; + const normalizePendingInputAnswers = ( request: PendingInputRequest | undefined, answers: Record | undefined, @@ -4713,8 +5134,14 @@ export function createAgentChatService(args: { const trimmedResponse = typeof responseText === "string" ? responseText.trim() : ""; if (trimmedResponse.length > 0) { - const fallbackQuestionId = request?.questions[0]?.id ?? "response"; - normalized[fallbackQuestionId] = [trimmedResponse]; + if (request?.questions.length === 1) { + const [question] = request.questions; + if (question && !normalized[question.id]?.length) { + normalized[question.id] = [trimmedResponse]; + } + } else { + normalized["response"] = [trimmedResponse]; + } } return normalized; @@ -4908,6 +5335,54 @@ export function createAgentChatService(args: { clearLaneDirectiveKey(managed); }; + const keepChatSessionOpen = ( + managed: ManagedChatSession, + args: { + message: string; + turnId?: string | null; + turnStatus?: "failed" | "interrupted"; + }, + ): void => { + if (managed.closed) return; + + const resolvedTurnId = typeof args.turnId === "string" && args.turnId.trim().length + ? args.turnId.trim() + : null; + + for (const pending of managed.localPendingInputs.values()) { + pending.resolve({ decision: "cancel" }); + } + managed.localPendingInputs.clear(); + + emitChatEvent(managed, { + type: "error", + message: args.message, + ...(resolvedTurnId ? { turnId: resolvedTurnId } : {}), + }); + + if (resolvedTurnId && args.turnStatus) { + emitChatEvent(managed, { + type: "status", + turnStatus: args.turnStatus, + turnId: resolvedTurnId, + }); + emitChatEvent(managed, { + type: "done", + turnId: resolvedTurnId, + status: args.turnStatus, + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + } + + managed.session.status = "idle"; + teardownRuntime(managed); + managed.closed = false; + managed.endedNotified = false; + sessionService.reopen(managed.session.id); + persistChatState(managed); + }; + const maybeGenerateSessionSummary = async ( managed: ManagedChatSession, deterministicSummary: string | null @@ -5337,10 +5812,28 @@ export function createAgentChatService(args: { }); } + await runtime.collaborationModesReady?.catch(() => {}); + const requestedCollaborationMode = resolveRequestedCodexCollaborationMode(managed.session); + const collaborationMode = buildCodexCollaborationMode(managed.session, runtime.collaborationModes); + if ( + requestedCollaborationMode === "plan" + && collaborationMode?.mode !== "plan" + && !runtime.planModeFallbackNotified + ) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Native Codex plan mode is unavailable for this session, so ADE is continuing in default collaboration mode.", + }); + runtime.planModeFallbackNotified = true; + } else if (collaborationMode?.mode === "plan") { + runtime.planModeFallbackNotified = false; + } const result = await managed.runtime.request<{ turn?: { id?: string } }>("turn/start", { threadId: managed.session.threadId, input, - ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}) + ...(managed.session.reasoningEffort ? { reasoningEffort: managed.session.reasoningEffort } : {}), + ...(collaborationMode ? { collaborationMode } : {}), }); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); @@ -5473,6 +5966,7 @@ export function createAgentChatService(args: { runtime.busy = true; runtime.activeTurnId = turnId; runtime.interrupted = false; + runtime.resolvedToolUseIds.clear(); managed.session.status = "active"; const attachments = args.attachments ?? []; @@ -5506,9 +6000,61 @@ export function createAgentChatService(args: { let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); const emittedSyntheticItemIds = new Set(); + const openClaudeToolUses = new Map(); const toolInputJsonByContentIndex = new Map(); const toolUseMetaByContentIndex = new Map(); const emittedClaudeTodoIds = new Set(); + const emitClaudeToolCompletion = ( + itemId: string, + result: Record, + status: "completed" | "failed" | "interrupted", + ): void => { + const toolMeta = openClaudeToolUses.get(itemId); + if (!toolMeta) return; + openClaudeToolUses.delete(itemId); + emitChatEvent(managed, { + type: "tool_result", + tool: toolMeta.toolName, + result, + itemId, + turnId, + status, + }); + }; + const completeClaudeToolUsesFromSummary = ( + toolUseIds: string[], + summaryText: string, + ): void => { + const cleanedSummary = summaryText.trim(); + for (const toolUseId of toolUseIds) { + const normalizedToolUseId = toolUseId.trim(); + if (!normalizedToolUseId || !openClaudeToolUses.has(normalizedToolUseId)) continue; + emitClaudeToolCompletion(normalizedToolUseId, { + synthetic: true, + source: "claude_tool_use_summary", + summary: cleanedSummary || `Completed ${openClaudeToolUses.get(normalizedToolUseId)?.toolName ?? "tool"}.`, + }, "completed"); + } + }; + const flushOpenClaudeToolUses = ( + finalTurnStatus: "completed" | "failed" | "interrupted", + ): void => { + const remainingToolUses = [...openClaudeToolUses.entries()]; + for (const [itemId, toolMeta] of remainingToolUses) { + // Skip tools already resolved by canUseTool (e.g. answered AskUserQuestion) + // — their tool_result was emitted inline; don't double-emit a synthetic one. + if (runtime.resolvedToolUseIds.has(itemId)) { + openClaudeToolUses.delete(itemId); + continue; + } + emitClaudeToolCompletion(itemId, { + synthetic: true, + source: "claude_turn_finalization", + finalTurnStatus, + summary: `Completed ${toolMeta.toolName} when the Claude turn ended.`, + }, finalTurnStatus); + } + }; const maybeEmitTodoUpdate = (toolName: string, input: unknown, itemId: string): void => { if (toolName !== "TodoWrite") return; if (emittedClaudeTodoIds.has(itemId)) return; @@ -5517,9 +6063,9 @@ export function createAgentChatService(args: { emittedClaudeTodoIds.add(itemId); emitChatEvent(managed, { type: "todo_update", items: todoItems, turnId }); }; - let turnTimeout: ReturnType | undefined; let idleTimeout: ReturnType | undefined; let timeoutError: Error | null = null; + let idleWatchdogPauseCount = 0; const buildDoneModelPayload = (): { model: string; modelId?: string } => resolveClaudeTurnModelPayload(managed.session, [ reportedAssistantModel, @@ -5548,15 +6094,21 @@ export function createAgentChatService(args: { return `claude-${kind}:${turnId}:${contentIndex}`; }; const clearClaudeTurnTimers = (): void => { - if (turnTimeout) { - clearTimeout(turnTimeout); - turnTimeout = undefined; - } if (idleTimeout) { clearTimeout(idleTimeout); idleTimeout = undefined; } }; + const pauseClaudeIdleWatchdog = (): void => { + idleWatchdogPauseCount += 1; + clearClaudeTurnTimers(); + }; + const resumeClaudeIdleWatchdog = (): void => { + idleWatchdogPauseCount = Math.max(0, idleWatchdogPauseCount - 1); + if (idleWatchdogPauseCount === 0 && !timeoutError && !runtime.interrupted && runtime.busy) { + bumpClaudeIdleDeadline(); + } + }; const failClaudeTurn = (message: string, reason: "timeout" | "idle"): void => { if (timeoutError || runtime.interrupted) return; timeoutError = new Error(message); @@ -5567,28 +6119,28 @@ export function createAgentChatService(args: { }); cancelClaudeWarmup(managed, runtime, "timeout"); try { runtime.v2Session?.close(); } catch { /* ignore */ } - runtime.sdkSessionId = null; + // Keep the persisted Claude V2 session id so the next turn can resume + // the same conversation after this local process is torn down. }; const bumpClaudeIdleDeadline = (): void => { + if (idleWatchdogPauseCount > 0) { + clearClaudeTurnTimers(); + return; + } if (idleTimeout) { clearTimeout(idleTimeout); } idleTimeout = setTimeout(() => { failClaudeTurn( - `Claude stopped streaming for ${Math.round(CLAUDE_STREAM_IDLE_TIMEOUT_MS / 1000)}s. The turn was reset so you can retry.`, + `Claude stopped streaming for ${Math.round(CLAUDE_STREAM_IDLE_TIMEOUT_MS / 1000)}s. This turn was interrupted, but the chat stayed open so you can retry.`, "idle", ); }, CLAUDE_STREAM_IDLE_TIMEOUT_MS); }; + runtime.pauseIdleWatchdog = pauseClaudeIdleWatchdog; + runtime.resumeIdleWatchdog = resumeClaudeIdleWatchdog; try { - turnTimeout = setTimeout(() => { - failClaudeTurn( - `Claude turn exceeded ${Math.round(TURN_TIMEOUT_MS / 1000)}s. The runtime was reset so you can retry.`, - "timeout", - ); - }, TURN_TIMEOUT_MS); - const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); const autoMemoryNotice = buildAutoMemorySystemNotice(autoMemoryPlan); @@ -5959,6 +6511,7 @@ export function createAgentChatService(args: { const nextActivity = activityForToolName(toolName); if (!emittedClaudeToolIds.has(itemId)) { emittedClaudeToolIds.add(itemId); + openClaudeToolUses.set(itemId, { toolName }); emitChatEvent(managed, { type: "activity", activity: nextActivity.activity, @@ -6075,6 +6628,7 @@ export function createAgentChatService(args: { const nextActivity = activityForToolName(toolName); if (!emittedClaudeToolIds.has(itemId)) { emittedClaudeToolIds.add(itemId); + openClaudeToolUses.set(itemId, { toolName }); emitChatEvent(managed, { type: "activity", activity: nextActivity.activity, @@ -6197,6 +6751,15 @@ export function createAgentChatService(args: { message: `${denials.length} tool call${denials.length === 1 ? " was" : "s were"} denied this turn: ${denialSummary}`, turnId, }); + for (const denial of denials) { + if (denial.tool_use_id && openClaudeToolUses.has(denial.tool_use_id)) { + emitClaudeToolCompletion(denial.tool_use_id, { + synthetic: true, + source: "permission_denied", + tool: denial.tool_name, + }, "failed"); + } + } } continue; } @@ -6204,10 +6767,12 @@ export function createAgentChatService(args: { // tool_use_summary — summarizes groups of tool calls if ((msg as any).type === "tool_use_summary") { const summaryMsg = msg as any; + const toolUseIds = Array.isArray(summaryMsg.preceding_tool_use_ids) ? summaryMsg.preceding_tool_use_ids.map(String) : []; + completeClaudeToolUsesFromSummary(toolUseIds, String(summaryMsg.summary ?? "")); emitChatEvent(managed, { type: "tool_use_summary", summary: String(summaryMsg.summary ?? ""), - toolUseIds: Array.isArray(summaryMsg.preceding_tool_use_ids) ? summaryMsg.preceding_tool_use_ids.map(String) : [], + toolUseIds, turnId, }); continue; @@ -6247,6 +6812,9 @@ export function createAgentChatService(args: { // ── Turn completion ── clearClaudeTurnTimers(); + runtime.pauseIdleWatchdog = null; + runtime.resumeIdleWatchdog = null; + flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); // Note: v2Session is NOT closed here — it stays alive for the next turn runtime.activeQuery = null; runtime.busy = false; @@ -6296,10 +6864,18 @@ export function createAgentChatService(args: { } } catch (error) { clearClaudeTurnTimers(); + runtime.pauseIdleWatchdog = null; + runtime.resumeIdleWatchdog = null; runtime.activeQuery = null; runtime.busy = false; runtime.activeTurnId = null; runtime.turnMemoryPolicyState = null; + const effectiveError = timeoutError ?? error; + const finalToolStatus: "completed" | "failed" | "interrupted" = + runtime.interrupted || isAbortRelatedError(effectiveError) + ? "interrupted" + : "failed"; + flushOpenClaudeToolUses(finalToolStatus); // Close V2 session on error so the next turn starts fresh try { runtime.v2Session?.close(); } catch { /* ignore */ } @@ -6317,7 +6893,28 @@ export function createAgentChatService(args: { status: "interrupted", ...doneModel, }); - } else if (isAbortRelatedError(error)) { + } else if (timeoutError) { + managed.session.status = "idle"; + const errorMessage = effectiveError instanceof Error ? effectiveError.message : String(effectiveError); + reportProviderRuntimeFailure("claude", errorMessage); + emitChatEvent(managed, { + type: "error", + message: errorMessage, + turnId, + }); + emitChatEvent(managed, { type: "status", turnStatus: "failed", turnId }); + emitChatEvent(managed, { + type: "done", + turnId, + status: "failed", + ...doneModel, + }); + + appendWorkerActivityToCto(managed, { + activityType: "chat_turn", + summary: `Turn failed: ${errorMessage}`, + }); + } else if (isAbortRelatedError(effectiveError)) { // System-triggered abort (dispose/teardown) that wasn't flagged as interrupted. // Treat as interruption to avoid surfacing raw SDK messages like "aborted by user". managed.session.status = "idle"; @@ -6330,10 +6927,10 @@ export function createAgentChatService(args: { }); } else { managed.session.status = "idle"; - const isAuthFailure = isClaudeRuntimeAuthError(error); + const isAuthFailure = isClaudeRuntimeAuthError(effectiveError); const errorMessage = isAuthFailure ? CLAUDE_RUNTIME_AUTH_ERROR - : (error instanceof Error ? error.message : String(error)); + : (effectiveError instanceof Error ? effectiveError.message : String(effectiveError)); if (isAuthFailure) { reportProviderRuntimeAuthFailure("claude", CLAUDE_RUNTIME_AUTH_ERROR); } else { @@ -6358,11 +6955,15 @@ export function createAgentChatService(args: { }); // If resume failed, clear sessionId and the caller can retry fresh - if (runtime.sdkSessionId && String(error).includes("session")) { + const isStaleSessionError = (err: unknown): boolean => { + const msg = (err instanceof Error ? err.message : String(err)).toLowerCase(); + return msg.includes("session not found") || msg.includes("invalid session") || msg.includes("stale session") || msg.includes("session expired"); + }; + if (runtime.sdkSessionId && isStaleSessionError(effectiveError)) { logger.warn("agent_chat.claude_sdk_session_error", { sessionId: managed.session.id, sdkSessionId: runtime.sdkSessionId, - error: error instanceof Error ? error.message : String(error), + error: effectiveError instanceof Error ? effectiveError.message : String(effectiveError), }); runtime.sdkSessionId = null; managed.runtimeInvalidated = true; @@ -6449,8 +7050,6 @@ export function createAgentChatService(args: { }); }; - let turnTimeout: ReturnType | undefined; - try { const autoMemoryPrompt = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const autoMemoryPlan = await buildAutoMemoryTurnPlan(managed, autoMemoryPrompt, attachments); @@ -6490,22 +7089,6 @@ export function createAgentChatService(args: { const abortController = new AbortController(); runtime.abortController = abortController; - // Turn-level timeout: abort if the entire turn exceeds the limit - turnTimeout = setTimeout(() => { - logger.warn("agent_chat.turn_timeout", { - sessionId: managed.session.id, - turnId, - timeoutMs: TURN_TIMEOUT_MS, - }); - emitChatEvent(managed, { - type: "error", - message: `Turn timed out after ${TURN_TIMEOUT_MS / 1000}s. The agent loop was aborted.`, - turnId, - }); - runtime.interrupted = true; - abortController.abort(); - }, TURN_TIMEOUT_MS); - const streamMessages = runtime.messages.map((message, index): ModelMessage => { const isCurrentUserMessage = index === runtime.messages.length - 1 && message.role === "user"; if (!isCurrentUserMessage) { @@ -6738,7 +7321,24 @@ export function createAgentChatService(args: { testService: getTestService?.() ?? null, ptyService: ptyService ?? null, automationService: getAutomationService?.() ?? null, + gitService: getGitService?.() ?? null, + conflictService: conflictService ?? null, + contextDocService: contextDocService ?? null, + computerUseArtifactBrokerService: computerUseArtifactBrokerRef ?? null, + workerBudgetService: getWorkerBudgetService?.() ?? null, + missionBudgetService: getMissionBudgetService?.() ?? null, + steerChat: (steerArgs: { sessionId: string; instruction: string }) => + steer({ sessionId: steerArgs.sessionId, text: steerArgs.instruction }), + cancelSteer: (cancelArgs: { sessionId: string }) => + cancelSteer({ sessionId: cancelArgs.sessionId, steerId: "" }), + handoffChat: (handoffArgs: { sessionId: string; targetIdentityKey?: string; reason?: string }) => + handoffSession(handoffArgs as any), + listSubagents: (subArgs: { sessionId: string }) => + Promise.resolve(listSubagents(subArgs as any)), + approveToolUse: (approveArgs: { sessionId: string; toolUseId: string; decision: "accept" | "accept_for_session" | "decline" | "cancel" }) => + approveToolUse({ sessionId: approveArgs.sessionId, itemId: approveArgs.toolUseId, decision: approveArgs.decision }), issueTracker: linearIssueTracker ?? null, + ctoStateService: ctoStateService ?? null, listChats: listSessions, getChatStatus: getSessionSummary, getChatTranscript, @@ -7031,7 +7631,6 @@ export function createAgentChatService(args: { // ── Shared turn completion ── persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); - clearTimeout(turnTimeout); if (runtime.interrupted) { runtime.busy = false; runtime.activeTurnId = null; @@ -7086,7 +7685,6 @@ export function createAgentChatService(args: { } } } catch (error) { - clearTimeout(turnTimeout); runtime.busy = false; runtime.activeTurnId = null; runtime.abortController = null; @@ -7298,7 +7896,8 @@ export function createAgentChatService(args: { question?: string; isOther?: boolean; isSecret?: boolean; - options?: Array<{ label?: string; description?: string }> | null; + multiSelect?: boolean; + options?: Array<{ label?: string; description?: string; preview?: string; previewFormat?: "markdown" | "html" }> | null; }>; } | null) ?? {}; const itemId = String(params.itemId ?? randomUUID()); @@ -7312,10 +7911,12 @@ export function createAgentChatService(args: { const label = typeof option?.label === "string" ? option.label.trim() : ""; if (!label.length) return []; const description = typeof option?.description === "string" ? option.description.trim() : ""; + const preview = typeof option?.preview === "string" ? option.preview : ""; return [{ label, value: label, ...(description ? { description } : {}), + ...(preview.trim().length ? { preview, ...(option?.previewFormat ? { previewFormat: option.previewFormat } : {}) } : {}), }]; }) : []; @@ -7323,6 +7924,7 @@ export function createAgentChatService(args: { id: questionId, header: typeof question?.header === "string" && question.header.trim().length ? question.header.trim() : `Question ${index + 1}`, question: questionText, + ...(question?.multiSelect === true ? { multiSelect: true } : {}), allowsFreeform: question?.isOther === true || options.length === 0, isSecret: question?.isSecret === true, ...(options.length ? { options } : {}), @@ -7345,17 +7947,278 @@ export function createAgentChatService(args: { threadId: params.threadId ?? null, }, }; - runtime.approvals.set(itemId, { requestId: id, kind: "structured_question", request }); - emitPendingInputRequest(managed, request, { - kind: "tool_call", - description: request.description ?? "Codex requested input", - }); + runtime.approvals.set(itemId, { + requestId: id, + kind: "structured_question", + request, + questionResponseKind: "native_request_user_input", + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? "Codex requested input", + }); + return; + } + + // ── MCP Elicitation (used by mcp__ade__ask_user for standalone chat) ── + if (method === "mcpServer/elicitation/request") { + const params = (payload.params as { + serverName?: string; + message?: string; + turnId?: string; + requestedSchema?: Record; + } | null) ?? {}; + const serverName = typeof params.serverName === "string" ? params.serverName.trim() : "MCP"; + const message = typeof params.message === "string" ? params.message.trim() : "The agent needs input."; + const itemId = randomUUID(); + const requestedSchema = params.requestedSchema && typeof params.requestedSchema === "object" + ? params.requestedSchema + : null; + + const inferQuestionsFromSchema = (): PendingInputQuestion[] => { + const properties = requestedSchema && typeof requestedSchema.properties === "object" && requestedSchema.properties + ? requestedSchema.properties as Record + : null; + if (!properties) { + return [{ + id: "elicitation_answer", + header: "Question", + question: message, + allowsFreeform: true, + }]; + } + + const entries = Object.entries(properties).flatMap(([propertyKey, rawProperty], index) => { + if (!rawProperty || typeof rawProperty !== "object") return []; + const property = rawProperty as Record; + const header = typeof property.title === "string" && property.title.trim().length + ? property.title.trim() + : `Question ${index + 1}`; + const enumOptions = Array.isArray(property.enum) + ? property.enum.filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const itemEnumOptions = property.type === "array" + && property.items + && typeof property.items === "object" + && Array.isArray((property.items as Record).enum) + ? ((property.items as Record).enum as unknown[]) + .filter((value): value is string => typeof value === "string" && value.trim().length > 0) + : []; + const questionText = typeof property.description === "string" && property.description.trim().length + ? property.description.trim() + : Object.keys(properties).length === 1 + ? message + : `${message} (${header})`; + + if (enumOptions.length > 0) { + return [{ + id: propertyKey, + header, + question: questionText, + options: enumOptions.map((value) => ({ label: value, value })), + allowsFreeform: false, + }]; + } + + if (itemEnumOptions.length > 0) { + return [{ + id: propertyKey, + header, + question: questionText, + multiSelect: true, + options: itemEnumOptions.map((value) => ({ label: value, value })), + allowsFreeform: false, + }]; + } + + if (property.type === "boolean") { + return [{ + id: propertyKey, + header, + question: questionText, + options: [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ], + allowsFreeform: false, + }]; + } + + return [{ + id: propertyKey, + header, + question: questionText, + allowsFreeform: true, + }]; + }); + + return entries.length > 0 + ? entries + : [{ + id: "elicitation_answer", + header: "Question", + question: message, + allowsFreeform: true, + }]; + }; + + const questions = inferQuestionsFromSchema(); + + const request: PendingInputRequest = { + requestId: String(id), + itemId, + source: "codex", + kind: "structured_question", + title: `Question from ${serverName}`, + description: questions[0]?.question ?? message, + questions, + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: requestedSchema ? { serverName, requestedSchema } : { serverName }, + turnId: typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? null, + }; + runtime.approvals.set(itemId, { + requestId: id, + kind: "structured_question", + request, + questionResponseKind: "mcp_elicitation", + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: message, + }); return; } runtime.sendError(id, `Unsupported server request: ${method || "unknown"}`); }; + const parseCodexPlanPayload = ( + value: unknown, + ): { steps: Array<{ text: string; status: "pending" | "in_progress" | "completed" | "failed" }>; explanation: string | null } | null => { + const record = (() => { + if (typeof value !== "string") return asRecord(value); + try { + return asRecord(JSON.parse(value)); + } catch { + return null; + } + })(); + if (!record) return null; + + const rawPlan = Array.isArray(record.plan) + ? record.plan + : Array.isArray(record.steps) + ? record.steps + : null; + if (!rawPlan) return null; + + const steps = rawPlan + .map((step) => { + const entry = asRecord(step); + if (!entry) return null; + const text = typeof entry.step === "string" + ? entry.step + : typeof entry.text === "string" + ? entry.text + : typeof entry.description === "string" + ? entry.description + : ""; + const normalizedText = text.trim(); + if (!normalizedText.length) return null; + const rawStatus = typeof entry.status === "string" ? entry.status : "pending"; + return { + text: normalizedText, + status: PLAN_STEP_STATUS_MAP[rawStatus] ?? "pending", + }; + }) + .filter((entry): entry is { text: string; status: "pending" | "in_progress" | "completed" | "failed" } => entry != null); + + if (!steps.length) return null; + + return { + steps, + explanation: typeof record.explanation === "string" && record.explanation.trim().length + ? record.explanation + : null, + }; + }; + + const emitCodexPlanUpdate = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + payload: unknown, + turnId: string | undefined, + ): boolean => { + const normalized = parseCodexPlanPayload(payload); + if (!normalized) return false; + + emitChatEvent(managed, { + type: "plan", + steps: normalized.steps, + ...(turnId ? { turnId } : {}), + explanation: normalized.explanation, + }); + + if (managed.session.permissionMode !== "plan") { + return true; + } + + const allPending = normalized.steps.every((step) => step.status === "pending"); + if (!allPending) { + return true; + } + + const planSummary = normalized.steps.map((step, index) => `${index + 1}. ${step.text}`).join("\n"); + const hasExistingApproval = [...runtime.approvals.values()].some((pending) => + pending.kind === "plan_approval" + && ( + (turnId && (pending.request?.turnId ?? null) === turnId) + || pending.request?.description === planSummary + ), + ); + if (hasExistingApproval) { + return true; + } + + const planApprovalItemId = randomUUID(); + const request: PendingInputRequest = { + requestId: planApprovalItemId, + itemId: planApprovalItemId, + source: "codex", + kind: "plan_approval", + title: "Plan Ready for Review", + description: planSummary, + questions: [{ + id: "plan_decision", + header: "Implementation Plan", + question: planSummary, + options: [ + { label: "Approve & Implement", value: "approve", recommended: true }, + { label: "Reject & Revise", value: "reject" }, + ], + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { tool: "codexPlanApproval" }, + turnId: turnId ?? runtime.activeTurnId ?? null, + }; + runtime.approvals.set(planApprovalItemId, { + requestId: planApprovalItemId, + kind: "plan_approval", + request, + }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: "Plan ready for approval", + detail: { planContent: planSummary }, + }); + return true; + }; + const handleCodexItemEvent = ( managed: ManagedChatSession, runtime: CodexRuntime, @@ -7576,6 +8439,9 @@ export function createAgentChatService(args: { // dynamicToolCall items → tool_call/tool_result events if (itemType === "dynamicToolCall") { const toolName = String(item.tool ?? "dynamic_tool"); + if (toolName === "update_plan" && eventKind === "started") { + emitCodexPlanUpdate(managed, runtime, item.arguments, turnId); + } if (eventKind === "started") { emitChatEvent(managed, { type: "activity", @@ -7720,6 +8586,7 @@ export function createAgentChatService(args: { const status = mapCodexTurnStatus(turn?.status); const usage = normalizeUsagePayload(turn?.usage ?? turn?.totalUsage); managed.session.status = "idle"; + runtime.approvals.clear(); if (status === "failed" && turn?.error?.message) { emitChatEvent(managed, { @@ -7882,73 +8749,15 @@ export function createAgentChatService(args: { } if (method === "turn/plan/updated") { - const plan = Array.isArray(params.plan) ? params.plan : []; - const steps = plan - .map((step) => { - if (!step || typeof step !== "object") return null; - const record = step as { step?: unknown; status?: unknown }; - const text = typeof record.step === "string" ? record.step : ""; - if (!text) return null; - const rawStatus = typeof record.status === "string" ? record.status : "pending"; - const mappedStatus = PLAN_STEP_STATUS_MAP[rawStatus] ?? "pending"; - return { - text, - status: mappedStatus - }; - }) - .filter((entry): entry is { text: string; status: "pending" | "in_progress" | "completed" | "failed" } => entry != null); - - emitChatEvent(managed, { - type: "plan", - steps, - turnId: typeof params.turnId === "string" ? params.turnId : undefined, - explanation: typeof params.explanation === "string" ? params.explanation : null - }); - - // Emit plan approval request when the session is in plan mode and a - // complete plan has been proposed (all steps still pending = freshly created). - if (managed.session.permissionMode === "plan" && steps.length > 0) { - const allPending = steps.every((s) => s.status === "pending"); - if (allPending) { - const planSummary = steps.map((s, i) => `${i + 1}. ${s.text}`).join("\n"); - const planApprovalItemId = randomUUID(); - const planTurnId = typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined; - const request: PendingInputRequest = { - requestId: planApprovalItemId, - itemId: planApprovalItemId, - source: "codex", - kind: "plan_approval", - title: "Plan Ready for Review", - description: planSummary, - questions: [{ - id: "plan_decision", - header: "Implementation Plan", - question: planSummary, - options: [ - { label: "Approve & Implement", value: "approve", recommended: true }, - { label: "Reject & Revise", value: "reject" }, - ], - allowsFreeform: true, - }], - allowsFreeform: true, - blocking: true, - canProceedWithoutAnswer: false, - providerMetadata: { tool: "codexPlanApproval" }, - turnId: planTurnId ?? null, - }; - runtime.approvals.set(planApprovalItemId, { - requestId: planApprovalItemId, - kind: "plan_approval", - request, - }); - emitPendingInputRequest(managed, request, { - kind: "tool_call", - description: "Plan ready for approval", - detail: { planContent: planSummary }, - }); - } - } - + emitCodexPlanUpdate( + managed, + runtime, + { + plan: Array.isArray(params.plan) ? params.plan : [], + explanation: typeof params.explanation === "string" ? params.explanation : null, + }, + typeof params.turnId === "string" ? params.turnId : runtime.activeTurnId ?? undefined, + ); return; } @@ -7993,6 +8802,7 @@ export function createAgentChatService(args: { runtime.agentMessageScopeByTurn.clear(); runtime.agentMessageTextByTurn.clear(); runtime.recentNotificationKeys.clear(); + runtime.approvals.clear(); managed.session.status = "idle"; emitChatEvent(managed, { type: "status", @@ -8194,12 +9004,15 @@ export function createAgentChatService(args: { fileChangesByItemId: new Map>(), agentMessageScopeByTurn: new Map(), agentMessageTextByTurn: new Map(), - recentNotificationKeys: new Set(), - slashCommands: [], - rateLimits: null, - request: async (method: string, params?: unknown): Promise => { - const id = runtime.nextRequestId; - runtime.nextRequestId += 1; + recentNotificationKeys: new Set(), + slashCommands: [], + rateLimits: null, + collaborationModes: null, + collaborationModesReady: null, + planModeFallbackNotified: false, + request: async (method: string, params?: unknown): Promise => { + const id = runtime.nextRequestId; + runtime.nextRequestId += 1; const payload: JsonRpcEnvelope = { jsonrpc: "2.0", @@ -8301,16 +9114,11 @@ export function createAgentChatService(args: { runtime.suppressExitError = true; if (managed.closed || managed.session.status === "ended") return; - - emitChatEvent(managed, { - type: "error", + keepChatSessionOpen(managed, { message, + turnId: runtime.activeTurnId, + ...(runtime.activeTurnId ? { turnStatus: "failed" as const } : {}), }); - - void finishSession(managed, "failed", { - exitCode: null, - summary: message, - }).catch(() => {}); }); proc.on("exit", (code, signal) => { @@ -8329,16 +9137,11 @@ export function createAgentChatService(args: { if (runtime.suppressExitError) return; if (managed.closed || managed.session.status === "ended") return; - - emitChatEvent(managed, { - type: "error", - message + keepChatSessionOpen(managed, { + message, + turnId: runtime.activeTurnId, + ...(runtime.activeTurnId ? { turnStatus: "failed" as const } : {}), }); - - void finishSession(managed, "failed", { - summary: message, - exitCode: code ?? null - }).catch(() => {}); }); await runtime.request("initialize", { @@ -8352,6 +9155,23 @@ export function createAgentChatService(args: { } }); + const collaborationModesRequest = runtime.request("collaborationMode/list", {}) + .then((res) => { + const modes = parseCodexCollaborationModes(res); + if (modes) { + runtime.collaborationModes = modes; + } + }) + .catch(() => { /* collaborationMode/list not supported — ignore */ }); + runtime.collaborationModesReady = Promise.race([ + collaborationModesRequest, + new Promise((resolve) => { + const timer = setTimeout(resolve, DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS); + timer.unref?.(); + collaborationModesRequest.finally(() => clearTimeout(timer)).catch(() => {}); + }), + ]).then(() => undefined); + runtime.notify("initialized"); return runtime; }; @@ -8482,6 +9302,11 @@ export function createAgentChatService(args: { pathToClaudeCodeExecutable: claudeExecutable.path, }; if (!lightweight) { + opts.toolConfig = { + askUserQuestion: { + previewFormat: "markdown", + }, + }; opts.systemPrompt = { type: "preset", preset: "claude_code", @@ -8575,6 +9400,7 @@ export function createAgentChatService(args: { // Race: user clicks "Done" OR elicitation_complete arrives let userResponse: { decision?: AgentChatApprovalDecision }; try { + runtime.pauseIdleWatchdog?.(); userResponse = await Promise.race([ new Promise<{ decision?: AgentChatApprovalDecision }>((resolve) => { runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); @@ -8584,6 +9410,7 @@ export function createAgentChatService(args: { } finally { runtime.approvals.delete(approvalItemId); runtime.pendingElicitations.delete(elicitReq.elicitationId); + runtime.resumeIdleWatchdog?.(); } if (userResponse.decision === "cancel" || userResponse.decision === "decline") { return { action: "cancel" }; @@ -8594,11 +9421,13 @@ export function createAgentChatService(args: { // No elicitationId — just wait for user click let elicitResponse: { decision?: AgentChatApprovalDecision }; try { + runtime.pauseIdleWatchdog?.(); elicitResponse = await new Promise((resolve) => { runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); }); } finally { runtime.approvals.delete(approvalItemId); + runtime.resumeIdleWatchdog?.(); } return elicitResponse.decision === "cancel" || elicitResponse.decision === "decline" ? { action: "cancel" } @@ -8653,11 +9482,13 @@ export function createAgentChatService(args: { let formResponse: { decision?: AgentChatApprovalDecision; answers?: Record; responseText?: string | null }; try { + runtime.pauseIdleWatchdog?.(); formResponse = await new Promise((resolve) => { runtime.approvals.set(approvalItemId, { kind: "approval", resolve, request }); }); } finally { runtime.approvals.delete(approvalItemId); + runtime.resumeIdleWatchdog?.(); } if (formResponse.decision === "cancel" || formResponse.decision === "decline") { @@ -8674,13 +9505,15 @@ export function createAgentChatService(args: { return { action: "accept", content }; }; - // Enable MCP tool search for sessions with many MCP tools. + // Enable MCP tool search for non-CTO sessions with many MCP tools. // When enabled, the SDK defers tool definitions and loads them on-demand // via the ToolSearch tool, keeping the context window lean. + // CTO sessions disable deferral so operator tools (spawnChat, gitCommit, etc.) + // are always visible without needing ToolSearch. opts.env = { ...process.env as Record, ...opts.env as Record | undefined, - ENABLE_TOOL_SEARCH: "auto", + ENABLE_TOOL_SEARCH: managed.session.identityKey === "cto" ? "0" : "auto", }; } const claudeDescriptor = resolveSessionModelDescriptor(managed.session); @@ -8790,7 +9623,6 @@ export function createAgentChatService(args: { try { runtime.v2Session?.close(); } catch { /* ignore */ } runtime.v2Session = null; runtime.v2WarmupDone = null; - runtime.sdkSessionId = null; emitChatEvent(managed, { type: "system_notice", noticeKind: "info", @@ -9115,6 +9947,7 @@ export function createAgentChatService(args: { turnMemoryPolicyState: null, approvalOverrides: new Set(persisted?.approvalOverrides ?? []), pendingElicitations: new Map void>(), + resolvedToolUseIds: new Set(), }; managed.runtime = runtime; managed.runtimeInvalidated = false; @@ -11004,6 +11837,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); + await runtime.collaborationModesReady?.catch(() => {}); if (!managed.session.threadId || !runtime.activeTurnId) { throw new Error("No active turn to steer."); } @@ -11134,6 +11968,7 @@ export function createAgentChatService(args: { if (managed.session.provider === "codex") { const runtime = await ensureCodexSessionRuntime(managed); + await runtime.collaborationModesReady?.catch(() => {}); if (!managed.session.threadId || !runtime.activeTurnId) return; await runtime.request("turn/interrupt", { threadId: managed.session.threadId, @@ -11454,16 +12289,6 @@ export function createAgentChatService(args: { const canonicalExisting = args.reuseExisting === false ? null : identitySessions.find((entry) => entry.laneId === canonicalLaneId) ?? null; - const legacySessions = identitySessions.filter((entry) => entry.laneId !== canonicalLaneId && entry.status !== "ended"); - - const retireLegacySessions = async (): Promise => { - for (const legacy of legacySessions) { - const legacyManaged = ensureManagedSession(legacy.sessionId); - await finishSession(legacyManaged, "disposed", { - summary: "Superseded by the canonical primary-hosted identity session.", - }); - } - }; const preferred = canonicalExisting; if (preferred) { @@ -11483,7 +12308,6 @@ export function createAgentChatService(args: { refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); - await retireLegacySessions(); if (managed.session.status === "ended") { await resumeSession({ sessionId: managed.session.id }); @@ -11550,7 +12374,6 @@ export function createAgentChatService(args: { refreshReconstructionContext(managed, { includeConversationTail: usesIdentityContinuity(managed) }); await refreshHeadShaStartForManagedExecutionLane(managed); persistChatState(managed); - await retireLegacySessions(); return managed.session; }; @@ -11562,25 +12385,52 @@ export function createAgentChatService(args: { responseText, }: AgentChatRespondToInputArgs): Promise => { const managed = ensureManagedSession(sessionId); + const resolvedDecision: AgentChatApprovalDecision = decision ?? "decline"; const localPending = managed.localPendingInputs.get(itemId); if (localPending) { managed.localPendingInputs.delete(itemId); - localPending.resolve({ decision, answers, responseText }); + localPending.resolve({ decision: resolvedDecision, answers, responseText }); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: localPending.request.turnId ?? null, + }); return; } if (managed.runtime?.kind === "codex") { - const pending = managed.runtime.approvals.get(itemId); + const runtime = managed.runtime; + const pending = runtime.approvals.get(itemId); if (!pending) { - throw new Error(`No pending approval found for item '${itemId}'.`); + logger.warn("agent_chat.codex_approval_not_found", { + sessionId, + itemId, + decision: resolvedDecision, + }); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision === "accept" || resolvedDecision === "accept_for_session" ? "cancel" : resolvedDecision, + turnId: null, + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "That request is no longer active.", + }); + persistChatState(managed); + return; } - managed.runtime.approvals.delete(itemId); + const ensureWritable = (): void => { + if (!runtime.process.stdin.writable) { + throw new Error("Codex app-server connection is unavailable. Retry after the session reconnects."); + } + }; // Plan approval is created locally (not a JSON-RPC server request). // On approve, send a follow-up turn telling Codex to implement. // On reject, send feedback for revision. if (pending.kind === "plan_approval") { - const approved = decision === "accept" || decision === "accept_for_session"; + const approved = resolvedDecision === "accept" || resolvedDecision === "accept_for_session"; const feedback = typeof responseText === "string" ? responseText.trim() : ""; if (approved) { // Switch out of plan mode and send implementation steer @@ -11598,29 +12448,85 @@ export function createAgentChatService(args: { : "The user rejected the plan. Please revise your approach.", }); } + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); return; } if (pending.kind === "permissions") { - const approved = decision === "accept" || decision === "accept_for_session"; - managed.runtime.sendResponse(pending.requestId, { + const approved = resolvedDecision === "accept" || resolvedDecision === "accept_for_session"; + ensureWritable(); + runtime.sendResponse(pending.requestId, { permissions: approved ? (pending.permissions ?? {}) : {}, - scope: decision === "accept_for_session" ? "session" : "turn", + scope: resolvedDecision === "accept_for_session" ? "session" : "turn", + }); + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, }); return; } if (pending.kind === "structured_question") { + if (resolvedDecision === "decline" || resolvedDecision === "cancel") { + if (pending.questionResponseKind === "mcp_elicitation") { + ensureWritable(); + runtime.sendResponse(pending.requestId, { + action: resolvedDecision === "cancel" ? "cancel" : "decline", + content: null, + }); + } else { + // Native Codex request_user_input only accepts an answers map. + // Empty answers represent a declined/cancelled prompt without + // interrupting the surrounding turn. + ensureWritable(); + runtime.sendResponse(pending.requestId, { answers: {} }); + } + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); + return; + } const normalizedAnswers = normalizePendingInputAnswers(pending.request, answers, responseText); - managed.runtime.sendResponse(pending.requestId, { - answers: Object.fromEntries( - Object.entries(normalizedAnswers).map(([questionId, values]) => [questionId, { answers: values }]), - ), + ensureWritable(); + if (pending.questionResponseKind === "mcp_elicitation") { + runtime.sendResponse(pending.requestId, { + action: "accept", + content: coerceCodexMcpElicitationContent(pending.request, normalizedAnswers), + }); + } else { + runtime.sendResponse(pending.requestId, { + answers: Object.fromEntries( + Object.entries(normalizedAnswers).map(([questionId, values]) => [questionId, { answers: values }]), + ), + }); + } + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, }); return; } - const mapped = mapApprovalDecisionForCodex(decision ?? "decline"); - managed.runtime.sendResponse(pending.requestId, { decision: mapped }); + const mapped = mapApprovalDecisionForCodex(resolvedDecision); + ensureWritable(); + runtime.sendResponse(pending.requestId, { decision: mapped }); + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); return; } @@ -11638,7 +12544,12 @@ export function createAgentChatService(args: { return; } managed.runtime.approvals.delete(itemId); - pending.resolve({ decision, answers, responseText }); + pending.resolve({ decision: resolvedDecision, answers, responseText }); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); return; } @@ -11647,8 +12558,8 @@ export function createAgentChatService(args: { if (!pending) { throw new Error(`No pending approval found for item '${itemId}'.`); } - const approved = decision === "accept" || decision === "accept_for_session"; - if (decision === "accept_for_session" && pending.category !== "askUser") { + const approved = resolvedDecision === "accept" || resolvedDecision === "accept_for_session"; + if (resolvedDecision === "accept_for_session" && pending.category !== "askUser") { managed.runtime.approvalOverrides.add(pending.category); if (pending.category === "bash") { managed.runtime.permissionMode = "full-auto"; @@ -11664,7 +12575,12 @@ export function createAgentChatService(args: { } managed.session.permissionMode = syncLegacyPermissionMode(managed.session) ?? managed.session.permissionMode; managed.runtime.pendingApprovals.delete(itemId); - pending.resolve({ decision, answers, responseText }); + pending.resolve({ decision: resolvedDecision, answers, responseText }); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); return; } @@ -11679,11 +12595,32 @@ export function createAgentChatService(args: { }); return; } - pending.resolve(mapChatDecisionToCursorPermission(decision, pending.options, answers)); + managed.runtime.permissionWaiters.delete(itemId); + pending.resolve(mapChatDecisionToCursorPermission(resolvedDecision, pending.options, answers)); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: managed.runtime.activeTurnId ?? null, + }); return; } - throw new Error(`Session '${sessionId}' does not have a live runtime for approvals.`); + logger.warn("agent_chat.approval_without_live_runtime", { + sessionId, + itemId, + decision: resolvedDecision, + }); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision === "accept" || resolvedDecision === "accept_for_session" ? "cancel" : resolvedDecision, + turnId: null, + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "That request is no longer active.", + }); + persistChatState(managed); }; const approveToolUse = async ({ @@ -12259,8 +13196,8 @@ export function createAgentChatService(args: { attachments = [], reasoningEffort, executionMode, - timeoutMs = 300_000, - }: AgentChatSendArgs & { timeoutMs?: number }): Promise<{ + timeoutMs, + }: AgentChatSendArgs & { timeoutMs?: number | null }): Promise<{ sessionId: string; provider: AgentChatProvider; model: string; @@ -12312,29 +13249,213 @@ export function createAgentChatService(args: { }; } - const safeTimeoutMs = Math.max(15_000, Math.floor(timeoutMs)); + const normalizedTimeoutMs = timeoutMs === undefined + ? DEFAULT_RUN_SESSION_TURN_TIMEOUT_MS + : timeoutMs == null || Number(timeoutMs) === 0 + ? null + : Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0 + ? Math.max(15_000, Math.floor(Number(timeoutMs))) + : DEFAULT_RUN_SESSION_TURN_TIMEOUT_MS; return await new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - sessionTurnCollectors.delete(sessionId); - reject(new Error(`Timed out waiting for session '${sessionId}' to finish the current turn.`)); - }, safeTimeoutMs); - - sessionTurnCollectors.set(sessionId, { + const collector: SessionTurnCollector = { resolve, reject, outputText: "", lastError: null, - timeout, - }); + timeout: null, + }; + + if (normalizedTimeoutMs != null) { + collector.timeout = setTimeout(() => { + if (sessionTurnCollectors.get(sessionId) !== collector) return; + sessionTurnCollectors.delete(sessionId); + void interrupt({ sessionId }).catch((interruptError) => { + logger.warn("agent_chat.run_session_turn_timeout_interrupt_failed", { + sessionId, + error: interruptError instanceof Error ? interruptError.message : String(interruptError), + }); + }); + reject(new Error( + `Timed out waiting for session '${sessionId}' to finish the current turn. The turn was interrupted, but the chat stayed open.`, + )); + }, normalizedTimeoutMs); + } + + sessionTurnCollectors.set(sessionId, collector); void executePreparedSendMessage(prepared).catch((error) => { - clearTimeout(timeout); - sessionTurnCollectors.delete(sessionId); + if (collector.timeout) { + clearTimeout(collector.timeout); + } + if (sessionTurnCollectors.get(sessionId) === collector) { + sessionTurnCollectors.delete(sessionId); + } reject(error instanceof Error ? error : new Error(String(error))); }); }); }; + /** + * Create a blocking pending-input request for a chat session (used by MCP ask_user + * when no missionId is available). Returns the user's answer. + */ + const requestChatInput = async (args: { + chatSessionId: string; + title: string; + body: string; + questions?: Array<{ + id?: string; + header?: string; + question: string; + options?: Array<{ + label: string; + value?: string; + description?: string; + recommended?: boolean; + preview?: string; + previewFormat?: string; + }>; + multiSelect?: boolean; + allowsFreeform?: boolean; + isSecret?: boolean; + defaultAssumption?: string | null; + impact?: string | null; + }>; + }): Promise<{ decision: string; answers: Record; responseText: string | null }> => { + const inferQuestionsFromBody = (bodyText: string): PendingInputQuestion[] | null => { + const normalizedBody = bodyText.replace(/\r/g, "").trim(); + if (!normalizedBody.length) return null; + + const buildStructuredQuestion = ( + prompt: string, + options: Array<{ label: string; value: string }>, + ): PendingInputQuestion[] | null => { + const question = prompt.trim().replace(/\s+/g, " "); + const normalizedOptions = options + .map((option) => ({ + label: option.label.trim(), + value: option.value.trim(), + })) + .filter((option) => option.label.length > 0 && option.value.length > 0); + if (!question.length || normalizedOptions.length < 2) return null; + return [{ + id: "answer", + header: "Question 1", + question, + options: normalizedOptions, + allowsFreeform: true, + }]; + }; + + const optionLinePattern = /^(?:[-*]\s*)?([0-9A-Za-z]+)[.)]\s+(.+)$/; + const nonEmptyLines = normalizedBody + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); + const firstOptionLineIndex = nonEmptyLines.findIndex((line) => optionLinePattern.test(line)); + if (firstOptionLineIndex >= 1) { + const prompt = nonEmptyLines.slice(0, firstOptionLineIndex).join(" "); + const options = nonEmptyLines.slice(firstOptionLineIndex).flatMap((line) => { + const match = line.match(optionLinePattern); + if (!match) return []; + return [{ + value: match[1]!.trim(), + label: match[2]!.trim(), + }]; + }); + const inferred = buildStructuredQuestion(prompt, options); + if (inferred) return inferred; + } + + const optionMarkerPattern = /([0-9A-Za-z]+)[.)]\s+/g; + const markers = Array.from(normalizedBody.matchAll(optionMarkerPattern)); + if (markers.length < 2) return null; + const firstMarker = markers[0]; + if (!firstMarker || firstMarker.index == null || firstMarker.index <= 0) return null; + + const prompt = normalizedBody.slice(0, firstMarker.index).trim(); + const options = markers.flatMap((match, index) => { + const start = (match.index ?? 0) + match[0].length; + const end = index + 1 < markers.length + ? (markers[index + 1]?.index ?? normalizedBody.length) + : normalizedBody.length; + const rawLabel = normalizedBody + .slice(start, end) + .replace(/\s+(?:Reply|Respond|Choose|Select|Pick|Answer)\b[\s\S]*$/i, "") + .trim(); + if (!rawLabel.length) return []; + return [{ + value: match[1]!.trim(), + label: rawLabel, + }]; + }); + return buildStructuredQuestion(prompt, options); + }; + + const managed = ensureManagedSession(args.chatSessionId); + const itemId = randomUUID(); + const fallbackQuestions = inferQuestionsFromBody(args.body) ?? [{ id: "answer", header: "Question 1", question: args.body, allowsFreeform: true }]; + const requestedQuestions = args.questions?.length ? args.questions : fallbackQuestions; + const questions: PendingInputQuestion[] = requestedQuestions.map( + (q, i) => ({ + id: q.id ?? `q_${i + 1}`, + header: q.header?.trim().length ? q.header.trim() : `Question ${i + 1}`, + question: q.question.trim(), + ...(q.multiSelect === true ? { multiSelect: true } : {}), + ...(q.allowsFreeform !== undefined ? { allowsFreeform: q.allowsFreeform } : { allowsFreeform: true }), + ...(q.isSecret === true ? { isSecret: true } : {}), + ...(typeof q.defaultAssumption === "string" && q.defaultAssumption.trim().length + ? { defaultAssumption: q.defaultAssumption.trim() } + : {}), + ...(typeof q.impact === "string" && q.impact.trim().length + ? { impact: q.impact.trim() } + : {}), + ...(q.options?.length ? { + options: q.options.map((o) => ({ + label: o.label, + value: o.value ?? o.label, + ...(typeof o.description === "string" && o.description.trim().length ? { description: o.description.trim() } : {}), + ...(o.recommended === true ? { recommended: true } : {}), + ...(typeof o.preview === "string" && o.preview.trim().length ? { preview: o.preview } : {}), + ...(o.previewFormat === "markdown" || o.previewFormat === "html" ? { previewFormat: o.previewFormat } : {}), + })), + } : {}), + }), + ); + const request: PendingInputRequest = { + requestId: itemId, + itemId, + source: "ade", + kind: questions.some((q) => q.options?.length) ? "structured_question" : "question", + title: args.title, + description: questions[0]?.question ?? args.body, + questions, + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: managed.runtime?.activeTurnId ?? null, + }; + + const response = await new Promise<{ + decision?: AgentChatApprovalDecision; + answers?: Record; + responseText?: string | null; + }>((resolve) => { + managed.localPendingInputs.set(itemId, { request, resolve }); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: request.description ?? args.body, + }); + }); + + const normalizedAnswers = normalizePendingInputAnswers(request, response.answers, response.responseText); + return { + decision: response.decision ?? "none", + answers: normalizedAnswers, + responseText: typeof response.responseText === "string" ? response.responseText : null, + }; + }; + return { createSession, handoffSession, @@ -12351,6 +13472,7 @@ export function createAgentChatService(args: { ensureIdentitySession, approveToolUse, respondToInput, + requestChatInput, getAvailableModels, getSlashCommands, codexFuzzyFileSearch, diff --git a/apps/desktop/src/main/services/cto/ctoStateService.test.ts b/apps/desktop/src/main/services/cto/ctoStateService.test.ts index 3792a2fc0..d023d4c34 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.test.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.test.ts @@ -543,17 +543,25 @@ describe("ctoStateService", () => { }); const preview = service.previewSystemPrompt(); - expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory", "capabilities"]); + expect(preview.sections.map((section) => section.id)).toEqual(["doctrine", "personality", "memory", "knowledge", "capabilities"]); expect(preview.sections[0]?.content).toContain("You are the CTO for the current project inside ADE."); expect(preview.sections[1]?.content).toContain("Operate as a strategic CTO."); expect(preview.sections[2]?.content).toContain("Immutable doctrine"); expect(preview.sections[2]?.content).toContain("Use memoryUpdateCore only when the standing project brief changes"); expect(preview.sections[2]?.content).toContain("Do not write ephemeral turn-by-turn status"); - expect(preview.sections[3]?.content).toContain("ADE operator capability manifest"); - expect(preview.sections[3]?.content).toContain("UI navigation is suggestion-only."); + // Knowledge section: ADE environment glossary, chat vs terminal disambiguation, task routing + expect(preview.sections[3]?.content).toContain("ADE environment glossary"); + expect(preview.sections[3]?.content).toContain("spawnChat"); + expect(preview.sections[3]?.content).toContain("createTerminal"); + expect(preview.sections[3]?.content).toContain("spawn_agent is an MCP tool"); + // Capabilities section: concrete tool list + expect(preview.sections[4]?.content).toContain("ADE operator tools"); + expect(preview.sections[4]?.content).toContain("listLanes, inspectLane, createLane"); + expect(preview.sections[4]?.content).toContain("UI navigation is suggestion-only."); expect(preview.prompt).toContain("Immutable ADE doctrine"); expect(preview.prompt).toContain("Selected personality overlay"); - expect(preview.prompt).toContain("ADE operator capability manifest"); + expect(preview.prompt).toContain("ADE environment knowledge"); + expect(preview.prompt).toContain("ADE operator tools"); fixture.db.close(); }); diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index 4a05dce27..d52ff559c 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -96,15 +96,65 @@ const CTO_MEMORY_OPERATING_MODEL = [ "- Distill important session context before compaction removes detail, but persist only durable insights.", ].join("\n"); +const CTO_ENVIRONMENT_KNOWLEDGE = [ + "ADE environment glossary:", + "- Lane: a git worktree with its own branch, directory, processes, and chat sessions. Lanes isolate parallel work streams. Tools: listLanes, inspectLane, createLane, deleteLane.", + "- Native ADE chat: a persistent chat session in the ADE UI with streaming, tool approval, and full service integration. Created with spawnChat. NOT a terminal.", + "- PTY terminal: a shell terminal session (may run any CLI command). Created with createTerminal. Indirect tool access, no ADE service integration.", + "- Mission: a structured task unit with runs, steps, and workers. Can be launched, steered, paused, and observed. Tools: startMission, launchMissionRun, steerMission, getMissionStatus.", + "- Worker: a named agent instance (engineer, QA, researcher) that runs in a lane executing missions or tasks. Tools: listWorkers, createWorker, wakeWorker, getWorkerStatus.", + "- Convergence: ADE's PR merge pipeline with automated validation, issue detection, and iterative resolution rounds. Tools: getPullRequestConvergence, startPullRequestConvergenceRound.", + "- Conflict resolution: ADE can predict, simulate, propose, and apply merge conflict resolutions. Tools: getConflictStatus, simulateMerge, requestConflictProposal, applyConflictProposal.", + "", + "Critical distinction — chats vs terminals:", + "- To launch a native ADE chat session: call spawnChat({ laneId?, title?, initialPrompt? }) directly. Do NOT use ToolSearch to find it — just call it. This creates a full ADE work chat with UI, streaming, tool approval, and supervision.", + "- To open a terminal: call createTerminal({ laneId, title?, startupCommand? }) directly.", + "- spawn_agent is an MCP tool for Claude CLI subprocesses in tracked terminals. It is NOT the same as spawnChat. Never use spawn_agent when the user asks for 'a chat' or 'a new session'.", + "", + "Tool calling convention:", + "- ADE tools are available as MCP tools. When you see them in your tool list, they may be prefixed (e.g., mcp__ade__spawnChat). Call them directly by name — do not search for them with ToolSearch.", + "- If a tool from the manifest below is not in your immediate tool list, it may still be available. Try calling it directly before concluding it does not exist.", + "", + "Task routing:", + "- 'Start a chat' or 'launch an agent' → spawnChat with an initialPrompt describing the task.", + "- 'Check PR status' → getPullRequestStatus or getPullRequestConvergence.", + "- 'Start work on [feature]' → create/find a lane, then spawnChat or startMission.", + "- 'Open a terminal' → createTerminal.", + "- 'Commit and push' → gitCommit then gitPush.", + "- 'Check for conflicts' → getConflictStatus or getConflictRiskMatrix.", + "- 'Resolve merge conflicts' → getConflictStatus, requestConflictProposal, applyConflictProposal.", + "- 'Steer an active agent' → steerChat({ sessionId, instruction }).", + "- 'How is the project doing?' → getProjectHealthSummary.", + "- 'What happened recently?' → getRecentEvents.", + "- 'Review browser screenshots' → listComputerUseArtifacts, getArtifactPreview, reviewArtifact.", + "- 'How much are we spending?' → getProjectBudgetStatus or getWorkerCostBreakdown.", + "- 'Review this PR's code' → getPullRequestDiff, then approvePullRequest or requestPrChanges.", +].join("\n"); + +// Keep in sync with ctoOperatorTools.ts tool registrations const CTO_CAPABILITY_MANIFEST = [ - "ADE operator capability manifest:", - "- Work chats: create, list, inspect, read transcripts, send follow-ups, interrupt, and end supervised sessions through ADE services.", - "- Lanes: list, inspect, create, and archive lanes, then hand back explicit navigation suggestions for opening them in ADE.", - "- Missions: create, inspect, launch, steer, read logs, export context, and route work into mission execution without exposing raw coordinator internals.", - "- Run / processes: inspect managed lane processes, start them, stop them, and read bounded log tails through ADE's process service.", - "- Pull requests and GitHub: inspect PR state, create PRs from lanes, update PR metadata, and post review-facing comments through stable PR services.", - "- Files and context: enumerate workspaces, read files, search text, and export bounded ADE context packs for project, lane, mission, conflict, plan, and feature scopes.", - "- Workers and Linear: supervise worker agents, trigger wakeups, inspect workflow runs, and route Linear issues into CTO, mission, or worker paths.", + "ADE operator tools (complete list):", + "- Lanes: listLanes, inspectLane, createLane, deleteLane", + "- Chats: listChats, spawnChat, sendChatMessage, interruptChat, resumeChat, endChat, getChatStatus, getChatTranscript", + "- Chat steering: steerChat, cancelSteer, handoffChat, listSubagents, approveToolUse", + "- Missions: listMissions, startMission, getMissionStatus, updateMission, launchMissionRun, resolveMissionIntervention, getMissionRunView, getMissionLogs, listMissionWorkerDigests, steerMission", + "- Workers: listWorkers, createWorker, updateWorker, removeWorker, updateWorkerStatus, wakeWorker, getWorkerStatus", + "- Git: gitStatus, gitCommit, gitPush, gitPull, gitFetch, gitListRecentCommits, gitListBranches, gitCheckoutBranch, gitStashPush, gitStashPop, gitStashList, gitGetConflictState, gitRebaseContinue, gitRebaseAbort, gitMergeAbort", + "- PRs: listPullRequests, getPullRequestStatus, commentOnPullRequest, updatePullRequestTitle, updatePullRequestBody, createPrFromLane, landPullRequest, closePullRequest, requestPrReviewers, getPullRequestDiff, approvePullRequest, requestPrChanges", + "- Convergence: getPullRequestConvergence, updatePullRequestConvergencePipeline, updatePullRequestConvergenceRuntime, startPullRequestConvergenceRound, stopPullRequestConvergence", + "- Conflicts: getConflictStatus, getConflictRiskMatrix, simulateMerge, runConflictPrediction, listConflictProposals, requestConflictProposal, applyConflictProposal, undoConflictProposal", + "- Files: listFileWorkspaces, readWorkspaceFile, searchWorkspaceText", + "- Context: getContextStatus, generateContextDocs", + "- Processes: listManagedProcesses, startManagedProcess, stopManagedProcess, getManagedProcessLog", + "- Tests: listTestSuites, runTests, stopTestRun, listTestRuns, getTestLog", + "- Terminals: createTerminal", + "- Linear: listLinearWorkflows, getLinearRunStatus, resolveLinearRunAction, cancelLinearRun, commentOnLinearIssue, updateLinearIssueState, routeLinearIssueToCto, routeLinearIssueToMission, routeLinearIssueToWorker, rerouteLinearRun, listLinearIssues, getLinearIssue, updateLinearIssueAssignee, addLinearIssueLabel", + "- Automations: listAutomations, triggerAutomation, listAutomationRuns", + "- Events: getRecentEvents", + "- Project health: getProjectHealthSummary", + "- Computer use: listComputerUseArtifacts, getArtifactPreview, reviewArtifact", + "- Budget: getProjectBudgetStatus, getWorkerCostBreakdown", + "- Memory: memorySearch, memoryAdd, memoryUpdateCore, memoryPin, memoryDelete", "", "Operating rules:", "- Internal ADE actions run through service-backed tools even when no renderer click occurs.", @@ -978,6 +1028,9 @@ export function createCtoStateService(args: CtoStateServiceArgs) { sections.push("- Layer 4 — searchable durable project memory. Use memorySearch before non-trivial work and memoryAdd for reusable decisions, conventions, patterns, gotchas, and stable preferences."); sections.push("- Memory write policy: use memoryUpdateCore for standing brief changes, use memoryAdd for durable reusable lessons, and skip ephemeral status notes."); sections.push(""); + sections.push("ADE Operational Knowledge"); + sections.push(CTO_ENVIRONMENT_KNOWLEDGE); + sections.push(""); sections.push("CTO Identity"); sections.push(`- Name: ${snapshot.identity.name}`); sections.push(`- Persona: ${snapshot.identity.persona}`); @@ -1087,9 +1140,14 @@ export function createCtoStateService(args: CtoStateServiceArgs) { title: "Memory and continuity model", content: CTO_MEMORY_OPERATING_MODEL, }, + { + id: "knowledge", + title: "ADE environment knowledge", + content: CTO_ENVIRONMENT_KNOWLEDGE, + }, { id: "capabilities", - title: "ADE operator capability manifest", + title: "ADE operator tools", content: CTO_CAPABILITY_MANIFEST, }, ]; diff --git a/apps/desktop/src/main/services/git/gitOperationsService.test.ts b/apps/desktop/src/main/services/git/gitOperationsService.test.ts index df2090841..4d27e7177 100644 --- a/apps/desktop/src/main/services/git/gitOperationsService.test.ts +++ b/apps/desktop/src/main/services/git/gitOperationsService.test.ts @@ -14,6 +14,46 @@ vi.mock("./git", () => ({ import { createGitOperationsService } from "./gitOperationsService"; +function createTestGitOperationsService(branchRef = "feature/stash-test") { + const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); + const mockFinish = vi.fn(); + + const service = createGitOperationsService({ + laneService: { + getLaneBaseAndBranch: vi.fn().mockReturnValue({ + baseRef: "main", + branchRef, + worktreePath: "/tmp/ade-lane", + laneType: "worktree", + }), + } as any, + operationService: { + start: mockStart, + finish: mockFinish, + } as any, + projectConfigService: { + get: () => ({ effective: { ai: {} } }), + } as any, + aiIntegrationService: { + getFeatureFlag: () => false, + getStatus: vi.fn(async () => ({ availableModelIds: [] })), + generateCommitMessage: vi.fn(), + } as any, + logger: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any, + }); + + return { + service, + mockStart, + mockFinish, + }; +} + describe("gitOperationsService.stashClear", () => { beforeEach(() => { vi.clearAllMocks(); @@ -22,38 +62,7 @@ describe("gitOperationsService.stashClear", () => { it("calls git stash clear with the lane worktree path and returns the action result", async () => { mockGit.getHeadSha.mockResolvedValue("abc123"); mockGit.runGitOrThrow.mockResolvedValue(undefined); - - const mockStart = vi.fn().mockReturnValue({ operationId: "op-1" }); - const mockFinish = vi.fn(); - - const service = createGitOperationsService({ - laneService: { - getLaneBaseAndBranch: vi.fn().mockReturnValue({ - baseRef: "main", - branchRef: "feature/stash-test", - worktreePath: "/tmp/ade-lane", - laneType: "worktree", - }), - } as any, - operationService: { - start: mockStart, - finish: mockFinish, - } as any, - projectConfigService: { - get: () => ({ effective: { ai: {} } }), - } as any, - aiIntegrationService: { - getFeatureFlag: () => false, - getStatus: vi.fn(async () => ({ availableModelIds: [] })), - generateCommitMessage: vi.fn(), - } as any, - logger: { - info: vi.fn(), - warn: vi.fn(), - error: vi.fn(), - debug: vi.fn(), - } as any, - }); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); const result = await service.stashClear({ laneId: "lane-1" }); @@ -81,6 +90,74 @@ describe("gitOperationsService.stashClear", () => { }); }); +describe("gitOperationsService stash item commands", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("calls git stash pop with the lane worktree path and stash ref", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); + + const result = await service.stashPop({ laneId: "lane-1", stashRef: "stash@{1}" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["stash", "pop", "stash@{1}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_pop", + metadata: expect.objectContaining({ stashRef: "stash@{1}" }), + }), + ); + expect(mockFinish).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "op-1", + status: "succeeded", + }), + ); + }); + + it("calls git stash drop with the lane worktree path and stash ref", async () => { + mockGit.getHeadSha.mockResolvedValue("abc123"); + mockGit.runGitOrThrow.mockResolvedValue(undefined); + const { service, mockStart, mockFinish } = createTestGitOperationsService(); + + const result = await service.stashDrop({ laneId: "lane-1", stashRef: "stash@{0}" }); + + expect(mockGit.runGitOrThrow).toHaveBeenCalledWith( + ["stash", "drop", "stash@{0}"], + { cwd: "/tmp/ade-lane", timeoutMs: 30_000 }, + ); + expect(result).toEqual({ + operationId: "op-1", + preHeadSha: "abc123", + postHeadSha: "abc123", + }); + expect(mockStart).toHaveBeenCalledWith( + expect.objectContaining({ + laneId: "lane-1", + kind: "git_stash_drop", + metadata: expect.objectContaining({ stashRef: "stash@{0}" }), + }), + ); + expect(mockFinish).toHaveBeenCalledWith( + expect.objectContaining({ + operationId: "op-1", + status: "succeeded", + }), + ); + }); +}); + describe("gitOperationsService.generateCommitMessage", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index c2ee480b6..29c99d9ae 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -11,6 +11,7 @@ import { buildPrAiResolutionContextKey } from "../../../shared/types"; import { launchPrIssueResolutionChat, previewPrIssueResolutionPrompt } from "../prs/prIssueResolver"; import { launchRebaseResolutionChat } from "../prs/prRebaseResolver"; import type { AdeCleanupResult, AdeProjectSnapshot } from "../../../shared/types"; +import { toRecentProjectSummary } from "../projects/recentProjectSummary"; import type { ApplyConflictProposalArgs, BatchAssessmentResult, @@ -772,33 +773,6 @@ function normalizeAutopilotExecutor(value: unknown): OrchestratorExecutorKind { return "unified"; } -function toRecentProjectSummary(entry: { rootPath: string; displayName: string; lastOpenedAt: string }): RecentProjectSummary { - let laneCount: number | undefined; - try { - const gitPath = path.join(entry.rootPath, ".git"); - const worktreesPath = path.join(gitPath, "worktrees"); - if (fs.existsSync(gitPath)) { - laneCount = 1; // Primary lane - if (fs.existsSync(worktreesPath)) { - const wtCount = fs.readdirSync(worktreesPath, { withFileTypes: true }) - .filter((dirent) => dirent.isDirectory()) - .length; - laneCount += wtCount; - } - } - } catch { - // ignore - } - - return { - rootPath: entry.rootPath, - displayName: entry.displayName, - lastOpenedAt: entry.lastOpenedAt, - exists: fs.existsSync(entry.rootPath), - laneCount, - }; -} - type MemoryScope = "user" | "project" | "lane" | "mission"; type UnifiedMemoryScope = "project" | "agent" | "mission"; diff --git a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts index 2a227b427..b3750df40 100644 --- a/apps/desktop/src/main/services/orchestrator/orchestratorService.ts +++ b/apps/desktop/src/main/services/orchestrator/orchestratorService.ts @@ -7302,10 +7302,19 @@ export function createOrchestratorService({ if (sessionId && managedLaunch && agentChatService) { void (async () => { try { + const rawTimeoutMs = step.metadata?.timeoutMs; + const parsedTimeoutMs = Number(rawTimeoutMs); + const managedTurnTimeoutMs = + rawTimeoutMs === null || parsedTimeoutMs === 0 + ? null + : Number.isFinite(parsedTimeoutMs) && parsedTimeoutMs > 0 + ? Math.max(1_000, Math.floor(parsedTimeoutMs)) + : runtimeConfig.stepTimeoutDefaultMs; await agentChatService.runSessionTurn({ sessionId, text: managedLaunch.prompt, displayText: managedLaunch.displayText, + timeoutMs: managedTurnTimeoutMs, ...(managedLaunch.reasoningEffort ? { reasoningEffort: managedLaunch.reasoningEffort } : {}), ...(managedLaunch.executionMode ? { executionMode: managedLaunch.executionMode } : {}), }); diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts new file mode 100644 index 000000000..e6316de86 --- /dev/null +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.test.ts @@ -0,0 +1,176 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { openKvDb } from "../state/kvDb"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import { toRecentProjectSummary } from "./recentProjectSummary"; + +function createLogger() { + return { + debug: () => {}, + info: () => {}, + warn: () => {}, + error: () => {}, + } as any; +} + +function insertProject(db: Awaited>, projectId: string, projectRoot: string, now: string) { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, projectRoot, path.basename(projectRoot), "main", now, now], + ); +} + +function insertLane( + db: Awaited>, + args: { + laneId: string; + projectId: string; + laneType: "primary" | "worktree" | "attached"; + worktreePath: string; + branchRef: string; + status?: "active" | "archived"; + archivedAt?: string | null; + attachedRootPath?: string | null; + }, +) { + db.run( + ` + insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `, + [ + args.laneId, + args.projectId, + args.laneId, + null, + args.laneType, + "main", + args.branchRef, + args.worktreePath, + args.attachedRootPath ?? null, + args.laneType === "primary" ? 1 : 0, + null, + null, + null, + null, + args.status ?? "active", + "2026-04-02T12:00:00.000Z", + args.archivedAt ?? null, + ], + ); +} + +describe("toRecentProjectSummary", () => { + it("prefers active ADE lanes over raw git worktree metadata", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-summary-")); + const gitWorktreesDir = path.join(projectRoot, ".git", "worktrees"); + fs.mkdirSync(gitWorktreesDir, { recursive: true }); + for (let index = 0; index < 18; index += 1) { + fs.mkdirSync(path.join(gitWorktreesDir, `raw-${index}`), { recursive: true }); + } + + const layout = resolveAdeLayout(projectRoot); + const managedLanePath = path.join(layout.worktreesDir, "lane-managed"); + const missingLanePath = path.join(layout.worktreesDir, "lane-missing"); + const attachedLanePath = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-attached-")); + fs.mkdirSync(managedLanePath, { recursive: true }); + + const db = await openKvDb(layout.dbPath, createLogger()); + const now = "2026-04-02T12:00:00.000Z"; + insertProject(db, "proj-recent", projectRoot, now); + insertLane(db, { + laneId: "lane-primary", + projectId: "proj-recent", + laneType: "primary", + worktreePath: projectRoot, + branchRef: "main", + }); + insertLane(db, { + laneId: "lane-managed", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: managedLanePath, + branchRef: "feature/managed", + }); + insertLane(db, { + laneId: "lane-missing", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: missingLanePath, + branchRef: "feature/missing", + }); + insertLane(db, { + laneId: "lane-attached", + projectId: "proj-recent", + laneType: "attached", + worktreePath: attachedLanePath, + attachedRootPath: attachedLanePath, + branchRef: "feature/attached", + }); + insertLane(db, { + laneId: "lane-archived", + projectId: "proj-recent", + laneType: "worktree", + worktreePath: path.join(layout.worktreesDir, "lane-archived"), + branchRef: "feature/archived", + status: "archived", + archivedAt: now, + }); + db.close(); + + const summary = toRecentProjectSummary({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: now, + }); + + expect(summary.exists).toBe(true); + expect(summary.laneCount).toBe(3); + }); + + it("falls back to git worktree metadata when no ADE lane registry exists", () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-git-fallback-")); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-b"), { recursive: true }); + + const summary = toRecentProjectSummary({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: "2026-04-02T12:00:00.000Z", + }); + + expect(summary.laneCount).toBe(3); + }); + + it("falls back to git worktree metadata when ADE only has archived lanes", async () => { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-recent-project-archived-fallback-")); + fs.mkdirSync(path.join(projectRoot, ".git", "worktrees", "lane-a"), { recursive: true }); + + const layout = resolveAdeLayout(projectRoot); + const db = await openKvDb(layout.dbPath, createLogger()); + const now = "2026-04-02T12:00:00.000Z"; + insertProject(db, "proj-recent-archived", projectRoot, now); + insertLane(db, { + laneId: "lane-primary-archived", + projectId: "proj-recent-archived", + laneType: "primary", + worktreePath: projectRoot, + branchRef: "main", + status: "archived", + archivedAt: now, + }); + db.close(); + + const summary = toRecentProjectSummary({ + rootPath: projectRoot, + displayName: "demo", + lastOpenedAt: now, + }); + + expect(summary.laneCount).toBe(2); + }); +}); diff --git a/apps/desktop/src/main/services/projects/recentProjectSummary.ts b/apps/desktop/src/main/services/projects/recentProjectSummary.ts new file mode 100644 index 000000000..c6e69fdfe --- /dev/null +++ b/apps/desktop/src/main/services/projects/recentProjectSummary.ts @@ -0,0 +1,113 @@ +import { createRequire } from "node:module"; +import fs from "node:fs"; +import path from "node:path"; +import type { DatabaseSync as DatabaseSyncType } from "node:sqlite"; +import { resolveAdeLayout } from "../../../shared/adeLayout"; +import type { RecentProjectSummary } from "../../../shared/types"; + +const require = createRequire(__filename); +const { DatabaseSync } = require("node:sqlite") as { DatabaseSync: typeof DatabaseSyncType }; + +type RecentProjectEntry = { + rootPath: string; + displayName: string; + lastOpenedAt: string; +}; + +type LaneCountRow = { + lane_type: string | null; + worktree_path: string | null; + attached_root_path: string | null; + status: string | null; + archived_at: string | null; +}; + +function normalizePath(value: string | null | undefined): string | null { + const trimmed = typeof value === "string" ? value.trim() : ""; + return trimmed ? path.resolve(trimmed) : null; +} + +function laneExistsOnDisk(row: LaneCountRow, projectRoot: string): boolean { + if ((row.lane_type ?? "").trim() === "primary") { + return fs.existsSync(projectRoot); + } + const candidatePath = normalizePath(row.worktree_path) ?? normalizePath(row.attached_root_path); + return candidatePath ? fs.existsSync(candidatePath) : false; +} + +function readAdeLaneCount(projectRoot: string): number | null { + const dbPath = resolveAdeLayout(projectRoot).dbPath; + if (!fs.existsSync(dbPath)) return null; + + let db: DatabaseSyncType | null = null; + try { + db = new DatabaseSync(dbPath); + const hasLanesTable = Boolean( + db.prepare("select 1 as present from sqlite_master where type = 'table' and name = ? limit 1") + .get<{ present?: number }>("lanes")?.present, + ); + if (!hasLanesTable) return null; + + const rows = db.prepare( + ` + select lane_type, worktree_path, attached_root_path, status, archived_at + from lanes + where coalesce(status, 'active') != 'archived' + and archived_at is null + `, + ).all(); + + let count = 0; + for (const row of rows) { + if (laneExistsOnDisk(row, projectRoot)) count += 1; + } + return count > 0 ? count : null; + } catch { + return null; + } finally { + db?.close(); + } +} + +function readGitLaneCount(projectRoot: string): number | undefined { + try { + const gitPath = path.join(projectRoot, ".git"); + const gitStat = fs.existsSync(gitPath) ? fs.statSync(gitPath) : null; + if (!gitStat) return undefined; + + let actualGitDir = gitPath; + if (gitStat.isFile()) { + // .git file in a worktree checkout — read the gitdir pointer + const content = fs.readFileSync(gitPath, "utf-8").trim(); + const match = content.match(/^gitdir:\s*(.+)$/); + if (!match) return 1; + actualGitDir = path.resolve(projectRoot, match[1]); + } else if (!gitStat.isDirectory()) { + return 1; + } + + let laneCount = 1; + const worktreesPath = path.join(actualGitDir, "worktrees"); + if (fs.existsSync(worktreesPath)) { + laneCount += fs.readdirSync(worktreesPath, { withFileTypes: true }) + .filter((dirent) => dirent.isDirectory()) + .length; + } + return laneCount; + } catch { + return undefined; + } +} + +export function toRecentProjectSummary(entry: RecentProjectEntry): RecentProjectSummary { + const exists = fs.existsSync(entry.rootPath); + const laneCount = exists ? (readAdeLaneCount(entry.rootPath) ?? readGitLaneCount(entry.rootPath)) : undefined; + + return { + rootPath: entry.rootPath, + displayName: entry.displayName, + lastOpenedAt: entry.lastOpenedAt, + exists, + laneCount, + }; +} diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index c2b20f2ed..6071195cb 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -3532,6 +3532,13 @@ export function createPrService({ adeKind: deriveAdeKind(workflowRow, groupRow, linkedPrRow), workflowDisplayState: workflowRow ? parseWorkflowDisplayState(workflowRow.workflow_display_state) : null, cleanupState: workflowRow ? parseCleanupState(workflowRow.cleanup_state) : null, + labels: Array.isArray(rawPr?.labels) + ? rawPr.labels + .filter((l: any) => l?.name) + .map((l: any) => ({ name: String(l.name), color: String(l.color || "cccccc"), description: l.description != null ? String(l.description) : null })) + : [], + isBot: asString(rawPr?.user?.type).toLowerCase() === "bot", + commentCount: Number(rawPr?.comments) || 0, }; }; diff --git a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts index 4bd65964a..34da34d87 100644 --- a/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts +++ b/apps/desktop/src/main/services/runtime/adeMcpLaunch.ts @@ -25,6 +25,7 @@ export type DesktopAdeMcpLaunchArgs = { runId?: string; stepId?: string; attemptId?: string; + chatSessionId?: string; defaultRole?: string; ownerId?: string; computerUsePolicy?: ComputerUsePolicy | null; @@ -100,7 +101,7 @@ function buildLaunchEnv(args: { projectRoot: string; workspaceRoot: string; socketPath: string; -} & Pick): Record { +} & Pick): Record { return { ADE_PROJECT_ROOT: args.projectRoot, ADE_WORKSPACE_ROOT: args.workspaceRoot, @@ -109,6 +110,7 @@ function buildLaunchEnv(args: { ADE_RUN_ID: args.runId ?? "", ADE_STEP_ID: args.stepId ?? "", ADE_ATTEMPT_ID: args.attemptId ?? "", + ADE_CHAT_SESSION_ID: args.chatSessionId ?? "", ADE_DEFAULT_ROLE: args.defaultRole ?? "agent", ADE_OWNER_ID: args.ownerId ?? "", ADE_COMPUTER_USE_MODE: args.computerUsePolicy?.mode ?? "", @@ -136,6 +138,7 @@ export function resolveDesktopAdeMcpLaunch(args: DesktopAdeMcpLaunchArgs): AdeMc runId: args.runId, stepId: args.stepId, attemptId: args.attemptId, + chatSessionId: args.chatSessionId, defaultRole: args.defaultRole, ownerId: args.ownerId, socketPath, diff --git a/apps/desktop/src/main/services/sessions/sessionService.ts b/apps/desktop/src/main/services/sessions/sessionService.ts index ea36ff74c..3e3a0d74a 100644 --- a/apps/desktop/src/main/services/sessions/sessionService.ts +++ b/apps/desktop/src/main/services/sessions/sessionService.ts @@ -135,22 +135,40 @@ export function createSessionService({ db }: { db: AdeDb }) { reconcileStaleRunningSessions({ endedAt, - status + status, + excludeToolTypes, }: { endedAt?: string; status?: TerminalSessionStatus; + excludeToolTypes?: string[]; } = {}): number { - const row = db.get<{ count: number }>("select count(1) as count from terminal_sessions where status = 'running'"); + const normalizedExcludedToolTypes = Array.isArray(excludeToolTypes) + ? excludeToolTypes + .map((toolType) => normalizeToolType(toolType)) + .filter((toolType): toolType is TerminalToolType => toolType != null) + : []; + const exclusionSql = normalizedExcludedToolTypes.length + ? ` and (tool_type is null or tool_type not in (${normalizedExcludedToolTypes.map(() => "?").join(", ")}))` + : ""; + const whereSql = `status = 'running'${exclusionSql}`; + const row = db.get<{ count: number }>( + `select count(1) as count from terminal_sessions where ${whereSql}`, + normalizedExcludedToolTypes, + ); const count = Number(row?.count ?? 0); if (!Number.isFinite(count) || count <= 0) return 0; const finalEndedAt = endedAt ?? new Date().toISOString(); const finalStatus = status ?? "disposed"; - db.run("update terminal_sessions set ended_at = ?, exit_code = ?, status = ?, pty_id = null where status = 'running'", [ - finalEndedAt, - null, - finalStatus - ]); + db.run( + `update terminal_sessions set ended_at = ?, exit_code = ?, status = ?, pty_id = null where ${whereSql}`, + [ + finalEndedAt, + null, + finalStatus, + ...normalizedExcludedToolTypes, + ], + ); return count; }, diff --git a/apps/desktop/src/renderer/components/app/App.tsx b/apps/desktop/src/renderer/components/app/App.tsx index bef97e810..baeec96ed 100644 --- a/apps/desktop/src/renderer/components/app/App.tsx +++ b/apps/desktop/src/renderer/components/app/App.tsx @@ -125,7 +125,7 @@ class PageErrorBoundaryInner extends React.Component< function PageErrorBoundary({ children }: { children: React.ReactNode }) { const navigate = useNavigate(); return ( - navigate("/project")}> + navigate("/work")}> {children} ); @@ -198,9 +198,9 @@ export function App() {
- } /> + } /> }> - } /> + } /> )} /> )} /> )} /> @@ -213,7 +213,7 @@ export function App() { )} /> )} /> )} /> - } /> + } />
diff --git a/apps/desktop/src/renderer/components/app/AppShell.test.tsx b/apps/desktop/src/renderer/components/app/AppShell.test.tsx index 5ff7a41c6..97ae7544c 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.test.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.test.tsx @@ -3,7 +3,7 @@ import React from "react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { AppShell } from "./AppShell"; import { useAppStore } from "../../state/appStore"; @@ -57,6 +57,11 @@ vi.mock("../../lib/zoom", () => ({ displayZoomToLevel: () => 0, })); +function LocationProbe() { + const location = useLocation(); + return
{location.pathname}
; +} + function resetStore() { useAppStore.setState({ project: { rootPath: "/Users/arul/ADE", name: "ADE" } as any, @@ -194,4 +199,67 @@ describe("AppShell", () => { expect(screen.queryByText(/ADE context docs need regeneration/i)).toBeNull(); }); + + it("moves project selection flows from run to work", async () => { + useAppStore.setState({ + project: null, + showWelcome: true, + } as any); + globalThis.window.ade.app.getProject = vi.fn(async () => null); + + render( + + + + + , + ); + + expect(screen.getByTestId("location-probe").textContent).toBe("/project"); + + await act(async () => { + useAppStore.getState().setProject({ rootPath: "/Users/arul/ADE-next", name: "ADE next" } as any); + useAppStore.getState().setShowWelcome(false); + }); + + expect(await screen.findByText("/work")).toBeTruthy(); + }); + + it("waits for AI status before showing the missing provider banner", async () => { + vi.useFakeTimers(); + try { + globalThis.window.ade.ai.getStatus = vi.fn(async () => ({ + detectedAuth: [], + providerConnections: { + claude: { authAvailable: false }, + codex: { authAvailable: false }, + cursor: { authAvailable: false }, + }, + availableProviders: { + claude: false, + codex: false, + cursor: false, + }, + })) as any; + + render( + + +
child
+
+
, + ); + + expect(screen.queryByText(/No AI provider is configured yet/i)).toBeNull(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + await Promise.resolve(); + }); + + expect(screen.getByText(/No AI provider is configured yet/i)).toBeTruthy(); + } finally { + vi.useRealTimers(); + } + }); }); diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 852054096..66a332a55 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -148,12 +148,14 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [aiFailure, setAiFailure] = useState(null); const [aiMockProvider, setAiMockProvider] = useState<{ createdAt: string } | null>(null); const [aiStatus, setAiStatus] = useState(null); + const [aiStatusLoaded, setAiStatusLoaded] = useState(false); const [githubStatus, setGithubStatus] = useState(null); const [onboardingStatus, setOnboardingStatus] = useState(null); const [onboardingStatusLoading, setOnboardingStatusLoading] = useState(false); const [contextStatus, setContextStatus] = useState(null); const [dismissedContextBannerRoots, setDismissedContextBannerRoots] = useState>({}); const [projectMissing, setProjectMissing] = useState(false); + const previousProjectRootRef = useRef(undefined); const isOnboardingRoute = location.pathname === "/onboarding"; const shouldTrackTerminalAttention = Boolean(project?.rootPath) @@ -348,14 +350,28 @@ export function AppShell({ children }: { children: React.ReactNode }) { setProjectMissing(false); }, [project?.rootPath]); + useEffect(() => { + const previousProjectRoot = previousProjectRootRef.current; + const nextProjectRoot = project?.rootPath ?? null; + previousProjectRootRef.current = nextProjectRoot; + + if (previousProjectRoot === undefined) return; + if (!nextProjectRoot || showWelcome) return; + if (location.pathname !== "/project") return; + if (previousProjectRoot === nextProjectRoot) return; + navigate("/work", { replace: true }); + }, [location.pathname, navigate, project?.rootPath, showWelcome]); + useEffect(() => { let cancelled = false; if (!project?.rootPath) { setContextStatus(null); setAiStatus(null); + setAiStatusLoaded(false); setGithubStatus(null); return; } + setAiStatusLoaded(false); const timer = window.setTimeout(() => { void Promise.allSettled([ window.ade.context.getStatus(), @@ -366,6 +382,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { const [contextResult, aiResult, githubResult] = results; setContextStatus(contextResult.status === "fulfilled" ? contextResult.value : null); setAiStatus(aiResult.status === "fulfilled" ? aiResult.value : null); + setAiStatusLoaded(true); setGithubStatus(githubResult.status === "fulfilled" ? githubResult.value : null); }); }, 1_000); @@ -603,7 +620,7 @@ export function AppShell({ children }: { children: React.ReactNode }) { ) : null} - {!hideSidebar && project?.rootPath && !showWelcome && !hasAnyAiProvider ? ( + {!hideSidebar && project?.rootPath && !showWelcome && aiStatusLoaded && aiStatus !== null && !hasAnyAiProvider ? (
No AI provider is configured yet. Set up AI
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 50d1c4440..13ea0904b 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -172,6 +172,163 @@ describe("AgentChatComposer", () => { }); }); + it("avoids promising option chips when a pending question is freeform only", () => { + renderComposer({ + pendingInput: { + requestId: "req-1", + itemId: "item-1", + source: "ade", + kind: "question", + title: "Input needed", + description: "What should we test first?", + questions: [{ + id: "answer", + header: "Question 1", + question: "What should we test first?", + allowsFreeform: true, + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: null, + }, + }); + + expect(screen.getByText("Type your answer below.")).toBeTruthy(); + expect(screen.queryByText("Type your answer below or pick an option above.")).toBeNull(); + }); + + it("keeps the option hint when a pending question includes selectable options", () => { + renderComposer({ + pendingInput: { + requestId: "req-2", + itemId: "item-2", + source: "ade", + kind: "structured_question", + title: "Input needed", + description: "Which flow should we test first?", + questions: [{ + id: "answer", + header: "Question 1", + question: "Which flow should we test first?", + allowsFreeform: true, + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: null, + }, + }); + + expect(screen.getByText("Type your answer below or pick an option above.")).toBeTruthy(); + }); + + it("keeps the option hint when any pending question includes selectable options", () => { + renderComposer({ + pendingInput: { + requestId: "req-2b", + itemId: "item-2b", + source: "codex", + kind: "structured_question", + title: "Input needed", + description: "Two questions are pending", + questions: [ + { + id: "first", + header: "Question 1", + question: "What should we inspect first?", + allowsFreeform: true, + }, + { + id: "second", + header: "Question 2", + question: "Which flow should we use?", + allowsFreeform: true, + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: null, + }, + }); + + expect(screen.getByText("Type your answer below or pick an option above.")).toBeTruthy(); + }); + + it("uses decline wording for native Codex structured questions", () => { + const props = renderComposer({ + pendingInput: { + requestId: "req-2c", + itemId: "item-2c", + source: "codex", + kind: "structured_question", + title: "Input needed", + description: "Which flow should we test first?", + questions: [{ + id: "answer", + header: "Question 1", + question: "Which flow should we test first?", + allowsFreeform: true, + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + }], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: null, + }, + }); + + const decline = screen.getByRole("button", { name: "Decline" }); + fireEvent.click(decline); + + expect(props.onApproval).toHaveBeenCalledWith("decline"); + }); + + it("labels multi-question prompts explicitly in the pending banner", () => { + renderComposer({ + pendingInput: { + requestId: "req-3", + itemId: "item-3", + source: "codex", + kind: "structured_question", + title: "Input needed", + description: "Multiple decisions are needed", + questions: [ + { + id: "q1", + header: "Question 1", + question: "What should we test first?", + allowsFreeform: true, + }, + { + id: "q2", + header: "Question 2", + question: "Which validation strategy should we use?", + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + turnId: null, + }, + }); + + expect(screen.getByText("2 Questions · codex")).toBeTruthy(); + }); + it("disables attachments while steering an active turn", () => { renderComposer({ turnActive: true }); @@ -216,4 +373,29 @@ describe("AgentChatComposer", () => { expect(screen.queryByTitle("Include project context (PRD + architecture) with first message")).toBeNull(); }); + + it("uses a constrained resizable textarea in grid-tile mode", () => { + renderComposer({ + layoutVariant: "grid-tile", + composerMaxHeightPx: 128, + }); + + const textarea = screen.getByPlaceholderText("Steer the active turn...") as HTMLTextAreaElement; + expect(textarea.dataset.chatLayoutVariant).toBe("grid-tile"); + expect(textarea.style.maxHeight).toBe("128px"); + expect(textarea.className).toContain("resize-y"); + }); + + it("shows only the available session models when the chat catalog is restricted", () => { + renderComposer({ + availableModelIds: ["openai/gpt-5.4-codex", "openai/gpt-5.2-codex"], + restrictModelCatalogToAvailable: true, + turnActive: false, + }); + + fireEvent.click(screen.getByRole("button", { name: "Select model" })); + + expect(screen.getByText("GPT-5.2-Codex")).toBeTruthy(); + expect(screen.queryByText("Claude Sonnet 4.6")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 27fbfbd4c..ca4429849 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -25,6 +25,7 @@ import { UnifiedModelSelector } from "../shared/UnifiedModelSelector"; import { getPermissionOptions, safetyColors } from "../shared/permissionOptions"; import { ChatAttachmentTray } from "./ChatAttachmentTray"; import { ChatComposerShell } from "./ChatComposerShell"; +import { getPendingInputQuestionCount, hasPendingInputOptions } from "./pendingInput"; import { CURSOR_MODE_LABELS } from "../../../shared/cursorModes"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { ChatSubagentStrip } from "./ChatSubagentStrip"; @@ -263,6 +264,8 @@ function PendingSteerItem({ export function AgentChatComposer({ surfaceMode = "standard", + layoutVariant = "standard", + composerMaxHeightPx = null, sdkSlashCommands = [], modelId, availableModelIds, @@ -320,12 +323,15 @@ export function AgentChatComposer({ promptSuggestion, subagentSnapshots = [], chatHasMessages = false, + restrictModelCatalogToAvailable = false, pendingSteers = [], onCancelSteer, onEditSteer, onOpenAiSettings, }: { surfaceMode?: ChatSurfaceMode; + layoutVariant?: "standard" | "grid-tile"; + composerMaxHeightPx?: number | null; sdkSlashCommands?: AgentChatSlashCommand[]; modelId: string; availableModelIds?: string[]; @@ -360,7 +366,7 @@ export function AgentChatComposer({ onClearDraft?: () => void; onSubmit: () => void; onInterrupt: () => void; - onApproval: (decision: AgentChatApprovalDecision) => void; + onApproval: (decision: AgentChatApprovalDecision, responseText?: string | null) => void; onAddAttachment: (attachment: AgentChatFileRef) => void; onRemoveAttachment: (path: string) => void; onSearchAttachments: (query: string) => Promise; @@ -387,6 +393,7 @@ export function AgentChatComposer({ promptSuggestion?: string | null; subagentSnapshots?: ChatSubagentSnapshot[]; chatHasMessages?: boolean; + restrictModelCatalogToAvailable?: boolean; pendingSteers?: Array<{ steerId: string; text: string }>; onCancelSteer?: (steerId: string) => void; onEditSteer?: (steerId: string, text: string) => void; @@ -897,8 +904,7 @@ export function AgentChatComposer({ const shouldSend = sendOnEnter ? !commandEnter : commandEnter; if (!shouldSend) return; event.preventDefault(); - if (busy || !modelId || !draft.trim().length) return; - onSubmit(); + submitComposerDraft(); }; const openUploadPicker = () => { @@ -930,11 +936,30 @@ export function AgentChatComposer({ void addFileAttachments(event.dataTransfer.files); }; + const submitComposerDraft = useCallback(() => { + const isQuestionPending = pendingInput && (pendingInput.kind === "question" || pendingInput.kind === "structured_question"); + if (isQuestionPending) { + const answer = draft.trim(); + if (!answer.length && !pendingInput.canProceedWithoutAnswer) return; + onApproval("accept", answer || null); + onDraftChange(""); + return; + } + if (busy || !modelId || !draft.trim().length) return; + onSubmit(); + }, [busy, draft, modelId, onApproval, onDraftChange, onSubmit, pendingInput]); + + const pendingQuestionCount = getPendingInputQuestionCount(pendingInput); + const showPendingInputOptionsHint = hasPendingInputOptions(pendingInput); + return ( <> @@ -961,7 +986,11 @@ export function AgentChatComposer({ - {pendingInput.kind === "approval" || pendingInput.kind === "permissions" ? "Approval" : "Input needed"} · {pendingInput.source} + {pendingInput.kind === "approval" || pendingInput.kind === "permissions" + ? "Approval" + : pendingQuestionCount > 1 + ? `${pendingQuestionCount} Questions` + : "Input needed"} · {pendingInput.source}
@@ -974,8 +1003,18 @@ export function AgentChatComposer({
) : ( -
- Open the question modal to answer and continue. +
+ + {showPendingInputOptionsHint ? "Type your answer below or pick an option above." : "Type your answer below."} + +
)}
@@ -1104,14 +1143,15 @@ export function AgentChatComposer({ } footer={ -
+
{/* Left: permission + model controls */} -
+
{nativeControlPanel} @@ -1238,7 +1278,7 @@ export function AgentChatComposer({ : "border-[color:color-mix(in_srgb,var(--chat-accent)_28%,transparent)] bg-[color:color-mix(in_srgb,var(--chat-accent)_12%,transparent)] text-[var(--chat-accent)] hover:bg-[color:color-mix(in_srgb,var(--chat-accent)_20%,transparent)]", )} disabled={busy || !draft.trim().length || !modelId} - onClick={onSubmit} + onClick={submitComposerDraft} title={!modelId ? "Select a model first" : "Send"} > @@ -1306,10 +1346,15 @@ export function AgentChatComposer({ if (val.startsWith("/")) { setSlashQuery(val.slice(1)); setSlashCursor(0); } }} className={cn( - "min-h-[44px] max-h-[200px] w-full resize-none bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + "min-h-[44px] w-full bg-transparent px-4 py-2.5 text-[13px] leading-[1.6] text-fg/88 outline-none transition-colors placeholder:text-muted-fg/25", + layoutVariant === "grid-tile" ? "resize-y" : "max-h-[200px] resize-none", dragActive ? "opacity-30" : "", )} - placeholder={turnActive ? "Steer the active turn..." : (promptSuggestion ? "" : (messagePlaceholder ?? "Message the assistant..."))} + style={layoutVariant === "grid-tile" && composerMaxHeightPx != null + ? { maxHeight: `${composerMaxHeightPx}px` } + : undefined} + data-chat-layout-variant={layoutVariant} + placeholder={turnActive ? "Steer the active turn..." : (promptSuggestion ? "" : (messagePlaceholder ?? "Type to vibecode..."))} onKeyDown={handleKeyDown} onPaste={handlePaste} /> diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index d23135fd3..07d427447 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { MemoryRouter, useLocation } from "react-router-dom"; -import type { AgentChatEventEnvelope } from "../../../shared/types"; +import type { AgentChatApprovalDecision, AgentChatEventEnvelope } from "../../../shared/types"; import * as modelRegistry from "../../../shared/modelRegistry"; import { AgentChatMessageList, @@ -37,6 +37,7 @@ function renderMessageList( assistantLabel?: string; initialState?: Record; showStreamingIndicator?: boolean; + onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; }, ) { return render( @@ -45,6 +46,7 @@ function renderMessageList( events={events} assistantLabel={options?.assistantLabel} showStreamingIndicator={options?.showStreamingIndicator} + onApproval={options?.onApproval as any} /> , @@ -267,10 +269,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Work log (2)")).toBeTruthy(); - - // Only the most recent entry is visible by default; expand to reveal the older one - fireEvent.click(screen.getByRole("button", { name: "Show 1 more" })); + expect(screen.getByText("Ran shell")).toBeTruthy(); fireEvent.click(findButtonByTextContent(/npm test/i)); fireEvent.click(findButtonByTextContent(/npm run lint/i)); @@ -309,10 +308,7 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("Work log (2)")).toBeTruthy(); - - // Only the most recent entry is visible by default; expand to reveal the older one - fireEvent.click(screen.getByRole("button", { name: "Show 1 more" })); + expect(screen.getByText("Edited files")).toBeTruthy(); fireEvent.click(findButtonByTextContent(/foo\.ts/i)); fireEvent.click(findButtonByTextContent(/bar\.ts/i)); @@ -322,7 +318,7 @@ describe("AgentChatMessageList transcript rendering", () => { expect(body).toContain("bar.ts"); }); - it("shows only the most recent work-log entry by default and expands overflow on demand", () => { + it("shows the four most recent work-log entries by default and expands overflow on demand", () => { renderMessageList( Array.from({ length: 7 }, (_, index) => ({ sessionId: "session-1", @@ -340,16 +336,98 @@ describe("AgentChatMessageList transcript rendering", () => { })), ); - expect(screen.getByText("Work log (7)")).toBeTruthy(); - expect(screen.getByText("Show 6 more")).toBeTruthy(); - expect(screen.queryByText(/Shell - echo 1/i)).toBeNull(); + expect(screen.getByText("Ran shell")).toBeTruthy(); + expect(screen.getByText("Show 3 earlier")).toBeTruthy(); + expect(screen.queryByText(/echo 3/i)).toBeNull(); + expect(findButtonByTextContent(/echo 4/i)).toBeTruthy(); expect(findButtonByTextContent(/echo 7/i)).toBeTruthy(); - fireEvent.click(screen.getByRole("button", { name: "Show 6 more" })); + fireEvent.click(screen.getByRole("button", { name: "Show 3 earlier" })); expect(findButtonByTextContent(/echo 1/i)).toBeTruthy(); }); + it("uses a bounded assistant bubble width for long markdown responses", () => { + const view = renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "text", + text: "Streaming response", + itemId: "text-1", + turnId: "turn-1", + }, + }, + ]); + + expect(view.container.innerHTML).toContain("max-w-[78ch]"); + }); + + it("renders markdown tables inside a dedicated scroll shell", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "text", + text: [ + "| Aspect | ADE | Other UI |", + "| --- | --- | --- |", + "| Task progress | Flat tool cards | Step-based progress |", + ].join("\n"), + itemId: "text-table", + turnId: "turn-1", + }, + }, + ]); + + const table = screen.getByRole("table"); + expect(table.parentElement?.className).toContain("overflow-x-auto"); + expect(screen.getByText("Task progress")).toBeTruthy(); + }); + + it("absorbs tool summaries into the grouped work-log header instead of rendering a separate row", () => { + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_call", + tool: "functions.exec_command", + args: { cmd: "pwd" }, + itemId: "tool-1", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "tool_result", + tool: "functions.exec_command", + result: { stdout: "/tmp/project" }, + itemId: "tool-1", + turnId: "turn-1", + status: "completed", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:02.000Z", + event: { + type: "tool_use_summary", + summary: "Checked the current working directory", + toolUseIds: ["tool-1"], + turnId: "turn-1", + }, + }, + ]); + + expect(screen.getByText("Checked the current working directory")).toBeTruthy(); + expect(screen.queryByText("Tool summary")).toBeNull(); + }); + it("makes workspace markdown links open the Files tab", () => { renderMessageList( [ @@ -623,13 +701,16 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - expect(screen.getAllByText("1 of 2 tasks completed").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 file changed/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 background agent/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/2 complete").length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 file$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 agent$/i).length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("1 active").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("+1").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("-1").length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole("button", { name: "Review changes" })).toBeNull(); + fireEvent.click(screen.getByText("Turn recap")); fireEvent.click(screen.getByRole("button", { name: "Review changes" })); expect(screen.getByTestId("location").textContent).toBe("/files::{\"laneId\":\"lane-123\"}"); @@ -659,6 +740,131 @@ describe("AgentChatMessageList transcript rendering", () => { expect(view.container.querySelector(".animate-spin.text-amber-400")).toBeFalsy(); }); + it("renders structured question blocks and forwards structured answers from option chips", () => { + const onApproval = vi.fn(); + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "approval_request", + itemId: "approval-structured", + kind: "tool_call", + description: "Choose how to proceed", + turnId: "turn-1", + detail: { + request: { + requestId: "request-structured", + itemId: "approval-structured", + source: "codex", + kind: "structured_question", + title: "Input needed", + description: "Choose how to proceed", + questions: [ + { + id: "question_1", + header: "Question 1", + question: "Which area should we test first?", + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + allowsFreeform: true, + }, + { + id: "question_2", + header: "Question 2", + question: "What validation strategy should we use?", + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }, + }, + }, + }, + ], { onApproval }); + + expect(screen.getByText("Question 1")).toBeTruthy(); + expect(screen.getByText("Question 2")).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Question flow" })); + + expect(onApproval).toHaveBeenCalledWith( + "approval-structured", + "accept", + null, + { question_1: ["question_flow"] }, + ); + }); + + it("shows structured questions as declined once the first resolution arrives and disables stale option chips", () => { + const onApproval = vi.fn(); + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "approval_request", + itemId: "approval-structured", + kind: "tool_call", + description: "Choose how to proceed", + turnId: "turn-1", + detail: { + request: { + requestId: "request-structured", + itemId: "approval-structured", + source: "codex", + kind: "structured_question", + title: "Input needed", + description: "Choose how to proceed", + questions: [ + { + id: "question_1", + header: "Question 1", + question: "Which area should we test first?", + options: [ + { label: "Question flow", value: "question_flow" }, + { label: "Plan updates", value: "plan_updates" }, + ], + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }, + }, + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "pending_input_resolved", + itemId: "approval-structured", + resolution: "declined", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:02.000Z", + event: { + type: "pending_input_resolved", + itemId: "approval-structured", + resolution: "cancelled", + }, + }, + ], { onApproval }); + + expect(screen.getByText("Declined")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Question flow" })).toBeNull(); + expect(screen.queryByRole("button", { name: "Plan updates" })).toBeNull(); + expect(onApproval).not.toHaveBeenCalled(); + }); + it("labels provider chats as Codex and preserves explicit assistant labels", () => { renderMessageList([ { @@ -819,12 +1025,15 @@ describe("AgentChatMessageList transcript rendering", () => { }, ); - expect(screen.getAllByText("1 of 2 tasks completed").length).toBeGreaterThanOrEqual(1); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/2 complete").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Inspect shared renderer").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText("Implement calmer transcript rows").length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 file changed/i).length).toBeGreaterThanOrEqual(1); - expect(screen.getAllByText(/1 background agent/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 file$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.getAllByText(/^1 agent$/i).length).toBeGreaterThanOrEqual(1); + expect(screen.queryByRole("button", { name: /Review changes/i })).toBeNull(); + fireEvent.click(screen.getByText("Turn recap")); fireEvent.click(screen.getByRole("button", { name: /Review changes/i })); expect(screen.getByTestId("location").textContent).toBe( @@ -857,7 +1066,8 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - expect(screen.getByText("1 of 1 tasks completed")).toBeTruthy(); + expect(screen.getByText("Turn recap")).toBeTruthy(); + expect(screen.getAllByText("1/1 complete").length).toBeGreaterThanOrEqual(1); expect(screen.getAllByText(/Claude Sonnet 4\.6/).length).toBeGreaterThanOrEqual(1); }); @@ -896,14 +1106,14 @@ describe("AgentChatMessageList transcript rendering", () => { }, ]); - const reasoningButtons = screen.getAllByRole("button", { name: /Thought for/i }); + const reasoningButtons = screen.getAllByRole("button", { name: /Thought/i }); expect(reasoningButtons).toHaveLength(2); fireEvent.click(reasoningButtons[0]!); fireEvent.click(reasoningButtons[1]!); - expect(screen.getByText("First thought.")).toBeTruthy(); - expect(screen.getByText("Second thought.")).toBeTruthy(); + expect(screen.getAllByText("First thought.")).toHaveLength(2); + expect(screen.getAllByText("Second thought.")).toHaveLength(2); expect(screen.queryByText("First thought.Second thought.")).toBeNull(); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index c204670f4..0f1f3becf 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -1,6 +1,7 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; +import { motion } from "motion/react"; import { useLocation, useNavigate } from "react-router-dom"; import { CaretDown, @@ -23,6 +24,7 @@ import { Globe, ShieldCheck, CopySimple, + Brain, } from "@phosphor-icons/react"; import type { AgentChatApprovalDecision, @@ -58,6 +60,7 @@ import { } from "./chatTranscriptRows"; const NAVIGATION_SURFACES = new Set(["work", "missions", "lanes", "cto"]); +type PendingInputResolution = Extract["resolution"]; function readOperatorNavigationSuggestion(value: unknown): OperatorNavigationSuggestion | null { const record = readRecord(value); @@ -345,7 +348,8 @@ function MessageCopyButton({ /* ── Status indicators ── */ -function StatusIcon({ status }: { status: "running" | "completed" | "failed" }) { +function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" }) { + if (status === "interrupted") return ; if (status === "completed" || status === "failed") return ; return ; } @@ -372,6 +376,7 @@ function statusColorClass(status: string | undefined): string { switch (status) { case "failed": return "text-red-400/70"; + case "interrupted": case "running": return "text-amber-400/70"; default: @@ -539,7 +544,7 @@ const MarkdownBlock = React.memo(function MarkdownBlock({ }, [onOpenWorkspacePath, workspaceLaneId]); return ( -
+
), table: ({ children }) => ( -
- {children}
+
+ {children}
), + thead: ({ children, node: _, ...props }) => {children}, + tbody: ({ children, node: _, ...props }) => {children}, + tr: ({ children, node: _, ...props }) => {children}, + th: ({ children, node: _, ...props }) => ( + + {children} + + ), + td: ({ children, node: _, ...props }) => ( + + {children} + + ), pre: ({ children }) => (
               {children}
@@ -626,7 +644,8 @@ function CollapsibleCard({
   defaultOpen = false,
   forceOpen,
   summary,
-  className
+  className,
+  style: styleProp,
 }: {
   children: React.ReactNode;
   defaultOpen?: boolean;
@@ -634,6 +653,7 @@ function CollapsibleCard({
   forceOpen?: boolean;
   summary: React.ReactNode;
   className?: string;
+  style?: React.CSSProperties;
 }) {
   const [open, setOpen] = useState(defaultOpen);
   // Track whether the user explicitly collapsed while forceOpen is active
@@ -657,12 +677,12 @@ function CollapsibleCard({
   const isOpen = forceOpen === true ? !userCollapsed : open;
 
   return (
-    
+
+ {isQuestionRequest && questionCards.length > 0 ? ( +
+ {questionCards.map((card) => ( +
+
+ + {card.header} + + {card.allowsFreeform ? ( + + Freeform allowed + + ) : null} +
+
+ {card.questionText} +
+ {card.defaultAssumption.length || card.impact.length || card.isSecret ? ( +
+ {card.isSecret ? Secret answer : null} + {card.defaultAssumption.length ? Assumption: {card.defaultAssumption} : null} + {card.impact.length ? Impact: {card.impact} : null} +
+ ) : null} + {card.options.length > 0 && options?.onApproval && !isResolved ? ( +
+ {card.options.map((option) => ( + + ))} +
+ ) : null} +
))}
) : null} @@ -1871,9 +1976,43 @@ function renderEvent(
) : null} - {isAskUser ? ( -
- Answer this from the question modal to keep the agent moving. + {handleApproval && isAskUser && !isResolved ? ( +
+ +
+ ) : null} + {isAskUser && isResolved ? ( +
+ + {resolvedState === "accepted" ? ( + + ) : resolvedState === "declined" ? ( + + ) : ( + + )} + {resolvedState === "accepted" + ? "Answered" + : resolvedState === "declined" + ? "Declined" + : "Closed"} +
) : null} {handleApproval && !isAskUser ? ( @@ -2067,13 +2206,13 @@ function renderEvent( : "border-amber-500/15 bg-amber-500/[0.05] text-amber-300"; return ( -
-
+
+
Usage {modelLabel ? ( - + - {modelLabel} + {modelLabel} ) : null} {inputTokens ? In {inputTokens} : null} @@ -2081,10 +2220,10 @@ function renderEvent( {cacheRead ? Cache {cacheRead} : null} {cacheCreation ? New cache {cacheCreation} : null} {costLabel ? {costLabel} : null} + {event.status !== "completed" ? ( + {event.status} + ) : null}
- {event.status !== "completed" ? ( - {event.status} - ) : null}
); } @@ -2261,74 +2400,150 @@ function TurnSummaryCard({ const completedCount = summary.tasks.filter((task) => task.status === "completed").length; const totalCount = summary.tasks.length; const filesLabel = summary.files.length - ? `${summary.files.length} file${summary.files.length === 1 ? "" : "s"} changed` + ? `${summary.files.length} file${summary.files.length === 1 ? "" : "s"}` : null; const agentsLabel = summary.backgroundAgentCount - ? `${summary.backgroundAgentCount} background agent${summary.backgroundAgentCount === 1 ? "" : "s"}` + ? `${summary.backgroundAgentCount} agent${summary.backgroundAgentCount === 1 ? "" : "s"}` : null; + const taskLabel = totalCount ? `${completedCount}/${totalCount} complete` : null; + const hasDetails = summary.tasks.length > 0 || summary.files.length > 0 || summary.backgroundAgentCount > 0; + + const summaryHeader = ( +
+ + + Turn recap + + {taskLabel ? ( + {taskLabel} + ) : null} + {filesLabel ? ( + + {filesLabel} + {summary.totalAdditions > 0 ? +{summary.totalAdditions} : null} + {summary.totalDeletions > 0 ? -{summary.totalDeletions} : null} + + ) : null} + {agentsLabel ? ( + + {agentsLabel} + {summary.activeBackgroundAgentCount > 0 ? {summary.activeBackgroundAgentCount} active : null} + + ) : null} + {summary.turnModel?.label ? ( + + + {summary.turnModel.label} + + ) : null} +
+ ); - return ( -
-
-
- - - {totalCount - ? `${completedCount} of ${totalCount} tasks completed` - : filesLabel ?? agentsLabel ?? "Turn summary"} - - {summary.turnModel?.label ? ( - - - {summary.turnModel.label} + const details = hasDetails ? ( +
+ {summary.tasks.length > 0 ? ( +
+
Tasks
+
+ {summary.tasks.map((task) => ( +
+
+ +
+
+ {task.description} +
+ + {task.status.replace("_", " ")} + +
+ ))} +
+
+ ) : null} + {summary.files.length > 0 ? ( +
+
Files
+
+ {summary.files.map((file) => { + const basename = basenamePathLabel(file.path); + const dirname = dirnamePathLabel(file.path); + return ( +
+
+ +
+
+
+ {formatFileAction(file.kind)} + {basename} + {file.additions > 0 ? +{file.additions} : null} + {file.deletions > 0 || file.kind === "delete" ? -{file.deletions} : null} +
+ {dirname ? ( +
+ {dirname} +
+ ) : null} +
+
+ ); + })} +
+
+ ) : null} + {summary.backgroundAgentCount > 0 ? ( +
+
Background agents
+
+ + + {summary.activeBackgroundAgentCount === summary.backgroundAgentCount + ? `${summary.backgroundAgentCount} background ${summary.backgroundAgentCount === 1 ? "agent" : "agents"} running` + : summary.activeBackgroundAgentCount > 0 + ? `${summary.activeBackgroundAgentCount} of ${summary.backgroundAgentCount} background ${summary.backgroundAgentCount === 1 ? "agent" : "agents"} still running` + : `${summary.backgroundAgentCount} background ${summary.backgroundAgentCount === 1 ? "agent" : "agents"} completed`} - ) : null} - {onReviewChanges && summary.files.length > 0 ? ( - - ) : null} +
-
- - {summary.tasks.length ? ( -
- {summary.tasks.map((task, index) => ( -
-
- -
-
- {task.description} -
-
- ))} + ) : null} + {onReviewChanges && summary.files.length > 0 ? ( +
+
) : null} - -
- {filesLabel ? ( - - {filesLabel} - {summary.totalAdditions > 0 ? +{summary.totalAdditions} : null} - {summary.totalDeletions > 0 ? -{summary.totalDeletions} : null} - - ) : null} - {agentsLabel ? ( - - {agentsLabel} - {summary.activeBackgroundAgentCount > 0 ? {summary.activeBackgroundAgentCount} active : null} - - ) : null} -
+ ) : null; + + return ( + + {details} + ); } @@ -2361,7 +2576,7 @@ function deriveActiveTurnId(events: AgentChatEventEnvelope[]): string | null { function getGroupedTurnId(envelope: TranscriptGroupedEnvelope | undefined): string | null { if (!envelope) return null; if (envelope.event.type === "work_log_group") { - return envelope.event.entries[0]?.turnId ?? null; + return envelope.event.turnId ?? envelope.event.entries[0]?.turnId ?? null; } return "turnId" in envelope.event ? envelope.event.turnId ?? null : null; } @@ -2373,7 +2588,7 @@ type EventRowProps = { showTurnDivider: boolean; turnDividerLabel: string | null; turnModel: { label: string; modelId?: string; model?: string } | null; - onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null) => void; + onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; @@ -2382,6 +2597,7 @@ type EventRowProps = { onNavigateSuggestion?: (suggestion: OperatorNavigationSuggestion) => void; respondingApprovalIds?: Set; pendingApprovalIds?: Set; + resolvedInputStates?: Map; }; const EventRow = React.memo(function EventRow({ @@ -2398,6 +2614,7 @@ const EventRow = React.memo(function EventRow({ onNavigateSuggestion, respondingApprovalIds, pendingApprovalIds, + resolvedInputStates, }: EventRowProps) { return (
@@ -2421,10 +2638,22 @@ const EventRow = React.memo(function EventRow({ ? ( ) - : renderEvent(envelope as RenderEnvelope, { onApproval, turnModel, surfaceMode, surfaceProfile, assistantLabel, turnActive, onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds })} + : renderEvent(envelope as RenderEnvelope, { + onApproval, + turnModel, + surfaceMode, + surfaceProfile, + assistantLabel, + turnActive, + onOpenWorkspacePath, + respondingApprovalIds, + pendingApprovalIds, + resolvedInputStates, + })}
); }); @@ -2567,19 +2796,19 @@ export function reconcileMeasuredScrollTop({ export function AgentChatMessageList({ events, showStreamingIndicator = false, - className, - onApproval, - surfaceMode = "standard", + className, + onApproval, + surfaceMode = "standard", surfaceProfile = "standard", assistantLabel, onOpenWorkspacePath, respondingApprovalIds, pendingApprovalIds, -}: { + }: { events: AgentChatEventEnvelope[]; showStreamingIndicator?: boolean; className?: string; - onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null) => void; + onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; @@ -2598,6 +2827,16 @@ export function AgentChatMessageList({ const [filesWorkspaces, setFilesWorkspaces] = useState([]); const stickToBottomRef = useRef(true); const onApprovalRef = useRef(onApproval); + const resolvedInputStates = useMemo(() => { + const resolved = new Map(); + for (const envelope of events) { + if (envelope.event.type !== "pending_input_resolved") continue; + if (!resolved.has(envelope.event.itemId)) { + resolved.set(envelope.event.itemId, envelope.event.resolution); + } + } + return resolved; + }, [events]); // Virtualization scroll tracking const [scrollTop, setScrollTop] = useState(0); @@ -2616,8 +2855,8 @@ export function AgentChatMessageList({ onApprovalRef.current = onApproval; }, [onApproval]); - const handleApproval = useCallback((itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null) => { - onApprovalRef.current?.(itemId, decision, responseText); + const handleApproval = useCallback((itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => { + onApprovalRef.current?.(itemId, decision, responseText, answers); }, []); const rows = useMemo(() => { @@ -2863,6 +3102,7 @@ export function AgentChatMessageList({ onNavigateSuggestion={handleNavigateSuggestion} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} + resolvedInputStates={resolvedInputStates} /> ); } @@ -2883,9 +3123,10 @@ export function AgentChatMessageList({ onNavigateSuggestion={handleNavigateSuggestion} respondingApprovalIds={respondingApprovalIds} pendingApprovalIds={pendingApprovalIds} + resolvedInputStates={resolvedInputStates} /> ); - }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, respondingApprovalIds, pendingApprovalIds]); + }, [activeTurnId, assistantLabel, surfaceMode, surfaceProfile, groupedRows, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, respondingApprovalIds, pendingApprovalIds, resolvedInputStates]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -2906,7 +3147,7 @@ export function AgentChatMessageList({ {latestActivity ? ( ) : ( -
+
Working...
@@ -2925,18 +3166,7 @@ export function AgentChatMessageList({ onScroll={handleScroll} > {rows.length === 0 && !streamingIndicator ? ( -
-
- -
-
-
-
Start a chat session
- - {surfaceMode === "resolver" ? "Launch the resolver to start the transcript" : "Start a conversation"} - -
-
+ null ) : shouldVirtualize ? ( /* ── Virtualized path: only render rows in / near the viewport ── */
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx index 23906ca95..20c695b38 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.submit.test.tsx @@ -7,6 +7,7 @@ import { MemoryRouter } from "react-router-dom"; import { createDefaultComputerUsePolicy, type AgentChatEventEnvelope, + type AgentChatSession, type AgentChatSessionSummary, } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; @@ -41,6 +42,24 @@ function buildSession(sessionId: string, overrides: Partial = {}): AgentChatSession { + return { + id: sessionId, + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + modelId: "openai/gpt-5.4-codex", + status: "idle", + sessionProfile: "workflow", + reasoningEffort: "xhigh", + executionMode: "focused", + computerUse: createDefaultComputerUsePolicy(), + createdAt: "2026-03-24T05:57:45.700Z", + lastActivityAt: "2026-03-24T05:57:45.700Z", + ...overrides, + }; +} + function buildStatusStartedTranscript(sessionId: string): string { return `${JSON.stringify({ sessionId, @@ -76,7 +95,7 @@ function installAdeMocks(options?: { sendError?: Error; steerError?: Error; listError?: Error; - handoffResult?: { session: { id: string }; usedFallbackSummary: boolean }; + handoffResult?: { session: AgentChatSession; usedFallbackSummary: boolean }; sessions?: AgentChatSessionSummary[]; includeClaudeModel?: boolean; }) { @@ -90,9 +109,10 @@ function installAdeMocks(options?: { ? vi.fn().mockRejectedValue(options.listError) : vi.fn().mockResolvedValue(options?.sessions ?? [buildSession("session-1")]); const handoff = vi.fn().mockResolvedValue(options?.handoffResult ?? { - session: { id: "handoff-session-1" }, + session: buildCreatedSession("handoff-session-1"), usedFallbackSummary: false, }); + const create = vi.fn().mockResolvedValue(buildCreatedSession("created-session")); const chatEventListeners = new Set<(event: AgentChatEventEnvelope) => void>(); globalThis.window.ade = { @@ -134,7 +154,7 @@ function installAdeMocks(options?: { respondToInput: vi.fn().mockResolvedValue(undefined), warmupModel: vi.fn().mockResolvedValue(undefined), fileSearch: vi.fn().mockResolvedValue([]), - create: vi.fn().mockResolvedValue({ id: "created-session" }), + create, dispose: vi.fn().mockResolvedValue(undefined), }, sessions: { @@ -158,6 +178,7 @@ function installAdeMocks(options?: { send, steer, list, + create, handoff, emitChatEvent: (event: AgentChatEventEnvelope) => { for (const listener of chatEventListeners) { @@ -257,6 +278,38 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByLabelText("Waiting for your input")).toBeTruthy(); }); + it("falls back to the session summary when a chat is awaiting input", async () => { + const session = buildSession("session-1", { + status: "active", + awaitingInput: true, + }); + installAdeMocks({ + sessions: [session], + }); + + renderTabbedPane(session); + + expect(await screen.findByLabelText("Waiting for your input")).toBeTruthy(); + expect(screen.queryByLabelText("Agent working")).toBeNull(); + }); + + it("does not keep showing a working indicator when the session summary is idle", async () => { + const session = buildSession("session-1", { + status: "idle", + }); + installAdeMocks({ + sessions: [session], + transcript: buildStatusStartedTranscript(session.sessionId), + }); + + renderTabbedPane(session); + + await waitFor(() => { + expect(screen.queryByLabelText("Agent working")).toBeNull(); + }); + expect(screen.getByLabelText("Ready for next prompt")).toBeTruthy(); + }); + it("keeps the draft cleared after send succeeds even if session refresh fails", async () => { const session = buildSession("session-1"); const { send, list } = installAdeMocks({ @@ -600,7 +653,7 @@ describe("AgentChatPane submit recovery", () => { const onSessionCreated = vi.fn().mockResolvedValue(undefined); const { handoff } = installAdeMocks({ handoffResult: { - session: { id: "session-2" }, + session: buildCreatedSession("session-2"), usedFallbackSummary: false, }, }); @@ -625,7 +678,43 @@ describe("AgentChatPane submit recovery", () => { sourceSessionId: session.sessionId, targetModelId: "openai/gpt-5.4-mini", }); - expect(onSessionCreated).toHaveBeenCalledWith("session-2"); + expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "session-2" })); + }); + }); + + it("does not wait for onSessionCreated before sending the first message in a new chat", async () => { + const onSessionCreated = vi.fn().mockImplementation(() => new Promise(() => {})); + const { send, create } = installAdeMocks({ sessions: [] }); + + render( + + + , + ); + + const trigger = await screen.findByRole("button", { name: "Select model" }); + const codexLabel = getModelById("openai/gpt-5.4-codex")?.displayName ?? "GPT-5.4 Codex"; + + fireEvent.click(trigger); + fireEvent.click(await screen.findByRole("button", { name: /OpenAI/i })); + await clickEnabledModelOption(new RegExp(escapeRegExp(codexLabel), "i")); + + const textbox = await screen.findByRole("textbox"); + fireEvent.change(textbox, { target: { value: "Ship the instant route fix." } }); + fireEvent.click(screen.getByTitle("Send")); + + await waitFor(() => { + expect(create).toHaveBeenCalled(); + expect(onSessionCreated).toHaveBeenCalledWith(expect.objectContaining({ id: "created-session" })); + expect(send).toHaveBeenCalledWith(expect.objectContaining({ + sessionId: "created-session", + text: "Ship the instant route fix.", + displayText: "Ship the instant route fix.", + })); }); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index a84bfa5f4..8340d840f 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useNavigate } from "react-router-dom"; +import { AnimatePresence, motion } from "motion/react"; import { GitBranch, Plus } from "@phosphor-icons/react"; import { createDefaultComputerUsePolicy, @@ -14,6 +15,7 @@ import { type AgentChatFileRef, type AgentChatInteractionMode, type AiProviderConnectionStatus, + type AgentChatSession, type AgentChatUnifiedPermissionMode, type AgentChatSessionProfile, type ChatSurfaceChip, @@ -36,7 +38,6 @@ import { CURSOR_AVAILABLE_MODE_IDS } from "../../../shared/cursorModes"; import { cn } from "../ui/cn"; import { AgentChatComposer } from "./AgentChatComposer"; import { AgentChatMessageList } from "./AgentChatMessageList"; -import { AgentQuestionModal } from "./AgentQuestionModal"; import { ChatStatusGlyph } from "./chatStatusVisuals"; import { isChatToolType } from "../../lib/sessions"; import { ToolLogo } from "../terminals/ToolLogos"; @@ -534,7 +535,10 @@ export function AgentChatPane({ permissionModeLocked = false, presentation, embeddedWorkLayout = false, + layoutVariant = "standard", onSessionCreated, + availableLanes, + onLaneChange, }: { laneId: string | null; laneLabel?: string | null; @@ -550,7 +554,12 @@ export function AgentChatPane({ presentation?: ChatSurfacePresentation; /** Work tab draft: flatter shell, no duplicate header chrome above the composer. */ embeddedWorkLayout?: boolean; - onSessionCreated?: (sessionId: string) => void | Promise; + layoutVariant?: "standard" | "grid-tile"; + onSessionCreated?: (session: AgentChatSession) => void | Promise; + /** Available lanes for the lane selector in empty state */ + availableLanes?: Array<{ id: string; name: string; color?: string | null }>; + /** Callback when lane selection changes in empty state */ + onLaneChange?: (laneId: string) => void; }) { const navigate = useNavigate(); const openAiProvidersSettings = useCallback(() => { @@ -611,6 +620,10 @@ export function AgentChatPane({ const [handoffOpen, setHandoffOpen] = useState(false); const [handoffBusy, setHandoffBusy] = useState(false); const [handoffModelId, setHandoffModelId] = useState(""); + const shellRef = useRef(null); + const [composerMaxHeightPx, setComposerMaxHeightPx] = useState(null); + const composerMaxHeightPxRef = useRef(null); + const sessionsRef = useRef(sessions); const appliedInitialSessionIdRef = useRef(initialSessionId ?? null); const loadedHistoryRef = useRef>(new Set()); @@ -652,6 +665,8 @@ export function AgentChatPane({ return [...selectedEvents, optimisticOutgoingMessage.envelope]; }, [optimisticOutgoingMessage, selectedEvents, selectedSessionId]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); + const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; + const selectedSessionAwaitingInput = Boolean(pendingInput) || selectedSession?.awaitingInput === true; const turnActive = selectedSessionId ? (turnActiveBySession[selectedSessionId] ?? false) : false; const activeProviderConnection = selectedSession?.provider === "claude" ? (providerConnections?.claude ?? null) @@ -660,7 +675,6 @@ export function AgentChatPane({ : selectedSession?.provider === "cursor" ? (providerConnections?.cursor ?? null) : null; - const pendingInput = selectedSessionId ? (pendingInputsBySession[selectedSessionId]?.[0] ?? null) : null; const pendingApprovalIds = useMemo(() => { const ids = new Set(); for (const entry of pendingInputsBySession[selectedSessionId ?? ""] ?? []) { @@ -674,6 +688,14 @@ export function AgentChatPane({ const surfaceMode = presentation?.mode ?? "standard"; const identitySessionSettingsBusy = isPersistentIdentitySurface && sessionMutationKind !== null; + useEffect(() => { + sessionsRef.current = sessions; + }, [sessions]); + + useEffect(() => { + composerMaxHeightPxRef.current = composerMaxHeightPx; + }, [composerMaxHeightPx]); + const modelSelectionDiffersFromSession = Boolean(selectedSession && selectedSessionModelId && selectedSessionModelId !== modelId); const sessionProvider = useMemo(() => { @@ -749,8 +771,7 @@ export function AgentChatPane({ || (surfaceMode === "resolver" ? "AI Resolver" : selectedSession ? chatSessionTitle(selectedSession) : "New chat"); const assistantLabel = presentation?.assistantLabel?.trim() || resolveAssistantLabel(selectedModelDesc, selectedSession?.provider); - const messagePlaceholder = presentation?.messagePlaceholder?.trim() - || (assistantLabel === "Assistant" ? "Message the assistant..." : `Message ${assistantLabel}...`); + const messagePlaceholder = presentation?.messagePlaceholder?.trim() || "Type to vibecode..."; const chipsJson = JSON.stringify(presentation?.chips ?? []); const resolvedChips = useMemo(() => JSON.parse(chipsJson) as ChatSurfaceChip[], [chipsJson]); @@ -783,7 +804,7 @@ export function AgentChatPane({ && !isPersistentIdentitySurface && (selectedSession.surface ?? "work") === "work", ); - const handoffBlocked = turnActive || Boolean(pendingInput) || handoffBusy; + const handoffBlocked = turnActive || selectedSessionAwaitingInput || handoffBusy; const handoffButtonTitle = handoffBlocked ? "Wait for the current output or approval to finish before handing off this chat." : "Create a new work chat on another model and seed it with a summary of this chat."; @@ -878,6 +899,17 @@ export function AgentChatPane({ const rows = await window.ade.agentChat.list({ laneId }); const nextRows = sortSessionSummariesByRecency(rows, localTouchBySessionRef.current); setSessions(nextRows); + setTurnActiveBySession((prev) => { + let next: Record | null = null; + for (const row of nextRows) { + const shouldAppearRunning = row.status === "active" && row.awaitingInput !== true; + if ((prev[row.sessionId] ?? false) && !shouldAppearRunning) { + next ??= { ...prev }; + next[row.sessionId] = false; + } + } + return next ?? prev; + }); const nextSessionIds = new Set(nextRows.map((row) => row.sessionId)); for (const sessionId of [...localTouchBySessionRef.current.keys()]) { if (!nextSessionIds.has(sessionId) && !optimisticSessionIdsRef.current.has(sessionId)) { @@ -994,7 +1026,10 @@ export function AgentChatPane({ } }, []); - const loadHistory = useCallback(async (sessionId: string) => { + const loadHistory = useCallback(async (sessionId: string, options?: { force?: boolean }) => { + if (options?.force) { + loadedHistoryRef.current.delete(sessionId); + } if (loadedHistoryRef.current.has(sessionId)) return; loadedHistoryRef.current.add(sessionId); @@ -1027,15 +1062,18 @@ export function AgentChatPane({ } const derived = deriveRuntimeState(merged); + const sessionSummary = sessionsRef.current.find((entry) => entry.sessionId === sessionId) + ?? (initialSessionSummary?.sessionId === sessionId ? initialSessionSummary : null); + const allowRunningFromSummary = sessionSummary?.status === "active" && sessionSummary.awaitingInput !== true; eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: merged }; setEventsBySession((prev) => ({ ...prev, [sessionId]: merged })); - setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: derived.turnActive })); + setTurnActiveBySession((prev) => ({ ...prev, [sessionId]: allowRunningFromSummary ? derived.turnActive : false })); setPendingInputsBySession((prev) => ({ ...prev, [sessionId]: derived.pendingInputs })); setPendingSteersBySession((prev) => ({ ...prev, [sessionId]: derived.pendingSteers })); } catch { // Ignore transcript history failures. } - }, []); + }, [initialSessionSummary]); const clearSessionView = useCallback((sessionId: string) => { eventsBySessionRef.current = { ...eventsBySessionRef.current, [sessionId]: [] }; @@ -1578,6 +1616,10 @@ export function AgentChatPane({ setModelId(snapshot.nextModelId); setReasoningEffort(snapshot.nextReasoningEffort); }, []); + const notifySessionCreated = useCallback((session: AgentChatSession) => { + if (!onSessionCreated) return; + void Promise.resolve(onSessionCreated(session)).catch((err) => { console.error("notifySessionCreated failed:", err); }); + }, [onSessionCreated]); const createSession = useCallback(async (): Promise => { if (createSessionPromiseRef.current) { @@ -1611,11 +1653,8 @@ export function AgentChatPane({ modelId, }).then(() => refreshSessions()).catch(() => { /* warmup is best-effort */ }); } - // Await tab navigation and session-list refresh before returning so the - // caller doesn't send the first message while the user is still on the - // blank "new chat" screen. - await onSessionCreated?.(created.id); - await refreshSessions().catch(() => {}); + notifySessionCreated(created); + void refreshSessions().catch(() => {}); return created.id; })(); createSessionPromiseRef.current = createPromise; @@ -1626,7 +1665,7 @@ export function AgentChatPane({ createSessionPromiseRef.current = null; } } - }, [buildNativeControlPayload, computerUsePolicy, laneId, modelId, onSessionCreated, reasoningEffort, refreshSessions, touchSession]); + }, [buildNativeControlPayload, computerUsePolicy, laneId, modelId, notifySessionCreated, reasoningEffort, refreshSessions, touchSession]); const handoffSession = useCallback(async () => { if (!canShowHandoff || !selectedSessionId || !handoffModelId || handoffBlocked) return; @@ -1638,14 +1677,14 @@ export function AgentChatPane({ targetModelId: handoffModelId, }); setHandoffOpen(false); - await onSessionCreated?.(result.session.id); + notifySessionCreated(result.session); void refreshSessions().catch(() => {}); } catch (handoffError) { setError(handoffError instanceof Error ? handoffError.message : String(handoffError)); } finally { setHandoffBusy(false); } - }, [canShowHandoff, handoffBlocked, handoffModelId, onSessionCreated, refreshSessions, selectedSessionId]); + }, [canShowHandoff, handoffBlocked, handoffModelId, notifySessionCreated, refreshSessions, selectedSessionId]); // ── Eager session creation ── // Create a session as soon as we have a model + lane, so slash commands, @@ -1952,6 +1991,26 @@ export function AgentChatPane({ } }, [isPersistentIdentitySurface, patchSessionSummary, refreshComputerUseSnapshot, refreshSessions, selectedSessionId, sessionMutationKind]); + useEffect(() => { + if (layoutVariant !== "grid-tile") { + setComposerMaxHeightPx(null); + return; + } + const node = shellRef.current; + if (!node || typeof ResizeObserver === "undefined") return; + const observer = new ResizeObserver((entries) => { + const entry = entries[0]; + if (!entry) return; + const next = Math.max(96, Math.min(168, Math.floor(entry.contentRect.height * 0.28))); + if (composerMaxHeightPxRef.current !== next) { + composerMaxHeightPxRef.current = next; + setComposerMaxHeightPx(next); + } + }); + observer.observe(node); + return () => observer.disconnect(); + }, [layoutVariant]); + if (!laneId) { return ( @@ -1962,6 +2021,29 @@ export function AgentChatPane({ ); } const draftAccent = selectedModelDesc?.color ?? "#A1A1AA"; + const proofSessionId = selectedSessionId ?? ""; + const proofPanelContent = ( + <> +
+ Artifacts + +
+
+ refreshComputerUseSnapshot(selectedSessionId, { force: true })} + /> +
+ + ); const shellHeader = (
{/* Clean single-row header */} @@ -2024,6 +2106,7 @@ export function AgentChatPane({ value={handoffModelId} onChange={setHandoffModelId} availableModelIds={handoffAvailableModelIds} + catalogMode="available-only" showReasoning={false} onOpenAiSettings={openAiProvidersSettings} /> @@ -2071,9 +2154,19 @@ export function AgentChatPane({ {sessions.map((session) => { const title = chatSessionTitle(session); const isActive = session.sessionId === selectedSessionId; - const isRunning = turnActiveBySession[session.sessionId] ?? false; - const sessionNeedsInput = Boolean(pendingInputsBySession[session.sessionId]?.length); - const sessionIndicatorStatus = sessionNeedsInput ? "waiting" : isRunning ? "working" : null; + const sessionNeedsInput = Boolean(pendingInputsBySession[session.sessionId]?.length) || session.awaitingInput === true; + const isRunning = !sessionNeedsInput && turnActiveBySession[session.sessionId] === true; + const sessionReadyForPrompt = !sessionNeedsInput && !isRunning && session.status === "idle"; + const sessionIndicatorStatus = sessionNeedsInput || sessionReadyForPrompt + ? "waiting" + : isRunning + ? "working" + : null; + const sessionIndicatorLabel = sessionNeedsInput + ? "Waiting for your input" + : sessionReadyForPrompt + ? "Ready for next prompt" + : "Agent working"; return (
) : null} -
- {loading ? ( +
+ {loading && !embedDraft && !selectedSessionId ? (
@@ -2343,104 +2446,134 @@ export function AgentChatPane({ Loading sessions...
- ) : selectedSessionId ? ( -
- {/* Chat column */} -
- { - void handleApproval(itemId, decision, responseText); - }} - /> - {sessionDelta ? ( -
- +{sessionDelta.insertions} - -{sessionDelta.deletions} + ) : ( + + {selectedSessionId ? ( + + {/* Chat column */} +
+ { + void handleApproval(itemId, decision, responseText, answers); + }} + /> + {sessionDelta ? ( +
+ +{sessionDelta.insertions} + -{sessionDelta.deletions} +
+ ) : null}
- ) : null} -
- {/* Proof panel (push) */} - {proofDrawerOpen ? ( -
-
- Artifacts - -
-
- refreshComputerUseSnapshot(selectedSessionId, { force: true })} - /> +
+ ADE + + + {/* Lane selector pill */} + {availableLanes && availableLanes.length > 0 && onLaneChange ? ( + + + + ) : laneDisplayLabel ? ( + + + {laneDisplayLabel} + + ) : null} + + {/* Inline composer for empty state */} +
+ {composerElement} +
-
- ) : null} -
- ) : ( -
-
-
- - - {laneDisplayLabel} - -
-
- Start typing below -
-
- {[ - "Explain the project structure", - "Review recent changes", - "Plan the next feature", - "Find bugs and propose fixes", - ].map((prompt) => ( - - ))} -
-
-
+ + )} + )}
- {pendingInput && selectedSessionId && (pendingInput.request.kind === "question" || pendingInput.request.kind === "structured_question") ? ( - { - void approve("cancel"); - }} - onSubmit={({ answers, responseText }) => { - void approve("accept", responseText, answers); - }} - onDecline={() => { - void approve("decline"); - }} - /> - ) : null} ); } diff --git a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx new file mode 100644 index 000000000..bb5ee19fd --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.test.tsx @@ -0,0 +1,236 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { PendingInputRequest } from "../../../shared/types"; +import { AgentQuestionModal } from "./AgentQuestionModal"; + +afterEach(cleanup); + +function renderModal(request: PendingInputRequest) { + const onClose = vi.fn(); + const onDecline = vi.fn(); + const onSubmit = vi.fn(); + + render( + , + ); + + return { onClose, onDecline, onSubmit }; +} + +describe("AgentQuestionModal", () => { + it("supports multi-select answers and preview rendering", () => { + const request: PendingInputRequest = { + requestId: "req-1", + itemId: "item-1", + source: "claude", + kind: "structured_question", + title: "Task list decision", + description: "Claude needs help choosing a layout.", + questions: [ + { + id: "layout", + header: "Layout", + question: "Which layouts should we keep exploring?", + multiSelect: true, + allowsFreeform: true, + options: [ + { + label: "Cards", + value: "Cards", + preview: "
Cards preview panel
", + previewFormat: "html", + }, + { + label: "Table", + value: "Table", + preview: "
Table preview panel
", + previewFormat: "html", + recommended: true, + }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + expect(screen.getByText("Task list decision")).toBeTruthy(); + expect(screen.getByText("Table preview panel")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Cards/i })); + expect(screen.getByText("Cards preview panel")).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: /Table/i })); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Kanban, Timeline" }, + }); + + fireEvent.click(screen.getByRole("button", { name: /Send answer/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + answers: { + layout: ["Cards", "Table", "Kanban", "Timeline"], + }, + responseText: null, + }); + }); + + it("does not allow passive dismissal for blocking requests", () => { + const request: PendingInputRequest = { + requestId: "req-blocking", + itemId: "item-blocking", + source: "claude", + kind: "structured_question", + title: "Blocking clarification", + description: "Claude needs a decision before it can continue.", + questions: [ + { + id: "direction", + header: "Direction", + question: "Which direction should we take?", + allowsFreeform: true, + options: [ + { label: "Option A", value: "Option A" }, + { label: "Option B", value: "Option B" }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onClose } = renderModal(request); + + fireEvent.keyDown(window, { key: "Escape" }); + fireEvent.click(screen.getByTestId("agent-question-modal-overlay")); + + expect(onClose).not.toHaveBeenCalled(); + expect(screen.queryByLabelText("Close question modal")).toBeNull(); + expect(screen.queryByRole("button", { name: "Cancel" })).toBeNull(); + }); + + it("lets freeform text override a single selected option", () => { + const request: PendingInputRequest = { + requestId: "req-2", + itemId: "item-2", + source: "claude", + kind: "structured_question", + description: "Claude needs a final preference.", + questions: [ + { + id: "summary", + header: "Summary", + question: "What should the summary card do?", + allowsFreeform: true, + options: [ + { label: "Collapse automatically", value: "Collapse automatically" }, + { label: "Stay expanded", value: "Stay expanded" }, + ], + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + fireEvent.click(screen.getByRole("button", { name: /Collapse automatically/i })); + fireEvent.change(screen.getByRole("textbox"), { + target: { value: "Collapse unless the agent is actively streaming." }, + }); + + fireEvent.click(screen.getByRole("button", { name: /Send answer/i })); + + expect(onSubmit).toHaveBeenCalledWith({ + answers: { + summary: "Collapse unless the agent is actively streaming.", + }, + responseText: "Collapse unless the agent is actively streaming.", + }); + }); + + it("submits on Cmd/Ctrl+Enter when answers are ready", () => { + const request: PendingInputRequest = { + requestId: "req-shortcut", + itemId: "item-shortcut", + source: "claude", + kind: "question", + description: "Claude needs a concise answer.", + questions: [ + { + id: "reply", + header: "Reply", + question: "What should Claude do next?", + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onSubmit } = renderModal(request); + + fireEvent.change(screen.getByRole("textbox", { name: "Reply" }), { + target: { value: "Use the shared modal everywhere chat questions appear." }, + }); + + const expectedPayload = { + answers: { + reply: "Use the shared modal everywhere chat questions appear.", + }, + responseText: "Use the shared modal everywhere chat questions appear.", + }; + + fireEvent.keyDown(window, { key: "Enter", metaKey: true }); + expect(onSubmit).toHaveBeenCalledWith(expectedPayload); + + onSubmit.mockClear(); + + fireEvent.keyDown(window, { key: "Enter", ctrlKey: true }); + expect(onSubmit).toHaveBeenCalledWith(expectedPayload); + }); + + it("keeps Codex structured questions on a normal decline path", () => { + const request: PendingInputRequest = { + requestId: "req-codex", + itemId: "item-codex", + source: "codex", + kind: "structured_question", + title: "Planning question", + description: "Which planning path should we take?", + questions: [ + { + id: "plan", + header: "Plan", + question: "Which planning path should we take?", + allowsFreeform: true, + }, + ], + allowsFreeform: true, + blocking: true, + canProceedWithoutAnswer: false, + }; + + const { onDecline } = renderModal(request); + + expect(screen.getByText(/Send a concrete answer so the agent can continue/i)).toBeTruthy(); + + fireEvent.click(screen.getByRole("button", { name: "Decline" })); + + expect(onDecline).toHaveBeenCalledTimes(1); + expect(screen.queryByRole("button", { name: "Cancel turn" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx index 87bbc7a7b..9182e1544 100644 --- a/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentQuestionModal.tsx @@ -1,4 +1,8 @@ -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import rehypeSanitize, { defaultSchema } from "rehype-sanitize"; +import remarkGfm from "remark-gfm"; import { ChatCircleText, HandPalm, PaperPlaneTilt, X } from "@phosphor-icons/react"; import type { PendingInputRequest } from "../../../shared/types"; import { Button } from "../ui/Button"; @@ -10,76 +14,173 @@ type AgentQuestionModalProps = { onDecline?: () => void; }; +type QuestionDraft = { + text: string; + selectedValues: string[]; + activePreviewValue: string | null; +}; + +const SAFE_PREVIEW_SCHEMA = { + ...defaultSchema, + tagNames: [ + "p", + "ul", + "ol", + "li", + "strong", + "em", + "code", + "pre", + "blockquote", + "table", + "thead", + "tbody", + "tr", + "th", + "td", + "br", + "hr", + ], +}; + +function createEmptyDraft(): QuestionDraft { + return { + text: "", + selectedValues: [], + activePreviewValue: null, + }; +} + export function AgentQuestionModal({ request, onClose, onSubmit, onDecline, }: AgentQuestionModalProps) { - const [answers, setAnswers] = useState>({}); + const [drafts, setDrafts] = useState>({}); + const passiveDismissAllowed = request.canProceedWithoutAnswer; + const questionCountLabel = request.questions.length === 1 ? "1 question" : `${request.questions.length} questions`; + const modalTitle = request.title?.trim().length + ? request.title.trim() + : request.kind === "structured_question" + ? "Input needed" + : "Agent question"; useEffect(() => { - setAnswers({}); + setDrafts({}); }, [request.itemId, request.requestId]); - useEffect(() => { - const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Escape") { - event.stopPropagation(); - onClose(); - } - }; - window.addEventListener("keydown", onKeyDown, true); - return () => window.removeEventListener("keydown", onKeyDown, true); - }, [onClose]); + const getDraft = (questionId: string): QuestionDraft => drafts[questionId] ?? createEmptyDraft(); const normalizedAnswers = useMemo(() => { - return Object.fromEntries( - Object.entries(answers) - .map(([questionId, value]) => [questionId, value.trim()]) - .filter((entry): entry is [string, string] => entry[1].length > 0), - ); - }, [answers]); + const next: Record = {}; + + for (const question of request.questions) { + const draft = drafts[question.id] ?? createEmptyDraft(); + const selectedValues = draft.selectedValues + .map((value) => value.trim()) + .filter((value, index, values) => value.length > 0 && values.indexOf(value) === index); + const text = draft.text.trim(); + + if (question.multiSelect) { + const extraValues = text.length > 0 + ? text.split(",").map((value) => value.trim()).filter((value) => value.length > 0) + : []; + const values = [...selectedValues]; + for (const value of extraValues) { + if (!values.includes(value)) values.push(value); + } + if (values.length > 0) { + next[question.id] = values; + } + continue; + } + + if (text.length > 0) { + next[question.id] = text; + continue; + } + + if (selectedValues[0]) { + next[question.id] = selectedValues[0]; + } + } + + return next; + }, [drafts, request.questions]); const canSubmit = request.canProceedWithoutAnswer || request.questions.every((question) => { const value = normalizedAnswers[question.id]; - return typeof value === "string" && value.length > 0; + return (typeof value === "string" && value.length > 0) + || (Array.isArray(value) && value.length > 0); }); - const handleSubmit = () => { + const handleSubmit = useCallback(() => { const primaryQuestionId = request.questions[0]?.id; + const primaryAnswer = primaryQuestionId ? normalizedAnswers[primaryQuestionId] : undefined; onSubmit({ answers: { ...normalizedAnswers }, - responseText: primaryQuestionId ? (normalizedAnswers[primaryQuestionId] ?? null) : null, + responseText: typeof primaryAnswer === "string" ? primaryAnswer : null, }); - }; + }, [normalizedAnswers, onSubmit, request.questions]); + + useEffect(() => { + const onKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.stopPropagation(); + event.preventDefault(); + if (!passiveDismissAllowed) return; + onClose(); + return; + } + if (event.key === "Enter" && (event.metaKey || event.ctrlKey)) { + event.stopPropagation(); + event.preventDefault(); + if (!canSubmit) return; + handleSubmit(); + } + }; + window.addEventListener("keydown", onKeyDown, true); + return () => window.removeEventListener("keydown", onKeyDown, true); + }, [canSubmit, handleSubmit, onClose, passiveDismissAllowed]); return (
{ - if (event.target === event.currentTarget) onClose(); + if (passiveDismissAllowed && event.target === event.currentTarget) onClose(); }} >
-
-
- -
- {request.kind === "structured_question" ? "Input needed" : "Agent question"} +
+
+
+ +
+ {request.kind === "structured_question" ? "Input needed" : "Agent question"} +
+
+ {request.source} +
+
+ {questionCountLabel} +
-
- {request.source} +
+ {modalTitle}
- + {passiveDismissAllowed ? ( + + ) : null}
@@ -96,11 +197,28 @@ export function AgentQuestionModal({
{request.questions.map((question, index) => { - const selectedValue = answers[question.id] ?? ""; - const helperText = question.defaultAssumption ?? question.impact ?? null; - const placeholder = question.options?.length - ? "Choose an option or type a custom answer..." - : "Type the answer you want the agent to follow..."; + const draft = getDraft(question.id); + const selectedValues = draft.selectedValues; + const selectedValue = selectedValues[0] ?? ""; + const helperText = [question.defaultAssumption, question.impact].filter((value): value is string => Boolean(value?.trim())).join(" "); + const placeholder = question.multiSelect + ? "Select one or more options, or type comma-separated custom answers..." + : question.options?.length + ? "Choose an option or type a custom answer..." + : "Type the answer you want the agent to follow..."; + const normalizedQuestionAnswer = normalizedAnswers[question.id]; + const selectedAnswerValues = Array.isArray(normalizedQuestionAnswer) + ? normalizedQuestionAnswer + : typeof normalizedQuestionAnswer === "string" && normalizedQuestionAnswer.trim().length > 0 + ? [normalizedQuestionAnswer.trim()] + : []; + const previewOptions = (question.options ?? []).filter((option) => typeof option.preview === "string" && option.preview.trim().length > 0); + const activePreviewOption = ( + previewOptions.find((option) => option.value === draft.activePreviewValue) + ?? previewOptions.find((option) => selectedValues.includes(option.value)) + ?? previewOptions.find((option) => option.recommended) + ?? previewOptions[0] + ) ?? null; return (
@@ -108,6 +226,11 @@ export function AgentQuestionModal({
{question.header?.trim() || `Question ${index + 1}`}
+ {question.multiSelect ? ( +
+ Multi-select +
+ ) : null} {question.isSecret ? (
Secret @@ -123,54 +246,189 @@ export function AgentQuestionModal({
) : null} {question.options?.length ? ( -
- {question.options.map((option) => { - const isSelected = selectedValue === option.value; - return ( - - ); - })} + {option.description ? ( +
+ {option.description} +
+ ) : null} + + ); + })} +
+ {!previewOptions.length && selectedAnswerValues.length > 0 ? ( +
+ {selectedAnswerValues.map((value) => ( +
+ {value} +
+ ))} +
+ ) : null} + {activePreviewOption?.preview?.trim().length ? ( +
+
+
+ Preview +
+
+ {activePreviewOption.label} +
+
+
+

{children}

, + ul: ({ children }) =>
    {children}
, + ol: ({ children }) =>
    {children}
, + li: ({ children }) =>
  • {children}
  • , + pre: ({ children }) => ( +
    +                                    {children}
    +                                  
    + ), + code: ({ children, className }) => ( + + {children} + + ), + blockquote: ({ children }) => ( +
    + {children} +
    + ), + table: ({ children }) => {children}
    , + th: ({ children }) => {children}, + td: ({ children }) => {children}, + }} + > + {activePreviewOption.preview} +
    +
    +
    + ) : null} +
    + ) : null} + {question.options?.length && question.allowsFreeform !== false ? ( +
    + {question.multiSelect + ? "Selected options stay active while you add custom answers. Separate custom answers with commas." + : "Leave the text box empty to send the selected option as-is. Typing a custom answer overrides the selection."}
    ) : null} - {question.allowsFreeform !== false ? ( - question.isSecret ? ( + {(() => { + if (question.allowsFreeform === false) return null; + const handleTextChange = (event: React.ChangeEvent) => { + const nextText = event.target.value; + setDrafts((current) => { + const existing = current[question.id] ?? createEmptyDraft(); + return { + ...current, + [question.id]: { + ...existing, + text: nextText, + selectedValues: + !question.multiSelect + && nextText.trim().length > 0 + && existing.selectedValues.length > 0 + && nextText.trim() !== existing.selectedValues[0] + ? [] + : existing.selectedValues, + }, + }; + }); + }; + const ariaLabel = question.header?.trim() || question.question; + return question.isSecret ? ( setAnswers((current) => ({ ...current, [question.id]: event.target.value }))} + value={draft.text} + onChange={handleTextChange} placeholder={placeholder} className="h-10 w-full border border-border/20 bg-[linear-gradient(180deg,rgba(17,15,26,0.94),rgba(13,11,19,0.9))] px-3 font-mono text-[12px] text-fg outline-none transition-colors placeholder:text-muted-fg/30 focus:border-sky-300/35" + aria-label={ariaLabel} /> ) : (