diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index d90fafb59..2016987a4 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -319,16 +319,18 @@ ade run start web --lane lane-id ade shell start --lane lane-id -- npm test ade terminal list --lane lane-id --text ade terminal resume --terminal session-id --text -ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" -ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" +ade new chat --mode chat --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" +ade new chat --mode cli --lane lane-id --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto --prompt "fix failing tests" ade new chat --mode chat --lane auto --lane-name fix-checkout-flow --prompt "fix failing tests" ade chat list --lane lane-id --include-automation --no-archived --text -ade chat create --lane lane-id --provider codex --model openai/gpt-5.5 --permissions full-auto --print-config --json +ade chat create --lane lane-id --provider codex --model openai/gpt-5.6-sol --permissions full-auto --print-config --json ade chat create --lane lane-id --provider codex --no-parent # spawned chats default their parent to $ADE_CHAT_SESSION_ID; --parent overrides, --no-parent opts out ade chat read session-id --limit 20 --text ade chat message session-id --kind auto --text "status/context" ade chat steer session-id --text "active-turn context" ade chat wait session-id --for idle --timeout-ms 600000 +ade chat recover session-id --turn turn-id --action nudge # wait | nudge | retry | resume +ade chat models --provider codex --json # model order + supported reasoning tiers ade code ade code --embedded ade tests run --lane lane-id --suite unit --wait diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 18f75912d..8baa52809 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -14,7 +14,7 @@ "@factory/droid-sdk": "^0.2.0", "@linear/sdk": "^84.0.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex": "0.142.5", + "@openai/codex": "0.144.0", "@opencode-ai/sdk": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", @@ -1007,9 +1007,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", - "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0.tgz", + "integrity": "sha512-QFh6f+v5QUx/Vg0HjIl9HB94p7aDLBDkZjc4IXX5RXUcXHPVCZNb6Hl2R49Og/fqW7orgZkeDcgWfRANUa1WoQ==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -1018,19 +1018,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.142.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", - "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", + "version": "0.144.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-arm64.tgz", + "integrity": "sha512-rqFAJdOa2I0VRgepVsSZeLxs96+Y+LXTjccOOvH6894FyaFAYPZ/o+6hgpB1iGHxxdoY/DsGa8jrJC8Leqn9Kg==", "cpu": [ "arm64" ], @@ -1045,9 +1045,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.142.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", - "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", + "version": "0.144.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-x64.tgz", + "integrity": "sha512-4p2jxRbN+Khg5UQzpkzT9upFj+qkEF/abmdvrtflkkWmVKP6Nt+yi8ospdqv9PDqvQ9SotPvX7iXaFaeUTrtmA==", "cpu": [ "x64" ], @@ -1062,9 +1062,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.142.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", - "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", + "version": "0.144.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-arm64.tgz", + "integrity": "sha512-k++xhZrn9P3laO00Q92APG6mdOFDD66nUBo+8ExCa1NXi2pjLEMLC4+UNJTUUtUT1PEflOZ5pDKxPXgzaiFFFg==", "cpu": [ "arm64" ], @@ -1079,9 +1079,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.142.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", - "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", + "version": "0.144.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-x64.tgz", + "integrity": "sha512-GmKtQeX+cO9lN7mQD1FEVcXYEMLMgMByHwZdvlluH0bj/+c2ind3hwbRtE3eECFDekNhEiB80Ez0FfbkyFQqoA==", "cpu": [ "x64" ], @@ -1096,9 +1096,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.142.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", - "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", + "version": "0.144.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-arm64.tgz", + "integrity": "sha512-e2yGSgwdzrT1SoJMoOzWD58WBEsIaAMZpEchuV2VGkE2T955SG7dn7EyVQTQcy7/rdpE8aEDktZ/1eQQfjkdtQ==", "cpu": [ "arm64" ], @@ -1113,9 +1113,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.142.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", - "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", + "version": "0.144.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-x64.tgz", + "integrity": "sha512-QiholLCYqNeYvNM77HOmPtrOFrY0rQc/N9nXt+sQGXO3rEGmcWjpLzujY4Oegl3CLRHoieWqlep3EqEvFBjoIA==", "cpu": [ "x64" ], @@ -5851,52 +5851,52 @@ } }, "@openai/codex": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", - "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0.tgz", + "integrity": "sha512-QFh6f+v5QUx/Vg0HjIl9HB94p7aDLBDkZjc4IXX5RXUcXHPVCZNb6Hl2R49Og/fqW7orgZkeDcgWfRANUa1WoQ==", "requires": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.0-win32-x64" } }, "@openai/codex-darwin-arm64": { - "version": "npm:@openai/codex@0.142.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", - "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", + "version": "npm:@openai/codex@0.144.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-arm64.tgz", + "integrity": "sha512-rqFAJdOa2I0VRgepVsSZeLxs96+Y+LXTjccOOvH6894FyaFAYPZ/o+6hgpB1iGHxxdoY/DsGa8jrJC8Leqn9Kg==", "optional": true }, "@openai/codex-darwin-x64": { - "version": "npm:@openai/codex@0.142.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", - "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", + "version": "npm:@openai/codex@0.144.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-x64.tgz", + "integrity": "sha512-4p2jxRbN+Khg5UQzpkzT9upFj+qkEF/abmdvrtflkkWmVKP6Nt+yi8ospdqv9PDqvQ9SotPvX7iXaFaeUTrtmA==", "optional": true }, "@openai/codex-linux-arm64": { - "version": "npm:@openai/codex@0.142.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", - "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", + "version": "npm:@openai/codex@0.144.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-arm64.tgz", + "integrity": "sha512-k++xhZrn9P3laO00Q92APG6mdOFDD66nUBo+8ExCa1NXi2pjLEMLC4+UNJTUUtUT1PEflOZ5pDKxPXgzaiFFFg==", "optional": true }, "@openai/codex-linux-x64": { - "version": "npm:@openai/codex@0.142.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", - "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", + "version": "npm:@openai/codex@0.144.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-x64.tgz", + "integrity": "sha512-GmKtQeX+cO9lN7mQD1FEVcXYEMLMgMByHwZdvlluH0bj/+c2ind3hwbRtE3eECFDekNhEiB80Ez0FfbkyFQqoA==", "optional": true }, "@openai/codex-win32-arm64": { - "version": "npm:@openai/codex@0.142.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", - "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", + "version": "npm:@openai/codex@0.144.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-arm64.tgz", + "integrity": "sha512-e2yGSgwdzrT1SoJMoOzWD58WBEsIaAMZpEchuV2VGkE2T955SG7dn7EyVQTQcy7/rdpE8aEDktZ/1eQQfjkdtQ==", "optional": true }, "@openai/codex-win32-x64": { - "version": "npm:@openai/codex@0.142.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", - "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", + "version": "npm:@openai/codex@0.144.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-x64.tgz", + "integrity": "sha512-QiholLCYqNeYvNM77HOmPtrOFrY0rQc/N9nXt+sQGXO3rEGmcWjpLzujY4Oegl3CLRHoieWqlep3EqEvFBjoIA==", "optional": true }, "@opencode-ai/sdk": { diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 48308bb8a..9fb69ddcd 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -30,7 +30,7 @@ "@factory/droid-sdk": "^0.2.0", "@linear/sdk": "^84.0.0", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex": "0.142.5", + "@openai/codex": "0.144.0", "@opencode-ai/sdk": "^1.15.5", "@wize-logic/nodejs-rfb": "^4.2.0", "@xterm/addon-fit": "^0.11.0", diff --git a/apps/ade-cli/src/adeRpcServer.test.ts b/apps/ade-cli/src/adeRpcServer.test.ts index d009e5f7c..6f6178d59 100644 --- a/apps/ade-cli/src/adeRpcServer.test.ts +++ b/apps/ade-cli/src/adeRpcServer.test.ts @@ -1728,9 +1728,16 @@ describe("adeRpcServer", () => { expect.objectContaining({ cols: 400, rows: 200, - startupCommand: expect.stringContaining("codex --no-alt-screen --sandbox workspace-write --ask-for-approval on-request"), + startupCommand: expect.stringContaining("codex --no-alt-screen"), }), ); + const createCall = fixture.runtime.ptyService.create.mock.calls.at(-1)?.[0]; + expect(createCall?.args).toEqual(expect.arrayContaining([ + "--sandbox", + "workspace-write", + "--ask-for-approval", + "on-request", + ])); expect(response.structuredContent.startupCommand).not.toContain("--full-auto"); }); diff --git a/apps/ade-cli/src/adeRpcServer.ts b/apps/ade-cli/src/adeRpcServer.ts index 179802c66..8f4ef3070 100644 --- a/apps/ade-cli/src/adeRpcServer.ts +++ b/apps/ade-cli/src/adeRpcServer.ts @@ -69,6 +69,7 @@ import { JsonRpcError, JsonRpcErrorCode, type JsonRpcHandler, type JsonRpcReques import { normalizeAdeRuntimeRole } from "./runtimeRoles"; import { getSharedModelPickerStore } from "./services/modelPickerStore"; import { resolveLaneCreateRemoteBase } from "./services/laneCreateRemoteBase"; +import { resolveCodexComputerUseMcpConfig } from "../../desktop/src/main/utils/codexComputerUse"; // Cross-surface (desktop + TUI + iOS) model picker favorites & recents. // Backed by the per-project cr-sqlite CRR DB (runtime.db) so the three surfaces @@ -3516,6 +3517,9 @@ async function runTool(args: { const ptyService = runtime.ptyService; const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; const laneWorktreePath = resolveLaneWorktreePath(runtime, laneId); + const codexComputerUse = provider === "codex" + ? await resolveCodexComputerUseMcpConfig() + : null; const launchFields: Partial = (() => { if (provider === "shell") { @@ -3535,6 +3539,7 @@ async function runTool(args: { fastMode, initialPrompt: initialInput, laneWorktreePath, + ...(provider === "codex" ? { codexComputerUse } : {}), }); })(); diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index c14ea45f6..e60c66c2e 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -2000,6 +2000,76 @@ describe("ADE CLI", () => { }); }); + it.each([ + ["wait", "wait"], + ["nudge", "steer"], + ["retry", "interrupt_retry_same_thread"], + ["resume", "restart_resume_thread"], + ] as const)("maps chat recovery action %s to %s", (cliAction, action) => { + const executePlan = expectExecutePlan(buildCliPlan([ + "chat", + "recover", + "chat-1", + "--turn", + "turn-1", + "--action", + cliAction, + ])); + + expect(executePlan.label).toBe("chat recover"); + expect(executePlan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "recoverCodexTurn", + args: { + sessionId: "chat-1", + turnId: "turn-1", + action, + }, + }, + }); + }); + + it("rejects incomplete or unknown chat recovery requests", () => { + expect(() => buildCliPlan([ + "chat", "recover", "chat-1", "--action", "wait", + ])).toThrow(/turnId/); + expect(() => buildCliPlan([ + "chat", "recover", "chat-1", "--turn", "turn-1", "--action", "replace", + ])).toThrow(/wait, nudge, retry, or resume/); + }); + + it("filters the typed chat model inventory by provider", () => { + const executePlan = expectExecutePlan(buildCliPlan([ + "chat", + "models", + "--provider", + "codex", + ])); + + expect(executePlan.label).toBe("chat models"); + expect(executePlan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "getAvailableModels", + args: { provider: "codex" }, + }, + }); + }); + + it("requests the complete typed chat model inventory when provider is omitted", () => { + const executePlan = expectExecutePlan(buildCliPlan(["chat", "models"])); + + expect(executePlan.label).toBe("chat models"); + expect(executePlan.steps[0]?.params).toMatchObject({ + arguments: { + domain: "chat", + action: "getAvailableModels", + args: {}, + }, + }); + }); + it("builds chat message with an explicit routing kind", () => { const executePlan = expectExecutePlan(buildCliPlan([ "chat", @@ -3874,9 +3944,17 @@ describe("ADE CLI", () => { expect(chatHelp.text).toContain("ade chat message "); expect(chatHelp.text).toContain("ade chat steer "); expect(chatHelp.text).toContain("ade chat wait "); + expect(chatHelp.text).toContain("ade chat recover "); + expect(chatHelp.text).toContain("ade chat models --provider codex"); expect(chatHelp.text).toContain("ade chat read "); expect(chatHelp.text).toContain("ade new chat --mode cli"); + const chatRecoveryHelp = buildCliPlan(["help", "chat", "recover"]); + expect(chatRecoveryHelp.kind).toBe("help"); + if (chatRecoveryHelp.kind !== "help") return; + expect(chatRecoveryHelp.text).toContain("same actions as the desktop"); + expect(chatRecoveryHelp.text).toContain("--action resume"); + const agentSpawnHelp = buildCliPlan(["agent", "spawn", "--help"]); expect(agentSpawnHelp.kind).toBe("help"); if (agentSpawnHelp.kind !== "help") return; diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 65f748aaa..0bec09510 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -572,8 +572,8 @@ const TOP_LEVEL_HELP = `${ADE_BANNER} $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -1265,8 +1265,8 @@ const HELP_BY_COMMAND: Record = { Start either a persistent ADE Work chat or a tracked provider CLI session with one command. This mirrors the desktop New Chat mode toggle. - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" $ ade new chat --mode chat --auto-create-lane --prompt "Fix login" $ ade new cli --lane --provider claude --model anthropic/claude-opus-4-8 --effort ultracode --prompt "Review the diff" @@ -1296,8 +1296,8 @@ const HELP_BY_COMMAND: Record = { --mode chat creates a persistent ADE Work chat. --mode cli starts a tracked provider CLI terminal. - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" $ ade new chat --mode chat --lane auto --lane-name fix-login --prompt "Fix login" The command defaults to the current ADE lane when ADE_LANE_ID is set. Use @@ -1514,8 +1514,8 @@ const HELP_BY_COMMAND: Record = { $ ade chat actions --personal --text List machine personal-chat actions $ ade chat action --personal models --input-json '{"provider":"codex"}' $ ade chat list --include-automation --no-archived --text - $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto - $ ade chat create --personal --provider codex --model openai/gpt-5.5 --prompt "Plan my trip" + $ ade chat create --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto + $ ade chat create --personal --provider codex --model openai/gpt-5.6-sol --prompt "Plan my trip" $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active @@ -1527,12 +1527,15 @@ const HELP_BY_COMMAND: Record = { $ ade chat steer --text "context" Steer/queue context into an active turn $ ade chat wait --for idle --timeout-ms 600000 Wait for idle, active, awaiting-input, or terminal + $ ade chat recover --turn --action nudge + Recover a stalled Codex turn: wait, nudge, retry, or resume + $ ade chat models --provider codex --json List models and supported reasoning tiers $ ade chat read --limit 20 --text Read recent chat messages $ ade chat goal --objective "Ship it" Set or inspect a Codex goal $ ade chat goal --status paused Update a Codex goal status - $ ade chat handoff --model openai/gpt-5.5 --note "focus on tests" + $ ade chat handoff --model openai/gpt-5.6-sol --note "focus on tests" Start a new chat with an extra handoff note - $ ade chat fork --model openai/gpt-5.5 + $ ade chat fork --model openai/gpt-5.6-sol Fork full provider history into a new chat $ ade chat rewind-files --message --dry-run Preview or apply file/context rewind @@ -1553,7 +1556,7 @@ const HELP_BY_COMMAND: Record = { --provider claude | codex | cursor | droid | opencode. --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when the selected model supports it. - Common tiers: minimal, low, medium, high, xhigh, max, ultracode. + Common tiers: minimal, low, medium, high, xhigh, ultra, ultracode. --prompt Create the chat, then send this as the first message. --permissions Alias for --permission-mode. --permission-mode default | auto | plan | edit | full-auto | config-toml. @@ -1582,11 +1585,11 @@ const HELP_BY_COMMAND: Record = { Create a persistent ADE Work chat session with provider/model/runtime settings. - $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto + $ ade chat create --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --effort high --permissions plan $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --effort ultracode --prompt "fix" $ ade chat create --lane --provider cursor --model cursor/ --standard --print-config --json - $ ade chat create --from-linear-issue ENG-431 --provider codex --model openai/gpt-5.5 --prompt "Work this issue" + $ ade chat create --from-linear-issue ENG-431 --provider codex --model openai/gpt-5.6-sol --prompt "Work this issue" Flags: --personal Use machine-owned chats instead of a project/lane chat. @@ -1595,7 +1598,7 @@ const HELP_BY_COMMAND: Record = { --model Model id, also sent as modelId for runtime parity. --reasoning-effort Reasoning tier when supported by the model. --effort Alias for --reasoning-effort. - Common tiers: minimal, low, medium, high, xhigh, max, ultracode. + Common tiers: minimal, low, medium, high, xhigh, ultra, ultracode. --prompt Create the chat, then send this as the first message. --kickoff Alias for --prompt. --permissions Alias for --permission-mode. @@ -1627,6 +1630,23 @@ const HELP_BY_COMMAND: Record = { CLI sessions: Use ade new chat --mode cli ... when you want a tracked provider CLI terminal instead of a persistent Work chat. +`, + "chat recover": `${ADE_BANNER} + Chat recovery + + Recover a stalled Codex Work-chat turn using the same actions as the desktop + recovery card. The session and turn must still be the active Codex turn. + + $ ade chat recover --turn --action wait + $ ade chat recover --turn --action nudge + $ ade chat recover --turn --action retry + $ ade chat recover --turn --action resume + + Actions: + wait Keep the current turn alive and restart its stalled-turn watchdog. + nudge Steer a short status request into the current turn. + retry Interrupt, then retry on the same Codex thread. + resume Restart the app server, resume the thread, then retry the turn. `, agent: `${ADE_BANNER} Agent sessions @@ -1635,7 +1655,7 @@ const HELP_BY_COMMAND: Record = { $ ade new chat --mode cli --lane --provider codex --prompt "Fix the failing test" $ ade agent spawn --lane --prompt "Fix the failing test" - $ ade agent spawn --lane --provider codex --model openai/gpt-5.5 --permissions full-auto + $ ade agent spawn --lane --provider codex --model openai/gpt-5.6-sol --permissions full-auto $ ade agent spawn --lane --context-file docs/context.md --prompt "continue" $ ade agent spawn --lane --tool=git --tool=files --prompt "review changes" @@ -1660,7 +1680,7 @@ const HELP_BY_COMMAND: Record = { ade new chat --mode cli, which supports the desktop New Chat CLI mode and reasoning/fast launch settings. This command does not support reasoning effort. - $ ade agent spawn --lane --provider codex --model openai/gpt-5.5 --permissions full-auto --prompt "Fix the failing test" + $ ade agent spawn --lane --provider codex --model openai/gpt-5.6-sol --permissions full-auto --prompt "Fix the failing test" $ ade agent spawn --lane --provider claude --model claude-opus-4-8 --permissions plan --prompt "Review the diff" Flags: @@ -2406,10 +2426,39 @@ type ToolClaimArgs = { type CodexGoalCliStatus = "active" | "paused" | "blocked" | "complete"; +type CodexRecoveryCliAction = + | "wait" + | "steer" + | "interrupt_retry_same_thread" + | "restart_resume_thread"; + function isCodexGoalCliStatus(value: string | null): value is CodexGoalCliStatus { return value === "active" || value === "paused" || value === "blocked" || value === "complete"; } +function normalizeCodexRecoveryCliAction(value: string | null): CodexRecoveryCliAction { + const normalized = value?.trim().toLowerCase().replace(/-/g, "_") ?? ""; + if (normalized === "wait") return "wait"; + if (normalized === "nudge" || normalized === "steer") return "steer"; + if ( + normalized === "retry" + || normalized === "interrupt_retry" + || normalized === "interrupt_retry_same_thread" + ) { + return "interrupt_retry_same_thread"; + } + if ( + normalized === "resume" + || normalized === "restart_resume" + || normalized === "restart_resume_thread" + ) { + return "restart_resume_thread"; + } + throw new CliUsageError( + "chat recover --action must be wait, nudge, retry, or resume.", + ); +} + function readToolClaimArgs(args: string[]): ToolClaimArgs { const laneId = asString( readValue(args, ["--lane", "--lane-id"]) ?? process.env.ADE_LANE_ID, @@ -6782,6 +6831,29 @@ function buildChatPlan(args: string[]): CliPlan { ), ], }; + if (sub === "recover" || sub === "recovery") { + const turnId = requireValue(readValue(args, ["--turn", "--turn-id"]), "turnId"); + const action = normalizeCodexRecoveryCliAction( + readValue(args, ["--action", "--recovery-action"]) + ?? firstStandalonePositional(args), + ); + return { + kind: "execute", + label: "chat recover", + steps: [ + actionStep( + "result", + "chat", + "recoverCodexTurn", + withSession({ + sessionId: requireValue(sessionId, "sessionId"), + turnId, + action, + }), + ), + ], + }; + } if (sub === "goal" || sub === "codex-goal") { const clear = readFlag(args, ["--clear", "--delete", "--rm"]); const statusFlag = readValue(args, ["--status"]); @@ -6957,7 +7029,8 @@ function buildChatPlan(args: string[]): CliPlan { label: "chat delete", steps: [actionStep("result", "chat", "deleteSession", withSession())], }; - if (sub === "models") + if (sub === "models") { + const provider = readValue(args, ["--provider"]); return { kind: "execute", label: "chat models", @@ -6966,10 +7039,11 @@ function buildChatPlan(args: string[]): CliPlan { "result", "chat", "getAvailableModels", - collectGenericObjectArgs(args), + collectGenericObjectArgs(args, provider ? { provider } : {}), ), ], }; + } if (sub === "slash") return { kind: "execute", @@ -10052,7 +10126,7 @@ function automationsExampleText(): string { prompt: "Investigate and propose a fix for {{trigger.issue.title}}.", modelConfig: { orchestratorModel: { - modelId: "openai/gpt-5.5", + modelId: "openai/gpt-5.6-sol", thinkingLevel: "xhigh", }, }, @@ -10642,6 +10716,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "-t", "--additional-instructions", "--app", + "--action", "--app-bundle", "--arg", "--arg-json", @@ -10772,6 +10847,7 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--question", "--reason", "--reasoning", + "--recovery-action", "--recent-limit", "--ref", "--require-dpop", @@ -10822,6 +10898,8 @@ const VALUE_CARRIER_FLAGS: ReadonlySet = new Set([ "--terminal-id", "--thread", "--thread-id", + "--turn", + "--turn-id", "--timeout", "--timeout-ms", "--title", diff --git a/apps/ade-cli/src/headlessLinearServices.test.ts b/apps/ade-cli/src/headlessLinearServices.test.ts index 05e306793..eb5a9cc66 100644 --- a/apps/ade-cli/src/headlessLinearServices.test.ts +++ b/apps/ade-cli/src/headlessLinearServices.test.ts @@ -573,8 +573,8 @@ describe("headlessLinearServices", () => { laneId: "lane-1", }); expect(session.title).toBe("CTO Headless Session"); - expect(session.model).toBe("gpt-5.5"); - expect(session.modelId).toBe("openai/gpt-5.5"); + expect(session.model).toBe("gpt-5.6-sol"); + expect(session.modelId).toBe("openai/gpt-5.6-sol"); services.dispose(); }); diff --git a/apps/ade-cli/src/headlessLinearServices.ts b/apps/ade-cli/src/headlessLinearServices.ts index 6ecdf2fb9..2ef1a009b 100644 --- a/apps/ade-cli/src/headlessLinearServices.ts +++ b/apps/ade-cli/src/headlessLinearServices.ts @@ -1581,7 +1581,7 @@ function createHeadlessAgentChatService( const identitySessionIds = new Map(); const transcripts = new Map(); - const HEADLESS_MODEL_ID = "openai/gpt-5.5"; + const HEADLESS_MODEL_ID = "openai/gpt-5.6-sol"; const clipText = (value: string, maxChars: number): string => { const trimmed = value.trim(); diff --git a/apps/ade-cli/src/services/sync/syncHostService.test.ts b/apps/ade-cli/src/services/sync/syncHostService.test.ts index 27623271d..18f67680c 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.test.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.test.ts @@ -23,6 +23,7 @@ import { SYNC_HOST_CHAT_ACTIVE_BACKGROUND_BACKPRESSURE_BYTES, buildSyncHostHelloOkPayload, buildSyncProjectCatalogMessages, + compactChatEventEnvelopeForSync, createChatEventReplayBuffer, createSyncHostService, planChatEventResume, @@ -2832,6 +2833,169 @@ describe("chat event replay buffer (resumable chat streams)", () => { const plan = planChatEventResume(buffer, buffer.entries[0]!.seq); expect(plan.mode).toBe("replay"); }); + + it("compacts oversized inline images in sync envelopes without mutating desktop events", () => { + const imageDataUrl = `data:image/png;base64,${"A".repeat(80 * 1024)}`; + const generated: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-07-09T00:00:00.000Z", + sequence: 1, + event: { + type: "codex_image_generation", + itemId: "generated-image", + result: imageDataUrl, + savedPath: imageDataUrl, + status: "completed", + }, + }; + const viewed: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-07-09T00:00:01.000Z", + sequence: 2, + event: { + type: "codex_image_view", + itemId: "viewed-image", + url: imageDataUrl, + path: imageDataUrl, + status: "completed", + }, + }; + + const compactedGenerated = compactChatEventEnvelopeForSync(generated); + const compactedViewed = compactChatEventEnvelopeForSync(viewed); + + expect(compactedGenerated.event).toMatchObject({ + type: "codex_image_generation", + result: null, + savedPath: null, + resultOriginalBytes: Buffer.byteLength(imageDataUrl, "utf8"), + resultOmittedBytes: Buffer.byteLength(imageDataUrl, "utf8"), + }); + expect(compactedViewed.event).toMatchObject({ + type: "codex_image_view", + url: null, + path: null, + urlOriginalBytes: Buffer.byteLength(imageDataUrl, "utf8"), + urlOmittedBytes: Buffer.byteLength(imageDataUrl, "utf8"), + }); + expect(generated.event).toMatchObject({ result: imageDataUrl, savedPath: imageDataUrl }); + expect(viewed.event).toMatchObject({ url: imageDataUrl, path: imageDataUrl }); + expect(JSON.stringify(compactedGenerated)).not.toContain("A".repeat(1024)); + expect(JSON.stringify(compactedViewed)).not.toContain("A".repeat(1024)); + }); + + it("redacts nested tool-result images while preserving small and ordinary values", () => { + const largeImage = `data:image/png;base64,${"C".repeat(80 * 1024)}`; + const smallImage = "data:image/png;base64,AAAA"; + const result = { + output: { + images: [largeImage, smallImage], + message: "generated two previews", + }, + count: 2, + }; + const desktopEnvelope: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-07-09T00:00:02.000Z", + sequence: 3, + event: { + type: "tool_result", + tool: "mcp__images__generate", + itemId: "tool-result-image", + result, + status: "completed", + }, + }; + + const compacted = compactChatEventEnvelopeForSync(desktopEnvelope); + expect(compacted.event).toMatchObject({ + type: "tool_result", + result: { + output: { + images: [ + `[ADE] Inline image data omitted from mobile chat sync (${Buffer.byteLength(largeImage, "utf8")} bytes).`, + smallImage, + ], + message: "generated two previews", + }, + count: 2, + }, + resultOriginalBytes: Buffer.byteLength(JSON.stringify(result), "utf8"), + resultOmittedBytes: Buffer.byteLength(largeImage, "utf8"), + }); + expect(desktopEnvelope.event).toMatchObject({ result }); + expect((desktopEnvelope.event as { result: unknown }).result).toBe(result); + expect(JSON.stringify(compacted)).not.toContain("C".repeat(1024)); + + const smallOnly: AgentChatEventEnvelope = { + ...desktopEnvelope, + sequence: 4, + event: { + type: "tool_result", + tool: "mcp__images__generate", + itemId: "tool-result-small-image", + result: { image: smallImage, message: "keep me" }, + status: "completed", + }, + }; + expect(compactChatEventEnvelopeForSync(smallOnly)).toBe(smallOnly); + }); + + it("retains only compacted image envelopes in the mobile replay ring", () => { + const buffer = createChatEventReplayBuffer(); + const imageDataUrl = `data:image/png;base64,${"B".repeat(900_000)}`; + const desktopEnvelope: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-07-09T00:00:00.000Z", + sequence: 1, + event: { + type: "codex_image_generation", + itemId: "droid-image", + result: imageDataUrl, + status: "completed", + }, + }; + + expect(recordChatEventInReplayBuffer(buffer, desktopEnvelope)).toBe(1); + expect(buffer.entries).toHaveLength(1); + expect(buffer.totalBytes).toBeLessThan(2_000); + expect(buffer.entries[0]!.event.event).toMatchObject({ + type: "codex_image_generation", + result: null, + resultOriginalBytes: Buffer.byteLength(imageDataUrl, "utf8"), + resultOmittedBytes: Buffer.byteLength(imageDataUrl, "utf8"), + }); + expect(desktopEnvelope.event).toMatchObject({ result: imageDataUrl }); + }); + + it("retains only redacted nested tool results in the mobile replay ring", () => { + const buffer = createChatEventReplayBuffer(); + const imageDataUrl = `data:image/jpeg;base64,${"D".repeat(900_000)}`; + const result = { response: { content: [{ type: "image", data: imageDataUrl }] } }; + const desktopEnvelope: AgentChatEventEnvelope = { + sessionId, + timestamp: "2026-07-09T00:00:03.000Z", + sequence: 1, + event: { + type: "tool_result", + tool: "mcp__computer__screenshot", + itemId: "nested-image-tool-result", + result, + status: "completed", + }, + }; + + expect(recordChatEventInReplayBuffer(buffer, desktopEnvelope)).toBe(1); + expect(buffer.entries).toHaveLength(1); + expect(buffer.totalBytes).toBeLessThan(2_000); + expect(buffer.entries[0]!.event.event).toMatchObject({ + type: "tool_result", + resultOmittedBytes: Buffer.byteLength(imageDataUrl, "utf8"), + }); + expect(JSON.stringify(buffer.entries[0]!.event)).not.toContain("D".repeat(1024)); + expect((desktopEnvelope.event as { result: unknown }).result).toBe(result); + expect(JSON.stringify(desktopEnvelope)).toContain("D".repeat(1024)); + }); }); describe("terminal byte-offset streaming, history paging, and resize ownership", () => { diff --git a/apps/ade-cli/src/services/sync/syncHostService.ts b/apps/ade-cli/src/services/sync/syncHostService.ts index 9d5a6499f..b3b3cb478 100644 --- a/apps/ade-cli/src/services/sync/syncHostService.ts +++ b/apps/ade-cli/src/services/sync/syncHostService.ts @@ -1142,6 +1142,7 @@ function looksLikePendingTailnetApproval(text: string): boolean { export const CHAT_EVENT_REPLAY_MAX_EVENTS = 500; export const CHAT_EVENT_REPLAY_MAX_BYTES = 2_000_000; +const SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES = 64 * 1024; // Delivery-key dedupe map cap. Must exceed CHAT_EVENT_REPLAY_MAX_EVENTS so a // buffered event's key cannot be evicted while the event itself is still in // the ring buffer (which could double-assign a seq to the same event). @@ -1149,6 +1150,157 @@ const CHAT_EVENT_REPLAY_MAX_KEYS = 1_500; // Bound the number of sessions with live replay buffers (LRU-evicted). const CHAT_EVENT_REPLAY_MAX_SESSIONS = 64; +function inlineImageDataUrlBytes(value: string | null | undefined): number | null { + if (!value || !/^data:image\//i.test(value.trim())) return null; + return Buffer.byteLength(value, "utf8"); +} + +function redactInlineImageDataUrlsForSync( + value: unknown, +): { value: unknown; omittedBytes: number; changed: boolean } { + const seen = new WeakSet(); + const visit = ( + candidate: unknown, + depth: number, + ): { value: unknown; omittedBytes: number; changed: boolean } => { + if (typeof candidate === "string") { + const bytes = inlineImageDataUrlBytes(candidate); + if (bytes == null || bytes <= SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { value: candidate, omittedBytes: 0, changed: false }; + } + return { + value: `[ADE] Inline image data omitted from mobile chat sync (${bytes} bytes).`, + omittedBytes: bytes, + changed: true, + }; + } + if (!candidate || typeof candidate !== "object") { + return { value: candidate, omittedBytes: 0, changed: false }; + } + if (depth >= 32) { + return { + value: "[ADE] Deep structured payload omitted from mobile chat sync.", + omittedBytes: 0, + changed: true, + }; + } + if (seen.has(candidate)) { + return { value: "[Circular]", omittedBytes: 0, changed: true }; + } + + seen.add(candidate); + if (Array.isArray(candidate)) { + let omittedBytes = 0; + let changed = false; + const next = candidate.map((entry) => { + const result = visit(entry, depth + 1); + omittedBytes += result.omittedBytes; + changed ||= result.changed; + return result.value; + }); + seen.delete(candidate); + return { value: changed ? next : candidate, omittedBytes, changed }; + } + + let omittedBytes = 0; + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(candidate)) { + const result = visit(entry, depth + 1); + next[key] = result.value; + omittedBytes += result.omittedBytes; + changed ||= result.changed; + } + seen.delete(candidate); + return { value: changed ? next : candidate, omittedBytes, changed }; + }; + + return visit(value, 0); +} + +function serializedSyncPayloadBytes(value: unknown): number { + if (typeof value === "string") return Buffer.byteLength(value, "utf8"); + try { + const serialized = JSON.stringify(value); + return Buffer.byteLength(serialized ?? String(value), "utf8"); + } catch { + return Buffer.byteLength(String(value), "utf8"); + } +} + +/** + * Bound inline image payloads at the mobile-sync boundary. The agent chat + * service intentionally keeps the original envelope for desktop live + * previews; only WebSocket snapshots/events and their replay ring use this + * compact copy. + */ +export function compactChatEventEnvelopeForSync( + envelope: AgentChatEventEnvelope, +): AgentChatEventEnvelope { + const event = envelope.event; + if (event.type === "tool_result") { + const redacted = redactInlineImageDataUrlsForSync(event.result); + if (!redacted.changed) return envelope; + const originalBytes = Math.max( + event.resultOriginalBytes ?? 0, + serializedSyncPayloadBytes(event.result), + redacted.omittedBytes, + ); + return { + ...envelope, + event: { + ...event, + result: redacted.value, + resultOriginalBytes: originalBytes, + resultOmittedBytes: (event.resultOmittedBytes ?? 0) + redacted.omittedBytes, + }, + }; + } + + if (event.type === "codex_image_generation") { + const resultBytes = inlineImageDataUrlBytes(event.result); + const savedPathIsInline = inlineImageDataUrlBytes(event.savedPath) != null; + if (resultBytes != null && resultBytes > SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { + ...envelope, + event: { + ...event, + result: null, + ...(savedPathIsInline ? { savedPath: null } : {}), + resultOriginalBytes: resultBytes, + resultOmittedBytes: resultBytes, + }, + }; + } + if (savedPathIsInline) { + return { ...envelope, event: { ...event, savedPath: null } }; + } + return envelope; + } + + if (event.type === "codex_image_view") { + const urlBytes = inlineImageDataUrlBytes(event.url); + const pathIsInline = inlineImageDataUrlBytes(event.path) != null; + if (urlBytes != null && urlBytes > SYNC_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { + ...envelope, + event: { + ...event, + url: null, + ...(pathIsInline ? { path: null } : {}), + urlOriginalBytes: urlBytes, + urlOmittedBytes: urlBytes, + }, + }; + } + if (pathIsInline) { + return { ...envelope, event: { ...event, path: null } }; + } + } + + return envelope; +} + export type ChatEventReplayBufferEntry = { seq: number; bytes: number; @@ -1193,13 +1345,14 @@ export function recordChatEventInReplayBuffer( if (oldestKey == null) break; buffer.seqByKey.delete(oldestKey); } + const syncEvent = compactChatEventEnvelopeForSync(event); let bytes = 512; try { - bytes = JSON.stringify(event).length; + bytes = JSON.stringify(syncEvent).length; } catch { // keep the conservative default } - buffer.entries.push({ seq, bytes, event }); + buffer.entries.push({ seq, bytes, event: syncEvent }); buffer.totalBytes += bytes; while ( buffer.entries.length > 0 @@ -3465,7 +3618,8 @@ export function createSyncHostService(args: SyncHostServiceArgs) { function sendChatEvent(peer: PeerState, event: AgentChatEventEnvelope, seq: number): "sent" | "already-sent" | "failed" { if (chatEventAlreadySent(peer, event)) return "already-sent"; - const sent = send(peer.ws, "chat_event", { ...event, seq } satisfies SyncChatEventPayload); + const syncEvent = compactChatEventEnvelopeForSync(event); + const sent = send(peer.ws, "chat_event", { ...syncEvent, seq } satisfies SyncChatEventPayload); if (sent) markChatEventSent(peer, event); return sent ? "sent" : "failed"; } @@ -4885,6 +5039,7 @@ export function createSyncHostService(args: SyncHostServiceArgs) { : 0; truncated = history?.truncated ?? (transcriptSize > maxBytes); } + events = events.map(compactChatEventEnvelopeForSync); peer.chatTranscriptOffsets.set(sessionId, transcriptSize); const snapshot: SyncChatSubscribeSnapshotPayload = { sessionId, diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index 40616cfab..22396150d 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -288,6 +288,53 @@ describe("createSyncRemoteCommandService", () => { expect(ptyService.resumeSession).not.toHaveBeenCalled(); }); + it("routes all Codex recovery actions through the mobile sync command", async () => { + const recoverCodexTurn = vi.fn(async (args) => ({ + action: args.action, + turnId: args.turnId, + status: args.action === "wait" ? "waiting" : "retrying", + })); + const { service } = createService({ agentChatService: { recoverCodexTurn } }); + + expect(service.getDescriptor("chat.recoverCodexTurn")).toEqual({ + action: "chat.recoverCodexTurn", + scope: "project", + policy: { viewerAllowed: true, queueable: false }, + }); + + for (const action of [ + "wait", + "steer", + "interrupt_retry_same_thread", + "restart_resume_thread", + ]) { + await service.execute(makePayload("chat.recoverCodexTurn", { + sessionId: "chat-1", + turnId: "turn-1", + action, + })); + } + + expect(recoverCodexTurn.mock.calls.map(([args]) => args)).toEqual([ + { sessionId: "chat-1", turnId: "turn-1", action: "wait" }, + { sessionId: "chat-1", turnId: "turn-1", action: "steer" }, + { sessionId: "chat-1", turnId: "turn-1", action: "interrupt_retry_same_thread" }, + { sessionId: "chat-1", turnId: "turn-1", action: "restart_resume_thread" }, + ]); + }); + + it("rejects unsupported Codex recovery actions before invoking chat", async () => { + const recoverCodexTurn = vi.fn(); + const { service } = createService({ agentChatService: { recoverCodexTurn } }); + + await expect(service.execute(makePayload("chat.recoverCodexTurn", { + sessionId: "chat-1", + turnId: "turn-1", + action: "replace", + }))).rejects.toThrow("unsupported action 'replace'"); + expect(recoverCodexTurn).not.toHaveBeenCalled(); + }); + it("omits non-finite work.resumeCliSession dimensions", async () => { const { service, ptyService } = createService(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 1f9199aea..8cff07479 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -48,6 +48,7 @@ import type { AgentChatDispatchSteerArgs, AgentChatCancelDispatchedSteerArgs, AgentChatInterruptArgs, + AgentChatRecoverCodexTurnArgs, AgentChatUpdateSessionArgs, AddPrCommentArgs, AiReviewSummaryArgs, @@ -213,6 +214,7 @@ import { readImageFileAndSniffMime, saveImageTempAttachment } from "../imageAtta import { buildAiSettingsStatus, getUnavailableAiStatus, isDatabaseClosedError } from "../../../../desktop/src/main/services/ai/aiSettingsStatus"; import type { createAiIntegrationService } from "../../../../desktop/src/main/services/ai/aiIntegrationService"; import type { createAgentChatService } from "../../../../desktop/src/main/services/chat/agentChatService"; +import { resolveCodexComputerUseMcpConfig } from "../../../../desktop/src/main/utils/codexComputerUse"; import type { createCtoStateService } from "../../../../desktop/src/main/services/cto/ctoStateService"; import type { CtoMemoryService } from "../../../../desktop/src/main/services/cto/ctoMemoryService"; import type { createLinearCredentialService } from "../../../../desktop/src/main/services/cto/linearCredentialService"; @@ -1930,6 +1932,23 @@ function parseAgentChatInterruptArgs(value: Record): AgentChatI }; } +function parseAgentChatRecoverCodexTurnArgs(value: Record): AgentChatRecoverCodexTurnArgs { + const action = requireString(value.action, "chat.recoverCodexTurn requires action."); + if ( + action !== "wait" + && action !== "steer" + && action !== "interrupt_retry_same_thread" + && action !== "restart_resume_thread" + ) { + throw new Error(`chat.recoverCodexTurn received unsupported action '${action}'.`); + } + return { + sessionId: requireString(value.sessionId, "chat.recoverCodexTurn requires sessionId."), + turnId: requireString(value.turnId, "chat.recoverCodexTurn requires turnId."), + action, + }; +} + function parseAgentChatApproveArgs(value: Record): AgentChatApproveArgs { return { sessionId: requireString(value.sessionId, "chat.approve requires sessionId."), @@ -3371,6 +3390,9 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio }); const title = initialInputMeta.title || LAUNCH_PROFILE_TITLE[provider]; const preassignedSessionId = provider === "claude" ? randomUUID() : undefined; + const codexComputerUse = provider === "codex" + ? await resolveCodexComputerUseMcpConfig() + : null; function resolveLaunch(): Partial { if (provider === "shell") { @@ -3389,6 +3411,7 @@ function registerWorkRemoteCommands({ args, register }: RemoteCommandRegistratio fastMode: parsed.fastMode ?? undefined, initialPrompt: parsed.initialInput, laneWorktreePath: resolveLaneWorktreePathForSync(args, parsed.laneId), + ...(provider === "codex" ? { codexComputerUse } : {}), }); } @@ -3636,6 +3659,9 @@ function registerChatRemoteCommands({ args, register }: RemoteCommandRegistratio await requireService(args.agentChatService, "Agent chat service not available.").interrupt(parseAgentChatInterruptArgs(payload)); return { ok: true }; }); + register("chat.recoverCodexTurn", { viewerAllowed: true, queueable: false }, async (payload) => + requireService(args.agentChatService, "Agent chat service not available.") + .recoverCodexTurn(parseAgentChatRecoverCodexTurnArgs(payload))); register("chat.steer", { viewerAllowed: true, queueable: false }, async (payload) => { const result = await requireService(args.agentChatService, "Agent chat service not available.").steer(parseAgentChatSteerArgs(payload)); return isRecord(result) ? { ...result, ok: true } : { ok: true }; diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index deed955c0..3834ee026 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; @@ -461,7 +461,7 @@ describe("getAvailableModels", () => { }); describe("createChatSession", () => { - it("defaults Codex chats to GPT-5.5 low reasoning", async () => { + it("defaults Codex chats to GPT-5.6 Sol low reasoning", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const connection = { action: async (domain: string, action: string, args?: Record) => { @@ -470,7 +470,7 @@ describe("createChatSession", () => { id: "chat-1", laneId: "lane-1", provider: "codex", - model: "gpt-5.5", + model: "gpt-5.6-sol", status: "idle", createdAt: "2026-01-01T00:00:00.000Z", lastActivityAt: "2026-01-01T00:00:00.000Z", @@ -486,8 +486,8 @@ describe("createChatSession", () => { action: "createSession", args: expect.objectContaining({ provider: "codex", - model: "gpt-5.5", - modelId: "openai/gpt-5.5", + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, surface: "work", }), @@ -495,6 +495,28 @@ describe("createChatSession", () => { ]); }); + it("uses a selected GPT-5.6 model's advertised default reasoning", async () => { + const calls: Array<{ domain: string; action: string; args?: Record }> = []; + const connection = { + action: async (domain: string, action: string, args?: Record) => { + calls.push({ domain, action, args }); + return { id: "chat-1", laneId: "lane-1", provider: "codex", model: args?.model }; + }, + } as unknown as AdeCodeConnection; + + await createChatSession({ + connection, + laneId: "lane-1", + modelId: "openai/gpt-5.6-terra", + }); + + expect(calls[0]?.args).toEqual(expect.objectContaining({ + model: "gpt-5.6-terra", + modelId: "openai/gpt-5.6-terra", + reasoningEffort: "medium", + })); + }); + it("passes native model controls when creating chats", async () => { const calls: Array<{ domain: string; action: string; args?: Record }> = []; const connection = { @@ -937,4 +959,25 @@ describe("steer helpers", () => { { domain: "chat", action: "dispatchSteer", args: { sessionId: "chat-1", steerId: "steer-1", mode: "inline" } }, ]); }); + + it("routes stalled-turn recovery through the shared Codex chat action", async () => { + const calls: Array<{ domain: string; action: string; args: Record }> = []; + const connection = { + action: async (domain: string, action: string, args: Record) => { + calls.push({ domain, action, args }); + return { action: "steer", turnId: "turn-1", status: "nudged" }; + }, + } as unknown as AdeCodeConnection; + + await expect(recoverCodexTurn(connection, { + sessionId: "chat-1", + turnId: "turn-1", + action: "steer", + })).resolves.toEqual({ action: "steer", turnId: "turn-1", status: "nudged" }); + expect(calls).toEqual([{ + domain: "chat", + action: "recoverCodexTurn", + args: { sessionId: "chat-1", turnId: "turn-1", action: "steer" }, + }]); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index bf9af5986..434386e8b 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -577,6 +577,43 @@ describe("aggregateChatBlocks desktop work-log parity", () => { expect(group.entries[0]!.tool).toBe("custom_migration"); }); + it("renders MCP calls as connector + target and collapses their lifecycle", () => { + const mcp = { + server: "github", + tool: "search_issues", + appContext: { appName: "GitHub", actionName: "Search issues" }, + }; + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { + type: "tool_call", + tool: "github:search_issues", + args: { query: "is:open label:bug" }, + mcp, + itemId: "mcp-1", + turnId: "turn-1", + }), + env("2026-01-01T12:00:01.000Z", { + type: "tool_result", + tool: "github:search_issues", + result: "Issue 1", + mcp, + itemId: "mcp-1", + turnId: "turn-1", + status: "completed", + }), + ]; + const blocks = aggregate(events); + const group = blocks.find((b) => b.kind === "tool-calls-group") as Extract; + expect(group.entries).toEqual([ + expect.objectContaining({ + itemId: "mcp-1", + tool: "git_hub", + arg: "is:open label:bug", + status: "ok", + }), + ]); + }); + it("groups web_search lifecycle events into the tool-calls group keyed by query", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { @@ -599,6 +636,35 @@ describe("aggregateChatBlocks desktop work-log parity", () => { expect(group.entries[0]).toMatchObject({ tool: "search", arg: "ink truncate text", status: "ok" }); }); + it("collapses image generation lifecycle updates into one completed line", () => { + const events: AgentChatEventEnvelope[] = [ + env("2026-01-01T12:00:00.000Z", { + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "A tiny moon icon", + status: "running", + }), + env("2026-01-01T12:00:01.000Z", { + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "A tiny moon icon", + result: "/tmp/moon.png", + savedPath: "/tmp/moon.png", + status: "completed", + }), + ]; + const imageBlocks = aggregate(events).filter((block) => + block.kind === "notice" && block.line.body.includes("image generated") + ); + expect(imageBlocks).toHaveLength(1); + expect(imageBlocks[0]).toMatchObject({ + kind: "notice", + line: expect.objectContaining({ body: expect.stringContaining("A tiny moon icon") }), + }); + }); + it("merges streamed reasoning into one block and finishes it at turn end", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "reasoning", text: "part one ", turnId: "turn-1" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 42a4a38a7..6f632e3a5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -58,6 +58,8 @@ import { resolveContextDefault, resolveDrawerPaneWidth, resolvePromptChatSubmitTarget, + resolveTuiCodexRecoveryRequest, + resolveTuiCodexRecoveryTargetProvider, shouldHandlePendingQuestionKey, resolveModelPickerEscape, nextModelPickerProviderTabKey, @@ -96,9 +98,11 @@ import { cursorModeIdsForState, cursorSourceForInterfaceMode, initialModelState, + modelReasoningEfforts, applyProviderPermissionMode, permissionSummary, reconcileCursorModelStateForInterface, + registryModelsForProvider, resolveCursorCliModelForLaunch, } from "../modelState"; import { normalizeCatalogProvider } from "../providerMetadata"; @@ -1181,6 +1185,122 @@ describe("interface draft setup", () => { expect(initialModelState("cli").interfaceMode).toBe("cli"); expect(initialModelState("chat").interfaceMode).toBe("chat"); }); + + it("starts new TUI chats on GPT-5.6 Sol with its low default", () => { + expect(initialModelState("chat")).toMatchObject({ + provider: "codex", + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + reasoningEffort: "low", + }); + expect(registryModelsForProvider("codex").slice(0, 4).map((model) => model.modelId)).toEqual([ + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", + "openai/gpt-5.5", + ]); + }); + + it("shows the product-facing Sol effort ladder without internal max", () => { + const modelState = initialModelState("chat"); + const models = [{ + id: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + isDefault: true, + reasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"] + .map((effort) => ({ effort, description: effort })), + }]; + + expect(modelReasoningEfforts(modelState, models)).toEqual(["low", "medium", "high", "xhigh", "ultra"]); + expect(buildSetupRows({ + modelState, + models, + includeRefresh: false, + includeApply: true, + interfaceMode: "chat", + interfaceEditable: true, + }).find((row) => row.kind === "reasoning")).toMatchObject({ + value: "Light", + detail: "Light, Medium, High, Extra High, Ultra", + }); + }); + + it("maps friendly recovery aliases to the latest stalled turn in this chat", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "chat-1", + timestamp: "2026-01-01T00:00:00.000Z", + sequence: 1, + event: { + type: "codex_turn_stalled", + turnId: "child-turn", + sourceSessionId: "child-chat", + reason: "no_output", + message: "Child stalled", + }, + }, + { + sessionId: "chat-1", + timestamp: "2026-01-01T00:00:01.000Z", + sequence: 2, + event: { + type: "codex_turn_stalled", + turnId: "turn-1", + reason: "no_output", + message: "Parent stalled", + }, + }, + ]; + + expect(resolveTuiCodexRecoveryRequest({ input: "nudge", sessionId: "chat-1", events })).toEqual({ + action: "steer", + turnId: "turn-1", + sessionId: "chat-1", + }); + expect(resolveTuiCodexRecoveryRequest({ input: "resume explicit-turn", sessionId: "chat-1", events })).toEqual({ + action: "restart_resume_thread", + turnId: "explicit-turn", + sessionId: "chat-1", + }); + expect(resolveTuiCodexRecoveryRequest({ + input: "retry", + sessionId: "chat-1", + events: events.slice(0, 1), + })).toEqual({ + action: "interrupt_retry_same_thread", + turnId: "child-turn", + sessionId: "child-chat", + }); + expect(resolveTuiCodexRecoveryRequest({ input: "unknown", sessionId: "chat-1", events })).toBeNull(); + }); + + it("gates recovery on the resolved Codex child instead of its visible Claude parent", () => { + const sessions = [ + { sessionId: "parent-chat", provider: "claude" as const }, + { sessionId: "child-chat", provider: "codex" as const }, + ]; + + expect(resolveTuiCodexRecoveryTargetProvider({ + targetSessionId: "child-chat", + visibleSessionId: "parent-chat", + visibleProvider: "claude", + sessions, + })).toBe("codex"); + expect(resolveTuiCodexRecoveryTargetProvider({ + targetSessionId: "parent-chat", + visibleSessionId: "parent-chat", + visibleProvider: "claude", + sessions, + })).toBe("claude"); + expect(resolveTuiCodexRecoveryTargetProvider({ + targetSessionId: "child-not-yet-listed", + visibleSessionId: "parent-chat", + visibleProvider: "claude", + sessions, + })).toBeNull(); + }); }); describe("provider permission helpers", () => { diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 300419916..ae9b172e1 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -91,6 +91,20 @@ describe("commands", () => { })); }); + it("routes Codex stalled-turn recovery from Codex or orchestration-parent chats", () => { + const parsed = parseCommand("/recover nudge turn-1"); + expect(parsed?.name).toBe("/recover"); + expect(parsed?.args).toBe("nudge turn-1"); + expect(parsed ? commandPlacement(parsed) : null).toBe("right"); + expect(paletteCommands("/rec", [], { provider: "codex" })).toContainEqual(expect.objectContaining({ + name: "/recover", + description: "Recover the latest stalled Codex turn", + })); + expect(paletteCommands("/rec", [], { provider: "claude" })).toContainEqual(expect.objectContaining({ + name: "/recover", + })); + }); + it("routes /feedback to the ADE Code right pane", () => { const parsed = parseCommand("/feedback"); expect(parsed?.spec?.name).toBe("/feedback"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 0f07224af..7a91e4299 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -355,6 +355,17 @@ describe("renderChatLines", () => { usage: { total: { totalTokens: 1200 }, last: { totalTokens: 120 }, modelContextWindow: 200000 }, }, }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:05.000Z", + sequence: 6, + event: { + type: "codex_turn_stalled", + turnId: "turn-1", + reason: "no_output", + message: "Codex accepted the turn but has not streamed output.", + }, + }, ], }); @@ -367,6 +378,7 @@ describe("renderChatLines", () => { expect(body).toMatch(/search\s+Codex app server/); expect(body).toMatch(/openPage\s+https:\/\/example\.com\/codex/); expect(body).toContain("image generated"); + expect(body).toContain("/recover wait · nudge · retry · resume"); // Goal/token-usage events are suppressed in the chat transcript. expect(body).not.toContain("goal active"); expect(body).not.toContain("tokens · last"); diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 2a5dd4762..cdad12748 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -12,6 +12,8 @@ import type { AgentChatClaudePermissionMode, AgentChatCodexApprovalPolicy, AgentChatCodexConfigSource, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, AgentChatCodexSandbox, AgentChatContextUsage, AgentChatCursorConfigValue, @@ -519,8 +521,9 @@ export async function createChatSession(args: { ? "auto" : provider === "droid" ? (getDefaultModelDescriptor("droid")?.providerModelId ?? "claude-sonnet-4-5-20250929") - : "gpt-5.5"; - const reasoningEffort = args.reasoningEffort ?? (provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null); + : "gpt-5.6-sol"; + const reasoningEffort = args.reasoningEffort + ?? (provider === "codex" ? descriptor?.defaultReasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT : null); return await args.connection.action("chat", "createSession", { laneId: args.laneId, provider, @@ -664,6 +667,17 @@ export async function interruptChat(connection: AdeCodeConnection, sessionId: st await connection.action("chat", "interrupt", { sessionId }); } +export async function recoverCodexTurn( + connection: AdeCodeConnection, + args: AgentChatRecoverCodexTurnArgs, +): Promise { + return await connection.action( + "chat", + "recoverCodexTurn", + args, + ); +} + /** * Pull a subagent's real child transcript from the daemon. Only meaningful for * runtimes with `canViewFullTranscript` (Codex app-server threads, OpenCode diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index ebca797a2..a1f61de1e 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -214,20 +214,25 @@ function appendToolCallEvent( // title carried in the payload (readToolTitle in chatTranscriptRows). const payload = event.type === "tool_call" ? event.args : event.result; const titleFallback = readToolTitle(payload); - const resolvedToolName = isGenericToolIdentifier(event.tool) && titleFallback ? titleFallback : event.tool; + const connectorName = event.mcp?.appContext?.appName ?? event.mcp?.pluginId ?? event.mcp?.server; + const connectorAction = event.mcp?.appContext?.actionName ?? event.mcp?.tool; + const resolvedToolName = connectorName + ?? (isGenericToolIdentifier(event.tool) && titleFallback ? titleFallback : event.tool); + const argPreview = toolArgPreview(event.tool, payload) + || (connectorAction ? summarizeInlineText(connectorAction, 140) : ""); if (event.type === "tool_call") { const existing = registry.get(event.itemId); if (existing) { if (isGenericToolIdentifier(existing.tool) && !isGenericToolIdentifier(resolvedToolName)) { existing.tool = toolSlug(resolvedToolName); } - if (!existing.arg) existing.arg = toolArgPreview(resolvedToolName, event.args); + if (!existing.arg) existing.arg = argPreview; return; } registry.add({ itemId: event.itemId, tool: toolSlug(resolvedToolName), - arg: toolArgPreview(resolvedToolName, event.args), + arg: argPreview, status: "running", }); return; @@ -245,7 +250,7 @@ function appendToolCallEvent( registry.add({ itemId: event.itemId, tool: toolSlug(resolvedToolName), - arg: toolArgPreview(resolvedToolName, payload), + arg: argPreview, status, ...(durationMs !== undefined ? { durationMs } : {}), }); @@ -808,6 +813,7 @@ export function aggregateChatBlocks(args: { // turnId:itemId → live entry reference, so a tool's later lifecycle events // resolve the SAME entry even when assistant text split the visual groups. const toolEntryByItemKey = new Map(); + const imageBlockByItemKey = new Map>(); const assistantTextEventsByBlockId = new Map(); const reasoningItemIdByBlockId = new Map(); @@ -1043,6 +1049,20 @@ export function aggregateChatBlocks(args: { }); continue; } + if (event.type === "codex_image_generation" || event.type === "codex_image_view") { + const line = linesById.get(id); + if (!line) continue; + const key = `${event.type}:${workItemKey(turnId, event.itemId)}`; + const existing = imageBlockByItemKey.get(key); + if (existing) { + existing.line = line; + continue; + } + const block: Extract = { kind: "notice", id, line }; + blocks.push(block); + imageBlockByItemKey.set(key, block); + continue; + } if (event.type === "codex_context_compaction") { const existing = event.state === "completed" ? findCompactionBlockForCompletion(blocks, turnId) : null; if (existing && event.state === "completed") { diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 7c90989a7..a27257a50 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -22,6 +22,7 @@ import { } from "../../../desktop/src/shared/composerTriggers"; import type { AgentChatClaudePlugin, + AgentChatCodexRecoveryAction, AgentChatReloadClaudePluginsResult, AgentChatEventEnvelope, AgentChatFileRef, @@ -83,6 +84,7 @@ import { normalizeChatTerminalSession, previewTerminal, renameChat, + recoverCodexTurn, resumeTerminalSession, resizeTerminal, reloadClaudePlugins, @@ -183,6 +185,7 @@ import { modelStatePatchForModel, permissionSummary, providerModelsCacheKey, + reasoningEffortDisplayLabel, reconcileCursorModelStateForInterface, registryModelsForProvider, resolveCodexPreset, @@ -1671,6 +1674,60 @@ function splitFirstArg(input: string): { first: string; rest: string } { }; } +const CODEX_RECOVERY_ACTION_ALIASES: Readonly> = { + wait: "wait", + nudge: "steer", + steer: "steer", + retry: "interrupt_retry_same_thread", + interrupt_retry_same_thread: "interrupt_retry_same_thread", + resume: "restart_resume_thread", + restart_resume_thread: "restart_resume_thread", +}; + +export function resolveTuiCodexRecoveryRequest(args: { + input: string; + sessionId: string; + events: readonly AgentChatEventEnvelope[]; +}): { action: AgentChatCodexRecoveryAction; turnId: string; sessionId: string } | null { + const parsed = splitFirstArg(args.input); + const action = CODEX_RECOVERY_ACTION_ALIASES[parsed.first.trim().toLowerCase().replace(/-/g, "_")]; + if (!action) return null; + const explicitTurnId = splitFirstArg(parsed.rest).first; + if (explicitTurnId) { + const matchingEnvelope = [...args.events].reverse().find((envelope) => + envelope.event.type === "codex_turn_stalled" && envelope.event.turnId === explicitTurnId + ); + const targetSessionId = matchingEnvelope?.event.type === "codex_turn_stalled" + ? matchingEnvelope.event.sourceSessionId?.trim() || matchingEnvelope.sessionId + : args.sessionId; + return { action, turnId: explicitTurnId, sessionId: targetSessionId }; + } + for (let index = args.events.length - 1; index >= 0; index -= 1) { + const envelope = args.events[index]; + if (envelope?.event.type !== "codex_turn_stalled") continue; + return { + action, + turnId: envelope.event.turnId, + sessionId: envelope.event.sourceSessionId?.trim() || envelope.sessionId, + }; + } + return null; +} + +export function resolveTuiCodexRecoveryTargetProvider(args: { + targetSessionId: string; + visibleSessionId: string; + visibleProvider: AgentChatSessionSummary["provider"] | null | undefined; + sessions: ReadonlyArray>; +}): AgentChatSessionSummary["provider"] | null { + const targetSession = args.sessions.find((session) => session.sessionId === args.targetSessionId); + if (targetSession) return targetSession.provider; + // An orchestration child may not have reached the TUI's session inventory yet. + // In that case the forwarded stalled event remains authoritative and the + // service performs the final Codex-provider validation. + return args.targetSessionId === args.visibleSessionId ? args.visibleProvider ?? null : null; +} + type ParsedAdeActionPayload = | { args: Record } | { argsList: unknown[] } @@ -2683,6 +2740,7 @@ function modelInfoFromDescriptor(modelRef: string): AgentChatModelInfo | null { displayName: descriptor.displayName, isDefault: false, reasoningEfforts: descriptor.reasoningTiers?.map((effort) => ({ effort, description: effort })), + defaultReasoningEffort: descriptor.defaultReasoningEffort ?? null, ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), ...(descriptor.cursorAvailability ? { cursorAvailability: descriptor.cursorAvailability } : {}), ...(descriptor.cursorCliVariants?.length ? { cursorCliVariants: descriptor.cursorCliVariants } : {}), @@ -3710,6 +3768,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, () => modelReasoningEfforts(modelState, models).length > 0, [modelState, models], ); + const footerReasoningLabel = reasoningEffortDisplayLabel(modelState.reasoningEffort, modelState); const footerFastSupportedRef = useRef(false); const footerReasoningSupportedRef = useRef(false); useEffect(() => { @@ -9640,6 +9699,69 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } return; } + if (name === "/recover") { + if (!sessionId || activeTerminalSessionRef.current) { + setRightPane({ + kind: "details", + title: "Codex recovery", + body: "Recovery is available for an active Codex Work chat, not a CLI terminal.", + }); + return; + } + const request = resolveTuiCodexRecoveryRequest({ + input: args, + sessionId, + events: eventsRef.current, + }); + if (!request) { + setRightPane({ + kind: "details", + title: "Codex recovery", + body: [ + "Usage: /recover [turn-id]", + "", + "The turn id is optional when this chat has a recent stalled-turn notice.", + ].join("\n"), + }); + return; + } + const targetProvider = resolveTuiCodexRecoveryTargetProvider({ + targetSessionId: request.sessionId, + visibleSessionId: sessionId, + visibleProvider: activeSession?.provider, + sessions, + }); + if (targetProvider && targetProvider !== "codex") { + setRightPane({ + kind: "details", + title: "Codex recovery", + body: "/recover target is not a Codex chat.", + }); + return; + } + try { + const result = await recoverCodexTurn(conn, request); + const label = request.action === "wait" + ? "Waiting" + : request.action === "steer" + ? "Status nudge sent" + : request.action === "interrupt_retry_same_thread" + ? "Retrying the same thread" + : "App server restarted; thread resumed"; + setRightPane({ + kind: "details", + title: "Codex recovery", + body: `${label} · ${result.status}\nTurn ${result.turnId}`, + }); + addNotice(label, "success"); + await refreshState(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setRightPane({ kind: "details", title: "Codex recovery", body: message }); + addNotice(message, "error"); + } + return; + } if (name === "/model") { openModelPicker(); return; @@ -14244,9 +14366,9 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, }); footerX += width; } - if (footerReasoningSupported && modelState.reasoningEffort) { + if (footerReasoningSupported && footerReasoningLabel) { footerX += 2; - const width = footerCellWidth(modelState.reasoningEffort, "reasoning"); + const width = footerCellWidth(footerReasoningLabel, "reasoning"); addFooterInlineTarget("footer:inline:reasoning", footerX, width, () => { selectFooterControl(null); setPaneFocus("chat"); @@ -15223,7 +15345,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, favorites: modelPickerFavorites, recents: modelPickerRecents, activeModelId: modelState.modelId, - activeReasoningEffort: modelState.reasoningEffort, + activeReasoningEffort: footerReasoningLabel, aiStatus, interfaceMode: modelState.interfaceMode, }), [ @@ -15233,7 +15355,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, modelPickerRecents, modelState.interfaceMode, modelState.modelId, - modelState.reasoningEffort, + footerReasoningLabel, models, ]); const activeTerminalScroll = readTerminalScroll(terminalScrollBySessionId, activeTerminalSession?.terminalId); @@ -15507,7 +15629,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, |clear|status active|paused|complete]", providers: ["claude", "codex"], category: "Model" }, + // A non-Codex orchestration parent can surface a stalled Codex child's event, + // so recovery availability is determined from that event's target session. + { name: "/recover", description: "Recover the latest stalled Codex turn", placement: "right", argumentHint: " [turn-id]", category: "Chats" }, { name: "/diff", description: "Show active lane diff", placement: "right", category: "Lanes" }, { name: "/log", description: "Show recent commits", placement: "right", category: "Lanes" }, { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: " [stack-base-ref]", category: "Lanes" }, diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index f833ffe00..ccb7bed4d 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -684,11 +684,10 @@ export function renderChatLines(args: { continue; } if (event.type === "codex_turn_stalled") { - const options = event.recoveryOptions?.map((option) => option.replace(/_/g, " ")).join(" · "); lines.push({ id, tone: "error", - body: `recovery · ${singleLine(event.message, 140)}${options ? `\n ${options}` : ""}`, + body: `recovery · ${singleLine(event.message, 140)}\n /recover wait · nudge · retry · resume`, }); continue; } diff --git a/apps/ade-cli/src/tuiClient/modelState.ts b/apps/ade-cli/src/tuiClient/modelState.ts index 9263b6965..7a44ad796 100644 --- a/apps/ade-cli/src/tuiClient/modelState.ts +++ b/apps/ade-cli/src/tuiClient/modelState.ts @@ -20,7 +20,7 @@ import { theme } from "./theme"; import type { AdeCodeInterfaceMode, AdeCodeModelState, AdeCodeProvider, SetupPaneRow, SetupPaneRowKind } from "./types"; import { normalizeProvider, providerLabel } from "./providerMetadata"; -export const EFFORTS = ["low", "medium", "high", "xhigh", "max", "ultracode"]; +export const EFFORTS = ["low", "medium", "high", "xhigh", "ultra"]; export const CODEX_PRESETS = ["default", "edit", "plan", "full-auto", "config-toml"] as const; export const CLAUDE_PERMISSION_OPTIONS = ["default", "auto", "plan", "acceptEdits", "bypassPermissions"] as const; export const OPENCODE_PERMISSION_OPTIONS = ["plan", "edit", "full-auto", "config-toml"] as const; @@ -33,10 +33,10 @@ export function initialModelState(draftKind: AdeCodeInterfaceMode = "chat"): Ade return { provider: "codex", interfaceMode: draftKind, - model: descriptor?.providerModelId ?? "gpt-5.5", + model: descriptor?.providerModelId ?? "gpt-5.6-sol", modelId: descriptor?.id ?? null, - displayName: descriptor?.displayName ?? "GPT-5.5", - reasoningEffort: DEFAULT_CODEX_REASONING_EFFORT, + displayName: descriptor?.displayName ?? "GPT-5.6 Sol", + reasoningEffort: descriptor?.defaultReasoningEffort ?? DEFAULT_CODEX_REASONING_EFFORT, fastMode: false, permissionMode: "default", interactionMode: "default", @@ -68,15 +68,23 @@ export function cliProviderForModelStateProvider(provider: AdeCodeProvider): Cli } function firstReasoningEffortForModel(model: AgentChatModelInfo | null | undefined, provider: AdeCodeProvider): string | null { - const efforts = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; const modelId = `${model?.modelId ?? ""} ${model?.id ?? ""} ${model?.displayName ?? ""}`.toLowerCase(); + const isGpt56CodexModel = provider === "codex" && /gpt-5\.6-(?:sol|terra|luna)/.test(modelId); + const efforts = model?.reasoningEfforts + ?.map((entry) => entry.effort) + .filter((effort) => Boolean(effort) && (!isGpt56CodexModel || effort !== "max")) ?? []; + const advertisedDefault = model?.defaultReasoningEffort?.trim().toLowerCase() ?? null; if (modelId.includes("fable") && efforts.includes("high")) return "high"; + if (advertisedDefault && efforts.includes(advertisedDefault)) return advertisedDefault; if (efforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; if (efforts.length) return efforts[0] ?? null; const descriptor = model?.modelId || model?.id ? getModelById(model.modelId ?? model.id) : undefined; const descriptorEfforts = descriptor?.reasoningTiers ?? []; const descriptorId = `${descriptor?.id ?? ""} ${descriptor?.providerModelId ?? ""} ${descriptor?.displayName ?? ""}`.toLowerCase(); if (descriptorId.includes("fable") && descriptorEfforts.includes("high")) return "high"; + if (descriptor?.defaultReasoningEffort && descriptorEfforts.includes(descriptor.defaultReasoningEffort)) { + return descriptor.defaultReasoningEffort; + } if (descriptorEfforts.includes(DEFAULT_CODEX_REASONING_EFFORT)) return DEFAULT_CODEX_REASONING_EFFORT; if (descriptorEfforts.length) return descriptorEfforts[0] ?? null; return provider === "codex" ? DEFAULT_CODEX_REASONING_EFFORT : null; @@ -147,12 +155,14 @@ export function fallbackModelStatePatch(provider: AdeCodeProvider): Pick ({ effort, description: effort })), + defaultReasoningEffort: descriptor.defaultReasoningEffort ?? null, ...(descriptor.serviceTiers?.length ? { serviceTiers: descriptor.serviceTiers } : {}), ...(descriptor.cursorAvailability ? { cursorAvailability: descriptor.cursorAvailability } : {}), ...(descriptor.cursorCliVariants?.length ? { cursorCliVariants: descriptor.cursorCliVariants } : {}), @@ -273,12 +284,33 @@ export function resolveCursorCliModelForLaunch( export function modelReasoningEfforts(modelState: AdeCodeModelState, models: AgentChatModelInfo[]): string[] { const model = models.find((entry) => entry.id === modelState.modelId || entry.modelId === modelState.modelId); const fromModel = model?.reasoningEfforts?.map((entry) => entry.effort).filter(Boolean) ?? []; - if (fromModel.length) return fromModel; const descriptor = modelState.modelId ? getModelById(modelState.modelId) : undefined; - if (descriptor?.reasoningTiers?.length) return descriptor.reasoningTiers; + const isGpt56CodexModel = modelState.provider === "codex" + && /gpt-5\.6-(?:sol|terra|luna)/i.test(`${descriptor?.providerModelId ?? ""} ${modelState.model}`); + const visibleEfforts = (efforts: string[]) => isGpt56CodexModel + ? efforts.filter((effort) => effort !== "max") + : efforts; + if (fromModel.length) return visibleEfforts(fromModel); + if (descriptor?.reasoningTiers?.length) return visibleEfforts(descriptor.reasoningTiers); return modelState.provider === "codex" ? EFFORTS : []; } +export function reasoningEffortDisplayLabel( + effort: string | null | undefined, + modelState: Pick, +): string | null { + if (!effort) return null; + const isGpt56CodexModel = modelState.provider === "codex" + && /gpt-5\.6-(?:sol|terra|luna)/i.test(`${modelState.modelId ?? ""} ${modelState.model}`); + if (!isGpt56CodexModel) return effort; + if (effort === "low") return "Light"; + if (effort === "medium") return "Medium"; + if (effort === "high") return "High"; + if (effort === "xhigh") return "Extra High"; + if (effort === "ultra") return "Ultra"; + return effort; +} + export function resolveCodexPreset(modelState: AdeCodeModelState): CodexPreset | "custom" { if (modelState.codexConfigSource === "config-toml") return "config-toml"; if (modelState.codexApprovalPolicy === "never" && modelState.codexSandbox === "danger-full-access") return "full-auto"; @@ -493,8 +525,12 @@ export function buildSetupRows(args: { { kind: "reasoning", label: "Reasoning", - value: args.modelState.reasoningEffort ?? "none", - detail: efforts.length ? efforts.join(", ") : "not exposed by this model", + value: reasoningEffortDisplayLabel(args.modelState.reasoningEffort, args.modelState) ?? "none", + detail: args.modelState.reasoningEffort === "ultra" + ? "multi-agent · can use limits faster" + : efforts.length + ? efforts.map((effort) => reasoningEffortDisplayLabel(effort, args.modelState) ?? effort).join(", ") + : "not exposed by this model", disabled: !efforts.length, cyclable: true, }, diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 43fd38605..db5f94e72 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -22,7 +22,7 @@ "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex": "0.142.5", + "@openai/codex": "0.144.0", "@opencode-ai/sdk": "^1.15.5", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", @@ -3346,9 +3346,9 @@ } }, "node_modules/@openai/codex": { - "version": "0.142.5", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5.tgz", - "integrity": "sha512-WQEpD7l3k68eIAP0aq28EdR18ENBAf8DyprzFhzNwCOQJSv4nHzpwT8Fl30IJacprko2ZCmUBZjM2u941l2yLw==", + "version": "0.144.0", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0.tgz", + "integrity": "sha512-QFh6f+v5QUx/Vg0HjIl9HB94p7aDLBDkZjc4IXX5RXUcXHPVCZNb6Hl2R49Og/fqW7orgZkeDcgWfRANUa1WoQ==", "license": "Apache-2.0", "bin": { "codex": "bin/codex.js" @@ -3357,19 +3357,19 @@ "node": ">=16" }, "optionalDependencies": { - "@openai/codex-darwin-arm64": "npm:@openai/codex@0.142.5-darwin-arm64", - "@openai/codex-darwin-x64": "npm:@openai/codex@0.142.5-darwin-x64", - "@openai/codex-linux-arm64": "npm:@openai/codex@0.142.5-linux-arm64", - "@openai/codex-linux-x64": "npm:@openai/codex@0.142.5-linux-x64", - "@openai/codex-win32-arm64": "npm:@openai/codex@0.142.5-win32-arm64", - "@openai/codex-win32-x64": "npm:@openai/codex@0.142.5-win32-x64" + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.144.0-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.144.0-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.144.0-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.144.0-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.144.0-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.144.0-win32-x64" } }, "node_modules/@openai/codex-darwin-arm64": { "name": "@openai/codex", - "version": "0.142.5-darwin-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-arm64.tgz", - "integrity": "sha512-l43p8xv+Z/2/b6fCUc7/FmcQZsaPB7RFizLponGwHAnFOWe3i9Vky69p+up3BUam9AetoQQUv7Mo+2KdaFEqhA==", + "version": "0.144.0-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-arm64.tgz", + "integrity": "sha512-rqFAJdOa2I0VRgepVsSZeLxs96+Y+LXTjccOOvH6894FyaFAYPZ/o+6hgpB1iGHxxdoY/DsGa8jrJC8Leqn9Kg==", "cpu": [ "arm64" ], @@ -3384,9 +3384,9 @@ }, "node_modules/@openai/codex-darwin-x64": { "name": "@openai/codex", - "version": "0.142.5-darwin-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-darwin-x64.tgz", - "integrity": "sha512-yk6A06/VmW7NFsa48OVPaj//g/zeSpd79wjuqfXZwW8ZKRYQm3+wCd3hWjPl79F3QnXvDvM2j3JMIBL3m3GXXg==", + "version": "0.144.0-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-darwin-x64.tgz", + "integrity": "sha512-4p2jxRbN+Khg5UQzpkzT9upFj+qkEF/abmdvrtflkkWmVKP6Nt+yi8ospdqv9PDqvQ9SotPvX7iXaFaeUTrtmA==", "cpu": [ "x64" ], @@ -3401,9 +3401,9 @@ }, "node_modules/@openai/codex-linux-arm64": { "name": "@openai/codex", - "version": "0.142.5-linux-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-arm64.tgz", - "integrity": "sha512-77ka5PSnm5HdxdBT99IwntCasmbqevlS0eiC0AtEb6ZXCLkim2gm0AWm+jNYy0EhbssvNK+KghayWo34HMgXeA==", + "version": "0.144.0-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-arm64.tgz", + "integrity": "sha512-k++xhZrn9P3laO00Q92APG6mdOFDD66nUBo+8ExCa1NXi2pjLEMLC4+UNJTUUtUT1PEflOZ5pDKxPXgzaiFFFg==", "cpu": [ "arm64" ], @@ -3418,9 +3418,9 @@ }, "node_modules/@openai/codex-linux-x64": { "name": "@openai/codex", - "version": "0.142.5-linux-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-linux-x64.tgz", - "integrity": "sha512-pxY+d3NgNE57Y/MApD3/TZUAygxJN6I9h3ZeDUwe67mxWjUxsuapxMRFTKSznCalYbRAeZp752+AAXmUbmguEg==", + "version": "0.144.0-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-linux-x64.tgz", + "integrity": "sha512-GmKtQeX+cO9lN7mQD1FEVcXYEMLMgMByHwZdvlluH0bj/+c2ind3hwbRtE3eECFDekNhEiB80Ez0FfbkyFQqoA==", "cpu": [ "x64" ], @@ -3435,9 +3435,9 @@ }, "node_modules/@openai/codex-win32-arm64": { "name": "@openai/codex", - "version": "0.142.5-win32-arm64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-arm64.tgz", - "integrity": "sha512-65BEqGbUZ7r0ayunIHdBjo5crwgbwKX/6puOcO+VCswUw/dXvDsN2IGcbXB52+bS9U5+FxP783cUHfTT6m40DQ==", + "version": "0.144.0-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-arm64.tgz", + "integrity": "sha512-e2yGSgwdzrT1SoJMoOzWD58WBEsIaAMZpEchuV2VGkE2T955SG7dn7EyVQTQcy7/rdpE8aEDktZ/1eQQfjkdtQ==", "cpu": [ "arm64" ], @@ -3452,9 +3452,9 @@ }, "node_modules/@openai/codex-win32-x64": { "name": "@openai/codex", - "version": "0.142.5-win32-x64", - "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.142.5-win32-x64.tgz", - "integrity": "sha512-a+wI4PEx9a2fg6V5ueTTDkOkr1XpEvA5RFXIbo/L2hOfzMmGtyRnbG24bCGu5Q2RSgVxSQV0aLkdb3vdYMNH9A==", + "version": "0.144.0-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.144.0-win32-x64.tgz", + "integrity": "sha512-QiholLCYqNeYvNM77HOmPtrOFrY0rQc/N9nXt+sQGXO3rEGmcWjpLzujY4Oegl3CLRHoieWqlep3EqEvFBjoIA==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 130448b7a..d6b90e25c 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -72,7 +72,7 @@ "@lobehub/icons-static-svg": "^1.84.0", "@lobehub/ui": "^5.6.3", "@modelcontextprotocol/sdk": "^1.29.0", - "@openai/codex": "0.142.5", + "@openai/codex": "0.144.0", "@opencode-ai/sdk": "^1.15.5", "@phosphor-icons/react": "^2.1.10", "@pierre/diffs": "^1.1.21", diff --git a/apps/desktop/resources/ade-cli-help.txt b/apps/desktop/resources/ade-cli-help.txt index d1a634684..22634e497 100644 --- a/apps/desktop/resources/ade-cli-help.txt +++ b/apps/desktop/resources/ade-cli-help.txt @@ -91,8 +91,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -198,8 +198,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -305,8 +305,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -658,7 +658,7 @@ _ ____ _____ $ ade chat list --lane --text List chat sessions $ ade chat list --include-automation --no-archived --text - $ ade chat create --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --no-fast --permissions full-auto + $ ade chat create --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --no-fast --permissions full-auto $ ade chat create --lane --provider claude --model anthropic/claude-opus-4-8 --prompt "fix the tests" $ ade chat create --from-linear-issue ENG-431 Start a chat with an attached issue + kickoff (alias: --linear-issue-json) $ ade chat send --text "next step" Send a message; steers automatically if the turn is active @@ -670,7 +670,9 @@ _ ____ _____ $ ade chat read --limit 20 --text Read recent chat messages $ ade chat goal --objective "Ship it" Set or inspect a Codex goal $ ade chat goal --status paused Update a Codex goal status - $ ade chat fork --model openai/gpt-5.5 + $ ade chat handoff --model openai/gpt-5.6-sol --note "focus on tests" + Start a new chat with an extra handoff note + $ ade chat fork --model openai/gpt-5.6-sol Fork full provider history into a new chat $ ade chat rewind-files --message --dry-run Preview or apply file/context rewind @@ -722,7 +724,7 @@ _ ____ _____ $ ade new chat --mode cli --lane --provider codex --prompt "Fix the failing test" $ ade agent spawn --lane --prompt "Fix the failing test" - $ ade agent spawn --lane --provider codex --model openai/gpt-5.5 --permissions full-auto + $ ade agent spawn --lane --provider codex --model openai/gpt-5.6-sol --permissions full-auto $ ade agent spawn --lane --context-file docs/context.md --prompt "continue" $ ade agent spawn --lane --tool=git --tool=files --prompt "review changes" @@ -830,8 +832,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -1015,8 +1017,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: @@ -1418,8 +1420,8 @@ _ ____ _____ $ ade --socket browser open http://localhost:5173 --new-tab --text $ ade terminal read --chat-session --text $ ade terminal read --pty --text - $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" - $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" + $ ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the tests" Generic ADE action JSON contract: Object-shaped call: diff --git a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md index abb73bfee..4eac0b031 100644 --- a/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md +++ b/apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md @@ -98,8 +98,8 @@ Use `ade new chat` as the canonical launch command. It mirrors the desktop New Chat mode toggle: ``` -ade new chat --mode chat --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the issue" -ade new chat --mode cli --lane --provider codex --model openai/gpt-5.5 --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the issue" +ade new chat --mode chat --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the issue" +ade new chat --mode cli --lane --provider codex --model openai/gpt-5.6-sol --reasoning-effort xhigh --permissions full-auto --no-fast --prompt "Fix the issue" ade new chat --mode chat --lane auto --lane-name fix-issue --prompt "Fix the issue" ``` diff --git a/apps/desktop/resources/voice/voice-glossary.json b/apps/desktop/resources/voice/voice-glossary.json index 5ca494dd4..c650c9077 100644 --- a/apps/desktop/resources/voice/voice-glossary.json +++ b/apps/desktop/resources/voice/voice-glossary.json @@ -16,6 +16,9 @@ "Claude Sonnet 5", "Claude Haiku 4.5", "Claude Fable 5", + "GPT-5.6 Sol", + "GPT-5.6 Terra", + "GPT-5.6 Luna", "GPT-5.5", "GPT-5.4", "GPT-5.3", @@ -48,10 +51,7 @@ "attached lane", "stack", "pack", - "worker", - "integration", "compaction", - "workspace", "brain", "daemon", "runtime", diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 0e83ad82a..4431f0e2f 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -368,6 +368,10 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { description: expect.stringContaining("normalized"), input: expect.stringContaining("interrupt-replace"), }); + expect(getAdeActionInputContract("chat", "recoverCodexTurn")).toMatchObject({ + description: expect.stringContaining("stalled Codex turn"), + input: expect.stringContaining("restart_resume_thread"), + }); }); it("normalizes chat action argument shapes for model discovery, summaries, transcript reads, and sends", async () => { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 7c534de8d..e26131da8 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -443,6 +443,7 @@ export const ADE_ACTION_ALLOWLIST: Partial = { }; const DEFAULT_CLAUDE_TASK_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-5"; -const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; +const DEFAULT_CODEX_TASK_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.6-sol"; const TASK_DEFAULTS: Record = { planning: { diff --git a/apps/desktop/src/main/services/ai/claudeModelUtils.ts b/apps/desktop/src/main/services/ai/claudeModelUtils.ts index 8d171fc6b..6bbb5ccec 100644 --- a/apps/desktop/src/main/services/ai/claudeModelUtils.ts +++ b/apps/desktop/src/main/services/ai/claudeModelUtils.ts @@ -16,7 +16,7 @@ export function resolveClaudeCliModel(model: string | null | undefined): string */ export function resolveCodexCliModel(model: string | null | undefined): string { const raw = String(model ?? "").trim(); - if (!raw.length) return getDefaultModelDescriptor("codex")?.providerModelId ?? "gpt-5.5"; + if (!raw.length) return getDefaultModelDescriptor("codex")?.providerModelId ?? "gpt-5.6-sol"; const descriptor = getModelById(raw) ?? resolveModelAlias(raw); if (descriptor?.isCliWrapped && descriptor.family === "openai") { diff --git a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts index fd6927f1c..61fa44c71 100644 --- a/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts +++ b/apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts @@ -335,7 +335,7 @@ export function createCtoOperatorTools(deps: CtoOperatorToolDeps): Record ({ + resolveCodexComputerUseMcpConfig: vi.fn(async (): Promise<{ + command: string; + args: ["mcp"]; + enabled: true; + } | null> => null), +})); + +vi.mock("../../utils/codexComputerUse", () => ({ + resolveCodexComputerUseMcpConfig: mocks.resolveCodexComputerUseMcpConfig, +})); + import { launchAgentChatCli, type AgentChatCliLaunchDeps } from "./agentChatCliLaunch"; type LaneBaseAndBranch = @@ -50,6 +63,11 @@ function makeArgs(overrides: Partial = {}): AgentChatLau }; } +beforeEach(() => { + mocks.resolveCodexComputerUseMcpConfig.mockReset(); + mocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); +}); + describe("launchAgentChatCli provider validation", () => { it("rejects an unknown provider at the runtime boundary", async () => { const deps = makeDeps(); @@ -141,6 +159,28 @@ describe("launchAgentChatCli orchestration policy", () => { }); describe("launchAgentChatCli Codex fast mode", () => { + it("includes the asynchronously resolved Computer Use MCP config", async () => { + const deps = makeDeps(); + const command = "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient"; + mocks.resolveCodexComputerUseMcpConfig.mockResolvedValueOnce({ + command, + args: ["mcp"], + enabled: true, + }); + + await launchAgentChatCli(makeArgs({ provider: "codex" }), deps); + + const createArg = deps.create.mock.calls[0]?.[0] as PtyCreateArgs; + expect(createArg.args).toEqual(expect.arrayContaining([ + "-c", + `mcp_servers.computer_use.command=${JSON.stringify(command)}`, + "-c", + 'mcp_servers.computer_use.args=["mcp"]', + "-c", + "mcp_servers.computer_use.enabled=true", + ])); + }); + it("passes explicit service tier flags to Codex CLI launches", async () => { const deps = makeDeps(); diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts index ad50e72ae..e98e8ef24 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.ts @@ -11,6 +11,7 @@ import type { } from "../../../shared/types/chat"; import type { createLaneService } from "../lanes/laneService"; import type { createPtyService } from "../pty/ptyService"; +import { resolveCodexComputerUseMcpConfig } from "../../utils/codexComputerUse"; type LaneServiceForCliLaunch = Pick< ReturnType, @@ -100,6 +101,7 @@ export async function launchAgentChatCli( : {}), initialPrompt: kickoffPrompt, laneWorktreePath: worktreePath, + ...(provider === "codex" ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } : {}), }); const result = await deps.ptyService.create({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index a10f2d801..0f85b7c16 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4,6 +4,7 @@ import os from "node:os"; import path from "node:path"; import { query, startup } from "@anthropic-ai/claude-agent-sdk"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; +import { codexComputerUseClientCandidates } from "../../utils/codexComputerUse"; import { buildOpenCodePromptParts, startOpenCodeSession } from "../opencode/openCodeRuntime"; import { openKvDb } from "../state/kvDb"; import { createCtoStateService } from "../cto/ctoStateService"; @@ -1537,6 +1538,9 @@ describe("buildComputerUseDirective", () => { expect(result).toContain("get_computer_use_backend_status"); expect(result).toContain("If it is not exposed, do not stall"); expect(result).toContain("Respect the backend the user requested"); + expect(result).toContain("mcp__computer_use"); + expect(result).toContain("do not bootstrap `@oai/sky`"); + expect(result).toContain("does not passively ingest"); }); it("includes Ghost OS section when Ghost OS backend is available", () => { @@ -4411,6 +4415,58 @@ describe("createAgentChatService", () => { expect(startParams?.reasoning_effort).toBeUndefined(); }); + it("routes new Codex chats to GPT-5.6 Sol with its low default", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", + }); + + expect(session).toMatchObject({ + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", + reasoningEffort: "low", + }); + await service.sendMessage({ sessionId: session.id, text: "Reply only OK." }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + + const threadStart = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect(threadStart?.params).toMatchObject({ + model: "gpt-5.6-sol", + config: { model_reasoning_effort: "low" }, + }); + const turnStart = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect(turnStart?.params).toMatchObject({ + model: "gpt-5.6-sol", + effort: "low", + }); + }); + + it("sends literal Ultra effort for Sol without aliasing it to xhigh", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", + reasoningEffort: "ultra", + }); + + 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 threadStart = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect((threadStart?.params as { config?: { model_reasoning_effort?: unknown } })?.config?.model_reasoning_effort).toBe("ultra"); + const turnStart = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + expect((turnStart?.params as { effort?: unknown })?.effort).toBe("ultra"); + }); + it("spawns Codex with ADE CLI agent env injected", async () => { const laneRootPath = path.join(tmpRoot, "lane-2"); fs.mkdirSync(laneRootPath, { recursive: true }); @@ -14187,6 +14243,99 @@ describe("createAgentChatService", () => { expect(Array.isArray(models)).toBe(true); }); + it("pins GPT-5.6 ordering/defaults in filtered and provider-omitted catalogs", async () => { + mockState.codexResponseOverrides.set("model/list", { + data: [ + { + id: "gpt-5.5", + displayName: "GPT-5.5", + isDefault: true, + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Low" }, + { reasoningEffort: "medium", description: "Medium" }, + ], + }, + { + id: "gpt-5.6-luna", + displayName: "GPT-5.6-Luna", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Low" }, + { reasoningEffort: "medium", description: "Medium" }, + { reasoningEffort: "high", description: "High" }, + { reasoningEffort: "xhigh", description: "Extra high" }, + { reasoningEffort: "max", description: "Max" }, + ], + additionalSpeedTiers: ["fast"], + }, + { + id: "gpt-5.6-sol", + displayName: "GPT-5.6-Sol", + defaultReasoningEffort: "low", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Low" }, + { reasoningEffort: "medium", description: "Medium" }, + { reasoningEffort: "high", description: "High" }, + { reasoningEffort: "xhigh", description: "Extra high" }, + { reasoningEffort: "max", description: "Max" }, + { reasoningEffort: "ultra", description: "Ultra" }, + ], + additionalSpeedTiers: ["fast"], + }, + { + id: "gpt-5.6-terra", + displayName: "GPT-5.6-Terra", + defaultReasoningEffort: "medium", + supportedReasoningEfforts: [ + { reasoningEffort: "low", description: "Low" }, + { reasoningEffort: "medium", description: "Medium" }, + { reasoningEffort: "high", description: "High" }, + { reasoningEffort: "xhigh", description: "Extra high" }, + { reasoningEffort: "max", description: "Max" }, + { reasoningEffort: "ultra", description: "Ultra" }, + ], + additionalSpeedTiers: ["fast"], + }, + ], + }); + const { service } = createService(); + + const models = await service.getAvailableModels({ provider: "codex" }); + + expect(models.slice(0, 4).map((model) => model.id)).toEqual([ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + "gpt-5.5", + ]); + expect(models[0]).toMatchObject({ + isDefault: true, + defaultReasoningEffort: "low", + reasoningEfforts: [ + expect.objectContaining({ effort: "low" }), + expect.objectContaining({ effort: "medium" }), + expect.objectContaining({ effort: "high" }), + expect.objectContaining({ effort: "xhigh" }), + expect.objectContaining({ effort: "ultra" }), + ], + serviceTiers: ["fast"], + }); + expect(models[1]).toMatchObject({ isDefault: false, defaultReasoningEffort: "medium" }); + expect(models[2]?.reasoningEfforts?.map((entry) => entry.effort)).toEqual([ + "low", "medium", "high", "xhigh", + ]); + expect(models.slice(0, 3).flatMap((model) => model.reasoningEfforts ?? [])) + .not.toContainEqual(expect.objectContaining({ effort: "max" })); + expect(models[3]?.isDefault).toBe(false); + + const aggregate = await service.getAvailableModels({}); + const codexIds = new Set(["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"]); + const aggregatedCodexModels = aggregate.filter((model) => codexIds.has(model.id)); + expect(aggregate.length).toBeGreaterThan(0); + expect(aggregatedCodexModels).toEqual(models.slice(0, 4)); + }); + it("returns an array for claude provider", async () => { const { service } = createService(); const models = await service.getAvailableModels({ provider: "claude" }); @@ -16411,6 +16560,286 @@ describe("createAgentChatService", () => { }); }); + it("awaits and injects the opted-in signed Computer Use MCP client into Codex threads", async () => { + const signedClient = codexComputerUseClientCandidates(path.join(tmpHomeRoot, ".codex"))[0]!; + + const { service } = createService({ + resolveCodexComputerUseMcp: async () => ({ command: signedClient, args: ["mcp"], enabled: true }), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.6-sol", + }); + await service.sendMessage({ sessionId: session.id, text: "List visible apps." }, { awaitDispatch: true }); + + const threadStart = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/start"); + expect(threadStart?.params).toMatchObject({ + config: { + model_reasoning_effort: "low", + mcp_servers: { + computer_use: { + command: signedClient, + args: ["mcp"], + enabled: true, + }, + }, + }, + }); + }); + + it("answers the app-server external clock request with Unix seconds", async () => { + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.6-sol", + }); + await service.sendMessage({ sessionId: session.id, text: "Check the time." }, { awaitDispatch: true }); + + const before = Math.floor(Date.now() / 1_000); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "clock-1", + method: "currentTime/read", + params: { threadId: "thread-1" }, + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.find((payload) => payload.id === "clock-1")).toEqual({ + id: "clock-1", + result: { + currentTimeAt: expect.any(Number), + }, + }); + }); + const response = mockState.codexRequestPayloads.find((payload) => payload.id === "clock-1"); + expect((response?.result as { currentTimeAt?: number })?.currentTimeAt).toBeGreaterThanOrEqual(before); + }); + + it("keeps Computer Use per-app elicitation user-controlled in full-auto sessions", 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.6-sol", + permissionMode: "full-auto", + codexApprovalPolicy: "never", + codexSandbox: "danger-full-access", + }); + await service.sendMessage({ sessionId: session.id, text: "Inspect Calculator." }, { awaitDispatch: true }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "cu-approval-1", + method: "mcpServer/elicitation/request", + params: { + threadId: "thread-1", + turnId: "turn-1", + serverName: "computer_use", + mode: "form", + _meta: { persist: ["always"] }, + message: "Allow Codex to use Calculator?", + requestedSchema: { type: "object", properties: {} }, + }, + }); + + const approval = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "approval_request" + && event.event.itemId === "mcp-elicitation:computer_use:cu-approval-1", + ); + expect((approval.event.detail as any)?.request).toMatchObject({ + kind: "approval", + description: "Allow Codex to use Calculator?", + providerMetadata: { + mcpElicitation: true, + persistenceSupported: true, + }, + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.id === "cu-approval-1")).toBe(false); + + await service.respondToInput({ + sessionId: session.id, + itemId: approval.event.itemId, + decision: "accept_for_session", + }); + expect(mockState.codexRequestPayloads.find((payload) => payload.id === "cu-approval-1")).toEqual({ + id: "cu-approval-1", + result: { + action: "accept", + content: {}, + _meta: { persist: "always" }, + }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "cu-approval-2", + method: "mcpServer/elicitation/request", + params: { + threadId: "thread-1", + turnId: "turn-1", + serverName: "computer_use", + mode: "form", + meta: { persist: ["always"] }, + message: "Allow Codex to use Preview?", + requestedSchema: { type: "object", properties: {} }, + }, + }); + await waitForEvent(events, (event): event is AgentChatEventEnvelope => + event.event.type === "approval_request" + && event.event.itemId === "mcp-elicitation:computer_use:cu-approval-2"); + await service.respondToInput({ + sessionId: session.id, + itemId: "mcp-elicitation:computer_use:cu-approval-2", + decision: "accept", + }); + expect(mockState.codexRequestPayloads.find((payload) => payload.id === "cu-approval-2")).toEqual({ + id: "cu-approval-2", + result: { action: "accept", content: {}, _meta: null }, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + id: "cu-approval-3", + method: "mcpServer/elicitation/request", + params: { + threadId: "thread-1", + turnId: "turn-1", + serverName: "computer_use", + mode: "form", + message: "Allow Codex to use Notes?", + requestedSchema: { type: "object", properties: {} }, + }, + }); + await waitForEvent(events, (event): event is AgentChatEventEnvelope => + event.event.type === "approval_request" + && event.event.itemId === "mcp-elicitation:computer_use:cu-approval-3"); + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "serverRequest/resolved", + params: { + threadId: "thread-1", + turnId: "turn-1", + requestId: "cu-approval-3", + }, + }); + const resolved = await waitForEvent(events, (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.event.type === "pending_input_resolved" + && event.event.itemId === "mcp-elicitation:computer_use:cu-approval-3"); + expect(resolved.event.resolution).toBe("cancelled"); + }); + + it("re-arms a stalled Codex turn when recovery chooses Wait", async () => { + vi.useFakeTimers(); + try { + 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.6-sol" }); + await service.sendMessage({ sessionId: session.id, text: "Keep working." }, { awaitDispatch: true }); + + const result = await service.recoverCodexTurn({ + sessionId: session.id, + turnId: "turn-1", + action: "wait", + }); + + expect(result).toEqual({ action: "wait", turnId: "turn-1", status: "waiting" }); + expect(events.some((event) => event.event.type === "system_notice" + && event.event.message === "Continuing to wait for Codex output.")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/interrupt")).toBe(false); + + await vi.advanceTimersByTimeAsync(120_000); + await vi.waitFor(() => { + expect(events.some((event) => event.event.type === "codex_turn_stalled" + && event.event.turnId === "turn-1")).toBe(true); + }); + } finally { + vi.useRealTimers(); + } + }); + + it("sends a same-turn Codex status nudge from recovery", async () => { + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.6-sol" }); + await service.sendMessage({ sessionId: session.id, text: "Keep working." }, { awaitDispatch: true }); + + const result = await service.recoverCodexTurn({ sessionId: session.id, turnId: "turn-1", action: "steer" }); + + expect(result.status).toBe("nudged"); + const steerRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/steer"); + expect(steerRequest?.params).toMatchObject({ threadId: "thread-1", expectedTurnId: "turn-1" }); + expect(JSON.stringify(steerRequest?.params)).toContain("briefly report your current progress"); + }); + + it("interrupts and retries a stalled Codex turn on the same thread", async () => { + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.6-sol" }); + await service.sendMessage({ sessionId: session.id, text: "Keep working." }, { awaitDispatch: true }); + + const result = await service.recoverCodexTurn({ + sessionId: session.id, + turnId: "turn-1", + action: "interrupt_retry_same_thread", + }); + + expect(result.status).toBe("retrying"); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/interrupt")).toBe(true); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "thread/start")).toHaveLength(1); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/resume")).toBe(false); + }); + + it("finalizes the adopted Codex turn before retrying recovery", async () => { + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.6-sol" }); + await service.sendMessage({ sessionId: session.id, text: "Keep working." }, { awaitDispatch: true }); + mockState.codexResponseOverrides.set("turn/interrupt", (payload) => { + const params = payload.params as Record; + return params.turnId === "turn-1" + ? { error: { code: -32000, message: "expected active turn id turn-1 but found turn-real" } } + : {}; + }); + + const result = await service.recoverCodexTurn({ + sessionId: session.id, + turnId: "turn-1", + action: "interrupt_retry_same_thread", + }); + + expect(result.status).toBe("retrying"); + const interrupts = mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/interrupt"); + expect(interrupts.map((payload) => (payload.params as Record).turnId)).toEqual([ + "turn-1", + "turn-real", + ]); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); + }); + + it("restarts app-server, resumes the Codex thread, and retries stalled work", async () => { + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.6-sol" }); + await service.sendMessage({ sessionId: session.id, text: "Keep working." }, { awaitDispatch: true }); + + const result = await service.recoverCodexTurn({ + sessionId: session.id, + turnId: "turn-1", + action: "restart_resume_thread", + }); + + expect(result.status).toBe("resumed"); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/interrupt")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/resume" + && (payload.params as any)?.threadId === "thread-1")).toBe(true); + expect(mockState.codexRequestPayloads.filter((payload) => payload.method === "turn/start")).toHaveLength(2); + }); + it("surfaces Codex MCP startup failures without treating them as turn progress", async () => { vi.useFakeTimers(); try { @@ -16589,6 +17018,13 @@ describe("createAgentChatService", () => { type: "mcpToolCall", server: "local-tools", tool: "probe", + pluginId: "local-plugin", + appContext: { + connectorId: "local", + appName: "Local tools", + actionName: "Probe file", + resourceUri: "ui://local/probe", + }, status: "running", arguments: { path: "README.md" }, }, @@ -16616,6 +17052,8 @@ describe("createAgentChatService", () => { expect(events.some((event) => event.event.type === "tool_call" && event.event.itemId === "mcp-1" + && event.event.mcp?.pluginId === "local-plugin" + && event.event.mcp?.appContext?.appName === "Local tools" )).toBe(true); }); @@ -16629,6 +17067,138 @@ describe("createAgentChatService", () => { } }); + it("preserves the Codex imageGeneration lifecycle and local output path", 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.5", + }); + await service.sendMessage({ sessionId: session.id, text: "Generate a tiny moon icon." }, { awaitDispatch: true }); + + const item = { + id: "image-1", + type: "imageGeneration", + status: "inProgress", + prompt: "A tiny moon icon", + }; + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { turnId: "turn-1", item }, + }); + const started = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "codex_image_generation" && event.event.itemId === "image-1", + ); + expect(started.event).toMatchObject({ + prompt: "A tiny moon icon", + status: "running", + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + ...item, + status: "completed", + revisedPrompt: "A crisp crescent moon icon", + result: "/tmp/generated-moon.png", + }, + }, + }); + await vi.waitFor(() => { + expect(events.some((event) => + event.event.type === "codex_image_generation" + && event.event.itemId === "image-1" + && event.event.status === "completed" + && event.event.savedPath === "/tmp/generated-moon.png" + )).toBe(true); + }); + }); + + it("preserves live Codex MCP app metadata for Sources aggregation", 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.6-sol", + modelId: "openai/gpt-5.6-sol", + }); + await service.sendMessage({ sessionId: session.id, text: "Use the docs connector." }, { awaitDispatch: true }); + + const item = { + id: "mcp-live-1", + type: "mcpToolCall", + server: "openaiDeveloperDocs", + tool: "search", + status: "inProgress", + arguments: { query: "GPT-5.6" }, + pluginId: "openai-docs", + appContext: { + connectorId: "openai-docs", + linkId: "docs-link", + resourceUri: "ui://openai-docs/search", + appName: "OpenAI Docs", + templateId: "search-results", + actionName: "Search documentation", + }, + }; + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/started", + params: { turnId: "turn-1", item }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "tool_call" && event.event.itemId === "mcp-live-1", + ); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + ...item, + status: "completed", + result: { title: "GPT-5.6", url: "https://developers.openai.com/api/docs/models" }, + }, + }, + }); + const completed = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "tool_result" && event.event.itemId === "mcp-live-1", + ); + + expect(completed.event).toMatchObject({ + tool: "openaiDeveloperDocs:search", + status: "completed", + mcp: { + server: "openaiDeveloperDocs", + tool: "search", + pluginId: "openai-docs", + resourceUri: "ui://openai-docs/search", + appContext: { + connectorId: "openai-docs", + appName: "OpenAI Docs", + actionName: "Search documentation", + }, + }, + }); + }); + it("re-arms the Codex watchdog after partial same-thread recovery", async () => { vi.useFakeTimers(); try { @@ -18244,6 +18814,98 @@ describe("createAgentChatService", () => { )).toBe(false); }); + it("omits oversized inline Codex image data from history without changing live previews", 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.5", + }); + await service.sendMessage({ + sessionId: session.id, + text: "Generate two icons.", + }, { awaitDispatch: true }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + const smallData = "data:image/png;base64,AAAA"; + const largeData = `data:image/png;base64,${"B".repeat(80 * 1024)}`; + for (const [id, result] of [["image-small", smallData], ["image-large", largeData]] as const) { + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id, + type: "imageGeneration", + prompt: "A tiny icon", + status: "completed", + result, + }, + }, + }); + } + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "item/completed", + params: { + turnId: "turn-1", + item: { + id: "image-view-large", + type: "imageView", + title: "Inline preview", + status: "completed", + url: largeData, + }, + }, + }); + + const liveLarge = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "codex_image_generation" && event.event.itemId === "image-large", + ); + expect(liveLarge.event.result).toBe(largeData); + expect(liveLarge.event.resultOriginalBytes).toBeUndefined(); + + const history = service.getChatEventHistory(session.id).events; + const storedSmall = history.find((event) => + event.event.type === "codex_image_generation" && event.event.itemId === "image-small" + ); + expect(storedSmall?.event.type).toBe("codex_image_generation"); + if (storedSmall?.event.type !== "codex_image_generation") throw new Error("Expected small stored image"); + expect(storedSmall.event.result).toBe(smallData); + expect(storedSmall.event.resultOmittedBytes).toBeUndefined(); + + const storedLarge = history.find((event) => + event.event.type === "codex_image_generation" && event.event.itemId === "image-large" + ); + expect(storedLarge?.event.type).toBe("codex_image_generation"); + if (storedLarge?.event.type !== "codex_image_generation") throw new Error("Expected large stored image"); + expect(storedLarge.event.result).toBeNull(); + expect(storedLarge.event.resultOriginalBytes).toBe(Buffer.byteLength(largeData, "utf8")); + expect(storedLarge.event.resultOmittedBytes).toBe(Buffer.byteLength(largeData, "utf8")); + expect(JSON.stringify(storedLarge.event)).not.toContain("B".repeat(1024)); + + const storedView = history.find((event) => + event.event.type === "codex_image_view" && event.event.itemId === "image-view-large" + ); + expect(storedView?.event.type).toBe("codex_image_view"); + if (storedView?.event.type !== "codex_image_view") throw new Error("Expected stored image view"); + expect(storedView.event.url).toBeNull(); + expect(storedView.event.urlOriginalBytes).toBe(Buffer.byteLength(largeData, "utf8")); + expect(storedView.event.urlOmittedBytes).toBe(Buffer.byteLength(largeData, "utf8")); + }); + it("compacts large tool result and file diff payloads before storing chat history", async () => { const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -19505,7 +20167,8 @@ describe("createAgentChatService", () => { const session = await service.createSession({ laneId: "lane-1", provider: "codex", - model: "gpt-5.5", + model: "gpt-5.6-sol", + modelId: "openai/gpt-5.6-sol", }); const completeLatestTurn = async (): Promise => { @@ -19533,7 +20196,7 @@ describe("createAgentChatService", () => { }); await completeLatestTurn(); - for (const effort of ["low", "medium", "high", "xhigh"]) { + for (const effort of ["low", "medium", "high", "xhigh", "ultra"]) { await service.updateSession({ sessionId: session.id, reasoningEffort: effort, @@ -21779,6 +22442,160 @@ describe("createAgentChatService", () => { await sendPromise; }); + it("renders assistant OpenCode image file parts without echoing user attachments", async () => { + const events: AgentChatEventEnvelope[] = []; + let releaseStream!: () => void; + const streamGate = new Promise((resolve) => { + releaseStream = () => resolve(); + }); + vi.mocked(streamText).mockImplementation(() => ({ + fullStream: (async function* () { + await streamGate; + yield { type: "finish", usage: {} }; + })(), + }) as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "opencode", + model: "opencode/openai/gpt-5.4", + modelId: "opencode/openai/gpt-5.4", + }); + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "Generate a small diagram.", + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" && event.event.turnStatus === "started", + ); + + const state = [...mockState.openCodeSessions.values()][0]!; + const inlineData = `data:image/png;base64,${"A".repeat(80 * 1024)}`; + state.events.push( + { + type: "message.updated", + properties: { info: { id: "user-msg", sessionID: "opencode-session-1", role: "user" } }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "user-image", + sessionID: "opencode-session-1", + messageID: "user-msg", + type: "file", + mime: "image/png", + filename: "reference.png", + url: "file:///tmp/reference.png", + }, + delta: "", + }, + }, + { + type: "message.updated", + properties: { info: { id: "assistant-msg", sessionID: "opencode-session-1", role: "assistant" } }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "generated-image", + sessionID: "opencode-session-1", + messageID: "assistant-msg", + type: "file", + mime: "image/png", + filename: "diagram.png", + url: "file:///tmp/diagram.png", + }, + delta: "", + }, + }, + { + type: "message.part.updated", + properties: { + part: { + id: "image-tool-part", + callID: "image-tool-call", + sessionID: "opencode-session-1", + messageID: "assistant-msg", + type: "tool", + tool: "generate_image", + state: { + status: "completed", + input: { prompt: "Inline image" }, + output: "Generated image", + title: "Generate image", + metadata: {}, + time: { start: 1, end: 2 }, + attachments: [{ + id: "generated-inline-image", + sessionID: "opencode-session-1", + messageID: "assistant-msg", + type: "file", + mime: "image/png", + filename: "inline.png", + url: inlineData, + }], + }, + }, + delta: "", + }, + }, + ); + const waiters = [...state.waiters]; + state.waiters.length = 0; + waiters.forEach((waiter) => waiter()); + + const image = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "codex_image_generation" && event.event.itemId === "generated-image", + ); + expect(image.event).toMatchObject({ + prompt: "diagram.png", + result: "file:///tmp/diagram.png", + savedPath: "/tmp/diagram.png", + status: "completed", + }); + expect(events.some((event) => + event.event.type === "codex_image_generation" && event.event.itemId === "user-image" + )).toBe(false); + + const inlineImage = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { event: Extract } => + event.event.type === "codex_image_generation" && event.event.itemId === "generated-inline-image", + ); + expect(inlineImage.event.result).toBe(inlineData); + expect(inlineImage.event.resultOriginalBytes).toBeUndefined(); + + const storedInlineImage = service.getChatEventHistory(session.id).events.find((event) => + event.event.type === "codex_image_generation" && event.event.itemId === "generated-inline-image" + ); + expect(storedInlineImage?.event.type).toBe("codex_image_generation"); + if (storedInlineImage?.event.type !== "codex_image_generation") throw new Error("Expected stored image event"); + expect(storedInlineImage.event.result).toBeNull(); + expect(storedInlineImage.event.resultOriginalBytes).toBe(Buffer.byteLength(inlineData, "utf8")); + expect(storedInlineImage.event.resultOmittedBytes).toBe(Buffer.byteLength(inlineData, "utf8")); + expect(JSON.stringify(storedInlineImage.event)).not.toContain("A".repeat(1024)); + expect(JSON.stringify(service.getChatEventHistory(session.id).events)).not.toContain("A".repeat(1024)); + + const storedToolResult = service.getChatEventHistory(session.id).events.find((event) => + event.event.type === "tool_result" && event.event.itemId === "image-tool-call" + ); + expect(storedToolResult?.event.type).toBe("tool_result"); + if (storedToolResult?.event.type !== "tool_result") throw new Error("Expected stored tool result"); + expect(JSON.stringify(storedToolResult.event.result)).toContain("Inline image data omitted"); + + releaseStream(); + await sendPromise; + }); + it("dedupes repeated OpenCode compaction part updates without relying on part ids", async () => { const events: AgentChatEventEnvelope[] = []; let releaseStream!: () => void; @@ -22354,6 +23171,105 @@ describe("createAgentChatService", () => { expect(toolCalls[1]?.args).toEqual({ file_path: "apps/desktop/src/a.ts" }); }); + it("normalizes Claude server web and MCP blocks into compact activity lifecycles", 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-structured-activity", + slash_commands: [], + }; + return; + } + yield { + type: "assistant", + message: { + id: "msg-structured-activity", + content: [ + { + type: "server_tool_use", + id: "search-1", + name: "web_search", + input: { query: "ADE transcript UI" }, + }, + { + type: "web_search_tool_result", + tool_use_id: "search-1", + content: [{ + type: "web_search_result", + title: "ADE", + url: "https://example.com/ade", + encrypted_content: "opaque", + }], + }, + { + type: "mcp_tool_use", + id: "mcp-1", + server_name: "github", + name: "search_issues", + input: { query: "label:bug" }, + }, + { + type: "mcp_tool_result", + tool_use_id: "mcp-1", + is_error: false, + content: [{ type: "text", text: "Issue 1" }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-structured-activity", + 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-5", + modelId: "anthropic/claude-sonnet-5", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "Research the transcript UI.", + }); + + expect(events.filter((event) => + event.event.type === "web_search" && event.event.itemId === "search-1" + ).map((event) => event.event.type === "web_search" ? event.event.status : null)).toEqual([ + "running", + "completed", + ]); + expect(events.filter((event) => + (event.event.type === "tool_call" || event.event.type === "tool_result") + && event.event.itemId === "mcp-1" + ).map((event) => event.event.type)).toEqual(["tool_call", "tool_result"]); + expect(events.find((event) => + event.event.type === "tool_call" && event.event.itemId === "mcp-1" + )?.event).toMatchObject({ + tool: "github:search_issues", + mcp: { server: "github", tool: "search_issues" }, + }); + }); + it("emits completed Claude tool_result rows when tool_use_summary arrives", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9f576d8ca..2badc9afe 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -41,6 +41,13 @@ import type { import { z, type ZodType } from "zod"; import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { ClaudeInputPump } from "./claudeInputPump"; +import { + createClaudeStructuredActivityState, + finalizeClaudeStructuredActivities, + mapClaudeStructuredActivityBlock, + type ClaudeStructuredActivityState, +} from "./claudeStructuredActivity"; +import { mapOpenCodeImagePart } from "./openCodeStructuredActivity"; import { isCorruptThinkingTranscriptError, repairClaudeResumeTranscript, @@ -64,6 +71,11 @@ import { } from "./claudeWorkflowProgress"; import { discoverClaudeSlashCommands } from "./claudeSlashCommandDiscovery"; import { discoverCodexSlashCommands } from "./codexSlashCommandDiscovery"; +import { + mcpElicitationAllowsAlways, + mcpElicitationContent, + mcpElicitationQuestions, +} from "./codexMcpElicitation"; import { discoverCursorSlashCommands } from "./cursorSlashCommandDiscovery"; import { resolveProviderSlashCommandPrompt } from "./slashCommandPromptExpansion"; import { buildCanonicalAgentChatRuntimeEvent } from "./runtimeEvents"; @@ -109,6 +121,10 @@ import { resolveCliSpawnInvocation, terminateProcessTree, } from "../shared/processExecution"; +import { + resolveCodexComputerUseMcpConfig, + type CodexComputerUseMcpConfig, +} from "../../utils/codexComputerUse"; import type { AgentChatApprovalDecision, AgentChatArchiveArgs, @@ -119,6 +135,8 @@ import type { AgentChatClaudePluginsArgs, AgentChatReloadClaudePluginsArgs, AgentChatReloadClaudePluginsResult, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, AgentChatClaudePermissionMode, AgentChatCompletionReport, AgentChatCodexClearGoalArgs, @@ -205,6 +223,7 @@ import type { AgentChatOpenCodePermissionMode, CodexPlanState, CodexModerationMetadata, + AgentChatMcpToolSource, CodexSafetyBufferingState, CodexThreadGoal, CodexThreadGoalUpdateKind, @@ -250,6 +269,7 @@ import { resolveModelAlias, resolveModelDescriptorForProvider, resolveProviderGroupForModel, + selectSupportedReasoningEffort, type LocalProviderFamily, type ModelDescriptor, type ModelProviderGroup, @@ -308,7 +328,6 @@ import type { LinearClient } from "../cto/linearClient"; import type { LinearCredentialService } from "../cto/linearCredentialService"; import type { createPrService } from "../prs/prService"; import type { ComputerUseArtifactBrokerService } from "../computerUse/computerUseArtifactBrokerService"; -import { maybeSyntheticToolResult } from "../computerUse/syntheticToolResult"; import { buildOpenCodePromptParts, mapPermissionModeToOpenCodeAgent, @@ -564,10 +583,12 @@ function rejectPendingCodexRequests( type PendingCodexApproval = { requestId: string | number; - kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval"; + kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval" | "mcp_elicitation"; request?: PendingInputRequest; permissions?: Record | null; questionResponseKind?: "native_request_user_input"; + elicitationSchema?: Record | null; + elicitationPersistenceAllowed?: boolean; }; type CodexSubagentThreadState = { @@ -1944,7 +1965,7 @@ const DEFAULT_CLAUDE_DESCRIPTOR = getDefaultModelDescriptor("claude"); const DEFAULT_OPENCODE_DESCRIPTOR = getDefaultModelDescriptor("opencode"); const DEFAULT_CURSOR_DESCRIPTOR = getDefaultModelDescriptor("cursor"); const DEFAULT_DROID_DESCRIPTOR = getDefaultModelDescriptor("droid"); -const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.providerModelId ?? "gpt-5.5"; +const DEFAULT_CODEX_MODEL = DEFAULT_CODEX_DESCRIPTOR?.providerModelId ?? "gpt-5.6-sol"; const DEFAULT_CLAUDE_MODEL = DEFAULT_CLAUDE_DESCRIPTOR?.providerModelId ?? DEFAULT_CLAUDE_DESCRIPTOR?.shortId ?? "sonnet"; const DEFAULT_OPENCODE_MODEL_ID = DEFAULT_OPENCODE_DESCRIPTOR?.id ?? "anthropic/claude-sonnet-5"; const DEFAULT_CURSOR_MODEL = DEFAULT_CURSOR_DESCRIPTOR?.providerModelId ?? "auto"; @@ -2074,7 +2095,9 @@ const CODEX_REASONING_EFFORTS: Array<{ effort: string; description: string }> = { effort: "low", description: "Fastest turn-around with shallow reasoning." }, { effort: "medium", description: "Balanced reasoning depth and speed." }, { effort: "high", description: "Deeper reasoning for multi-step implementation." }, - { effort: "xhigh", description: "Extra-high reasoning depth for complex tasks." } + { effort: "xhigh", description: "Extra-high reasoning depth for complex tasks." }, + { effort: "max", description: "Maximum reasoning depth for the hardest problems." }, + { effort: "ultra", description: "Maximum reasoning with automatic task delegation." }, ]; const QUIET_CODEX_NOTIFICATION_METHODS = new Set([ @@ -2097,18 +2120,37 @@ const CLAUDE_REASONING_EFFORTS: Array<{ effort: string; description: string }> = const KNOWN_CLAUDE_EFFORTS = new Set(CLAUDE_REASONING_EFFORTS.map((e) => e.effort)); +function isGpt56CodexDescriptor(descriptor: ModelDescriptor): boolean { + return /^gpt-5\.6-(?:sol|terra|luna)$/i.test(descriptor.providerModelId); +} + +function visibleCodexReasoningEfforts( + descriptor: ModelDescriptor, + efforts: Array<{ effort: string; description: string }>, +): Array<{ effort: string; description: string }> { + if (!isGpt56CodexDescriptor(descriptor)) return efforts; + // Codex Desktop keeps `max` behind an opt-in model-feature flag. ADE does + // not expose that flag, so omit only that internal/optional tier while still + // retaining unknown app-server values for forward compatibility. + return efforts.filter((entry) => entry.effort !== "max"); +} + function codexModelInfoFromDescriptor( descriptor: ModelDescriptor, - overrides?: Partial>, + overrides?: Partial>, ): AgentChatModelInfo { + const advertisedReasoningEfforts = overrides?.reasoningEfforts ?? (descriptor.reasoningTiers?.length + ? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort)) + : CODEX_REASONING_EFFORTS); return { id: descriptor.providerModelId, displayName: descriptor.displayName, description: overrides?.description ?? describeCodexModel(descriptor.displayName), isDefault: overrides?.isDefault ?? descriptor.id === DEFAULT_CODEX_DESCRIPTOR?.id, - reasoningEfforts: overrides?.reasoningEfforts ?? (descriptor.reasoningTiers?.length - ? CODEX_REASONING_EFFORTS.filter((effort) => descriptor.reasoningTiers?.includes(effort.effort)) - : CODEX_REASONING_EFFORTS), + reasoningEfforts: visibleCodexReasoningEfforts(descriptor, advertisedReasoningEfforts), + defaultReasoningEffort: overrides?.defaultReasoningEffort + ?? descriptor.defaultReasoningEffort + ?? null, ...(overrides?.serviceTiers !== undefined ? { serviceTiers: overrides.serviceTiers } : descriptor.serviceTiers?.length @@ -2330,9 +2372,22 @@ function codexServiceTierArgs(session: AgentChatSession): { serviceTier: CodexSe return { serviceTier }; } -function codexThreadConfigArgs(reasoningEffort: string | null | undefined): { config?: Record } { +function codexThreadConfigArgs( + reasoningEffort: string | null | undefined, + computerUse: CodexComputerUseMcpConfig | null = null, +): { config?: Record } { const effort = typeof reasoningEffort === "string" ? reasoningEffort.trim() : ""; - return effort ? { config: { model_reasoning_effort: effort } } : {}; + if (!effort && !computerUse) return {}; + return { + config: { + ...(effort ? { model_reasoning_effort: effort } : {}), + ...(computerUse ? { + mcp_servers: { + computer_use: computerUse, + }, + } : {}), + }, + }; } function normalizeCodexServiceTier(value: unknown): string | null { @@ -2637,6 +2692,33 @@ function normalizeCodexWebSearchActions(...values: unknown[]): CodexWebSearchAct .filter((action): action is CodexWebSearchAction => action != null); } +function normalizeCodexMcpToolSource(item: Record): AgentChatMcpToolSource { + const server = stringOrNull(item.server) ?? "mcp"; + const tool = stringOrNull(item.tool) ?? "tool"; + const appContext = asRecord(item.appContext); + const normalizedAppContext = appContext + ? Object.fromEntries( + ["connectorId", "linkId", "resourceUri", "appName", "templateId", "actionName"] + .flatMap((key) => { + const value = stringOrNull(appContext[key]); + return value ? [[key, value] as const] : []; + }), + ) + : null; + const pluginId = stringOrNull(item.pluginId); + const resourceUri = stringOrNull(item.mcpAppResourceUri) + ?? stringOrNull(normalizedAppContext?.resourceUri); + return { + server, + tool, + ...(pluginId ? { pluginId } : {}), + ...(resourceUri ? { resourceUri } : {}), + ...(normalizedAppContext && Object.keys(normalizedAppContext).length + ? { appContext: normalizedAppContext } + : {}), + }; +} + function normalizeStringList(value: unknown): string[] { if (!Array.isArray(value)) return []; return value.flatMap((entry) => { @@ -2678,7 +2760,7 @@ function formatCodexDurationMs(value: unknown): string { const KNOWN_CODEX_EFFORTS = new Set(CODEX_REASONING_EFFORTS.map((e) => e.effort)); const EFFORT_ALIASES: Record> = { - codex: { max: "xhigh" }, + codex: {}, claude: {}, }; @@ -2694,12 +2776,15 @@ function validateReasoningEffortForDescriptor( effort: string | null | undefined, descriptor?: ModelDescriptor | null, ): string | null { - const validated = validateReasoningEffort(provider, effort); - if (!validated) return null; - if (descriptor?.reasoningTiers?.length && !descriptor.reasoningTiers.includes(validated)) { - return null; + const normalized = normalizeReasoningEffort(effort); + if (!normalized) return null; + const aliased = EFFORT_ALIASES[provider]?.[normalized] ?? normalized; + if (descriptor?.reasoningTiers?.length) { + // App-server effort values are forward-compatible strings. Once a model + // advertises a tier, accept it even before ADE's global labels know it. + return descriptor.reasoningTiers.includes(aliased) ? aliased : null; } - return validated; + return validateReasoningEffort(provider, aliased); } function validateRuntimeReasoningEffortForDescriptor( @@ -2720,7 +2805,8 @@ function resolveCodexReasoningEffortForRuntime( descriptor?: ModelDescriptor | null, ): string { const descriptorDefault = - validateReasoningEffortForDescriptor("codex", DEFAULT_REASONING_EFFORT, descriptor) + validateReasoningEffortForDescriptor("codex", descriptor?.defaultReasoningEffort, descriptor) + ?? validateReasoningEffortForDescriptor("codex", DEFAULT_REASONING_EFFORT, descriptor) ?? descriptor?.reasoningTiers ?.map((tier) => validateReasoningEffort("codex", tier)) .find((tier): tier is string => Boolean(tier)) @@ -2742,6 +2828,9 @@ function describeClaudeModel(value: string): string | null { function describeCodexModel(value: string): string | null { const lower = value.trim().toLowerCase(); + if (lower.includes("5.6") && lower.includes("sol")) return "Latest frontier agentic coding model."; + if (lower.includes("5.6") && lower.includes("terra")) return "Balanced agentic coding model for everyday work."; + if (lower.includes("5.6") && lower.includes("luna")) return "Fast and affordable agentic coding model."; if (lower.includes("spark")) return "Low-latency Codex variant tuned for fast iteration."; if (lower.includes("mini")) return "Lightweight Codex model for quick edits and checks."; if (lower.includes("max")) return "High-context Codex variant for large refactors."; @@ -4017,8 +4106,9 @@ export function buildComputerUseDirective( "## Computer Use", "You have computer-use capabilities available. The proof drawer is for reviewer-visible evidence: screenshots/images, screen recordings, and browser captures or traces.", "When the user asks for proof, capture visual proof first. Console logs and text files are supporting diagnostics only; do not use them as the only proof unless the user explicitly asks for logs or visual capture fails and you say so.", - "ADE will automatically capture screenshots and other visual artifacts from your computer-use tool calls into the proof drawer — you do not need to manually call ingest_computer_use_artifacts for normal captures.", + "ADE does not passively ingest computer-use output. When a capture is worth keeping as reviewer-visible proof, attach it intentionally with `ade proof ...` or `ingest_computer_use_artifacts`.", "", + "When the `mcp__computer_use` tools are present, use that direct signed Computer Use MCP surface. Start with `list_apps` or `get_app_state` as appropriate, honor its per-app approval prompts, and do not bootstrap `@oai/sky` through `node_repl` as a substitute.", "If `get_computer_use_backend_status` is exposed in your current tool list, call it to check available backends before attempting computer use. If it is not exposed, do not stall; use the available computer-use, browser, app-control, or ADE CLI status tools and clearly report any missing backend-status visibility.", "Respect the backend the user requested. If that backend is unavailable or hangs, stop and report the block instead of silently switching to a different backend.", "When the user asks you to send proof, register the resulting artifact with ADE via `ade proof ...` or `ingest_computer_use_artifacts` so it appears in the active proof drawer.", @@ -4075,7 +4165,7 @@ export function buildComputerUseDirective( sections.push( [ "### Proof Capture", - "ADE automatically saves screenshots, recordings, and browser traces from computer-use tool calls to the proof drawer. Use `ingest_computer_use_artifacts` only to attach externally produced visual proof such as a screenshot/image/video/trace, or to add logs as secondary context alongside visual proof.", + "Proof is intentional. Use `ade proof capture` for a reviewer-facing checkpoint, or `ade proof attach` / `ingest_computer_use_artifacts` for an existing screenshot, image, video, or trace. Add logs only as secondary context unless the user explicitly asks for them.", ].join("\n"), ); @@ -5490,6 +5580,10 @@ export function createAgentChatService(args: { logger: Logger; appVersion: string; getAdeCliAgentEnv?: (baseEnv?: NodeJS.ProcessEnv) => NodeJS.ProcessEnv; + resolveCodexComputerUseMcp?: () => + | CodexComputerUseMcpConfig + | null + | Promise; claudeSubprocessReaper?: ClaudeSubprocessReaper; onEvent?: (event: AgentChatEventEnvelope) => void; onSessionEnded?: (args: { laneId: string; sessionId: string; exitCode: number | null }) => void; @@ -5528,12 +5622,15 @@ export function createAgentChatService(args: { logger, appVersion, getAdeCliAgentEnv, + resolveCodexComputerUseMcp: resolveCodexComputerUseMcpOverride, claudeSubprocessReaper: injectedClaudeSubprocessReaper, onEvent, onSessionEnded, onLinearIssueChatLinked, getDirtyFileTextForPath, } = args; + const resolveCodexComputerUseMcp = resolveCodexComputerUseMcpOverride + ?? resolveCodexComputerUseMcpConfig; if (!getDirtyFileTextForPath) { throw new Error("createAgentChatService: getDirtyFileTextForPath is required"); @@ -5744,9 +5841,72 @@ export function createAgentChatService(args: { const STORED_TOOL_RESULT_FAILED_MAX_BYTES = 64 * 1024; const STORED_FILE_DIFF_MAX_BYTES = 32 * 1024; const STORED_REASONING_MAX_BYTES = 8 * 1024; + const STORED_INLINE_IMAGE_DATA_URL_MAX_BYTES = 64 * 1024; const utf8Bytes = (value: string): number => Buffer.byteLength(value, "utf8"); + const inlineImageDataUrlBytes = (value: string | null | undefined): number | null => { + if (!value || !/^data:image\//i.test(value.trim())) return null; + return utf8Bytes(value); + }; + + const redactStoredInlineImageDataUrls = ( + value: unknown, + ): { value: unknown; omittedBytes: number; changed: boolean } => { + const seen = new WeakSet(); + const visit = (candidate: unknown, depth: number): { value: unknown; omittedBytes: number; changed: boolean } => { + if (typeof candidate === "string") { + const bytes = inlineImageDataUrlBytes(candidate); + if (bytes == null || bytes <= STORED_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { value: candidate, omittedBytes: 0, changed: false }; + } + return { + value: `[ADE] Inline image data omitted from stored chat history (${bytes} bytes).`, + omittedBytes: bytes, + changed: true, + }; + } + if (!candidate || typeof candidate !== "object") { + return { value: candidate, omittedBytes: 0, changed: false }; + } + if (depth >= 32) { + return { + value: "[ADE] Deep structured payload omitted from stored chat history.", + omittedBytes: 0, + changed: true, + }; + } + if (seen.has(candidate)) { + return { value: "[Circular]", omittedBytes: 0, changed: true }; + } + seen.add(candidate); + if (Array.isArray(candidate)) { + let omittedBytes = 0; + let changed = false; + const next = candidate.map((entry) => { + const result = visit(entry, depth + 1); + omittedBytes += result.omittedBytes; + changed ||= result.changed; + return result.value; + }); + seen.delete(candidate); + return { value: changed ? next : candidate, omittedBytes, changed }; + } + let omittedBytes = 0; + let changed = false; + const next: Record = {}; + for (const [key, entry] of Object.entries(candidate)) { + const result = visit(entry, depth + 1); + next[key] = result.value; + omittedBytes += result.omittedBytes; + changed ||= result.changed; + } + seen.delete(candidate); + return { value: changed ? next : candidate, omittedBytes, changed }; + }; + return visit(value, 0); + }; + const sliceUtf8FromStart = (value: string, maxBytes: number): string => { if (maxBytes <= 0) return ""; if (utf8Bytes(value) <= maxBytes) return value; @@ -5876,15 +6036,18 @@ export function createAgentChatService(args: { const maxBytes = event.status === "failed" || event.status === "interrupted" ? STORED_TOOL_RESULT_FAILED_MAX_BYTES : STORED_TOOL_RESULT_MAX_BYTES; - const compacted = compactStoredUnknownPayload("tool result", event.result, maxBytes); - return compacted - ? { - ...event, - result: compacted.value, - resultOriginalBytes: compacted.originalBytes, - resultOmittedBytes: compacted.omittedBytes, - } - : event; + const redacted = redactStoredInlineImageDataUrls(event.result); + const compacted = compactStoredUnknownPayload("tool result", redacted.value, maxBytes); + if (!redacted.changed && !compacted) return event; + const originalBytes = redacted.changed + ? utf8Bytes(stringifyPayloadForCompaction(event.result).text) + : compacted?.originalBytes ?? 0; + return { + ...event, + result: compacted?.value ?? redacted.value, + resultOriginalBytes: originalBytes, + resultOmittedBytes: redacted.omittedBytes + (compacted?.omittedBytes ?? 0), + }; } if (event.type === "file_change") { @@ -5911,6 +6074,36 @@ export function createAgentChatService(args: { : event; } + if (event.type === "codex_image_generation") { + const resultBytes = inlineImageDataUrlBytes(event.result); + const savedPathIsInline = inlineImageDataUrlBytes(event.savedPath) != null; + if (resultBytes != null && resultBytes > STORED_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { + ...event, + result: null, + ...(savedPathIsInline ? { savedPath: null } : {}), + resultOriginalBytes: resultBytes, + resultOmittedBytes: resultBytes, + }; + } + return savedPathIsInline ? { ...event, savedPath: null } : event; + } + + if (event.type === "codex_image_view") { + const urlBytes = inlineImageDataUrlBytes(event.url); + const pathIsInline = inlineImageDataUrlBytes(event.path) != null; + if (urlBytes != null && urlBytes > STORED_INLINE_IMAGE_DATA_URL_MAX_BYTES) { + return { + ...event, + url: null, + ...(pathIsInline ? { path: null } : {}), + urlOriginalBytes: urlBytes, + urlOmittedBytes: urlBytes, + }; + } + return pathIsInline ? { ...event, path: null } : event; + } + return event; }; @@ -6012,6 +6205,7 @@ export function createAgentChatService(args: { }; const managedSessions = new Map(); + const codexRecoveryInFlight = new Set(); const recordLinearIssueContextForLane = ( managed: ManagedChatSession, @@ -7676,15 +7870,6 @@ export function createAgentChatService(args: { }; }; - const selectPreferredReasoningTier = (tiers: string[]): string | null => { - const normalized = uniqueNonEmpty(tiers.map((tier) => normalizeReasoningEffort(tier))); - if (!normalized.length) return null; - for (const candidate of ["medium", "high", "low", "xhigh", "max", "none"]) { - if (normalized.includes(candidate)) return candidate; - } - return normalized[0] ?? null; - }; - const pickHandoffReasoningEffort = ( descriptor: ModelDescriptor, sourceReasoningEffort: string | null | undefined, @@ -7695,10 +7880,11 @@ export function createAgentChatService(args: { ); if (!supported.length) return normalizeReasoningEffort(sourceReasoningEffort); const normalizedSource = normalizeReasoningEffort(sourceReasoningEffort); - if (normalizedSource && supported.includes(normalizedSource)) { - return normalizedSource; - } - return selectPreferredReasoningTier(supported); + return selectSupportedReasoningEffort({ + tiers: supported, + preferred: normalizedSource, + advertisedDefault: descriptor.defaultReasoningEffort, + }); }; const buildRecentConversationContext = (managed: ManagedChatSession, limit = 20): string => { @@ -12657,6 +12843,7 @@ export function createAgentChatService(args: { costUsd: number | null; emittedToolIds: Set; openToolUses: Map; + structuredActivity: ClaudeStructuredActivityState; toolInputJsonByContentIndex: Map; toolUseMetaByContentIndex: Map; currentStreamMessageId: string | null; @@ -12709,6 +12896,10 @@ export function createAgentChatService(args: { }); } state.openToolUses.clear(); + for (const event of finalizeClaudeStructuredActivities(state.structuredActivity, turnId, status)) { + emitChatEvent(managed, event); + } + state.structuredActivity = createClaudeStructuredActivityState(); runtime.busy = false; runtime.activeTurnId = null; markSessionIdleWithFreshCache(managed); @@ -13031,6 +13222,14 @@ export function createAgentChatService(args: { for (const [index, rawBlock] of content.entries()) { const block = asRecord(rawBlock); if (!block) continue; + for (const event of mapClaudeStructuredActivityBlock({ + block, + turnId, + fallbackItemId: `claude-structured:${turnId}:${index}`, + state: state.structuredActivity, + })) { + emitChatEvent(managed, event); + } if (block.type === "text") { const text = typeof block.text === "string" ? block.text : ""; if (text.length) { @@ -13122,6 +13321,16 @@ export function createAgentChatService(args: { } if (event.type === "content_block_start") { const block = asRecord(event.content_block); + if (block) { + for (const structuredEvent of mapClaudeStructuredActivityBlock({ + block, + turnId, + fallbackItemId: `claude-structured:${turnId}:${contentIndex ?? "stream"}`, + state: state.structuredActivity, + })) { + emitChatEvent(managed, structuredEvent); + } + } if (block?.type !== "tool_use") return; const toolName = compactString(block.name) ?? "tool"; const itemId = compactString(block.id) ?? (contentIndex != null ? `claude-tool:${turnId}:${contentIndex}` : randomUUID()); @@ -13254,6 +13463,7 @@ export function createAgentChatService(args: { costUsd: null, emittedToolIds: new Set(), openToolUses: new Map(), + structuredActivity: createClaudeStructuredActivityState(), toolInputJsonByContentIndex: new Map(), toolUseMetaByContentIndex: new Map(), currentStreamMessageId: null, @@ -13436,8 +13646,8 @@ export function createAgentChatService(args: { const turnStartedAt = Date.now(); let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); - const emittedSyntheticItemIds = new Set(); const emittedPermissionDeniedToolUseIds = new Set(); + const claudeStructuredActivity = createClaudeStructuredActivityState(); const streamedClaudeTextContentKeys = new Set(); const streamedClaudeThinkingContentKeys = new Set(); let currentClaudeStreamMessageId: string | null = null; @@ -13517,6 +13727,17 @@ export function createAgentChatService(args: { }, finalTurnStatus); } }; + const flushClaudeStructuredActivities = ( + finalTurnStatus: "completed" | "failed" | "interrupted", + ): void => { + for (const event of finalizeClaudeStructuredActivities( + claudeStructuredActivity, + turnId, + finalTurnStatus, + )) { + emitChatEvent(managed, event); + } + }; const maybeEmitTodoUpdate = (toolName: string, input: unknown, itemId: string): void => { if (emittedClaudeTodoIds.has(itemId)) return; const todoItems = toolName === "TodoWrite" @@ -14618,6 +14839,14 @@ export function createAgentChatService(args: { reportedAssistantModel = normalizeReportedModelName(betaMessage?.model) ?? reportedAssistantModel; if (betaMessage?.content && Array.isArray(betaMessage.content)) { for (const [blockIndex, block] of betaMessage.content.entries()) { + for (const structuredEvent of mapClaudeStructuredActivityBlock({ + block, + turnId, + fallbackItemId: `claude-structured:${turnId}:${blockIndex}`, + state: claudeStructuredActivity, + })) { + emitChatEvent(managed, structuredEvent); + } if (block.type === "text") { const blockText = block.text ?? ""; // Check both the real-id key AND the id-less fallback key. When @@ -14709,13 +14938,6 @@ export function createAgentChatService(args: { }); maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, toolName, block.input, itemId, turnId); maybeEmitTodoUpdate(toolName, block.input, itemId); - // Synthesize a tool_result for the proof observer when the - // SDK stream does not surface tool results directly. - const syntheticResult = maybeSyntheticToolResult(toolName, block.input ?? {}, itemId, turnId); - if (syntheticResult && !emittedSyntheticItemIds.has(itemId)) { - emittedSyntheticItemIds.add(itemId); - emitChatEvent(managed, syntheticResult); - } } } } @@ -14797,6 +15019,16 @@ export function createAgentChatService(args: { } } else if (event.type === "content_block_start") { const block = event.content_block; + if (block) { + for (const structuredEvent of mapClaudeStructuredActivityBlock({ + block, + turnId, + fallbackItemId: `claude-structured:${turnId}:${contentIndex ?? "stream"}`, + state: claudeStructuredActivity, + })) { + emitChatEvent(managed, structuredEvent); + } + } if (block?.type === "thinking") { const reasoningItemId = buildClaudeContentItemId("thinking", contentIndex); emitChatEvent(managed, { @@ -14908,11 +15140,6 @@ export function createAgentChatService(args: { } maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, meta.toolName, parsed, meta.itemId, turnId); maybeEmitTodoUpdate(meta.toolName, parsed, meta.itemId); - const syntheticResult = maybeSyntheticToolResult(meta.toolName, parsed, meta.itemId, turnId); - if (syntheticResult && !emittedSyntheticItemIds.has(meta.itemId)) { - emittedSyntheticItemIds.add(meta.itemId); - emitChatEvent(managed, syntheticResult); - } } } } else if (event.type === "message_start") { @@ -15094,6 +15321,7 @@ export function createAgentChatService(args: { await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); } flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); + flushClaudeStructuredActivities(runtime.interrupted ? "interrupted" : "completed"); // Note: query is NOT closed here — it stays alive for the next turn. runtime.busy = false; runtime.activeTurnId = null; @@ -15157,6 +15385,7 @@ export function createAgentChatService(args: { await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); } flushOpenClaudeToolUses(finalToolStatus); + flushClaudeStructuredActivities(finalToolStatus); // Only close the query on genuine errors. User interrupts close and // clear the session immediately in interrupt() so the next turn cannot @@ -15921,6 +16150,16 @@ export function createAgentChatService(args: { } let stepNumber = 0; + const openCodeAssistantMessageIds = new Set(); + const emittedOpenCodeImagePartIds = new Set(); + const emitOpenCodeImagePart = (part: unknown): void => { + const imageEvent = mapOpenCodeImagePart({ + part, + turnId, + emittedPartIds: emittedOpenCodeImagePartIds, + }); + if (imageEvent) emitChatEvent(managed, imageEvent); + }; // Per-child (subagent) usage accumulator for this turn. OpenCode child // sessions stream their own `step-finish` parts (tagged with the child's // sessionID); we sum their tokens/cost here and attach the running total to @@ -15936,6 +16175,7 @@ export function createAgentChatService(args: { const resolveSessionId = (): string | null => { switch (event.type) { case "message.updated": + return event.properties.info.sessionID; case "session.created": case "session.updated": case "session.deleted": @@ -16075,6 +16315,15 @@ export function createAgentChatService(args: { continue; } + if (event.type === "message.updated") { + if (event.properties.info.role === "assistant") { + openCodeAssistantMessageIds.add(event.properties.info.id); + } else { + openCodeAssistantMessageIds.delete(event.properties.info.id); + } + continue; + } + if (event.type === "session.created" || event.type === "session.updated") { adoptRuntimeSessionTitle(managed, event.properties.info, `opencode_${event.type}`); continue; @@ -16196,6 +16445,15 @@ export function createAgentChatService(args: { continue; } + if (part.type === "file") { + // Prompt attachments use the same wire part. Only assistant-owned + // files are output; tool attachments are handled below directly. + if (openCodeAssistantMessageIds.has(part.messageID)) { + emitOpenCodeImagePart(part); + } + continue; + } + if (part.type === "tool") { const previousStatus = runtime.toolStateByPartId.get(part.id) ?? null; const nextStatus = part.state.status; @@ -16224,6 +16482,9 @@ export function createAgentChatService(args: { } if (nextStatus === "completed" && previousStatus !== "completed") { + for (const attachment of part.state.attachments ?? []) { + emitOpenCodeImagePart(attachment); + } emitChatEvent(managed, { type: "tool_result", tool: part.tool, @@ -16677,6 +16938,68 @@ export function createAgentChatService(args: { const id = payload.id; if (id == null) return; + if (method === "currentTime/read") { + runtime.sendResponse(id, { currentTimeAt: Math.floor(Date.now() / 1_000) }); + return; + } + + if (method === "mcpServer/elicitation/request") { + const params = asRecord(payload.params) ?? {}; + const serverName = stringOrNull(params.serverName) ?? "MCP app"; + const mode = stringOrNull(params.mode) ?? "form"; + const message = stringOrNull(params.message) + ?? (mode === "url" ? `Open the ${serverName} authorization link?` : `${serverName} is requesting input.`); + const requestedSchema = asRecord(params.requestedSchema); + const questions = mode === "form" || mode === "openai/form" + ? mcpElicitationQuestions(requestedSchema) + : []; + const persistenceAllowed = mcpElicitationAllowsAlways(params._meta) + || mcpElicitationAllowsAlways(params.meta); + const requestTurnId = normalizeCodexTurnId( + params.turnId, + runtime.activeTurnId ?? runtime.startedTurnId ?? null, + ); + const itemId = `mcp-elicitation:${serverName}:${String(id)}`; + const request: PendingInputRequest = { + requestId: String(id), + itemId, + source: "codex", + kind: questions.length ? "structured_question" : "approval", + title: `${serverName} permission`, + description: message, + questions, + allowsFreeform: questions.some((question) => question.allowsFreeform === true), + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + mcpElicitation: true, + serverName, + mode, + persistenceSupported: persistenceAllowed, + ...(stringOrNull(params.url) ? { url: stringOrNull(params.url) } : {}), + }, + turnId: requestTurnId, + }; + runtime.approvals.set(itemId, { + requestId: id, + kind: "mcp_elicitation", + request, + elicitationSchema: requestedSchema, + elicitationPersistenceAllowed: persistenceAllowed, + }); + markCodexTurnProgress(runtime, requestTurnId); + emitPendingInputRequest(managed, request, { + kind: "tool_call", + description: message, + detail: { + serverName, + mode, + persistenceSupported: persistenceAllowed, + }, + }); + return; + } + if (method === "item/commandExecution/requestApproval") { const params = (payload.params as { itemId?: string; @@ -18258,6 +18581,40 @@ export function createAgentChatService(args: { return; } + if (itemType === "mcpToolCall") { + const mcp = normalizeCodexMcpToolSource(item); + const label = `${mcp.server}:${mcp.tool}`; + emitChatEvent(managed, { + type: "activity", + activity: "tool_calling", + detail: mcp.appContext?.appName ?? label, + turnId, + }); + if (eventKind === "started") { + emitChatEvent(managed, { + type: "tool_call", + tool: label, + args: item.arguments ?? null, + mcp, + itemId, + turnId, + }); + } + if (eventKind === "completed") { + const failed = String(item.status ?? "completed") === "failed" || item.error != null; + emitChatEvent(managed, { + type: "tool_result", + tool: label, + result: failed ? item.error ?? "Failed" : item.result ?? "Completed", + mcp, + itemId, + turnId, + status: failed ? "failed" : "completed", + }); + } + return; + } + // Delegation items → subagent events if (itemType === "delegation") { if (eventKind === "started") { @@ -18860,13 +19217,13 @@ export function createAgentChatService(args: { return true; } if (itemType === "mcpToolCall") { - const tool = stringOrNull(item.tool) ?? "mcp_tool"; - const server = stringOrNull(item.server); - const label = server ? `${server}:${tool}` : tool; + const mcp = normalizeCodexMcpToolSource(item); + const label = `${mcp.server}:${mcp.tool}`; emitChatEvent(managed, { type: "tool_call", tool: label, args: item.arguments ?? null, + mcp, itemId, turnId, }); @@ -18875,6 +19232,7 @@ export function createAgentChatService(args: { type: "tool_result", tool: label, result: item.result ?? item.error ?? "", + mcp, itemId, turnId, status: item.error ? "failed" : "completed", @@ -19217,6 +19575,24 @@ export function createAgentChatService(args: { return; } + if (method === "serverRequest/resolved") { + const requestId = params.requestId; + if (typeof requestId === "string" || typeof requestId === "number") { + for (const [itemId, pending] of runtime.approvals) { + if (String(pending.requestId) !== String(requestId)) continue; + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: "cancel", + turnId: pending.request?.turnId ?? turnIdFromParams, + }); + persistChatState(managed); + break; + } + } + return; + } + if (method === "thread/name/updated" || method === "thread/updated") { if (extractRuntimeTitle(params)) { adoptRuntimeSessionTitle(managed, params, `codex_${method.replace(/[^\w]+/g, "_")}`); @@ -20384,7 +20760,7 @@ export function createAgentChatService(args: { const startResponse = await runtime.request("thread/start", { model: managed.session.model, cwd: managed.laneWorktreePath, - ...codexThreadConfigArgs(reasoningEffort), + ...codexThreadConfigArgs(reasoningEffort, await resolveCodexComputerUseMcp()), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -21528,6 +21904,7 @@ export function createAgentChatService(args: { ? row.description.trim() : null; const isDefault = Boolean(row.isDefault); + const defaultReasoningEffort = normalizeReasoningEffort(row.defaultReasoningEffort); const reasoningEfforts = Array.isArray(row.supportedReasoningEfforts) ? row.supportedReasoningEfforts @@ -21565,6 +21942,7 @@ export function createAgentChatService(args: { ...(description ? { description } : {}), isDefault, reasoningEfforts: normalizedEfforts, + ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(serviceTiers ? { serviceTiers } : {}) } satisfies AgentChatModelInfo; }) @@ -21592,6 +21970,7 @@ export function createAgentChatService(args: { reasoningEfforts: appServerEntry?.reasoningEfforts?.length ? appServerEntry.reasoningEfforts : undefined, + defaultReasoningEffort: appServerEntry?.defaultReasoningEffort, serviceTiers: appServerEntry?.serviceTiers, }); }); @@ -21860,7 +22239,9 @@ export function createAgentChatService(args: { } const rawEffort = effectiveProvider === "codex" - ? normalizeReasoningEffort(reasoningEffort) ?? DEFAULT_REASONING_EFFORT + ? normalizeReasoningEffort(reasoningEffort) + ?? resolvedDescriptor?.defaultReasoningEffort + ?? DEFAULT_REASONING_EFFORT : normalizeReasoningEffort(reasoningEffort); const normalizedReasoningEffort = effectiveProvider === "opencode" || effectiveProvider === "cursor" || effectiveProvider === "droid" ? validateRuntimeReasoningEffortForDescriptor(rawEffort, resolvedDescriptor) @@ -25847,7 +26228,11 @@ export function createAgentChatService(args: { if (nextReasoningEffort) { managed.session.reasoningEffort = nextReasoningEffort; } else if (!managed.session.reasoningEffort) { - managed.session.reasoningEffort = DEFAULT_REASONING_EFFORT; + managed.session.reasoningEffort = resolveCodexReasoningEffortForRuntime( + null, + null, + resolveSessionModelDescriptor(managed.session), + ); } // Re-sync codex approval policy so mid-session changes take effect on this turn. @@ -25887,7 +26272,7 @@ export function createAgentChatService(args: { threadId: threadIdToResume, model: managed.session.model, cwd: managed.laneWorktreePath, - ...codexThreadConfigArgs(resumeReasoningEffort), + ...codexThreadConfigArgs(resumeReasoningEffort, await resolveCodexComputerUseMcp()), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -27137,7 +27522,7 @@ export function createAgentChatService(args: { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, - ...codexThreadConfigArgs(managed.session.reasoningEffort), + ...codexThreadConfigArgs(managed.session.reasoningEffort, await resolveCodexComputerUseMcp()), ...codexServiceTierArgs(managed.session), ...codexPolicyArgs(codexPolicy), excludeTurns: true, @@ -27248,6 +27633,89 @@ export function createAgentChatService(args: { return managed.session; }; + const recoverCodexTurn = async ({ + sessionId, + turnId, + action, + }: AgentChatRecoverCodexTurnArgs): Promise => { + const normalizedTurnId = turnId.trim(); + if (!normalizedTurnId) throw new Error("Codex recovery requires a turn id."); + const managed = ensureManagedSession(sessionId); + if (managed.session.provider !== "codex") { + throw new Error("Turn recovery is only available for Codex chats."); + } + const runtime = managed.runtime?.kind === "codex" ? managed.runtime : null; + const activeTurnId = runtime?.activeTurnId ?? runtime?.startedTurnId ?? null; + if (!runtime || activeTurnId !== normalizedTurnId) { + throw new Error("This stalled Codex turn is no longer active."); + } + const recoveryKey = `${sessionId}:${normalizedTurnId}`; + if (codexRecoveryInFlight.has(recoveryKey)) { + throw new Error("A recovery action is already running for this Codex turn."); + } + codexRecoveryInFlight.add(recoveryKey); + runtime.stalledTurnIds.delete(normalizedTurnId); + + try { + if (action === "wait") { + scheduleCodexNoFirstEventWatchdog(managed, runtime, normalizedTurnId); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: "Continuing to wait for Codex output.", + turnId: normalizedTurnId, + }); + persistChatState(managed); + return { action, turnId: normalizedTurnId, status: "waiting" }; + } + + if (action === "steer") { + await steer({ + sessionId, + text: "Status check: briefly report your current progress or what is blocking this turn, then continue the task.", + }); + scheduleCodexNoFirstEventWatchdog(managed, runtime, runtime.activeTurnId ?? normalizedTurnId); + return { action, turnId: normalizedTurnId, status: "nudged" }; + } + + if (action !== "interrupt_retry_same_thread" && action !== "restart_resume_thread") { + throw new Error(`Unsupported Codex recovery action: ${String(action)}`); + } + + await interrupt({ sessionId }); + const interruptedTurnId = runtime.activeTurnId ?? runtime.startedTurnId; + if (managed.runtime === runtime && interruptedTurnId) { + finishCodexTurnInterruptedLocally( + managed, + runtime, + interruptedTurnId, + action === "restart_resume_thread" + ? "Interrupted so ADE could restart and resume the Codex thread" + : "Interrupted so ADE could retry the Codex turn", + ); + } + + if (action === "restart_resume_thread") { + teardownRuntime(managed, "handle_close"); + await resumeSession({ sessionId }); + } + + await sendMessage({ + sessionId, + text: action === "restart_resume_thread" + ? "Resume the interrupted work from the user's last request. Re-check the current workspace state, continue safely, and report progress." + : "Retry the interrupted work from the user's last request in this thread. Re-check the current workspace state, continue safely, and report progress.", + }, { awaitDispatch: true }); + return { + action, + turnId: normalizedTurnId, + status: action === "restart_resume_thread" ? "resumed" : "retrying", + }; + } finally { + codexRecoveryInFlight.delete(recoveryKey); + } + }; + const latestLivePendingInputItemId = (managed: ManagedChatSession | null | undefined): string | null => { if (!managed) return null; const localPending = managed.localPendingInputs.keys().next().value; @@ -27856,6 +28324,33 @@ export function createAgentChatService(args: { return; } + if (pending.kind === "mcp_elicitation") { + const accepted = resolvedDecision === "accept" || resolvedDecision === "accept_for_session"; + ensureWritable(); + runtime.sendResponse(pending.requestId, { + action: accepted + ? "accept" + : resolvedDecision === "cancel" + ? "cancel" + : "decline", + content: accepted + ? mcpElicitationContent(pending.elicitationSchema, answers, responseText) + : null, + _meta: accepted + && resolvedDecision === "accept_for_session" + && pending.elicitationPersistenceAllowed + ? { persist: "always" } + : null, + }); + runtime.approvals.delete(itemId); + emitPendingInputResolved(managed, { + itemId, + decision: resolvedDecision, + turnId: pending.request?.turnId ?? null, + }); + return; + } + if (pending.kind === "permissions") { const approved = resolvedDecision === "accept" || resolvedDecision === "accept_for_session"; ensureWritable(); @@ -28428,22 +28923,41 @@ export function createAgentChatService(args: { return []; }; + const aggregateModelProviders = [ + "claude", + "codex", + "cursor", + "droid", + "opencode", + ] as const satisfies readonly AgentChatProvider[]; + const getAvailableModels = async ({ provider, activateRuntime, cursorSource, }: { - provider: AgentChatProvider; + provider?: AgentChatProvider; activateRuntime?: boolean; cursorSource?: AgentChatCursorModelSource; - }): Promise => { - const requestKey = `${provider}:${activateRuntime === true ? "active" : "passive"}:${cursorSource ?? "all"}`; + } = {}): Promise => { + const requestedProvider = provider?.trim() ? provider : undefined; + const requestKey = `${requestedProvider ?? "all"}:${activateRuntime === true ? "active" : "passive"}:${cursorSource ?? "all"}`; const existingRequest = availableModelsRequests.get(requestKey); if (existingRequest) { return existingRequest; } - const request = loadAvailableModels({ provider, activateRuntime, cursorSource }); + const request = requestedProvider + ? loadAvailableModels({ provider: requestedProvider, activateRuntime, cursorSource }) + : Promise.all( + aggregateModelProviders.map((catalogProvider) => + getAvailableModels({ + provider: catalogProvider, + activateRuntime, + ...(catalogProvider === "cursor" && cursorSource ? { cursorSource } : {}), + }).catch(() => []), + ), + ).then((providerModels) => providerModels.flat()); availableModelsRequests.set(requestKey, request); try { return await request; @@ -28542,6 +29056,11 @@ export function createAgentChatService(args: { ...(typeof info.supportsTools === "boolean" ? { tools: info.supportsTools } : {}), }, ...(runtimeTiers?.length ? { reasoningTiers: runtimeTiers } : {}), + ...(info.defaultReasoningEffort + ? { defaultReasoningEffort: info.defaultReasoningEffort } + : descriptor.defaultReasoningEffort + ? { defaultReasoningEffort: descriptor.defaultReasoningEffort } + : {}), ...(info.serviceTiers !== undefined ? { serviceTiers: info.serviceTiers } : descriptor.serviceTiers?.length @@ -28633,6 +29152,11 @@ export function createAgentChatService(args: { ? { aliases: descriptor.aliases } : {}), ...(reasoningEfforts?.length ? { reasoningEfforts } : {}), + ...(entry?.info.defaultReasoningEffort + ? { defaultReasoningEffort: entry.info.defaultReasoningEffort } + : descriptor.defaultReasoningEffort + ? { defaultReasoningEffort: descriptor.defaultReasoningEffort } + : {}), maxThinkingTokens: entry?.info.maxThinkingTokens ?? null, modelId: descriptor.id, family: descriptor.family, @@ -29110,6 +29634,12 @@ export function createAgentChatService(args: { : nextProvider === "claude" ? validateReasoningEffortForDescriptor("claude", requested, descriptor) : validateRuntimeReasoningEffortForDescriptor(requested, descriptor); + } else if (modelChanged && nextProvider === "codex") { + managed.session.reasoningEffort = validateReasoningEffortForDescriptor( + "codex", + managed.session.reasoningEffort, + descriptor, + ) ?? resolveCodexReasoningEffortForRuntime(null, null, descriptor); } // Pre-warm the Claude query when the user selects an Anthropic model. @@ -31312,7 +31842,7 @@ export function createAgentChatService(args: { threadId, model: managed.session.model, cwd: managed.laneWorktreePath, - ...codexThreadConfigArgs(resumeReasoningEffort), + ...codexThreadConfigArgs(resumeReasoningEffort, await resolveCodexComputerUseMcp()), developerInstructions: buildCodexDeveloperInstructions({ laneWorktreePath: managed.laneWorktreePath, session: managed.session, @@ -31479,6 +32009,7 @@ export function createAgentChatService(args: { dispatchSteer, cancelDispatchedSteer, interrupt, + recoverCodexTurn, resumeSession, listSessions, getSessionSummary, diff --git a/apps/desktop/src/main/services/chat/claudeStructuredActivity.test.ts b/apps/desktop/src/main/services/chat/claudeStructuredActivity.test.ts new file mode 100644 index 000000000..53a7406de --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeStructuredActivity.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import { + createClaudeStructuredActivityState, + finalizeClaudeStructuredActivities, + mapClaudeStructuredActivityBlock, +} from "./claudeStructuredActivity"; + +function createMapper() { + const state = createClaudeStructuredActivityState(); + let fallbackIndex = 0; + return { + state, + map(block: unknown) { + fallbackIndex += 1; + return mapClaudeStructuredActivityBlock({ + block, + turnId: "turn-1", + fallbackItemId: `fallback-${fallbackIndex}`, + state, + }); + }, + }; +} + +describe("mapClaudeStructuredActivityBlock", () => { + it("maps a web search lifecycle with result links and dedupes stream snapshots", () => { + const mapper = createMapper(); + const start = { + type: "server_tool_use", + id: "search-1", + name: "web_search", + input: { query: "ADE coding agents" }, + }; + + expect(mapper.map(start)).toEqual([expect.objectContaining({ + type: "web_search", + itemId: "search-1", + query: "ADE coding agents", + status: "running", + })]); + expect(mapper.map(start)).toEqual([]); + + const completed = mapper.map({ + type: "web_search_tool_result", + tool_use_id: "search-1", + content: [{ + type: "web_search_result", + title: "ADE", + url: "https://example.com/ade", + encrypted_content: "opaque", + }], + }); + expect(completed).toEqual([expect.objectContaining({ + type: "web_search", + itemId: "search-1", + query: "ADE coding agents", + status: "completed", + actions: [expect.objectContaining({ + type: "search_result", + title: "ADE", + url: "https://example.com/ade", + })], + })]); + }); + + it("maps web fetch results and typed failures", () => { + const mapper = createMapper(); + mapper.map({ + type: "server_tool_use", + id: "fetch-1", + name: "web_fetch", + input: { url: "https://example.com/docs" }, + }); + expect(mapper.map({ + type: "web_fetch_tool_result", + tool_use_id: "fetch-1", + content: { + type: "web_fetch_result", + url: "https://example.com/docs", + retrieved_at: "2026-07-09T12:00:00Z", + }, + })).toEqual([expect.objectContaining({ + type: "web_search", + query: "https://example.com/docs", + status: "completed", + actions: [expect.objectContaining({ type: "open_page", url: "https://example.com/docs" })], + })]); + + mapper.map({ + type: "server_tool_use", + id: "fetch-2", + name: "web_fetch", + input: { url: "https://blocked.example" }, + }); + expect(mapper.map({ + type: "web_fetch_tool_result", + tool_use_id: "fetch-2", + content: { type: "web_fetch_tool_result_error", error_code: "url_not_allowed" }, + })).toEqual([expect.objectContaining({ + type: "web_search", + query: "https://blocked.example", + action: "url not allowed", + status: "failed", + })]); + }); + + it("maps MCP calls and results with connector identity preserved", () => { + const mapper = createMapper(); + expect(mapper.map({ + type: "mcp_tool_use", + id: "mcp-1", + server_name: "github", + name: "search_issues", + input: { query: "is:open label:bug" }, + })).toEqual([expect.objectContaining({ + type: "tool_call", + tool: "github:search_issues", + itemId: "mcp-1", + mcp: { server: "github", tool: "search_issues" }, + })]); + + expect(mapper.map({ + type: "mcp_tool_result", + tool_use_id: "mcp-1", + is_error: false, + content: [ + { type: "text", text: "Issue 1" }, + { type: "text", text: "Issue 2" }, + ], + })).toEqual([expect.objectContaining({ + type: "tool_result", + tool: "github:search_issues", + result: "Issue 1\nIssue 2", + status: "completed", + mcp: { server: "github", tool: "search_issues" }, + })]); + }); + + it("refreshes MCP args when a streamed empty start is followed by the full snapshot", () => { + const mapper = createMapper(); + const emptyStart = { + type: "mcp_tool_use", + id: "mcp-streamed", + server_name: "linear", + name: "get_issue", + input: {}, + }; + expect(mapper.map(emptyStart)).toEqual([expect.objectContaining({ args: {} })]); + expect(mapper.map({ ...emptyStart, input: { id: "ADE-123" } })).toEqual([ + expect.objectContaining({ + type: "tool_call", + itemId: "mcp-streamed", + args: { id: "ADE-123" }, + }), + ]); + expect(mapper.map({ ...emptyStart, input: { id: "ADE-123" } })).toEqual([]); + }); + + it("closes unresolved structured rows when a turn ends", () => { + const mapper = createMapper(); + mapper.map({ + type: "server_tool_use", + id: "search-pending", + name: "web_search", + input: { query: "still searching" }, + }); + mapper.map({ + type: "mcp_tool_use", + id: "mcp-pending", + server_name: "linear", + name: "get_issue", + input: { id: "ADE-1" }, + }); + + expect(finalizeClaudeStructuredActivities(mapper.state, "turn-1", "interrupted")).toEqual([ + expect.objectContaining({ type: "web_search", itemId: "search-pending", status: "failed" }), + expect.objectContaining({ type: "tool_result", itemId: "mcp-pending", status: "interrupted" }), + ]); + }); + + it("ignores unrelated Claude content blocks", () => { + const mapper = createMapper(); + expect(mapper.map({ type: "text", text: "hello" })).toEqual([]); + expect(mapper.map({ type: "server_tool_use", id: "code-1", name: "code_execution", input: {} })).toEqual([]); + }); +}); diff --git a/apps/desktop/src/main/services/chat/claudeStructuredActivity.ts b/apps/desktop/src/main/services/chat/claudeStructuredActivity.ts new file mode 100644 index 000000000..74cab260b --- /dev/null +++ b/apps/desktop/src/main/services/chat/claudeStructuredActivity.ts @@ -0,0 +1,266 @@ +import type { + AgentChatEvent, + AgentChatMcpToolSource, + CodexWebSearchAction, +} from "../../../shared/types"; + +type ClaudeWebRequest = { + kind: "web_search" | "web_fetch"; + query: string; + url?: string; +}; + +export type ClaudeStructuredActivityState = { + webByToolUseId: Map; + mcpByToolUseId: Map; + mcpInputSignatureByToolUseId: Map; + emittedStarts: Set; + emittedResults: Set; +}; + +export function createClaudeStructuredActivityState(): ClaudeStructuredActivityState { + return { + webByToolUseId: new Map(), + mcpByToolUseId: new Map(), + mcpInputSignatureByToolUseId: new Map(), + emittedStarts: new Set(), + emittedResults: new Set(), + }; +} + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function readStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value.flatMap((entry) => { + const text = readString(entry); + return text ? [text] : []; + }); +} + +function readWebRequest(name: "web_search" | "web_fetch", input: unknown): ClaudeWebRequest { + const record = readRecord(input) ?? {}; + const queries = readStringArray(record.queries); + const query = readString(record.query) ?? queries[0] ?? null; + const url = readString(record.url); + return { + kind: name, + query: name === "web_fetch" + ? (url ?? query ?? "Web page") + : (query ?? "Web search"), + ...(url ? { url } : {}), + }; +} + +function webStartActions(request: ClaudeWebRequest, input: unknown): CodexWebSearchAction[] { + const record = readRecord(input) ?? {}; + if (request.kind === "web_fetch") { + return request.url + ? [{ type: "open_page", status: "running", url: request.url }] + : []; + } + const queries = readStringArray(record.queries); + return [{ + type: "search_query", + status: "running", + query: request.query, + ...(queries.length ? { queries } : {}), + }]; +} + +function webResultActions(content: unknown): CodexWebSearchAction[] { + const values = Array.isArray(content) ? content : [content]; + return values.flatMap((value) => { + const record = readRecord(value); + if (!record) return []; + const url = readString(record.url); + if (!url) return []; + return [{ + type: record.type === "web_fetch_result" ? "open_page" : "search_result", + status: "completed" as const, + url, + ...(readString(record.title) ? { title: readString(record.title)! } : {}), + }]; + }); +} + +function webResultError(content: unknown): string | null { + const record = readRecord(content); + const type = readString(record?.type); + if (type !== "web_search_tool_result_error" && type !== "web_fetch_tool_result_error") { + return null; + } + return readString(record?.error_code) ?? "request_failed"; +} + +function mcpResultContent(content: unknown): unknown { + if (!Array.isArray(content)) return content; + const text = content + .map((entry) => readString(readRecord(entry)?.text)) + .filter((entry): entry is string => Boolean(entry)) + .join("\n"); + return text || content; +} + +function payloadSignature(value: unknown): string { + try { + return JSON.stringify(value) ?? String(value); + } catch { + return String(value); + } +} + +/** + * Normalize Claude API-owned server tools into ADE's provider-neutral activity + * events. These blocks are distinct from client `tool_use` blocks and were + * previously ignored by both Claude transcript paths. + */ +export function mapClaudeStructuredActivityBlock(args: { + block: unknown; + turnId: string; + fallbackItemId: string; + state: ClaudeStructuredActivityState; +}): AgentChatEvent[] { + const block = readRecord(args.block); + const type = readString(block?.type); + if (!block || !type) return []; + + if (type === "server_tool_use") { + const name = readString(block.name); + if (name !== "web_search" && name !== "web_fetch") return []; + const itemId = readString(block.id) ?? args.fallbackItemId; + const request = readWebRequest(name, block.input); + const previousRequest = args.state.webByToolUseId.get(itemId); + args.state.webByToolUseId.set(itemId, request); + if ( + args.state.emittedStarts.has(itemId) + && previousRequest?.query === request.query + && previousRequest.url === request.url + ) return []; + args.state.emittedStarts.add(itemId); + const actions = webStartActions(request, block.input); + return [{ + type: "web_search", + query: request.query, + action: name === "web_fetch" ? "Fetching page" : "Searching", + ...(actions.length ? { actions } : {}), + itemId, + turnId: args.turnId, + status: "running", + }]; + } + + if (type === "web_search_tool_result" || type === "web_fetch_tool_result") { + const itemId = readString(block.tool_use_id) ?? args.fallbackItemId; + if (args.state.emittedResults.has(itemId)) return []; + args.state.emittedResults.add(itemId); + const inferredKind = type === "web_fetch_tool_result" ? "web_fetch" : "web_search"; + const actions = webResultActions(block.content); + const firstUrl = actions.find((action) => action.url)?.url; + const request = args.state.webByToolUseId.get(itemId) ?? { + kind: inferredKind, + query: firstUrl ?? (inferredKind === "web_fetch" ? "Web page" : "Web search"), + ...(firstUrl ? { url: firstUrl } : {}), + }; + const error = webResultError(block.content); + return [{ + type: "web_search", + query: request.query, + action: error + ? error.replace(/_/g, " ") + : inferredKind === "web_fetch" ? "Page fetched" : "Search complete", + ...(actions.length ? { actions } : {}), + itemId, + turnId: args.turnId, + status: error ? "failed" : "completed", + }]; + } + + if (type === "mcp_tool_use") { + const itemId = readString(block.id) ?? args.fallbackItemId; + const source: AgentChatMcpToolSource = { + server: readString(block.server_name) ?? "mcp", + tool: readString(block.name) ?? "tool", + }; + const input = block.input ?? {}; + const inputSignature = payloadSignature(input); + const previousInputSignature = args.state.mcpInputSignatureByToolUseId.get(itemId); + args.state.mcpByToolUseId.set(itemId, source); + args.state.mcpInputSignatureByToolUseId.set(itemId, inputSignature); + if (args.state.emittedStarts.has(itemId) && previousInputSignature === inputSignature) return []; + args.state.emittedStarts.add(itemId); + return [{ + type: "tool_call", + tool: `${source.server}:${source.tool}`, + args: input, + mcp: source, + itemId, + turnId: args.turnId, + }]; + } + + if (type === "mcp_tool_result") { + const itemId = readString(block.tool_use_id) ?? args.fallbackItemId; + if (args.state.emittedResults.has(itemId)) return []; + args.state.emittedResults.add(itemId); + const source = args.state.mcpByToolUseId.get(itemId) ?? { server: "mcp", tool: "tool" }; + return [{ + type: "tool_result", + tool: `${source.server}:${source.tool}`, + result: mcpResultContent(block.content), + mcp: source, + itemId, + turnId: args.turnId, + status: block.is_error === true ? "failed" : "completed", + }]; + } + + return []; +} + +export function finalizeClaudeStructuredActivities( + state: ClaudeStructuredActivityState, + turnId: string, + status: "completed" | "failed" | "interrupted", +): AgentChatEvent[] { + const events: AgentChatEvent[] = []; + for (const [itemId, request] of state.webByToolUseId) { + if (state.emittedResults.has(itemId)) continue; + state.emittedResults.add(itemId); + events.push({ + type: "web_search", + query: request.query, + action: status === "completed" ? "Finished" : status === "interrupted" ? "Interrupted" : "Failed", + ...(request.url ? { actions: [{ type: "open_page", url: request.url }] } : {}), + itemId, + turnId, + status: status === "completed" ? "completed" : "failed", + }); + } + for (const [itemId, source] of state.mcpByToolUseId) { + if (state.emittedResults.has(itemId)) continue; + state.emittedResults.add(itemId); + events.push({ + type: "tool_result", + tool: `${source.server}:${source.tool}`, + result: { + synthetic: true, + source: "claude_turn_finalization", + finalTurnStatus: status, + }, + mcp: source, + itemId, + turnId, + status, + }); + } + return events; +} diff --git a/apps/desktop/src/main/services/chat/codexMcpElicitation.test.ts b/apps/desktop/src/main/services/chat/codexMcpElicitation.test.ts new file mode 100644 index 000000000..962a61b98 --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexMcpElicitation.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from "vitest"; +import { + mcpElicitationAllowsAlways, + mcpElicitationContent, + mcpElicitationQuestions, +} from "./codexMcpElicitation"; + +describe("Codex MCP elicitation mapping", () => { + it("recognizes persistent approval metadata", () => { + expect(mcpElicitationAllowsAlways({ persist: "always" })).toBe(true); + expect(mcpElicitationAllowsAlways({ persist: ["session", "always"] })).toBe(true); + expect(mcpElicitationAllowsAlways({ persist: "session" })).toBe(false); + }); + + it("maps titled and multi-select enum schemas to native questions", () => { + const questions = mcpElicitationQuestions({ + type: "object", + required: ["app"], + properties: { + app: { + type: "string", + title: "Application", + oneOf: [ + { const: "calculator", title: "Calculator" }, + { const: "notes", title: "Notes" }, + ], + }, + scopes: { + type: "array", + title: "Scopes", + items: { type: "string", enum: ["read", "write"] }, + }, + }, + }); + + expect(questions).toMatchObject([ + { + id: "app", + impact: "Required", + options: [ + { label: "Calculator", value: "calculator" }, + { label: "Notes", value: "notes" }, + ], + }, + { + id: "scopes", + multiSelect: true, + options: [ + { label: "read", value: "read" }, + { label: "write", value: "write" }, + ], + }, + ]); + }); + + it("returns schema-shaped primitive, array, and JSON values", () => { + const schema = { + type: "object", + properties: { + enabled: { type: "boolean" }, + count: { type: "integer" }, + scopes: { type: "array", items: { type: "string" } }, + settings: { type: "object" }, + }, + }; + expect(mcpElicitationContent(schema, { + enabled: "true", + count: "3", + scopes: ["read", "write"], + settings: "{\"safe\":true}", + }, null)).toEqual({ + enabled: true, + count: 3, + scopes: ["read", "write"], + settings: { safe: true }, + }); + }); + + it.each([ + ["integer", ["1", "not-an-integer"], [1, "not-an-integer"]], + ["number", ["1.25", "not-a-number"], [1.25, "not-a-number"]], + ["boolean", ["true", "false"], [true, false]], + ["string", ["01", "true"], ["01", "true"]], + ])("coerces %s MCP array answers through the item schema", (itemType, raw, expected) => { + const schema = { + type: "object", + properties: { + values: { type: "array", items: { type: itemType } }, + }, + }; + + expect(mcpElicitationContent(schema, { values: raw }, null)).toEqual({ values: expected }); + }); + + it("keeps a single selected MCP array answer array-shaped", () => { + const schema = { + type: "object", + properties: { + values: { type: "array", items: { type: "integer" } }, + }, + }; + + expect(mcpElicitationContent(schema, { values: "2" }, null)).toEqual({ values: [2] }); + }); +}); diff --git a/apps/desktop/src/main/services/chat/codexMcpElicitation.ts b/apps/desktop/src/main/services/chat/codexMcpElicitation.ts new file mode 100644 index 000000000..6d62c5f4d --- /dev/null +++ b/apps/desktop/src/main/services/chat/codexMcpElicitation.ts @@ -0,0 +1,144 @@ +import type { PendingInputQuestion } from "../../../shared/types"; + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function enumOptions(property: Record): Array<{ label: string; value: string }> { + const directValues = Array.isArray(property.enum) ? property.enum : []; + const directLabels = Array.isArray(property.enumNames) ? property.enumNames : []; + if (directValues.length) { + return directValues.flatMap((value, index) => { + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return []; + return [{ + label: readString(directLabels[index]) ?? String(value), + value: String(value), + }]; + }); + } + + const itemSchema = readRecord(property.items); + const alternatives = Array.isArray(property.oneOf) + ? property.oneOf + : Array.isArray(itemSchema?.anyOf) + ? itemSchema.anyOf + : Array.isArray(itemSchema?.oneOf) + ? itemSchema.oneOf + : null; + if (alternatives) { + return alternatives.flatMap((entry) => { + const option = readRecord(entry); + const value = option?.const; + if (typeof value !== "string" && typeof value !== "number" && typeof value !== "boolean") return []; + return [{ label: readString(option?.title) ?? String(value), value: String(value) }]; + }); + } + + const itemValues = Array.isArray(itemSchema?.enum) ? itemSchema.enum : []; + return itemValues.flatMap((value) => ( + typeof value === "string" || typeof value === "number" || typeof value === "boolean" + ? [{ label: String(value), value: String(value) }] + : [] + )); +} + +export function mcpElicitationAllowsAlways(meta: unknown): boolean { + const persist = readRecord(meta)?.persist; + return persist === "always" + || (Array.isArray(persist) && persist.some((value) => value === "always")); +} + +export function mcpElicitationQuestions(schemaValue: unknown): PendingInputQuestion[] { + const schema = readRecord(schemaValue); + const properties = readRecord(schema?.properties); + if (!properties) return []; + const required = new Set( + Array.isArray(schema?.required) + ? schema.required.filter((value): value is string => typeof value === "string") + : [], + ); + return Object.entries(properties).flatMap(([id, propertyValue]) => { + const property = readRecord(propertyValue); + if (!property) return []; + const title = readString(property.title) ?? id; + const description = readString(property.description); + const propertyOptions = enumOptions(property); + const options = propertyOptions.length + ? propertyOptions + : property.type === "boolean" + ? [ + { label: "Yes", value: "true" }, + { label: "No", value: "false" }, + ] + : null; + return [{ + id, + header: title, + question: description ?? title, + ...(options ? { options } : {}), + ...(property.type === "array" ? { multiSelect: true } : {}), + allowsFreeform: options == null, + isSecret: property.format === "password", + impact: required.has(id) ? "Required" : null, + } satisfies PendingInputQuestion]; + }); +} + +function coerceMcpElicitationValue(value: string, property: Record | null): unknown { + if (property?.type === "boolean") return value.toLowerCase() === "true"; + if (property?.type === "integer") { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : value; + } + if (property?.type === "number") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : value; + } + if (property?.type === "object" || property?.type === "array") { + try { + return JSON.parse(value); + } catch { + return value; + } + } + return value; +} + +export function mcpElicitationContent( + schemaValue: Record | null | undefined, + answers: Record | undefined, + responseText: string | null | undefined, +): Record { + const properties = readRecord(schemaValue?.properties) ?? {}; + const entries = Object.entries(properties); + const content: Record = {}; + for (const [id, propertyValue] of entries) { + const raw = answers?.[id]; + const property = readRecord(propertyValue); + if (property?.type === "array") { + const values = Array.isArray(raw) ? raw : typeof raw === "string" ? [raw] : null; + if (values) { + const itemSchema = readRecord(property.items); + content[id] = values + .map((value) => value.trim()) + .filter((value) => value.length > 0) + .map((value) => coerceMcpElicitationValue(value, itemSchema)); + } + continue; + } + const value = Array.isArray(raw) ? raw.at(-1) : raw; + if (typeof value !== "string") continue; + content[id] = coerceMcpElicitationValue(value, property); + } + if (entries.length === 1 && Object.keys(content).length === 0 && responseText?.trim()) { + const [id, propertyValue] = entries[0]!; + content[id] = coerceMcpElicitationValue(responseText.trim(), readRecord(propertyValue)); + } + return content; +} diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts index c87379e29..721c8bb5c 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.test.ts @@ -71,6 +71,76 @@ describe("Cursor SDK event mapper", () => { }]); }); + it("preserves typed Cursor MCP connector identity across the tool lifecycle", () => { + const args = { + providerIdentifier: "github", + toolName: "search_issues", + args: { query: "is:open label:bug" }, + }; + expect(mapCursorSdkMessageToChatEvents({ + type: "tool_call", + call_id: "mcp-1", + name: "mcp", + status: "running", + args, + }, mapperMeta())).toEqual([{ + type: "tool_call", + tool: "github:search_issues", + args: { query: "is:open label:bug" }, + mcp: { server: "github", tool: "search_issues" }, + itemId: "mcp-1", + turnId: "turn-1", + }]); + + expect(mapCursorSdkMessageToChatEvents({ + type: "tool_call", + call_id: "mcp-1", + name: "mcp", + status: "completed", + args, + result: { status: "success", value: { content: [], isError: false } }, + }, mapperMeta())).toEqual([expect.objectContaining({ + type: "tool_result", + tool: "github:search_issues", + mcp: { server: "github", tool: "search_issues" }, + itemId: "mcp-1", + status: "completed", + })]); + }); + + it("maps Cursor generateImage calls to the shared compact image lifecycle", () => { + expect(mapCursorSdkMessageToChatEvents({ + type: "tool_call", + call_id: "image-1", + name: "generateImage", + status: "running", + args: { description: "A tiny moon icon", filePath: "/tmp/moon.png" }, + }, mapperMeta())).toEqual([{ + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "A tiny moon icon", + status: "running", + }]); + + expect(mapCursorSdkMessageToChatEvents({ + type: "tool_call", + call_id: "image-1", + name: "generateImage", + status: "completed", + args: { description: "A tiny moon icon", filePath: "/tmp/moon.png" }, + result: { status: "success", value: { filePath: "/tmp/moon.png" } }, + }, mapperMeta())).toEqual([{ + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "A tiny moon icon", + result: "/tmp/moon.png", + savedPath: "/tmp/moon.png", + status: "completed", + }]); + }); + it("maps run results to done events", () => { expect(mapCursorSdkRunResultToDoneEvent({ status: "error" }, { turnId: "turn-1", diff --git a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts index 84d34320c..74dc498bc 100644 --- a/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts @@ -117,6 +117,34 @@ function extractExitCode(result: unknown): number | null { return readNumber(record?.exitCode) ?? readNumber(record?.exit_code) ?? readNumber(record?.code); } +function cursorMcpSource(args: unknown): { + source: NonNullable["mcp"]>; + args: unknown; +} | null { + const record = asRecord(args); + const server = readString(record?.providerIdentifier); + const tool = readString(record?.toolName); + if (!server && !tool) return null; + return { + source: { server: server ?? "cursor-mcp", tool: tool ?? "tool" }, + args: record?.args ?? {}, + }; +} + +function cursorMcpResultFailed(result: unknown): boolean { + const record = asRecord(result); + if (readString(record?.status)?.toLowerCase() === "error") return true; + const value = asRecord(record?.value); + return value?.isError === true; +} + +function cursorGeneratedImagePath(result: unknown): string | null { + const record = asRecord(result); + return readString(asRecord(record?.value)?.filePath) + ?? readString(record?.filePath) + ?? null; +} + function extractTextContent(message: unknown): string[] { const record = asRecord(message); const content = asRecord(record?.message)?.content; @@ -244,6 +272,46 @@ export function mapCursorSdkMessageToChatEvents( const result = record.result; const out: AgentChatEvent[] = []; const lowerTool = tool.toLowerCase(); + if (lowerTool === "mcp") { + const mcp = cursorMcpSource(args); + if (mcp) { + const label = `${mcp.source.server}:${mcp.source.tool}`; + if (status === "running") { + return [tagRuntime({ + type: "tool_call" as const, + tool: label, + args: mcp.args, + mcp: mcp.source, + itemId: callId, + turnId, + }, runtime)]; + } + const failed = status === "error" || cursorMcpResultFailed(result); + return [tagRuntime({ + type: "tool_result" as const, + tool: label, + result, + mcp: mcp.source, + itemId: callId, + turnId, + status: failed ? "failed" : "completed", + }, runtime)]; + } + } + if (lowerTool === "generateimage" || lowerTool === "generate_image") { + const input = asRecord(args); + const prompt = readString(input?.description) ?? "Generated image"; + const failed = status === "error" || readString(asRecord(result)?.status)?.toLowerCase() === "error"; + const savedPath = cursorGeneratedImagePath(result); + return [tagRuntime({ + type: "codex_image_generation" as const, + itemId: callId, + turnId, + prompt, + ...(savedPath ? { result: savedPath, savedPath } : {}), + status: status === "running" ? "running" : failed ? "failed" : "completed", + }, runtime)]; + } const command = lowerTool === "shell" || lowerTool === "bash" || lowerTool === "terminal" ? extractCommand(args) : null; diff --git a/apps/desktop/src/main/services/chat/droidSdkEventMapper.test.ts b/apps/desktop/src/main/services/chat/droidSdkEventMapper.test.ts index a2f8a12a3..31c4e4088 100644 --- a/apps/desktop/src/main/services/chat/droidSdkEventMapper.test.ts +++ b/apps/desktop/src/main/services/chat/droidSdkEventMapper.test.ts @@ -92,3 +92,81 @@ describe("mapDroidSdkMessageToChatEvents — AGI mission control", () => { expect(map({ type: "mission_state_changed" })).toEqual([]); }); }); + +describe("mapDroidSdkMessageToChatEvents — structured assistant content", () => { + it("maps assistant image blocks to the shared compact image event and dedupes replay", () => { + const state = createDroidSdkEventMapperState(); + const message = { + type: "create_message", + role: "assistant", + messageId: "message-1", + content: [ + { type: "text", text: "Here is the diagram." }, + { + type: "image", + id: "image-1", + source: { type: "base64", mediaType: "image/png", data: "AAAA" }, + }, + ], + }; + const mapWithState = () => mapDroidSdkMessageToChatEvents(message, { + turnId: "turn-1", + cwd: "/work", + state, + }); + + expect(mapWithState()).toEqual([ + expect.objectContaining({ type: "text", text: "Here is the diagram." }), + { + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "Droid image output", + result: "data:image/png;base64,AAAA", + status: "completed", + }, + ]); + expect(mapWithState()).toEqual([ + expect.objectContaining({ type: "text", text: "Here is the diagram." }), + ]); + }); + + it("keeps large inline images intact for the desktop live preview", () => { + const imageData = "A".repeat(80 * 1024); + + const events = map({ + type: "create_message", + role: "assistant", + messageId: "message-large-image", + content: [{ + type: "image", + id: "image-large", + source: { type: "base64", mediaType: "image/png", data: imageData }, + }], + }); + + expect(events).toEqual([{ + type: "codex_image_generation", + itemId: "image-large", + turnId: "turn-1", + prompt: "Droid image output", + result: `data:image/png;base64,${imageData}`, + status: "completed", + }]); + }); + + it("does not infer MCP identity from generic Droid tool names", () => { + expect(map({ + type: "tool_use", + toolUseId: "tool-1", + toolName: "search_issues", + toolInput: { query: "bug" }, + })).toEqual([{ + type: "tool_call", + tool: "search_issues", + args: { query: "bug" }, + itemId: "tool-1", + turnId: "turn-1", + }]); + }); +}); diff --git a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts index 16e3a8a94..d1a4959d1 100644 --- a/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts +++ b/apps/desktop/src/main/services/chat/droidSdkEventMapper.ts @@ -10,6 +10,7 @@ type SdkRecord = Record; export type DroidSdkEventMapperState = { assistantDeltaItemIds: Set; thinkingDeltaItemIds: Set; + imageItemIds: Set; toolNamesByUseId: Map; latestUsage: { inputTokens?: number; @@ -24,6 +25,7 @@ export function createDroidSdkEventMapperState(): DroidSdkEventMapperState { return { assistantDeltaItemIds: new Set(), thinkingDeltaItemIds: new Set(), + imageItemIds: new Set(), toolNamesByUseId: new Map(), latestUsage: null, }; @@ -93,6 +95,26 @@ function extractTextBlocks(content: unknown): Array<{ text: string; kind: "text" return out; } +function extractImageBlocks(content: unknown): Array<{ data: string; mediaType: string; id?: string; index: number }> { + if (!Array.isArray(content)) return []; + const out: Array<{ data: string; mediaType: string; id?: string; index: number }> = []; + for (const [index, block] of content.entries()) { + const record = asRecord(block); + if (record?.type !== "image") continue; + const source = asRecord(record.source); + const data = readString(source?.data); + const mediaType = readString(source?.mediaType); + if (!data || !mediaType?.toLowerCase().startsWith("image/")) continue; + out.push({ + data, + mediaType, + index, + ...(readString(record.id) ? { id: readString(record.id)! } : {}), + }); + } + return out; +} + function usageFrom(record: SdkRecord | null): DroidSdkEventMapperState["latestUsage"] { if (!record) return null; const inputTokens = readNumber(record.inputTokens); @@ -195,7 +217,8 @@ export function mapDroidSdkMessageToChatEvents( case "create_message": { const role = readString(record.role); if (role !== "assistant") return []; - return extractTextBlocks(record.content).flatMap((block, index): AgentChatEvent[] => { + const messageId = readString(record.messageId) ?? "droid-message"; + const textEvents = extractTextBlocks(record.content).flatMap((block, index): AgentChatEvent[] => { const messageId = readString(record.messageId) ?? `droid-${block.kind === "thinking" ? "thinking" : "text"}`; const itemId = block.id ?? `${messageId}:${block.kind}:${index}`; if (block.kind === "thinking") { @@ -205,6 +228,20 @@ export function mapDroidSdkMessageToChatEvents( if (meta.state.assistantDeltaItemIds.has(itemId)) return []; return [{ type: "text", text: block.text, itemId, turnId }]; }); + const imageEvents = extractImageBlocks(record.content).flatMap((block): AgentChatEvent[] => { + const itemId = block.id ?? `${messageId}:image:${block.index}`; + if (meta.state.imageItemIds.has(itemId)) return []; + meta.state.imageItemIds.add(itemId); + return [{ + type: "codex_image_generation", + itemId, + turnId, + prompt: "Droid image output", + result: `data:${block.mediaType};base64,${block.data}`, + status: "completed", + }]; + }); + return [...textEvents, ...imageEvents]; } case "tool_use": { const toolUseId = readString(record.toolUseId) ?? `droid-tool-${Date.now()}`; diff --git a/apps/desktop/src/main/services/chat/openCodeStructuredActivity.test.ts b/apps/desktop/src/main/services/chat/openCodeStructuredActivity.test.ts new file mode 100644 index 000000000..b31ef2afe --- /dev/null +++ b/apps/desktop/src/main/services/chat/openCodeStructuredActivity.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { mapOpenCodeImagePart } from "./openCodeStructuredActivity"; + +describe("mapOpenCodeImagePart", () => { + it("maps remote image file parts to the shared image output card", () => { + const emittedPartIds = new Set(); + expect(mapOpenCodeImagePart({ + part: { + id: "image-1", + type: "file", + mime: "image/png", + filename: "moon.png", + url: "https://cdn.example.com/moon.png", + }, + turnId: "turn-1", + emittedPartIds, + })).toEqual({ + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "moon.png", + result: "https://cdn.example.com/moon.png", + status: "completed", + }); + }); + + it("retains a local path for desktop open and TUI image targeting", () => { + expect(mapOpenCodeImagePart({ + part: { + id: "image-2", + type: "file", + mime: "image/webp", + url: "file:///tmp/generated%20image.webp", + }, + turnId: "turn-1", + emittedPartIds: new Set(), + })).toEqual(expect.objectContaining({ + result: "file:///tmp/generated%20image.webp", + savedPath: "/tmp/generated image.webp", + status: "completed", + })); + }); + + it("dedupes the same file surfaced as a part and tool attachment", () => { + const emittedPartIds = new Set(); + const part = { + id: "image-3", + type: "file", + mime: "image/png", + url: "data:image/png;base64,AAAA", + }; + expect(mapOpenCodeImagePart({ part, turnId: "turn-1", emittedPartIds })).not.toBeNull(); + expect(mapOpenCodeImagePart({ part, turnId: "turn-1", emittedPartIds })).toBeNull(); + }); + + it("ignores non-image and malformed file parts", () => { + const emittedPartIds = new Set(); + expect(mapOpenCodeImagePart({ + part: { id: "file-1", type: "file", mime: "application/pdf", url: "/tmp/a.pdf" }, + turnId: "turn-1", + emittedPartIds, + })).toBeNull(); + expect(mapOpenCodeImagePart({ + part: { type: "file", mime: "image/png" }, + turnId: "turn-1", + emittedPartIds, + })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/main/services/chat/openCodeStructuredActivity.ts b/apps/desktop/src/main/services/chat/openCodeStructuredActivity.ts new file mode 100644 index 000000000..918b8e8e4 --- /dev/null +++ b/apps/desktop/src/main/services/chat/openCodeStructuredActivity.ts @@ -0,0 +1,56 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import type { AgentChatEvent } from "../../../shared/types"; + +function readRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) + ? value as Record + : null; +} + +function readString(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function localImagePath(url: string): string | null { + if (/^file:/i.test(url)) { + try { + return fileURLToPath(url); + } catch { + return null; + } + } + if (path.isAbsolute(url) || /^[a-z]:[\\/]/i.test(url)) return url; + return null; +} + +/** + * OpenCode models and tools expose generated media as `file` parts. Reuse the + * existing image-generation presentation event (whose name predates provider + * parity) so desktop and TUI get the same compact image output immediately. + */ +export function mapOpenCodeImagePart(args: { + part: unknown; + turnId: string; + emittedPartIds: Set; +}): Extract | null { + const part = readRecord(args.part); + if (part?.type !== "file") return null; + const mime = readString(part.mime); + if (!mime?.toLowerCase().startsWith("image/")) return null; + const itemId = readString(part.id); + const url = readString(part.url); + if (!itemId || !url || args.emittedPartIds.has(itemId)) return null; + args.emittedPartIds.add(itemId); + const filename = readString(part.filename); + const savedPath = localImagePath(url); + return { + type: "codex_image_generation", + itemId, + turnId: args.turnId, + prompt: filename ?? "Generated image", + result: url, + ...(savedPath ? { savedPath } : {}), + status: "completed", + }; +} diff --git a/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts b/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts deleted file mode 100644 index 2e3e1361e..000000000 --- a/apps/desktop/src/main/services/computerUse/syntheticToolResult.test.ts +++ /dev/null @@ -1,170 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { extractArtifactPathsFromArgs, maybeSyntheticToolResult } from "./syntheticToolResult"; - -// --------------------------------------------------------------------------- -// Unit tests for extractArtifactPathsFromArgs -// --------------------------------------------------------------------------- - -describe("extractArtifactPathsFromArgs", () => { - it("extracts a png path from a Bash command string", () => { - const paths = extractArtifactPathsFromArgs({ - command: "screenshot /tmp/proof.png", - }); - expect(paths).toEqual(["/tmp/proof.png"]); - }); - - it("extracts multiple artifact paths from a single command", () => { - const paths = extractArtifactPathsFromArgs({ - command: "cp /home/user/screenshot.png /tmp/trace.zip && cat /var/log/app.log", - }); - expect(paths).toContain("/home/user/screenshot.png"); - expect(paths).toContain("/tmp/trace.zip"); - expect(paths).toContain("/var/log/app.log"); - expect(paths).toHaveLength(3); - }); - - it("extracts image path from Read tool args", () => { - const paths = extractArtifactPathsFromArgs({ - file_path: "/Users/dev/captures/test-result.jpg", - }); - expect(paths).toEqual(["/Users/dev/captures/test-result.jpg"]); - }); - - it("extracts paths from nested object structures", () => { - const paths = extractArtifactPathsFromArgs({ - options: { - output: { - screenshot: "/tmp/nested/shot.webp", - }, - }, - }); - expect(paths).toEqual(["/tmp/nested/shot.webp"]); - }); - - it("extracts paths from arrays", () => { - const paths = extractArtifactPathsFromArgs({ - files: ["/a/b.png", "/c/d.mp4"], - }); - expect(paths).toContain("/a/b.png"); - expect(paths).toContain("/c/d.mp4"); - expect(paths).toHaveLength(2); - }); - - it("ignores relative paths (no leading slash)", () => { - const paths = extractArtifactPathsFromArgs({ - command: "cat relative/path.png", - }); - expect(paths).toEqual([]); - }); - - it("returns empty for args with no artifact paths", () => { - const paths = extractArtifactPathsFromArgs({ - command: "echo hello world", - }); - expect(paths).toEqual([]); - }); - - it("returns empty for non-artifact extensions", () => { - const paths = extractArtifactPathsFromArgs({ - file_path: "/tmp/data.json", - }); - expect(paths).toEqual([]); - }); - - it("deduplicates repeated paths", () => { - const paths = extractArtifactPathsFromArgs({ - a: "/tmp/shot.png", - b: "/tmp/shot.png", - }); - expect(paths).toEqual(["/tmp/shot.png"]); - }); - - it("handles null/undefined/number args gracefully", () => { - expect(extractArtifactPathsFromArgs(null)).toEqual([]); - expect(extractArtifactPathsFromArgs(undefined)).toEqual([]); - expect(extractArtifactPathsFromArgs(42)).toEqual([]); - expect(extractArtifactPathsFromArgs("")).toEqual([]); - }); - - it("respects recursion depth limit", () => { - // Build deeply nested structure (depth > 8) - let obj: any = { path: "/tmp/deep.png" }; - for (let i = 0; i < 12; i++) { - obj = { nested: obj }; - } - // The path is at depth 13 — beyond the limit of 8 - const paths = extractArtifactPathsFromArgs(obj); - expect(paths).toEqual([]); - }); - - it("handles video extensions", () => { - const paths = extractArtifactPathsFromArgs({ - command: "ffmpeg -o /tmp/recording.mp4", - }); - expect(paths).toEqual(["/tmp/recording.mp4"]); - }); - - it("handles trace extensions", () => { - const paths = extractArtifactPathsFromArgs({ - command: "playwright trace /tmp/session.trace", - }); - expect(paths).toEqual(["/tmp/session.trace"]); - }); -}); - -// --------------------------------------------------------------------------- -// Unit tests for maybeSyntheticToolResult -// --------------------------------------------------------------------------- - -describe("maybeSyntheticToolResult", () => { - it("returns null when no artifact paths found", () => { - const result = maybeSyntheticToolResult("Bash", { command: "echo hi" }, "item-1", "turn-1"); - expect(result).toBeNull(); - }); - - it("returns a tool_result event with artifact paths", () => { - const result = maybeSyntheticToolResult( - "Bash", - { command: "screenshot /tmp/proof.png" }, - "item-1", - "turn-1", - ); - expect(result).not.toBeNull(); - expect(result!.type).toBe("tool_result"); - expect(result).toMatchObject({ - type: "tool_result", - tool: "Bash", - itemId: "item-1:synthetic", - turnId: "turn-1", - status: "completed", - }); - // The result object contains the artifact path - expect((result as any).result).toMatchObject({ - artifactPath0: "/tmp/proof.png", - }); - }); - - it("includes multiple artifact paths in the result", () => { - const result = maybeSyntheticToolResult( - "Bash", - { command: "cp /tmp/a.png /tmp/b.mp4" }, - "item-2", - "turn-2", - ); - expect(result).not.toBeNull(); - const resultObj = (result as any).result; - expect(resultObj.artifactPath0).toBe("/tmp/a.png"); - expect(resultObj.artifactPath1).toBe("/tmp/b.mp4"); - }); - - it("appends :synthetic to the itemId to avoid collisions", () => { - const result = maybeSyntheticToolResult( - "Read", - { file_path: "/tmp/shot.png" }, - "orig-id", - undefined, - ); - expect((result as any).itemId).toBe("orig-id:synthetic"); - }); -}); - diff --git a/apps/desktop/src/main/services/computerUse/syntheticToolResult.ts b/apps/desktop/src/main/services/computerUse/syntheticToolResult.ts deleted file mode 100644 index d97f47416..000000000 --- a/apps/desktop/src/main/services/computerUse/syntheticToolResult.ts +++ /dev/null @@ -1,112 +0,0 @@ -/** - * Synthetic tool_result generation for the Claude SDK streaming path. - * - * The Claude SDK executes tools internally and only surfaces `assistant` - * messages in the stream — tool results are opaque. The proof observer relies - * on `tool_result` events to auto-ingest screenshots and other artifacts into - * the proof drawer. - * - * This module scans tool call *args* for artifact-indicating file paths and - * builds a synthetic `tool_result` event that the proof observer can process. - */ - -import path from "node:path"; -import type { AgentChatEvent } from "../../../shared/types/chat"; - -const ARTIFACT_EXT = - "png|jpe?g|webp|gif|bmp|tiff|svg|mp4|webm|mov|avi|mkv|zip|trace|log|txt|ndjson|jsonl"; - -/** Broad match for POSIX, Windows, and quoted paths ending with known artifact extensions. */ -const ARTIFACT_PATH_RE = new RegExp( - [ - "(?:^|[\\s\"'=:,])(", - "(?:\"(?:[^\"\\\\]|\\\\.)+\"|'(?:[^'\\\\]|\\\\.)+'|", - "(?:[a-zA-Z]:[\\\\/]|\\\\\\\\|/)[^\\n\\r\\s\"'<>|*?]+|", - "(?:/[\\w.~\\-\\[\\] ]+/)+[\\w.~-]+)", - `\\.(?:${ARTIFACT_EXT})`, - ")(?:\\b|$)", - ].join(""), - "gi", -); - -function stripQuotes(raw: string): string { - const t = raw.trim(); - if ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'"))) { - return t.slice(1, -1).replace(/\\(.)/g, "$1"); - } - return t; -} - -function isAbsoluteArtifactPath(candidate: string): boolean { - const p = stripQuotes(candidate).trim(); - if (!p.length) return false; - if (path.posix.isAbsolute(p)) return true; - if (path.win32.isAbsolute(p)) return true; - return /^\\\\/.test(p); -} - -/** - * Extract file paths that look like artifacts from an arbitrary args value. - * Returns an array of unique absolute paths found. - */ -export function extractArtifactPathsFromArgs(args: unknown): string[] { - const paths = new Set(); - - function scan(value: unknown, depth: number): void { - if (depth > 8) return; - if (typeof value === "string") { - ARTIFACT_PATH_RE.lastIndex = 0; - let match: RegExpExecArray | null; - while ((match = ARTIFACT_PATH_RE.exec(value)) !== null) { - const raw = match[1]?.trim(); - if (!raw) continue; - const p = stripQuotes(raw); - if (isAbsoluteArtifactPath(p)) paths.add(p); - } - return; - } - if (Array.isArray(value)) { - for (const item of value) scan(item, depth + 1); - return; - } - if (value != null && typeof value === "object") { - for (const v of Object.values(value as Record)) { - scan(v, depth + 1); - } - } - } - - scan(args, 0); - return Array.from(paths); -} - -/** - * Build a synthetic `tool_result` AgentChatEvent from tool call args. - * Returns null if no artifact paths are found in the args. - */ -export function maybeSyntheticToolResult( - toolName: string, - args: unknown, - itemId: string, - turnId: string | undefined, -): AgentChatEvent | null { - const artifactPaths = extractArtifactPathsFromArgs(args); - if (artifactPaths.length === 0) return null; - - // Build a result object that the proof observer's scanners will pick up. - // Use a structure with named fields so the observer can infer kind from - // both the field name and the file extension. - const result: Record = {}; - for (let i = 0; i < artifactPaths.length; i++) { - result[`artifactPath${i}`] = artifactPaths[i]; - } - - return { - type: "tool_result", - tool: toolName, - result, - itemId: `${itemId}:synthetic`, - turnId, - status: "completed", - }; -} diff --git a/apps/desktop/src/main/services/cto/ctoStateService.ts b/apps/desktop/src/main/services/cto/ctoStateService.ts index f7ec60e77..a1712a289 100644 --- a/apps/desktop/src/main/services/cto/ctoStateService.ts +++ b/apps/desktop/src/main/services/cto/ctoStateService.ts @@ -78,7 +78,7 @@ function buildCtoModelSelectionKnowledge(): string[] { ...providerLines, " Local models: Ollama and LM Studio models are discovered at runtime; use their full resolved modelId when spawning chats.", " Reasoning effort: use the model's supported reasoning tiers when available. Do not invent tiers for a model that does not advertise them.", - " IMPORTANT: When the user says 'use opus', 'use sonnet', 'use gpt-5.5', or another short name, resolve it to the current full modelId from ADE's registry before calling spawnChat. Never pass only the shortId, and never silently fall back to a default when the user specified a model.", + " IMPORTANT: When the user says 'use opus', 'use sonnet', 'use sol', or another short name, resolve it to the current full modelId from ADE's registry before calling spawnChat. Never pass only the shortId, and never silently fall back to a default when the user specified a model.", "", ]; } @@ -99,7 +99,7 @@ const IMMUTABLE_CTO_DOCTRINE = [ "- Proactively check project health and recent events to stay aware of the project state", "", "Precision rules:", - "- When the user specifies a model (e.g. 'use opus', 'use gpt-5.5'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.", + "- When the user specifies a model (e.g. 'use opus', 'use sol'), pass the exact modelId to spawnChat or other tools. Never silently fall back to a default.", "- When the user asks to 'start a chat' or 'launch an agent', use spawnChat with the specified model and initial prompt. If the user explicitly asks for a terminal, CLI tool, or shell command, use createTerminal instead — both are valid, just match the intent.", "- All ADE internals are fair game. The user can request any action: launching chats, opening terminals, running CLI tools, spawning agents, managing lanes, etc. Never refuse an action that ADE supports.", "- When the user asks about something you can look up (lane status, PR checks, test results), call the tool first and report facts. Do not guess.", @@ -236,7 +236,7 @@ function buildCtoEnvironmentKnowledge(): string { "## Task Routing (intent → tool mapping)", "", " 'Start a chat' or 'launch an agent' → spawnChat({ modelId, initialPrompt, title }).", - " 'Start a chat with opus/sonnet/gpt-5.5/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", + " 'Start a chat with opus/sonnet/sol/terra/luna/haiku' → spawnChat({ modelId: '', ... }). Always map the name to the full ID.", " 'Check PR status' → getPullRequestStatus.", " 'Start work on [feature]' → create/find a lane, then spawnChat.", " 'Open a terminal' → createTerminal.", diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts index 53f454875..f481917c0 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.test.ts @@ -10,11 +10,19 @@ import { createExternalSessionsService } from "./externalSessionsService"; import { transplantClaudeSession } from "./claudeSessionTransplant"; import { claudeProjectSlugForCwd } from "./discoveryUtils"; +const computerUseMocks = vi.hoisted(() => ({ + resolveCodexComputerUseMcpConfig: vi.fn(async () => null), +})); + vi.mock("node:child_process", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, execFile: vi.fn() }; }); +vi.mock("../../utils/codexComputerUse", () => ({ + resolveCodexComputerUseMcpConfig: computerUseMocks.resolveCodexComputerUseMcpConfig, +})); + const execFileMock = vi.mocked(execFile); let root: string; @@ -44,6 +52,8 @@ function makeImportedChatSummary(sessionId: string): AgentChatSessionSummary { beforeEach(() => { execFileMock.mockReset(); + computerUseMocks.resolveCodexComputerUseMcpConfig.mockReset(); + computerUseMocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-external-service-")); }); diff --git a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts index aa97c5135..a1ad55204 100644 --- a/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts +++ b/apps/desktop/src/main/services/externalSessions/externalSessionsService.ts @@ -25,6 +25,7 @@ import { discoverCodexSessions } from "./discoverCodex"; import { discoverCursorSessions } from "./discoverCursor"; import { discoverDroidSessions } from "./discoverDroid"; import { discoverOpenCodeSessions } from "./discoverOpenCode"; +import { resolveCodexComputerUseMcpConfig } from "../../utils/codexComputerUse"; import { commandArrayToLine, directShellLaunchForCommandLine, @@ -339,14 +340,14 @@ function metadataForImport(args: { } as TerminalResumeMetadata; } -function forkCommandFor(args: { +async function forkCommandFor(args: { provider: ExternalSessionProvider; metadata: TerminalResumeMetadata; targetId: string; model?: string | null; permissionMode?: string | null; transplantedClaude: boolean; -}): string { +}): Promise { if (args.provider === "claude") { const command = buildTrackedCliResumeCommand( { ...args.metadata, targetId: args.targetId }, @@ -364,6 +365,7 @@ function forkCommandFor(args: { { model: args.model, permissionMode: args.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + codexComputerUse: await resolveCodexComputerUseMcpConfig(), }, ); return withCodexNoAltScreen(resume.replace(/\bresume\b/u, "fork")); @@ -700,8 +702,9 @@ export function createExternalSessionsService(args: ExternalSessionsServiceArgs) ? buildTrackedCliResumeCommand(metadata, { model: importArgs.model, permissionMode: importArgs.permissionMode as TerminalResumeMetadata["launch"]["permissionMode"], + ...(provider === "codex" ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } : {}), }) - : forkCommandFor({ + : await forkCommandFor({ provider, metadata, targetId: launchTargetId, diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index a1210e428..5ce149e96 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -279,6 +279,8 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, AgentChatListArgs, AgentChatModelInfo, AgentChatModelsArgs, @@ -6212,6 +6214,14 @@ export function registerIpc({ await ctx.agentChatService.interrupt(arg); }); + ipcMain.handle(IPC.agentChatRecoverCodexTurn, async ( + _event, + arg: AgentChatRecoverCodexTurnArgs, + ): Promise => { + const ctx = ensureAgentChatContext(); + return await ctx.agentChatService.recoverCodexTurn(arg); + }); + ipcMain.handle(IPC.agentChatApprove, async (_event, arg: AgentChatApproveArgs): Promise => { const ctx = ensureAgentChatContext(); await ctx.agentChatService.approveToolUse(arg); diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index c023390c2..7c532fdda 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -130,6 +130,11 @@ const mocks = vi.hoisted(() => { parseTrackedCliLaunchConfig: vi.fn(() => null), runtimeStateFromOsc133Chunk: vi.fn(() => "running"), resolveOpenCodeBinaryPath: vi.fn<[], string | null>(() => null), + resolveCodexComputerUseMcpConfig: vi.fn(async (): Promise<{ + command: string; + args: ["mcp"]; + enabled: true; + } | null> => null), execFileSync: vi.fn((_file?: unknown, _args?: unknown) => ""), spawnSync: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })), }; @@ -211,6 +216,10 @@ vi.mock("../opencode/openCodeBinaryManager", () => ({ resolveOpenCodeBinaryPath: mocks.resolveOpenCodeBinaryPath, })); +vi.mock("../../utils/codexComputerUse", () => ({ + resolveCodexComputerUseMcpConfig: mocks.resolveCodexComputerUseMcpConfig, +})); + import { createPtyService, ensureNodePtySpawnHelperExecutable, @@ -413,6 +422,7 @@ describe("ptyService", () => { mocks.extractResumeCommandFromOutput.mockReturnValue(null); mocks.derivePreviewFromChunk.mockReturnValue({ nextLine: "", preview: "preview" }); mocks.resolveOpenCodeBinaryPath.mockReturnValue(null); + mocks.resolveCodexComputerUseMcpConfig.mockResolvedValue(null); mocks.spawnSync.mockReturnValue({ status: 1, stdout: "", stderr: "" }); }); @@ -2615,6 +2625,53 @@ describe("ptyService", () => { expect(mockPty.write).not.toHaveBeenCalled(); }); + it("includes the asynchronously resolved Computer Use MCP config in Codex resume args", async () => { + const { service, sessionService, loadPty } = createHarness(); + const command = "/Applications/Codex Computer Use.app/Contents/MacOS/SkyComputerUseClient"; + mocks.resolveCodexComputerUseMcpConfig.mockResolvedValueOnce({ + command, + args: ["mcp"], + enabled: true, + }); + sessionService.create({ + sessionId: "session-codex-computer-use", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "Codex CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-codex-computer-use.log", + toolType: "codex", + resumeCommand: "codex resume thread-computer-use", + resumeMetadata: { + provider: "codex", + targetKind: "thread", + targetId: "thread-computer-use", + launch: { permissionMode: "default" }, + }, + }); + sessionService.end({ + sessionId: "session-codex-computer-use", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + await service.resumeSession({ + sessionId: "session-codex-computer-use", + cols: 120, + rows: 40, + }); + + expect(mocks.resolveCodexComputerUseMcpConfig).toHaveBeenCalledTimes(1); + const spawn = (loadPty.mock.results[0]?.value as any).spawn; + const startupCommand = spawn.mock.calls[0]?.[1]?.[3] as string; + expect(startupCommand).toContain("mcp_servers.computer_use.command"); + expect(startupCommand).toContain("mcp_servers.computer_use.args"); + expect(startupCommand).toContain("mcp_servers.computer_use.enabled=true"); + expect(startupCommand).toContain("SkyComputerUseClient"); + }); + it("sendToSession launches resumed Codex with the prompt argument when the composer is visible", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index 13f6a9b91..6c1f89f70 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -15,6 +15,10 @@ import type { createSessionService } from "../sessions/sessionService"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; import type { createAiIntegrationService } from "../ai/aiIntegrationService"; import type { createProjectConfigService } from "../config/projectConfigService"; +import { + resolveCodexComputerUseMcpConfig, + type CodexComputerUseMcpConfig, +} from "../../utils/codexComputerUse"; import { runGit } from "../git/git"; import { resolveOpenCodeBinaryPath } from "../opencode/openCodeBinaryManager"; import { resolveCliSpawnInvocation } from "../shared/processExecution"; @@ -3428,6 +3432,7 @@ export function createPtyService({ session: TerminalSessionSummary, provider: TerminalResumeProvider, overrides: ReturnType & { prompt?: string | null }, + codexComputerUse: CodexComputerUseMcpConfig | null = null, ): { command: string | null; promptAtLaunch: boolean } => { const prompt = typeof overrides.prompt === "string" && overrides.prompt.trim().length ? overrides.prompt @@ -3444,7 +3449,9 @@ export function createPtyService({ : null); const metadataOverrides = provider === "cursor" ? { ...overrides, prompt: null } - : overrides; + : provider === "codex" + ? { ...overrides, codexComputerUse } + : overrides; const metadataResumeCommand = metadata ? buildTrackedCliResumeCommand(metadata, metadataOverrides) : null; @@ -4315,11 +4322,23 @@ export function createPtyService({ prompt: text, }) : null; + const codexComputerUse = provider === "codex" + ? await resolveCodexComputerUseMcpConfig() + : null; + // Resolve Computer Use before this single-flight snapshot. Once the + // snapshot is taken, command construction and flight creation must stay + // synchronous so a concurrent send cannot also assume its prompt will be + // embedded in the newly launched command. const resumeFlightAlreadyInProgress = resumeRuntimeFlights.has(sessionId); - const builtResume = buildResumeCommandForSession(resumableSession, provider, { + const builtResume = buildResumeCommandForSession( + resumableSession, + provider, + { ...overrides, ...(!openCodeReplayCommand && !resumeFlightAlreadyInProgress ? { prompt: text } : {}), - }); + }, + codexComputerUse, + ); const promptAtLaunch = !openCodeReplayCommand && !resumeFlightAlreadyInProgress && builtResume.promptAtLaunch; const resumeCommand = openCodeReplayCommand ?? builtResume.command; if (!resumeCommand) { @@ -4364,7 +4383,15 @@ export function createPtyService({ } const { session: resumableSession, provider } = await resolveEndedResumeSession(sessionId, session); - const { command: resumeCommand } = buildResumeCommandForSession(resumableSession, provider, resumeLaunchOverrides(args)); + const codexComputerUse = provider === "codex" + ? await resolveCodexComputerUseMcpConfig() + : null; + const { command: resumeCommand } = buildResumeCommandForSession( + resumableSession, + provider, + resumeLaunchOverrides(args), + codexComputerUse, + ); if (!resumeCommand) { throw new Error(`Terminal session '${sessionId}' does not have a resume command.`); } @@ -4475,7 +4502,12 @@ export function createPtyService({ } const resumeCommand = session.resumeMetadata - ? buildTrackedCliResumeCommand(session.resumeMetadata) + ? buildTrackedCliResumeCommand( + session.resumeMetadata, + session.resumeMetadata.provider === "codex" + ? { codexComputerUse: await resolveCodexComputerUseMcpConfig() } + : {}, + ) : normalizeResumeCommand(session.resumeCommand, session.toolType); if (!resumeCommand) { throw new Error(`Chat CLI session '${chatSessionId}' has no resume command available.`); diff --git a/apps/desktop/src/main/services/sync/syncHostService.test.ts b/apps/desktop/src/main/services/sync/syncHostService.test.ts index 526cef6e2..ab3d99b74 100644 --- a/apps/desktop/src/main/services/sync/syncHostService.test.ts +++ b/apps/desktop/src/main/services/sync/syncHostService.test.ts @@ -24,18 +24,20 @@ import type { SyncPinStore } from "./syncPinStore"; import { encodeSyncEnvelope, parseSyncEnvelope } from "./syncProtocol"; import type { ParsedSyncEnvelope } from "./syncProtocol"; -const { execFileMock, spawnMock } = vi.hoisted(() => ({ +const { execFileMock, spawnMock, spawnSyncMock } = vi.hoisted(() => ({ execFileMock: vi.fn(), spawnMock: vi.fn(() => ({ kill: vi.fn(), once: vi.fn(), unref: vi.fn(), })), + spawnSyncMock: vi.fn(() => ({ status: 1, stdout: "", stderr: "" })), })); vi.mock("node:child_process", () => ({ execFile: execFileMock, spawn: spawnMock, + spawnSync: spawnSyncMock, })); function createStubPinStore(initialPin: string | null = null): SyncPinStore { @@ -2423,6 +2425,74 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { expect((eventA.payload as { sessionId: string; event: { text: string } }).event.text).toBe("hello from session 1"); expect((eventB.payload as { sessionId: string }).sessionId).toBe("session-1"); + const inlineImage = `data:image/png;base64,${"A".repeat(80 * 1024)}`; + const desktopImageEvent = { + sessionId: "session-1", + timestamp: "2026-03-17T00:10:00.500Z", + event: { + type: "codex_image_generation" as const, + itemId: "droid-image-1", + result: inlineImage, + status: "completed" as const, + }, + sequence: 2, + }; + chatService.emit(desktopImageEvent); + + const imageEventA = await clientA.queue.next("chat_event"); + const imageEventB = await clientB.queue.next("chat_event"); + for (const imageEvent of [imageEventA, imageEventB]) { + expect((imageEvent.payload as { + event: { result: string | null; resultOriginalBytes?: number; resultOmittedBytes?: number }; + }).event).toMatchObject({ + result: null, + resultOriginalBytes: Buffer.byteLength(inlineImage, "utf8"), + resultOmittedBytes: Buffer.byteLength(inlineImage, "utf8"), + }); + expect(JSON.stringify(imageEvent.payload)).not.toContain("A".repeat(1024)); + } + expect(desktopImageEvent.event.result).toBe(inlineImage); + + const nestedToolResult = { + output: { + preview: inlineImage, + message: "desktop keeps the full preview", + }, + }; + const desktopToolEvent = { + sessionId: "session-1", + timestamp: "2026-03-17T00:10:00.750Z", + event: { + type: "tool_result" as const, + tool: "mcp__images__generate", + itemId: "image-tool-result-1", + result: nestedToolResult, + status: "completed" as const, + }, + sequence: 3, + }; + chatService.emit(desktopToolEvent); + + const toolEventA = await clientA.queue.next("chat_event"); + const toolEventB = await clientB.queue.next("chat_event"); + for (const toolEvent of [toolEventA, toolEventB]) { + const payload = toolEvent.payload as { + event: { + result: { output: { preview: string; message: string } }; + resultOriginalBytes?: number; + resultOmittedBytes?: number; + }; + }; + expect(payload.event.result.output).toMatchObject({ + preview: expect.stringContaining("Inline image data omitted from mobile chat sync"), + message: "desktop keeps the full preview", + }); + expect(payload.event.resultOmittedBytes).toBe(Buffer.byteLength(inlineImage, "utf8")); + expect(JSON.stringify(toolEvent.payload)).not.toContain("A".repeat(1024)); + } + expect(desktopToolEvent.event.result).toBe(nestedToolResult); + expect(nestedToolResult.output.preview).toBe(inlineImage); + chatService.emit({ sessionId: "session-2", timestamp: "2026-03-17T00:10:01.000Z", @@ -2443,7 +2513,7 @@ describe.skipIf(!isCrsqliteAvailable())("syncHostService", () => { sessionId: "session-1", timestamp: "2026-03-17T00:10:02.000Z", event: { type: "text", text: "still live for A only", turnId: "turn-3", itemId: "item-3" }, - sequence: 3, + sequence: 4, }); const replayA = await clientA.queue.next("chat_event"); diff --git a/apps/desktop/src/main/services/usage/usagePricing.ts b/apps/desktop/src/main/services/usage/usagePricing.ts index 4617dbdb9..6b0eeddf3 100644 --- a/apps/desktop/src/main/services/usage/usagePricing.ts +++ b/apps/desktop/src/main/services/usage/usagePricing.ts @@ -55,6 +55,9 @@ const STATIC_TOKEN_PRICES: Record = { "claude-opus": tokenPrice(5, 25), "claude-sonnet": tokenPrice(3, 15), "claude-haiku": tokenPrice(1, 5), + "gpt-5.6-sol": tokenPrice(5, 30, 0.5), + "gpt-5.6-terra": tokenPrice(2.5, 15, 0.25), + "gpt-5.6-luna": tokenPrice(1, 6, 0.1), "gpt-5.5-pro": tokenPrice(30, 180, 3), "gpt-5.5": tokenPrice(5, 30, 0.5), "gpt-5.4-pro": tokenPrice(30, 180, 3), @@ -97,6 +100,9 @@ const STATIC_TOKEN_PRICES: Record = { const BUILTIN_PRICING_ALIASES: Record = { auto: "claude-sonnet-5", fable: "claude-fable-5", + sol: "gpt-5.6-sol", + terra: "gpt-5.6-terra", + luna: "gpt-5.6-luna", "anthropic--claude-4.6-opus": "claude-opus-4-6", "anthropic--claude-4.6-sonnet": "claude-sonnet-5", "anthropic--claude-4.5-opus": "claude-opus-4-5", diff --git a/apps/desktop/src/main/utils/codexComputerUse.test.ts b/apps/desktop/src/main/utils/codexComputerUse.test.ts new file mode 100644 index 000000000..b7d2c596b --- /dev/null +++ b/apps/desktop/src/main/utils/codexComputerUse.test.ts @@ -0,0 +1,193 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const mocks = vi.hoisted(() => ({ + execFile: vi.fn(), +})); + +vi.mock("node:child_process", () => ({ + execFile: mocks.execFile, +})); + +import { + codexComputerUseClientCandidates, + codexComputerUseOptedIn, + isOpenAiSignedComputerUseClient, + resolveCodexComputerUseMcpConfig, +} from "./codexComputerUse"; + +const tempRoots: string[] = []; + +function makeTempRoot(): string { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-codex-cu-")); + tempRoots.push(root); + return root; +} + +function makeExecutable(filePath: string): void { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "#!/bin/sh\n", { mode: 0o755 }); +} + +afterEach(() => { + mocks.execFile.mockReset(); + for (const root of tempRoots.splice(0)) { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function mockValidCodesign(): void { + mocks.execFile.mockImplementation(( + _file: string, + args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const details = args[0] === "-dv" + ? "Identifier=com.openai.sky.CUAService.cli\nTeamIdentifier=2DC432GLL2\n" + : ""; + queueMicrotask(() => callback(null, "", details)); + return {}; + }); +} + +describe("codexComputerUseOptedIn", () => { + it("requires the bundled plugin to be explicitly enabled", () => { + expect(codexComputerUseOptedIn(` +[plugins."computer-use@openai-bundled"] +enabled = true +`)).toBe(true); + expect(codexComputerUseOptedIn(` +[plugins."computer-use@openai-bundled"] +enabled = false +`)).toBe(false); + expect(codexComputerUseOptedIn("[plugins.\"browser@openai-bundled\"]\nenabled = true\n")).toBe(false); + }); + + it("accepts an enabled canonical computer_use MCP section", () => { + expect(codexComputerUseOptedIn(` +[mcp_servers.computer_use] +command = "/signed/client" +args = ["mcp"] +`)).toBe(true); + expect(codexComputerUseOptedIn(` +[mcp_servers.computer_use] +enabled = false +`)).toBe(false); + }); +}); + +describe("resolveCodexComputerUseMcpConfig", () => { + it("returns no config off macOS or without user opt-in", async () => { + const root = makeTempRoot(); + const stable = codexComputerUseClientCandidates(root)[0]!; + makeExecutable(stable); + await expect(resolveCodexComputerUseMcpConfig({ + platform: "linux", + codexHome: root, + configText: '[plugins."computer-use@openai-bundled"]\nenabled = true', + verifySignature: () => true, + })).resolves.toBeNull(); + await expect(resolveCodexComputerUseMcpConfig({ + platform: "darwin", + codexHome: root, + configText: "", + verifySignature: () => true, + })).resolves.toBeNull(); + }); + + it("prefers the stable signed client path", async () => { + const root = makeTempRoot(); + const stable = codexComputerUseClientCandidates(root)[0]!; + makeExecutable(stable); + await expect(resolveCodexComputerUseMcpConfig({ + platform: "darwin", + codexHome: root, + configText: '[plugins."computer-use@openai-bundled"]\nenabled = true', + verifySignature: () => true, + })).resolves.toEqual({ command: stable, args: ["mcp"], enabled: true }); + }); + + it("falls back to the newest executable cached client", async () => { + const root = makeTempRoot(); + const cacheRoot = path.join(root, "plugins", "cache", "openai-bundled", "computer-use"); + const relative = path.relative(root, codexComputerUseClientCandidates(root)[0]!) + .replace(/^computer-use[\\/]/, ""); + const oldClient = path.join(cacheRoot, "1.0.9", relative); + const newestClient = path.join(cacheRoot, "1.0.10", relative); + makeExecutable(oldClient); + makeExecutable(newestClient); + await expect(resolveCodexComputerUseMcpConfig({ + platform: "darwin", + codexHome: root, + configText: '[plugins."computer-use@openai-bundled"]\nenabled = true', + verifySignature: () => true, + })).resolves.toMatchObject({ command: newestClient }); + }); + + it("rejects executable clients that fail OpenAI signature verification", async () => { + const root = makeTempRoot(); + makeExecutable(codexComputerUseClientCandidates(root)[0]!); + await expect(resolveCodexComputerUseMcpConfig({ + platform: "darwin", + codexHome: root, + configText: '[plugins."computer-use@openai-bundled"]\nenabled = true', + verifySignature: () => false, + })).resolves.toBeNull(); + }); + + it("coalesces concurrent cold signature verification for one binary", async () => { + const root = makeTempRoot(); + const client = codexComputerUseClientCandidates(root)[0]!; + makeExecutable(client); + mockValidCodesign(); + + const [first, second] = await Promise.all([ + isOpenAiSignedComputerUseClient(client), + isOpenAiSignedComputerUseClient(client), + ]); + + expect(first).toBe(true); + expect(second).toBe(true); + expect(mocks.execFile).toHaveBeenCalledTimes(2); + }); + + it("fails closed when codesign errors or times out", async () => { + const root = makeTempRoot(); + const client = codexComputerUseClientCandidates(root)[0]!; + makeExecutable(client); + mocks.execFile.mockImplementation(( + _file: string, + _args: string[], + _options: unknown, + callback: (error: Error | null, stdout: string, stderr: string) => void, + ) => { + const error = Object.assign(new Error("codesign timed out"), { + code: "ETIMEDOUT", + killed: true, + }); + queueMicrotask(() => callback(error, "", "")); + return {}; + }); + + await expect(isOpenAiSignedComputerUseClient(client)).resolves.toBe(false); + expect(mocks.execFile).toHaveBeenCalledTimes(2); + }); + + it("re-verifies when the binary fingerprint changes", async () => { + const root = makeTempRoot(); + const client = codexComputerUseClientCandidates(root)[0]!; + makeExecutable(client); + mockValidCodesign(); + + await expect(isOpenAiSignedComputerUseClient(client)).resolves.toBe(true); + expect(mocks.execFile).toHaveBeenCalledTimes(2); + + fs.appendFileSync(client, "# upgraded\n"); + + await expect(isOpenAiSignedComputerUseClient(client)).resolves.toBe(true); + expect(mocks.execFile).toHaveBeenCalledTimes(4); + }); +}); diff --git a/apps/desktop/src/main/utils/codexComputerUse.ts b/apps/desktop/src/main/utils/codexComputerUse.ts new file mode 100644 index 000000000..2e12329dc --- /dev/null +++ b/apps/desktop/src/main/utils/codexComputerUse.ts @@ -0,0 +1,215 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; + +export type CodexComputerUseMcpConfig = { + command: string; + args: ["mcp"]; + enabled: true; +}; + +export type ResolveCodexComputerUseOptions = { + platform?: NodeJS.Platform; + codexHome?: string; + configText?: string | null; + verifySignature?: (filePath: string) => boolean | Promise; +}; + +const COMPUTER_USE_PLUGIN_SECTION = 'plugins."computer-use@openai-bundled"'; +const COMPUTER_USE_MCP_SECTION = "mcp_servers.computer_use"; +const OPENAI_TEAM_IDENTIFIER = "2DC432GLL2"; +const COMPUTER_USE_CLIENT_IDENTIFIER = "com.openai.sky.CUAService.cli"; +const CLIENT_RELATIVE_PATH = path.join( + "Codex Computer Use.app", + "Contents", + "SharedSupport", + "SkyComputerUseClient.app", + "Contents", + "MacOS", + "SkyComputerUseClient", +); + +function readSectionBody(configText: string, sectionName: string): string | null { + const lines = configText.replace(/\r\n?/g, "\n").split("\n"); + let active = false; + const body: string[] = []; + for (const line of lines) { + const header = line.match(/^\s*\[([^\]]+)]\s*(?:#.*)?$/)?.[1]?.trim(); + if (header != null) { + if (active) break; + active = header === sectionName; + continue; + } + if (active) body.push(line); + } + return active ? body.join("\n") : null; +} + +function enabledValue(sectionBody: string): boolean | null { + const match = sectionBody.match(/^\s*enabled\s*=\s*(true|false)\s*(?:#.*)?$/im); + if (!match) return null; + return match[1]?.toLowerCase() === "true"; +} + +/** + * Computer Use is powerful enough that ADE never enables it merely because a + * cached binary happens to exist. The user must have enabled the bundled + * plugin, or explicitly configured the canonical `computer_use` MCP server. + */ +export function codexComputerUseOptedIn(configText: string): boolean { + const plugin = readSectionBody(configText, COMPUTER_USE_PLUGIN_SECTION); + if (plugin != null && enabledValue(plugin) === true) return true; + + const mcp = readSectionBody(configText, COMPUTER_USE_MCP_SECTION); + if (mcp == null) return false; + // MCP servers are enabled by default when configured. An explicit false is + // authoritative; otherwise the section itself is the user's opt-in. + return enabledValue(mcp) !== false; +} + +function isExecutable(filePath: string): boolean { + try { + fs.accessSync(filePath, fs.constants.X_OK); + return fs.statSync(filePath).isFile(); + } catch { + return false; + } +} + +type CodesignExecutionResult = { + ok: boolean; + stdout: string; + stderr: string; +}; + +function executeCodesign(args: string[]): Promise { + return new Promise((resolve) => { + try { + execFile( + "/usr/bin/codesign", + args, + { encoding: "utf8", timeout: 5_000, maxBuffer: 1024 * 1024 }, + (error, stdout, stderr) => { + resolve({ + ok: error == null, + stdout: String(stdout ?? ""), + stderr: String(stderr ?? ""), + }); + }, + ); + } catch { + resolve({ ok: false, stdout: "", stderr: "" }); + } + }); +} + +function signatureVerificationFingerprint(filePath: string): string | null { + try { + const stat = fs.statSync(filePath); + // Include filesystem identity and change metadata, not just size/mtime. + // A replaced binary can preserve its size and mtime; inode/ctime ensure the + // replacement is verified again before ADE passes it to Codex. + return [ + filePath, + stat.dev, + stat.ino, + stat.mode, + stat.size, + stat.mtimeMs, + stat.ctimeMs, + ].join("\0"); + } catch { + return null; + } +} + +// Cache the in-flight Promise as well as the settled result. Concurrent launch +// and resume requests for the same binary must share one cold codesign pass. +const signatureVerificationCache = new Map>(); + +export async function isOpenAiSignedComputerUseClient(filePath: string): Promise { + const cacheKey = signatureVerificationFingerprint(filePath); + if (!cacheKey) return false; + const cached = signatureVerificationCache.get(cacheKey); + if (cached) return await cached; + + // Keep only the current fingerprint for this path so plugin upgrades do not + // accumulate stale verification entries for the lifetime of the runtime. + const pathPrefix = `${filePath}\0`; + for (const key of signatureVerificationCache.keys()) { + if (key !== cacheKey && key.startsWith(pathPrefix)) { + signatureVerificationCache.delete(key); + } + } + + const pending = (async (): Promise => { + const [verification, identity] = await Promise.all([ + executeCodesign(["--verify", "--strict", "--verbose=2", filePath]), + executeCodesign(["-dv", "--verbose=4", filePath]), + ]); + const details = `${identity.stdout}\n${identity.stderr}`; + return verification.ok + && identity.ok + && details.includes(`Identifier=${COMPUTER_USE_CLIENT_IDENTIFIER}`) + && details.includes(`TeamIdentifier=${OPENAI_TEAM_IDENTIFIER}`); + })().catch(() => false); + signatureVerificationCache.set(cacheKey, pending); + return await pending; +} + +function compareVersionDirectoryNames(left: string, right: string): number { + const leftParts = left.split(/[^0-9]+/).filter(Boolean).map(Number); + const rightParts = right.split(/[^0-9]+/).filter(Boolean).map(Number); + const length = Math.max(leftParts.length, rightParts.length); + for (let index = 0; index < length; index += 1) { + const delta = (leftParts[index] ?? 0) - (rightParts[index] ?? 0); + if (delta !== 0) return delta; + } + return left.localeCompare(right); +} + +export function codexComputerUseClientCandidates(codexHome: string): string[] { + const candidates = [path.join(codexHome, "computer-use", CLIENT_RELATIVE_PATH)]; + const cacheRoot = path.join(codexHome, "plugins", "cache", "openai-bundled", "computer-use"); + try { + const versions = fs.readdirSync(cacheRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name) + .sort((left, right) => compareVersionDirectoryNames(right, left)); + for (const version of versions) { + candidates.push(path.join(cacheRoot, version, CLIENT_RELATIVE_PATH)); + } + } catch { + // The stable installation path above can still be available without a + // plugin cache. Missing cache directories are expected on first install. + } + return candidates; +} + +export async function resolveCodexComputerUseMcpConfig( + options: ResolveCodexComputerUseOptions = {}, +): Promise { + if ((options.platform ?? process.platform) !== "darwin") return null; + const codexHome = options.codexHome?.trim() + || process.env.CODEX_HOME?.trim() + || path.join(os.homedir(), ".codex"); + let configText = options.configText; + if (configText === undefined) { + try { + configText = fs.readFileSync(path.join(codexHome, "config.toml"), "utf8"); + } catch { + configText = null; + } + } + if (!configText || !codexComputerUseOptedIn(configText)) return null; + + const verifySignature = options.verifySignature ?? isOpenAiSignedComputerUseClient; + for (const command of codexComputerUseClientCandidates(codexHome)) { + if (!isExecutable(command)) continue; + if (await verifySignature(command)) { + return { command, args: ["mcp"], enabled: true }; + } + } + return null; +} diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index 855986280..434c9eb38 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -373,6 +373,26 @@ describe("terminalSessionSignals", () => { }); }); + it("preserves Codex resume targets after quoted Computer Use config values with spaces", () => { + const command = buildTrackedCliResumeCommand({ + provider: "codex", + targetKind: "thread", + targetId: "thread_computer_use_123", + launch: { permissionMode: "default" }, + }, { + codexComputerUse: { + command: "/Applications/Codex Computer Use.app/Contents/MacOS/Sky Computer Use Client", + args: ["mcp", "--profile", "ADE Computer Use"], + }, + }); + + expect(command).toContain("Codex Computer Use.app"); + expect(parseTrackedCliResumeCommand(command, "codex")).toEqual({ + provider: "codex", + targetId: "thread_computer_use_123", + }); + }); + it("builds OpenCode run replay resume commands with question tool enabled", () => { const command = buildOpenCodeReplayResumeCommand({ permissionMode: "edit", diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index e65196665..8f49de5e7 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -342,10 +342,18 @@ function parseProviderResumeTarget(provider: TerminalResumeProvider, command: st } if (provider === "codex") { - const match = command.match(/^codex(?:(?:\s+--no-alt-screen)|(?:\s+--full-auto)|(?:\s+--dangerously-bypass-approvals-and-sandbox)|(?:\s+--yolo)|(?:\s+--sandbox\s+[^\s]+)|(?:\s+-s\s+[^\s]+)|(?:\s+--ask-for-approval\s+[^\s]+)|(?:\s+-a\s+[^\s]+)|(?:\s+-c\s+[^\s]+))*\s+resume(?:\s+([^\s]+))?(?:\s|$)/i); - if (!match) return undefined; - if (match[1] == null) return null; - return sanitizeResumeTargetId(match[1]) ?? undefined; + let parts: string[]; + try { + parts = parseCommandLine(command); + } catch { + return undefined; + } + if (parts[0]?.toLowerCase() !== "codex") return undefined; + const resumeIndex = parts.findIndex((part, index) => index > 0 && part.toLowerCase() === "resume"); + if (resumeIndex < 0) return undefined; + const raw = parts[resumeIndex + 1]; + if (raw == null) return null; + return sanitizeResumeTargetId(raw) ?? undefined; } if (provider === "cursor") { @@ -398,6 +406,7 @@ export function buildTrackedCliResumeCommand( fastMode?: boolean | null; permissionMode?: AgentChatPermissionMode | null; prompt?: string | null; + codexComputerUse?: { command: string; args?: readonly string[] } | null; } = {}, ): string | null { if (!metadata) return null; diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 45b09d307..464dfe591 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -110,6 +110,8 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, AgentChatListArgs, AgentChatModelCatalog, AgentChatModelCatalogArgs, @@ -1303,6 +1305,9 @@ declare global { args: AgentChatCancelDispatchedSteerArgs, ) => Promise; interrupt: (args: AgentChatInterruptArgs) => Promise; + recoverCodexTurn: ( + args: AgentChatRecoverCodexTurnArgs, + ) => Promise; approve: (args: AgentChatApproveArgs) => Promise; respondToInput: (args: AgentChatRespondToInputArgs) => Promise; models: (args: AgentChatModelsArgs) => Promise; diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index fa290a081..477f172a0 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -286,6 +286,8 @@ import type { AgentChatHandoffArgs, AgentChatHandoffResult, AgentChatInterruptArgs, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, AgentChatListArgs, AgentChatModelCatalog, AgentChatModelCatalogArgs, @@ -1183,6 +1185,7 @@ const MUTATING_CHAT_ACTIONS = new Set([ "respondToInput", "approveToolUse", "interrupt", + "recoverCodexTurn", "steer", "cancelSteer", "editSteer", @@ -5173,6 +5176,19 @@ contextBridge.exposeInMainWorld("ade", { await ipcRenderer.invoke(IPC.agentChatInterrupt, args); agentChatSummaryCache.clear(); }, + recoverCodexTurn: async ( + args: AgentChatRecoverCodexTurnArgs, + ): Promise => { + agentChatSummaryCache.clear(); + const result = await callProjectRuntimeActionOr( + "chat", + "recoverCodexTurn", + { args }, + () => ipcRenderer.invoke(IPC.agentChatRecoverCodexTurn, args), + ); + agentChatSummaryCache.clear(); + return result; + }, approve: async (args: AgentChatApproveArgs): Promise => { agentChatSummaryCache.clear(); const runtime = await callProjectRuntimeActionIfBound( diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index ce18d0fac..e180715cb 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -57,7 +57,7 @@ const resolvedArg2 = async (_a: any, _b: any) => v; const DEFAULT_BROWSER_MOCK_CODEX_MODEL = - getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; + getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.6-sol"; const DEFAULT_BROWSER_MOCK_CLAUDE_MODEL = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-5"; const BROWSER_MOCK_PREVIEW_CAPABILITY_UNSUPPORTED = { @@ -4693,6 +4693,17 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { }), cancelDispatchedSteer: resolvedArg({ cancelled: false }), interrupt: resolvedArg(undefined), + recoverCodexTurn: async (args: any) => ({ + action: args.action, + turnId: args.turnId, + status: args.action === "wait" + ? "waiting" + : args.action === "steer" + ? "nudged" + : args.action === "restart_resume_thread" + ? "resumed" + : "retrying", + }), approve: resolvedArg(undefined), respondToInput: resolvedArg(undefined), models: resolvedArg([]), diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 72cac4d46..69e32e969 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -120,6 +120,66 @@ function renderComposer(overrides: Partial; } +const CAPTION_FREE_PERMISSION_CASES: Array<{ + provider: string; + triggerName: string; + optionCount: number; + overrides: Partial>; +}> = [ + { + provider: "Claude", + triggerName: "Claude permission mode", + optionCount: 5, + overrides: { + sessionProvider: "claude", + modelId: "anthropic/claude-sonnet-5", + availableModelIds: ["anthropic/claude-sonnet-5"], + }, + }, + { + provider: "Codex", + triggerName: "Codex permission mode", + optionCount: 5, + overrides: { sessionProvider: "codex" }, + }, + { + provider: "Cursor", + triggerName: "Cursor mode", + optionCount: 4, + overrides: { + sessionProvider: "cursor", + modelId: "cursor/auto", + availableModelIds: ["cursor/auto"], + cursorModeSnapshot: { + currentModeId: "agent", + availableModeIds: ["agent", "ask", "plan", "full-auto"], + }, + onCursorModeChange: vi.fn(), + }, + }, + { + provider: "Droid", + triggerName: "Droid autonomy mode", + optionCount: 5, + overrides: { + sessionProvider: "droid", + modelId: "droid/gpt-5.2", + availableModelIds: ["droid/gpt-5.2"], + onDroidPermissionModeChange: vi.fn(), + }, + }, + { + provider: "OpenCode", + triggerName: "OpenCode permission mode", + optionCount: 4, + overrides: { + sessionProvider: "opencode", + modelId: "opencode/openai/gpt-5.4", + availableModelIds: ["opencode/openai/gpt-5.4"], + }, + }, +]; + function makeLinearIssue(overrides: Partial = {}): NormalizedLinearIssue { return { id: "issue-1", @@ -518,6 +578,22 @@ describe("AgentChatComposer", () => { expect(screen.getByRole("option", { name: /Bypass/ })).toBeTruthy(); }); + it.each(CAPTION_FREE_PERMISSION_CASES)( + "renders title-only $provider permission rows", + ({ triggerName, optionCount, overrides }) => { + renderComposer(overrides); + fireEvent.click(screen.getByRole("button", { name: triggerName })); + + const listbox = screen.getByRole("listbox", { name: triggerName }); + const options = Array.from(listbox.querySelectorAll('[role="option"]')); + expect(options).toHaveLength(optionCount); + for (const option of options) { + expect(option.textContent?.trim()).toBe(option.getAttribute("aria-label")); + expect(option.getAttribute("title")?.length).toBeGreaterThan(0); + } + }, + ); + it("routes Claude auto through the native permission callback", () => { const onInteractionModeChange = vi.fn(); const onClaudePermissionModeChange = vi.fn(); @@ -735,6 +811,89 @@ describe("AgentChatComposer", () => { expect(screen.queryByRole("button", { name: "Codex permission mode" })).toBeNull(); }); + it("uses explicit per-app choices for Computer Use elicitation", () => { + const props = renderComposer({ + pendingInput: { + requestId: "cu-1", + itemId: "mcp-elicitation:computer_use:cu-1", + source: "codex", + kind: "approval", + title: "Computer Use permission", + description: "Allow Codex to use Calculator?", + questions: [], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + mcpElicitation: true, + persistenceSupported: true, + url: "https://example.com/authorize", + }, + turnId: "turn-1", + }, + }); + + expect(screen.getByText("Allow Codex to use Calculator?")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Open authorization" })).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: "Allow once" })); + fireEvent.click(screen.getByRole("button", { name: "Always allow" })); + fireEvent.click(screen.getByRole("button", { name: "Deny" })); + expect(props.onApproval).toHaveBeenNthCalledWith(1, "accept"); + expect(props.onApproval).toHaveBeenNthCalledWith(2, "accept_for_session"); + expect(props.onApproval).toHaveBeenNthCalledWith(3, "decline"); + }); + + it.each([ + "file:///tmp/authorize.html", + "about:blank", + ])("does not expose MCP authorization for non-HTTP(S) URL %s", (url) => { + renderComposer({ + pendingInput: { + requestId: "mcp-auth-invalid-url", + itemId: "mcp-elicitation:computer_use:mcp-auth-invalid-url", + source: "codex", + kind: "approval", + description: "Allow Codex to authenticate this MCP server?", + questions: [], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + mcpElicitation: true, + persistenceSupported: false, + url, + }, + turnId: "turn-1", + }, + }); + + expect(screen.queryByRole("button", { name: "Open authorization" })).toBeNull(); + }); + + it("does not offer persistent Computer Use approval when the server disallows it", () => { + renderComposer({ + pendingInput: { + requestId: "cu-2", + itemId: "mcp-elicitation:computer_use:cu-2", + source: "codex", + kind: "approval", + description: "Allow Codex to use System Settings?", + questions: [], + allowsFreeform: false, + blocking: true, + canProceedWithoutAnswer: false, + providerMetadata: { + mcpElicitation: true, + persistenceSupported: false, + }, + turnId: "turn-1", + }, + }); + + expect(screen.getByRole("button", { name: "Allow once" })).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Always allow" })).toBeNull(); + }); + it("avoids promising option chips when a pending question is freeform only", () => { renderComposer({ pendingInput: { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index d66b0f39c..ac6b5e1c7 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -67,6 +67,7 @@ import { ChatProposedPlanCard } from "./ChatProposedPlanCard"; import { ChatModelSelectionPendingCard } from "./ChatModelSelectionPendingCard"; import { ChatCommandMenu, type ChatCommandMenuItem, type ChatCommandMenuHandle } from "./ChatCommandMenu"; import { modifierKeyLabel } from "../../lib/platform"; +import { canOpenInAdeBrowser, openUrlInAdeBrowser } from "../../lib/openExternal"; import { SmartTooltip } from "../ui/SmartTooltip"; import { VoiceDictationButton } from "./VoiceDictationButton"; import { ProviderLogo } from "../shared/ProviderLogos"; @@ -112,6 +113,17 @@ function ensureVoiceShimmerStyles(): void { document.head.appendChild(sheet); } +function isHttpAuthorizationUrl(value: string): boolean { + const trimmed = value.trim(); + if (!trimmed) return false; + try { + const parsed = new URL(trimmed); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + /** * Best-effort decoder for the `providerMetadata` payload carried on a * `model_selection` PendingInputRequest. The server packs @@ -746,24 +758,19 @@ function PermissionModePicker({ setOpen(false); }} className={cn( - "flex w-full items-start gap-2.5 px-3 py-2 text-left font-sans transition-colors", + "flex w-full items-center gap-2.5 px-3 py-2 text-left font-sans transition-colors", active ? tone.rowActive : "text-fg/72", tone.rowHover, )} title={option.detail} > - + - - - {option.label} - - - {option.detail} - + + {option.label} - {active ? : null} + {active ? : null} ); @@ -3178,6 +3185,13 @@ export function AgentChatComposer({ attachment.type === "linear_issue" ), )?.issue ?? null; + const isMcpElicitation = pendingInput?.providerMetadata?.mcpElicitation === true; + const mcpElicitationSupportsPersistence = pendingInput?.providerMetadata?.persistenceSupported === true; + const mcpElicitationUrl = typeof pendingInput?.providerMetadata?.url === "string" + && isHttpAuthorizationUrl(pendingInput.providerMetadata.url) + && canOpenInAdeBrowser(pendingInput.providerMetadata.url) + ? pendingInput.providerMetadata.url + : null; return ( <> @@ -3295,10 +3309,15 @@ export function AgentChatComposer({
{pendingInput.description ?? pendingInput.questions[0]?.question ?? "The agent is waiting for input."}
-
- - - +
+ {isMcpElicitation && mcpElicitationUrl ? ( + + ) : null} + + {!isMcpElicitation || mcpElicitationSupportsPersistence ? ( + + ) : null} +
) : ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index 2cc60997e..cb0a68eb0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -3,7 +3,12 @@ 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 { AgentChatApprovalDecision, AgentChatEventEnvelope } from "../../../shared/types"; +import type { + AgentChatApprovalDecision, + AgentChatEventEnvelope, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, +} from "../../../shared/types"; import * as modelRegistry from "../../../shared/modelRegistry"; vi.mock("lottie-react", () => ({ @@ -103,6 +108,7 @@ function renderMessageList( sessionId?: string | null; onInsertDraft?: (text: string) => void; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; + onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; }, ) { return render( @@ -114,6 +120,7 @@ function renderMessageList( sessionId={options?.sessionId} onInsertDraft={options?.onInsertDraft} onApproval={options?.onApproval as any} + onCodexRecovery={options?.onCodexRecovery} /> , @@ -744,6 +751,63 @@ describe("AgentChatMessageList transcript rendering", () => { expect(screen.queryByRole("button")).toBeNull(); }); + it("runs Codex stalled-turn recovery actions against the source chat", async () => { + const onCodexRecovery = vi.fn().mockResolvedValue({ + action: "wait", + turnId: "turn-stalled", + status: "waiting", + }); + renderMessageList([ + { + sessionId: "parent-session", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "codex_turn_stalled", + turnId: "turn-stalled", + reason: "no_output", + message: "Codex accepted this turn but has not streamed output yet.", + recoveryOptions: ["wait", "steer", "interrupt_retry_same_thread", "restart_resume_thread"], + sourceSessionId: "child-session", + parentSessionId: "parent-session", + }, + }, + ], { sessionId: "parent-session", onCodexRecovery }); + + fireEvent.click(screen.getByRole("button", { name: "Wait" })); + await waitFor(() => { + expect(onCodexRecovery).toHaveBeenCalledWith({ + sessionId: "child-session", + turnId: "turn-stalled", + action: "wait", + }); + }); + expect(await screen.findByText("Waiting for Codex output…")).toBeTruthy(); + expect(screen.getByRole("button", { name: "Nudge" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Retry" })).toBeTruthy(); + expect(screen.getByRole("button", { name: "Resume" })).toBeTruthy(); + }); + + it("shows a Codex recovery error without making the card inert", async () => { + const onCodexRecovery = vi.fn().mockRejectedValue(new Error("This stalled Codex turn is no longer active.")); + renderMessageList([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "codex_turn_stalled", + turnId: "turn-stalled", + reason: "app_server_state_unknown", + message: "Codex paused unexpectedly.", + recoveryOptions: ["restart_resume_thread"], + }, + }, + ], { sessionId: "session-1", onCodexRecovery }); + + fireEvent.click(screen.getByRole("button", { name: "Resume" })); + expect((await screen.findByRole("alert")).textContent).toContain("no longer active"); + expect((screen.getByRole("button", { name: "Resume" }) as HTMLButtonElement).disabled).toBe(false); + }); + it("keeps non-rate-limit notice details in collapsible cards", () => { renderMessageList([ { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 3f1b6d8b3..ad93210ce 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -38,6 +38,8 @@ import type { AgentChatEvent, AgentChatEventEnvelope, AgentChatNoticeDetail, + AgentChatRecoverCodexTurnArgs, + AgentChatRecoverCodexTurnResult, ChatSurfaceChipTone, FilesWorkspace, ChatSurfaceProfile, @@ -124,6 +126,88 @@ type WorkspacePathLocation = { startColumn?: number; }; +type CodexTurnStalledEvent = Extract; + +function CodexTurnRecoveryCard({ + event, + sessionId, + onRecover, +}: { + event: CodexTurnStalledEvent; + sessionId: string | null | undefined; + onRecover?: (args: AgentChatRecoverCodexTurnArgs) => Promise; +}) { + const [pendingAction, setPendingAction] = useState(null); + const [resultMessage, setResultMessage] = useState(null); + const [errorMessage, setErrorMessage] = useState(null); + const targetSessionId = event.sourceSessionId?.trim() || sessionId?.trim() || ""; + const optionLabels: Record = { + wait: "Wait", + steer: "Nudge", + interrupt_retry_same_thread: "Retry", + restart_resume_thread: "Resume", + }; + const resultLabels: Record = { + waiting: "Waiting for Codex output…", + nudged: "Status nudge sent.", + retrying: "Retry started in this thread.", + resumed: "Codex app-server restarted and the thread resumed.", + }; + + const recover = useCallback(async (action: AgentChatRecoverCodexTurnArgs["action"]) => { + if (!targetSessionId || !onRecover || pendingAction) return; + setPendingAction(action); + setErrorMessage(null); + setResultMessage(null); + try { + const result = await onRecover({ sessionId: targetSessionId, turnId: event.turnId, action }); + setResultMessage(resultLabels[result.status]); + } catch (error) { + setErrorMessage(error instanceof Error ? error.message : String(error)); + } finally { + setPendingAction(null); + } + }, [event.turnId, onRecover, pendingAction, resultLabels, targetSessionId]); + + return ( +
+
+ + recovery + Codex paused unexpectedly +
+
+ {event.message} +
+ {event.recoveryOptions?.length ? ( +
+ {event.recoveryOptions.slice(0, 4).map((option) => ( + + ))} +
+ ) : null} + {resultMessage ? ( +
+ {resultMessage} +
+ ) : null} + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null} +
+ ); +} + function formatDiffCounts(fileCount: number, additions: number, deletions: number): string { const fileLabel = fileCount === 1 ? "file" : "files"; return `${fileCount} ${fileLabel} +${additions} -${deletions}`; @@ -2658,6 +2742,7 @@ function renderEvent( envelope: RenderEnvelope, options?: { onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; + onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; turnModel?: { label: string; modelId?: string; model?: string } | null; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; @@ -3209,35 +3294,12 @@ function renderEvent( } if (event.type === "codex_turn_stalled") { - const optionLabels: Record = { - wait: "Wait", - steer: "Nudge", - interrupt_retry_same_thread: "Retry", - restart_resume_thread: "Resume", - }; return ( -
-
- - recovery - Codex paused unexpectedly -
-
- {event.message} -
- {event.recoveryOptions?.length ? ( -
- {event.recoveryOptions.slice(0, 4).map((option) => ( - - {optionLabels[option] ?? option} - - ))} -
- ) : null} -
+ ); } @@ -4233,6 +4295,7 @@ type EventRowProps = { turnModel: { label: string; modelId?: string; model?: string } | null; turnEndDurationMs?: number | null; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; + onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; @@ -4263,6 +4326,7 @@ const EventRow = React.memo(function EventRow({ turnModel, turnEndDurationMs, onApproval, + onCodexRecovery, surfaceMode = "standard", surfaceProfile = "standard", assistantLabel, @@ -4327,6 +4391,7 @@ const EventRow = React.memo(function EventRow({ ? : renderEvent(envelope as RenderEnvelope, { onApproval, + onCodexRecovery, turnModel, surfaceMode, surfaceProfile, @@ -4651,7 +4716,8 @@ function AgentChatMessageListMain({ events, showStreamingIndicator = false, className, - onApproval, + onApproval, + onCodexRecovery, surfaceMode = "standard", surfaceProfile = "standard", assistantLabel, @@ -4674,6 +4740,7 @@ function AgentChatMessageListMain({ showStreamingIndicator?: boolean; className?: string; onApproval?: (itemId: string, decision: AgentChatApprovalDecision, responseText?: string | null, answers?: Record) => void; + onCodexRecovery?: (args: AgentChatRecoverCodexTurnArgs) => Promise; surfaceMode?: ChatSurfaceMode; surfaceProfile?: ChatSurfaceProfile; assistantLabel?: string; @@ -5437,6 +5504,7 @@ function AgentChatMessageListMain({ turnModel={turnModel} turnEndDurationMs={turnEndDurationMs} onApproval={handleApproval} + onCodexRecovery={onCodexRecovery} surfaceMode={surfaceMode} surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} @@ -5470,6 +5538,7 @@ function AgentChatMessageListMain({ turnDividerLabel={turnDividerLabel} turnModel={turnModel} onApproval={handleApproval} + onCodexRecovery={onCodexRecovery} surfaceMode={surfaceMode} surfaceProfile={surfaceProfile} assistantLabel={assistantLabel} @@ -5493,7 +5562,7 @@ function AgentChatMessageListMain({ anchored={anchored} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); + }, [activeTurnId, anchoredRowKey, assistantLabel, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionEnded, runtimeName, mosaic]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 3441fd00d..3f7ff52a5 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11,6 +11,7 @@ import { type AgentChatCodexApprovalPolicy, type AgentChatCodexConfigSource, type AgentChatCodexSandbox, + type AgentChatRecoverCodexTurnArgs, type AgentChatCursorConfigValue, type AgentChatDroidPermissionMode, type AgentChatExecutionMode, @@ -77,6 +78,7 @@ import { resolveCliProviderForModel, resolveProviderGroupForModel, resolveModelDescriptorForProvider, + selectSupportedReasoningEffort, type LocalProviderFamily, type ModelDescriptor, type ProviderFamily, @@ -131,6 +133,7 @@ import { ModelPicker } from "../shared/ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "../shared/ModelPicker/ReasoningEffortPicker"; import { ConfirmDialog, useConfirmDialog } from "../shared/InlineDialogs"; import { ChatActionsDrawerPanel, type ChatActionsTab } from "./ChatActionsDrawerPanel"; +import { ChatSourcesPanel } from "./ChatSourcesPanel"; import { ChatPrPane } from "./ChatPrPane"; import { useChatPrAutoPop } from "./useChatPrAutoPop"; import { ClaudeLoginPromptButton, createClaudeLoginTerminalInWork } from "../work/ClaudeLoginPromptButton"; @@ -1544,12 +1547,15 @@ function selectReasoningEffort(args: { preferred: string | null; modelId?: string | null; }): string | null { - if (!args.tiers.length) return null; - if (args.preferred && args.tiers.includes(args.preferred)) { - return args.preferred; - } - if (args.modelId?.toLowerCase().includes("fable") && args.tiers.includes("high")) return "high"; - return args.tiers.includes("medium") ? "medium" : args.tiers[0]!; + const descriptor = args.modelId + ? resolveModelDescriptorWithRuntimeCatalog(args.modelId) ?? getModelById(args.modelId) + : undefined; + return selectSupportedReasoningEffort({ + tiers: args.tiers, + preferred: args.preferred, + advertisedDefault: descriptor?.defaultReasoningEffort, + fallback: args.modelId?.toLowerCase().includes("fable") ? "high" : null, + }); } function resolveAssistantLabel( @@ -2702,7 +2708,14 @@ const DEFAULT_CHAT_COMPANION_UI_STATE: ChatCompanionUiState = { }; function parseChatActionsTab(value: unknown): ChatActionsTab { - if (value === "agents" || value === "proof" || value === "handoff" || value === "missions") return value; + if ( + value === "sources" + || value === "agents" + || value === "proof" + || value === "handoff" + || value === "missions" + || value === "run" + ) return value; return "agents"; } @@ -9508,6 +9521,9 @@ export function AgentChatPane({ ) : undefined} proofContent={proofTabContent} handoffContent={handoffTabContent} + sourcesContent={selectedSession?.provider === "codex" ? ( + + ) : undefined} runContent={showWorkspaceChrome && laneId ? (
@@ -10819,6 +10835,8 @@ export function AgentChatPane({ onApproval={(itemId, decision, responseText, answers) => { void handleApproval(itemId, decision, responseText, answers); }} + onCodexRecovery={(args: AgentChatRecoverCodexTurnArgs) => + window.ade.agentChat.recoverCodexTurn(args)} mosaic={subagentView ? undefined : mosaicContext} /> {sessionDelta ? ( diff --git a/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.test.tsx new file mode 100644 index 000000000..5361751a7 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.test.tsx @@ -0,0 +1,44 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ChatActionsDrawerPanel } from "./ChatActionsDrawerPanel"; + +afterEach(cleanup); + +function commonProps() { + return { + onTabChange: vi.fn(), + onClose: vi.fn(), + agentsContent:
Agents body
, + proofContent:
Proof body
, + handoffContent:
Handoff body
, + }; +} + +describe("ChatActionsDrawerPanel", () => { + it("shows Sources as the first Codex-specific action tab", () => { + const props = commonProps(); + render( + Sources body
} + missionsContent={
Missions body
} + />, + ); + + expect(screen.getByText("Sources body")).toBeTruthy(); + const tabs = screen.getAllByRole("button").map((button) => button.getAttribute("aria-label")); + expect(tabs.slice(0, 5)).toEqual(["Sources", "Missions", "Agents", "Proof", "Handoff"]); + fireEvent.click(screen.getByRole("button", { name: "Agents" })); + expect(props.onTabChange).toHaveBeenCalledWith("agents"); + }); + + it("falls back to Agents when a persisted Sources tab is unavailable", () => { + render(); + + expect(screen.getByText("Agents body")).toBeTruthy(); + expect(screen.queryByRole("button", { name: "Sources" })).toBeNull(); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx index 9a4d1e0a5..e765e14c3 100644 --- a/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx @@ -1,8 +1,8 @@ import type { ReactNode } from "react"; -import { ArrowBendUpRight, Cube, Play, Rocket, TreeStructure, X } from "@phosphor-icons/react"; +import { ArrowBendUpRight, Cube, LinkSimple, Play, Rocket, TreeStructure, X } from "@phosphor-icons/react"; import { GlowMenu, type GlowMenuItem } from "../ui/GlowMenu"; -export type ChatActionsTab = "agents" | "proof" | "handoff" | "run" | "missions"; +export type ChatActionsTab = "sources" | "agents" | "proof" | "handoff" | "run" | "missions"; // The drawer tabs use GlowMenu's `neutral` mode, which renders every tab with // the same muted-grey-→-bright-fg treatment plus one shared violet indicator. @@ -20,6 +20,13 @@ const CHAT_ACTIONS_TABS: Array> = [ { id: "handoff", label: "Handoff", icon: ArrowBendUpRight, ...NEUTRAL_INDICATOR }, ]; +const SOURCES_TAB: GlowMenuItem = { + id: "sources", + label: "Sources", + icon: LinkSimple, + ...NEUTRAL_INDICATOR, +}; + // Run tab (moved off the header) — surfaced only when the chat has a lane that // can run process groups. const RUN_TAB: GlowMenuItem = { @@ -46,6 +53,7 @@ export function ChatActionsDrawerPanel({ agentsContent, proofContent, handoffContent, + sourcesContent, runContent, missionsContent, }: { @@ -55,10 +63,12 @@ export function ChatActionsDrawerPanel({ agentsContent: ReactNode; proofContent: ReactNode; handoffContent: ReactNode; + sourcesContent?: ReactNode; runContent?: ReactNode; missionsContent?: ReactNode; }) { const tabs = [ + ...(sourcesContent ? [SOURCES_TAB] : []), ...(missionsContent ? [MISSIONS_TAB] : []), ...CHAT_ACTIONS_TABS, ...(runContent ? [RUN_TAB] : []), @@ -67,11 +77,13 @@ export function ChatActionsDrawerPanel({ const activeTab: ChatActionsTab = (tab === "run" && !runContent) || (tab === "missions" && !missionsContent) + || (tab === "sources" && !sourcesContent) ? "agents" : tab; const bodyByTab: Record = { run: runContent, missions: missionsContent, + sources: sourcesContent, agents: agentsContent, proof: proofContent, handoff: handoffContent, diff --git a/apps/desktop/src/renderer/components/chat/ChatSourcesPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSourcesPanel.tsx new file mode 100644 index 000000000..7b3cd7446 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/ChatSourcesPanel.tsx @@ -0,0 +1,98 @@ +import { File, Globe, LinkSimple, Plugs, type Icon } from "@phosphor-icons/react"; +import { useMemo } from "react"; +import type { AgentChatEventEnvelope } from "../../../shared/types"; +import { openUrlInAdeBrowser } from "../../lib/openExternal"; +import { deriveChatSources, type ChatSource } from "./chatSources"; + +type SourceSection = { + label: string; + icon: Icon; + items: ChatSource[]; +}; + +function SourceRow({ source, Icon }: { source: ChatSource; Icon: SourceSection["icon"] }) { + const body = ( + <> + + + + + {source.title} + {source.detail ? ( + + {source.detail} + + ) : null} + + {source.url ? : null} + + ); + const className = "flex w-full min-w-0 gap-2.5 rounded-lg border border-white/[0.055] bg-white/[0.018] px-2.5 py-2 text-left"; + return source.url ? ( + + ) : ( +
+ {body} +
+ ); +} + +export function ChatSourcesPanel({ events }: { events: AgentChatEventEnvelope[] }) { + const sources = useMemo(() => deriveChatSources(events), [events]); + const sections: SourceSection[] = [ + { label: "Files", icon: File, items: sources.files }, + { label: "Apps & tools", icon: Plugs, items: sources.tools }, + { label: "Web", icon: Globe, items: sources.web }, + { label: "External resources", icon: LinkSimple, items: sources.external }, + ].filter((section) => section.items.length > 0); + + return ( +
+
+
+
Sources
+

+ Files, searches, and connected apps used in this Codex chat. +

+
+ + {sources.total} + +
+ + {sections.length ? ( +
+ {sections.map((section) => ( +
+
+ + {section.label} + {section.items.length} +
+
+ {section.items.map((source) => ( + + ))} +
+
+ ))} +
+ ) : ( +
+ +
No sources yet
+

+ Sources appear here when Codex reads an attachment, searches the web, or uses a connected app. +

+
+ )} +
+ ); +} diff --git a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx index 458c2fe01..f2c153499 100644 --- a/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatWorkLogBlock.tsx @@ -89,6 +89,16 @@ function entryArgText(entry: ChatWorkLogEntry): string { return summarizeInlineText(entry.detail ?? entry.label, 140); } if (entry.entryKind === "tool" && entry.toolName) { + if (entry.mcp) { + const args = readRecord(entry.args) ?? {}; + for (const key of ["query", "url", "path", "file_path", "name", "id"] as const) { + const value = args[key]; + if (typeof value === "string" && value.trim().length) { + return summarizeInlineText(value, 140); + } + } + return summarizeInlineText(entry.detail ?? entry.mcp.tool, 140); + } const meta = getToolMeta(entry.toolName); const args = readRecord(entry.args) ?? {}; const target = meta.getTarget ? meta.getTarget(args) : null; @@ -219,16 +229,19 @@ function workLogEntryKindSlug(entry: ChatWorkLogEntry): string { if (entry.entryKind === "web_search") return "search"; if (entry.entryKind === "hook") return "hook"; if (entry.entryKind === "tool" && entry.toolName?.trim()) { - let raw = entry.toolName.trim(); + let raw = entry.mcp + ? (entry.mcp.appContext?.appName ?? entry.mcp.pluginId ?? entry.mcp.server) + : entry.toolName.trim(); if (raw.includes(".")) { raw = raw.split(".").pop() ?? raw; } const snake = raw .replace(/([a-z0-9])([A-Z])/g, "$1_$2") - .replace(/[-\s.]+/g, "_") + .replace(/[^a-zA-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, "") .toLowerCase(); if (snake === "exec_command") return "shell"; - return snake; + return snake || "tool"; } return entry.label.replace(/\s+/g, "_").toLowerCase() || "tool"; } diff --git a/apps/desktop/src/renderer/components/chat/chatSources.test.ts b/apps/desktop/src/renderer/components/chat/chatSources.test.ts new file mode 100644 index 000000000..14a327d42 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatSources.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, it } from "vitest"; +import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../shared/types"; +import { deriveChatSources } from "./chatSources"; + +function envelope(event: AgentChatEvent, sequence = 1): AgentChatEventEnvelope { + return { + sessionId: "session-1", + timestamp: `2026-07-09T00:00:0${sequence}.000Z`, + sequence, + event, + }; +} + +describe("deriveChatSources", () => { + it("deduplicates attachments, web queries, and canonical web URLs", () => { + const sources = deriveChatSources([ + envelope({ + type: "user_message", + text: "Use these", + attachments: [ + { type: "file", path: "/repo/spec.md" }, + { type: "file", path: "/repo/spec.md" }, + { type: "image-url", path: "diagram.png", url: "https://example.com/diagram.png#preview" }, + ], + }), + envelope({ + type: "web_search", + query: "GPT-5.6 docs", + actions: [{ type: "open_page", url: "https://openai.com/docs/5.6#models" }], + itemId: "search-1", + status: "running", + }, 2), + envelope({ + type: "web_search", + query: "GPT-5.6 docs", + actions: [{ + type: "open_page", + url: "https://openai.com/docs/5.6#reasoning", + title: "GPT-5.6 model guide", + snippet: "The current reasoning ladder.", + }], + itemId: "search-1", + status: "completed", + }, 3), + ]); + + expect(sources.files).toHaveLength(2); + expect(sources.files.map((source) => source.title)).toEqual(["spec.md", "diagram.png"]); + expect(sources.web).toHaveLength(2); + expect(sources.web.find((source) => source.url)?.url).toBe("https://openai.com/docs/5.6"); + expect(sources.web.find((source) => source.url)?.title).toBe("GPT-5.6 model guide"); + expect(sources.web.find((source) => !source.url)?.title).toBe("GPT-5.6 docs"); + }); + + it("groups MCP calls by connected app and extracts safe external resources", () => { + const mcp = { + server: "github", + tool: "search_issues", + pluginId: "github-plugin", + appContext: { + connectorId: "github", + appName: "GitHub", + actionName: "Search issues", + resourceUri: "ui://github/search", + }, + }; + const sources = deriveChatSources([ + envelope({ + type: "tool_call", + tool: "github:search_issues", + args: { query: "5.6" }, + mcp, + itemId: "mcp-1", + }), + envelope({ + type: "tool_result", + tool: "github:search_issues", + result: { + items: [ + { title: "Model support", url: "https://github.com/acme/ade/issues/56#discussion" }, + { title: "Ignored", url: "file:///tmp/secret" }, + ], + }, + mcp, + itemId: "mcp-1", + status: "completed", + }, 2), + envelope({ + type: "tool_call", + tool: "legacy-server:lookup", + args: null, + itemId: "legacy-mcp", + }, 3), + ]); + + expect(sources.tools).toEqual([ + expect.objectContaining({ title: "GitHub", detail: "Search issues" }), + expect.objectContaining({ title: "legacy-server", detail: "lookup" }), + ]); + expect(sources.external).toEqual([ + expect.objectContaining({ + title: "Model support", + url: "https://github.com/acme/ade/issues/56", + }), + ]); + }); + + it("reads JSON tool results without treating arbitrary prose as a URL", () => { + const sources = deriveChatSources([ + envelope({ + type: "tool_result", + tool: "docs:read", + result: JSON.stringify({ title: "Reference", href: "https://docs.example.com/reference#top" }), + itemId: "mcp-2", + status: "completed", + }), + envelope({ + type: "tool_result", + tool: "docs:read", + result: "See https://docs.example.com/not-extracted in ordinary prose.", + itemId: "mcp-3", + status: "completed", + }, 2), + ]); + + expect(sources.tools).toHaveLength(1); + expect(sources.external).toEqual([ + expect.objectContaining({ url: "https://docs.example.com/reference" }), + ]); + }); + + it("omits Codex's internal node_repl host from Sources", () => { + const sources = deriveChatSources([ + envelope({ + type: "tool_call", + tool: "node_repl:js", + args: { code: "1 + 1" }, + mcp: { server: "node_repl", tool: "js" }, + itemId: "node-repl-1", + }), + envelope({ + type: "tool_result", + tool: "node_repl:js", + result: { value: 2 }, + mcp: { server: "node_repl", tool: "js" }, + itemId: "node-repl-1", + status: "completed", + }, 2), + ]); + + expect(sources.tools).toEqual([]); + expect(sources.external).toEqual([]); + expect(sources.total).toBe(0); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatSources.ts b/apps/desktop/src/renderer/components/chat/chatSources.ts new file mode 100644 index 000000000..d4ded8391 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatSources.ts @@ -0,0 +1,279 @@ +import type { + AgentChatEventEnvelope, + AgentChatMcpToolSource, +} from "../../../shared/types"; + +export type ChatSourceKind = "file" | "web" | "tool" | "external"; + +export type ChatSource = { + id: string; + kind: ChatSourceKind; + title: string; + detail?: string; + url?: string; + path?: string; +}; + +export type ChatSources = { + files: ChatSource[]; + tools: ChatSource[]; + web: ChatSource[]; + external: ChatSource[]; + total: number; +}; + +type ToolGroup = { + source: ChatSource; + actions: Set; +}; + +function isRecord(value: unknown): value is Record { + return Boolean(value) && typeof value === "object" && !Array.isArray(value); +} + +function stringValue(value: unknown): string | null { + return typeof value === "string" && value.trim().length ? value.trim() : null; +} + +function fileName(value: string): string { + const normalized = value.replace(/[\\/]+$/, ""); + return normalized.split(/[\\/]/).pop() || value; +} + +function canonicalHttpUrl(value: unknown): string | null { + const raw = stringValue(value); + if (!raw) return null; + try { + const parsed = new URL(raw); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null; + parsed.hash = ""; + return parsed.toString(); + } catch { + return null; + } +} + +function sourceLabelFromUrl(url: string): string { + try { + const parsed = new URL(url); + const tail = parsed.pathname.replace(/\/$/, "").split("/").filter(Boolean).pop(); + return tail ? decodeURIComponent(tail) : parsed.hostname; + } catch { + return url; + } +} + +function addPreferred(map: Map, key: string, incoming: ChatSource): void { + const current = map.get(key); + if (!current) { + map.set(key, incoming); + return; + } + const currentUsesUrlFallback = Boolean( + current.url + && (current.title === current.url || current.title === sourceLabelFromUrl(current.url)), + ); + const currentDetailUsesUrlFallback = Boolean(current.url && current.detail === current.url); + map.set(key, { + ...current, + title: currentUsesUrlFallback && incoming.title !== incoming.url + ? incoming.title + : current.title, + detail: !current.detail || currentDetailUsesUrlFallback + ? incoming.detail ?? current.detail + : current.detail, + url: current.url ?? incoming.url, + path: current.path ?? incoming.path, + }); +} + +function legacyMcpSource(toolLabel: string): AgentChatMcpToolSource | null { + const separator = toolLabel.indexOf(":"); + if (separator <= 0 || separator >= toolLabel.length - 1) return null; + return { + server: toolLabel.slice(0, separator), + tool: toolLabel.slice(separator + 1), + }; +} + +function toolSourceKey(mcp: AgentChatMcpToolSource): string { + return mcp.appContext?.connectorId + ?? mcp.pluginId + ?? mcp.server; +} + +function addToolSource(groups: Map, mcp: AgentChatMcpToolSource): void { + const key = toolSourceKey(mcp); + const action = mcp.appContext?.actionName ?? mcp.tool; + const existing = groups.get(key); + if (existing) { + existing.actions.add(action); + return; + } + groups.set(key, { + source: { + id: `tool:${key}`, + kind: "tool", + title: mcp.appContext?.appName ?? mcp.pluginId ?? mcp.server, + }, + actions: new Set([action]), + }); +} + +function addExternalResource( + map: Map, + urlValue: unknown, + titleValue?: unknown, +): void { + const url = canonicalHttpUrl(urlValue); + if (!url) return; + const title = stringValue(titleValue) ?? sourceLabelFromUrl(url); + addPreferred(map, url.toLowerCase(), { + id: `external:${url.toLowerCase()}`, + kind: "external", + title, + detail: url, + url, + }); +} + +function collectExternalResources( + value: unknown, + map: Map, + depth = 0, + seen: Set = new Set(), +): void { + if (depth > 6 || map.size >= 100) return; + if (typeof value === "string") { + const trimmed = value.trim(); + if (trimmed.length > 1 && trimmed.length < 100_000 && /^[{[]/.test(trimmed)) { + try { + collectExternalResources(JSON.parse(trimmed), map, depth + 1, seen); + } catch { + // Tool text is often ordinary prose; only valid JSON is traversed. + } + } + return; + } + if (!value || typeof value !== "object") return; + if (seen.has(value)) return; + seen.add(value); + if (Array.isArray(value)) { + for (const entry of value) collectExternalResources(entry, map, depth + 1, seen); + return; + } + + const record = value as Record; + const title = record.title ?? record.name ?? record.label; + for (const key of ["url", "href", "link", "resourceUrl", "resource_url", "uri"]) { + if (key in record) addExternalResource(map, record[key], title); + } + for (const nested of Object.values(record)) { + collectExternalResources(nested, map, depth + 1, seen); + } +} + +export function deriveChatSources(events: AgentChatEventEnvelope[]): ChatSources { + const files = new Map(); + const web = new Map(); + const tools = new Map(); + const external = new Map(); + const mcpByItemId = new Map(); + + for (const envelope of events) { + const event = envelope.event; + if (event.type === "user_message") { + for (const attachment of event.attachments ?? []) { + const key = attachment.type === "image-url" + ? canonicalHttpUrl(attachment.url) ?? attachment.url.trim() + : attachment.path.trim(); + if (!key) continue; + addPreferred(files, key.toLowerCase(), { + id: `file:${key.toLowerCase()}`, + kind: "file", + title: fileName(attachment.path) || (attachment.type === "image-url" ? sourceLabelFromUrl(attachment.url) : key), + detail: attachment.type === "image-url" ? attachment.url : attachment.path, + ...(attachment.type === "image-url" && canonicalHttpUrl(attachment.url) + ? { url: canonicalHttpUrl(attachment.url) as string } + : { path: attachment.path }), + }); + } + for (const context of event.contextAttachments ?? []) { + if (context.type !== "linear_issue") continue; + addExternalResource( + external, + context.issue.url, + `${context.issue.identifier}: ${context.issue.title}`, + ); + } + continue; + } + + if (event.type === "web_search") { + if (event.status === "failed") continue; + const queries = new Set(); + if (event.query.trim()) queries.add(event.query.trim()); + for (const action of event.actions ?? []) { + if (action.query?.trim()) queries.add(action.query.trim()); + for (const query of action.queries ?? []) { + if (query.trim()) queries.add(query.trim()); + } + const url = canonicalHttpUrl(action.url); + if (!url) continue; + addPreferred(web, `url:${url.toLowerCase()}`, { + id: `web:${url.toLowerCase()}`, + kind: "web", + title: action.title?.trim() || sourceLabelFromUrl(url), + detail: action.snippet?.trim() || url, + url, + }); + } + for (const query of queries) { + const key = query.toLocaleLowerCase(); + addPreferred(web, `query:${key}`, { + id: `web-query:${key}`, + kind: "web", + title: query, + detail: "Web search", + }); + } + continue; + } + + if (event.type !== "tool_call" && event.type !== "tool_result") continue; + const mcp = event.mcp + ?? mcpByItemId.get(event.itemId) + ?? legacyMcpSource(event.tool); + if (!mcp) continue; + mcpByItemId.set(event.itemId, mcp); + // Codex treats its internal JavaScript execution host as transcript + // plumbing, not a user-facing source or connected app. + if (mcp.server.trim().toLowerCase() === "node_repl") continue; + addToolSource(tools, mcp); + if (mcp.resourceUri) { + addExternalResource( + external, + mcp.resourceUri, + mcp.appContext?.appName ?? mcp.pluginId ?? mcp.server, + ); + } + if (event.type === "tool_result") { + collectExternalResources(event.result, external); + } + } + + const toolSources = [...tools.values()].map(({ source, actions }) => ({ + ...source, + detail: [...actions].join(", "), + })); + const result = { + files: [...files.values()], + tools: toolSources, + web: [...web.values()], + external: [...external.values()], + }; + return { + ...result, + total: result.files.length + result.tools.length + result.web.length + result.external.length, + }; +} diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index 0c26b38a0..13fd02282 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -551,6 +551,93 @@ describe("chatTranscriptRows", () => { ]); }); + it("retains MCP connector identity and action on compact tool rows", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "tool_call", + tool: "github:search_issues", + args: { query: "is:open label:bug" }, + mcp: { + server: "github", + tool: "search_issues", + appContext: { appName: "GitHub", actionName: "Search issues" }, + }, + itemId: "mcp-1", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "tool_result", + tool: "github:search_issues", + result: "Issue 1", + mcp: { + server: "github", + tool: "search_issues", + appContext: { appName: "GitHub", actionName: "Search issues" }, + }, + itemId: "mcp-1", + turnId: "turn-1", + status: "completed", + }, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event.type).toBe("work_log_entry"); + if (rows[0]!.event.type !== "work_log_entry") throw new Error("Expected work log entry"); + expect(rows[0]!.event.entry).toMatchObject({ + label: "GitHub", + detail: "Search issues", + status: "completed", + mcp: { server: "github", tool: "search_issues" }, + }); + }); + + it("collapses image generation lifecycle updates into one completed card", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + prompt: "A tiny moon icon", + status: "running", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "codex_image_generation", + itemId: "image-1", + turnId: "turn-1", + revisedPrompt: "A crisp crescent moon icon", + result: "/tmp/moon.png", + savedPath: "/tmp/moon.png", + status: "completed", + }, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event).toMatchObject({ + type: "codex_image_generation", + itemId: "image-1", + prompt: "A tiny moon icon", + revisedPrompt: "A crisp crescent moon icon", + result: "/tmp/moon.png", + status: "completed", + }); + }); + it("preserves failed tool result detail for expansion", () => { const grouped = groupEvents([ { diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index bab3deead..bea3ca9a4 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -43,6 +43,7 @@ export type ChatWorkLogEntry = { status: ChatWorkLogStatus; entryKind: ChatWorkLogEntryKind; toolName?: string; + mcp?: NonNullable["mcp"]>; args?: unknown; result?: unknown; output?: string; @@ -414,6 +415,8 @@ function buildToolWorkLogEvent( const status = event.type === "tool_call" ? "running" : (event.status ?? "completed"); const titleFallback = readToolTitle(event.type === "tool_call" ? event.args : event.result); const resolvedToolName = isGenericToolIdentifier(event.tool) && titleFallback ? titleFallback : event.tool; + const connectorLabel = event.mcp?.appContext?.appName ?? event.mcp?.pluginId ?? event.mcp?.server; + const connectorAction = event.mcp?.appContext?.actionName ?? event.mcp?.tool; const collapseKey = buildCollapseKey("tool", event); return { type: "work_log_entry", @@ -421,12 +424,15 @@ function buildToolWorkLogEvent( entry: withLocalhostUrls({ id: buildWorkLogEntryId(collapseKey, event), createdAt: timestamp, - label: resolvedToolName, + label: connectorLabel ?? resolvedToolName, tone: deriveTone(status, "tool"), status, entryKind: "tool", toolName: resolvedToolName, - ...(titleFallback && titleFallback !== resolvedToolName ? { detail: titleFallback } : {}), + ...(event.mcp ? { mcp: event.mcp } : {}), + ...(connectorAction + ? { detail: connectorAction } + : titleFallback && titleFallback !== resolvedToolName ? { detail: titleFallback } : {}), ...(event.type === "tool_call" ? { args: event.args } : {}), ...(event.type === "tool_result" ? { result: event.result } : {}), ...(event.itemId ? { itemId: event.itemId } : {}), @@ -832,6 +838,49 @@ export function appendCollapsedChatTranscriptEvent( } } + if (event.type === "codex_image_generation" || event.type === "codex_image_view") { + const matchIndex = [...rows] + .reverse() + .findIndex((candidate) => + candidate.event.type === event.type + && candidate.event.itemId === event.itemId + && (candidate.event.turnId ?? null) === (event.turnId ?? null), + ); + if (matchIndex >= 0) { + const actualIndex = rows.length - 1 - matchIndex; + const previous = rows[actualIndex]!; + if (event.type === "codex_image_generation" && previous.event.type === "codex_image_generation") { + rows[actualIndex] = { + ...previous, + timestamp: envelope.timestamp, + event: { + ...previous.event, + ...event, + prompt: event.prompt ?? previous.event.prompt, + revisedPrompt: event.revisedPrompt ?? previous.event.revisedPrompt, + result: event.result ?? previous.event.result, + savedPath: event.savedPath ?? previous.event.savedPath, + }, + }; + return; + } + if (event.type === "codex_image_view" && previous.event.type === "codex_image_view") { + rows[actualIndex] = { + ...previous, + timestamp: envelope.timestamp, + event: { + ...previous.event, + ...event, + path: event.path ?? previous.event.path, + url: event.url ?? previous.event.url, + title: event.title ?? previous.event.title, + }, + }; + return; + } + } + } + const normalizedSubagentEvent = normalizeSubagentLifecycleEvent(event); if (normalizedSubagentEvent && normalizedSubagentEvent !== event) { rows.push({ diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx index 167dc9934..fe17d7657 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexImageViewLine.tsx @@ -1,6 +1,6 @@ import { ArrowUpRight } from "@phosphor-icons/react"; import type { AgentChatEvent } from "../../../../shared/types"; -import { openExternalUrl } from "../../../lib/openExternal"; +import { canOpenInAdeBrowser, openUrlInAdeBrowser } from "../../../lib/openExternal"; type ImageViewEvent = Extract; @@ -49,7 +49,11 @@ export function CodexImageViewLine({ event }: CodexImageViewLineProps) { const trimmedUrl = event.url?.trim() || null; const localPath = stripFileUrlPrefix(trimmedPath) ?? (trimmedUrl && /^file:\/\//i.test(trimmedUrl) ? stripFileUrlPrefix(trimmedUrl) : null); - const url = trimmedUrl && !/^file:\/\//i.test(trimmedUrl) ? trimmedUrl : null; + const url = trimmedUrl + && !/^file:\/\//i.test(trimmedUrl) + && canOpenInAdeBrowser(trimmedUrl) + ? trimmedUrl + : null; const canOpen = Boolean(localPath || url); const handleOpen = () => { @@ -57,7 +61,7 @@ export function CodexImageViewLine({ event }: CodexImageViewLineProps) { void window.ade.app.openPath(localPath).catch(() => undefined); return; } - if (url) openExternalUrl(url); + if (url) openUrlInAdeBrowser(url); }; return ( diff --git a/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts b/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts index a1317db4e..de39ac493 100644 --- a/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts +++ b/apps/desktop/src/renderer/components/cto/useCtoModelOptions.ts @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useNavigate } from "react-router-dom"; -import { getModelById } from "../../../shared/modelRegistry"; +import { getModelById, selectSupportedReasoningEffort } from "../../../shared/modelRegistry"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; export type CtoModelSelection = { @@ -15,10 +15,13 @@ export function pickReasoningEffort( modelId: string | null | undefined, preferred: string | null | undefined, ): string | null { - const tiers = modelId ? (getModelById(modelId)?.reasoningTiers ?? []) : []; - if (!tiers.length) return null; - if (preferred && tiers.includes(preferred)) return preferred; - return tiers.includes("medium") ? "medium" : tiers[0] ?? null; + const descriptor = modelId ? getModelById(modelId) : undefined; + const tiers = descriptor?.reasoningTiers ?? []; + return selectSupportedReasoningEffort({ + tiers, + preferred, + advertisedDefault: descriptor?.defaultReasoningEffort, + }); } /** Resolve a full model selection (provider/model/reasoning) from a model id. */ diff --git a/apps/desktop/src/renderer/components/prs/shared/PrRequestAiReviewDialog.tsx b/apps/desktop/src/renderer/components/prs/shared/PrRequestAiReviewDialog.tsx index 4ccd9240f..29de3d461 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrRequestAiReviewDialog.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrRequestAiReviewDialog.tsx @@ -11,8 +11,9 @@ import { cn } from "../../ui/cn"; import { startReviewRun } from "../../review/reviewApi"; import { branchNameFromRef } from "./laneBranchTargets"; -const DEFAULT_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4"; -const DEFAULT_REASONING = "medium"; +const DEFAULT_MODEL = getDefaultModelDescriptor("codex"); +const DEFAULT_MODEL_ID = DEFAULT_MODEL?.id ?? "openai/gpt-5.6-sol"; +const DEFAULT_REASONING = DEFAULT_MODEL?.defaultReasoningEffort ?? "low"; const SCOPE_INSET = "rounded-xl border border-white/[0.06] bg-[var(--color-muted)]/45"; diff --git a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx index 2cd46b601..45ff38b6e 100644 --- a/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/PrResolverLaunchControls.tsx @@ -2,6 +2,7 @@ import React from "react"; import { getModelById, resolveProviderGroupForModel, + selectSupportedReasoningEffort, type ModelDescriptor, type ModelProviderGroup, } from "../../../../shared/modelRegistry"; @@ -37,9 +38,11 @@ type PrResolverLaunchControlsProps = PrResolverLaunchControlsBaseProps & ( function selectReasoningEffortForModel(descriptor: ModelDescriptor | undefined, current: string): string { const tiers = descriptor?.reasoningTiers ?? []; - if (!tiers.length) return ""; - if (current && tiers.includes(current)) return current; - return tiers.includes("medium") ? "medium" : tiers[0]!; + return selectSupportedReasoningEffort({ + tiers, + preferred: current, + advertisedDefault: descriptor?.defaultReasoningEffort, + }) ?? ""; } function permissionOptionsForModel(descriptor: ModelDescriptor | undefined, valueMode: "chat" | "legacy" = "chat") { diff --git a/apps/desktop/src/renderer/components/review/ReviewPage.tsx b/apps/desktop/src/renderer/components/review/ReviewPage.tsx index 52185c1f7..9dae8596d 100644 --- a/apps/desktop/src/renderer/components/review/ReviewPage.tsx +++ b/apps/desktop/src/renderer/components/review/ReviewPage.tsx @@ -15,7 +15,11 @@ import { CopySimple, } from "@phosphor-icons/react"; import { BranchIcon } from "../ui/vcsIcons"; -import { getDefaultModelDescriptor } from "../../../shared/modelRegistry"; +import { + getDefaultModelDescriptor, + resolveModelDescriptor, + selectSupportedReasoningEffort, +} from "../../../shared/modelRegistry"; import type { LaneSummary } from "../../../shared/types"; import { useAppStore } from "../../state/appStore"; import { Button } from "../ui/Button"; @@ -102,8 +106,9 @@ type LaunchDraft = { fastMode: boolean; }; -const DEFAULT_REVIEW_LAUNCH_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.4"; -const DEFAULT_REVIEW_REASONING_EFFORT = "medium"; +const DEFAULT_REVIEW_LAUNCH_MODEL = getDefaultModelDescriptor("codex"); +const DEFAULT_REVIEW_LAUNCH_MODEL_ID = DEFAULT_REVIEW_LAUNCH_MODEL?.id ?? "openai/gpt-5.6-sol"; +const DEFAULT_REVIEW_REASONING_EFFORT = DEFAULT_REVIEW_LAUNCH_MODEL?.defaultReasoningEffort ?? "low"; type NormalizedRun = Omit & { createdAt: string | null; @@ -1085,13 +1090,24 @@ export function ReviewPage({ active = true }: { active?: boolean } = {}) { const recommendedModelId = launchContext?.recommendedModelId?.trim(); if (!recommendedModelId || recommendedModelHydratedRef.current) return; recommendedModelHydratedRef.current = true; + const recommendedModel = resolveModelDescriptor(recommendedModelId); + const recommendedReasoningEffort = recommendedModel + ? selectSupportedReasoningEffort({ + tiers: recommendedModel.reasoningTiers ?? [], + advertisedDefault: recommendedModel.defaultReasoningEffort, + }) + : null; setLaunchDraft((prev) => { const currentModelId = prev.modelId.trim(); if (currentModelId && currentModelId !== DEFAULT_REVIEW_LAUNCH_MODEL_ID) { return prev; } if (currentModelId === recommendedModelId) return prev; - return { ...prev, modelId: recommendedModelId }; + return { + ...prev, + modelId: recommendedModelId, + reasoningEffort: recommendedReasoningEffort ?? prev.reasoningEffort, + }; }); }, [launchContext?.recommendedModelId]); diff --git a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx index 3c06c7c20..982ec3ca3 100644 --- a/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx +++ b/apps/desktop/src/renderer/components/settings/ChatAppearancePreview.tsx @@ -103,7 +103,7 @@ const PREVIEW_USAGE_MODEL: Record< PreviewProviderKey, { Logo: React.ComponentType<{ size?: number; className?: string }>; label: string } > = { - codex: { Logo: CodexLogo, label: "gpt-5.5" }, + codex: { Logo: CodexLogo, label: "gpt-5.6-sol" }, claude: { Logo: ClaudeLogo, label: "Claude Sonnet 5" }, opencode: { Logo: OpenCodeLogo, label: "local · runtime" }, cursor: { Logo: CursorAgentLogo, label: "cursor · auto" }, diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx index 2393215ed..b8bbb0d48 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ModelListRow.tsx @@ -50,11 +50,13 @@ const REASONING_LABELS: Record = { high: "High", xhigh: "Extra High", max: "Max", + ultra: "Ultra", ultracode: "Ultracode", }; -function reasoningChipLabel(effort: string | null): string { +function reasoningChipLabel(effort: string | null, model?: ModelDescriptor): string { if (!effort) return "Off"; + if (effort === "low" && /^gpt-5\.6-(?:sol|terra|luna)$/i.test(model?.providerModelId ?? "")) return "Light"; return REASONING_LABELS[effort] ?? effort.charAt(0).toUpperCase() + effort.slice(1); } @@ -233,7 +235,7 @@ export const ModelListRow = memo(function ModelListRow({ ) : null} diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx index d58c80505..6306dcf62 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.test.tsx @@ -1,7 +1,7 @@ /* @vitest-environment jsdom */ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { cleanup, render, screen } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; vi.mock("@lobehub/icons", () => { @@ -192,6 +192,52 @@ describe("ReasoningEffortPicker", () => { expect(screen.getByRole("button", { name: /Reasoning effort/i }).textContent).toContain("MAX"); }); + it("renders the GPT-5.6 Ultra tier with a multi-agent usage warning", async () => { + const user = userEvent.setup(); + render( + , + ); + + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + expect(trigger.textContent).toContain("ULTRA"); + await user.click(trigger); + + expect(screen.getAllByRole("radio")).toHaveLength(5); + expect(screen.queryByRole("radio", { name: "Max" })).toBeNull(); + expect(screen.getByRole("radio", { name: "Light" })).toBeTruthy(); + expect(screen.getByRole("radio", { name: "Ultra" })).toBeTruthy(); + expect(screen.getByText(/automatically delegates work to multiple agents/i)).toBeTruthy(); + }); + + it("labels GPT-5.6 low effort as Light", () => { + render( + , + ); + + expect(screen.getByRole("button", { name: /Reasoning effort/i }).textContent).toContain("LIGHT"); + }); + + it("clamps a remembered Ultra effort when switching to Luna", () => { + reasoningByFamilyStore.openai = "ultra"; + render( + , + ); + + expect(screen.getByRole("button", { name: /Reasoning effort/i }).textContent).toContain("MED"); + }); + it("calls onChange and persists the tier when a tier is selected", async () => { const user = userEvent.setup(); const onChange = vi.fn(); @@ -202,7 +248,8 @@ describe("ReasoningEffortPicker", () => { onChange={onChange} />, ); - await user.click(screen.getByRole("button", { name: /Reasoning effort/i })); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + await user.click(trigger); const radios = screen.getAllByRole("radio"); const high = radios.find((el) => el.textContent?.includes("High")); @@ -211,6 +258,169 @@ describe("ReasoningEffortPicker", () => { expect(onChange).toHaveBeenCalledWith("high"); expect(reasoningByFamilyStore.anthropic).toBe("high"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + + await user.keyboard("{Escape}"); + expect(trigger.getAttribute("aria-expanded")).toBe("false"); + }); + + it("previews pointer movement smoothly, captures the drag, and snaps once on release", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + await user.click(trigger); + + const track = container.ownerDocument.querySelector("[data-reasoning-slider-track]"); + expect(track).toBeTruthy(); + Object.defineProperty(track!, "getBoundingClientRect", { + configurable: true, + value: () => ({ + left: 100, + right: 300, + top: 0, + bottom: 18, + width: 200, + height: 18, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + const setPointerCapture = vi.fn(); + const releasePointerCapture = vi.fn(); + Object.defineProperties(track!, { + setPointerCapture: { configurable: true, value: setPointerCapture }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, + releasePointerCapture: { configurable: true, value: releasePointerCapture }, + }); + const firePointer = (type: "pointerdown" | "pointermove" | "pointerup", clientX: number) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX }); + Object.defineProperty(event, "pointerId", { value: 7 }); + fireEvent(track!, event); + }; + + firePointer("pointerdown", 120); + expect(setPointerCapture).toHaveBeenCalledWith(7); + firePointer("pointermove", 236); + + expect(track!.getAttribute("data-dragging")).toBe("true"); + expect(track!.style.getPropertyValue("--reasoning-slider-thumb-position")).toContain("68%"); + expect(onChange).not.toHaveBeenCalled(); + + firePointer("pointerup", 236); + + expect(track!.hasAttribute("data-dragging")).toBe(false); + expect(track!.style.getPropertyValue("--reasoning-slider-thumb-position")).toContain("75%"); + expect(releasePointerCapture).toHaveBeenCalledWith(7); + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("xhigh"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("drags from the current thumb tick without a trailing click toggling it", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + const { container } = render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + await user.click(trigger); + + const track = container.ownerDocument.querySelector("[data-reasoning-slider-track]"); + const currentTick = screen.getByRole("radio", { name: "Medium" }); + expect(track).toBeTruthy(); + Object.defineProperty(track!, "getBoundingClientRect", { + configurable: true, + value: () => ({ + left: 100, + right: 300, + top: 0, + bottom: 18, + width: 200, + height: 18, + x: 100, + y: 0, + toJSON: () => ({}), + }), + }); + const setPointerCapture = vi.fn(); + Object.defineProperties(track!, { + setPointerCapture: { configurable: true, value: setPointerCapture }, + hasPointerCapture: { configurable: true, value: vi.fn(() => true) }, + releasePointerCapture: { configurable: true, value: vi.fn() }, + }); + const firePointer = (target: Element, type: "pointerdown" | "pointermove" | "pointerup", clientX: number) => { + const event = new MouseEvent(type, { bubbles: true, cancelable: true, button: 0, clientX }); + Object.defineProperty(event, "pointerId", { value: 8 }); + fireEvent(target, event); + }; + + firePointer(currentTick, "pointerdown", 150); + expect(setPointerCapture).not.toHaveBeenCalled(); + firePointer(track!, "pointermove", 236); + expect(setPointerCapture).toHaveBeenCalledWith(8); + firePointer(track!, "pointerup", 236); + fireEvent.click(currentTick); + + expect(onChange).toHaveBeenCalledTimes(1); + expect(onChange).toHaveBeenCalledWith("xhigh"); + expect(track!.style.getPropertyValue("--reasoning-slider-thumb-position")).toContain("75%"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); + }); + + it("uses the complete tier palette as a progressive fill gradient", async () => { + const user = userEvent.setup(); + const { container } = render( + , + ); + await user.click(screen.getByRole("button", { name: /Reasoning effort/i })); + + const track = container.ownerDocument.querySelector("[data-reasoning-slider-track]"); + const fill = container.ownerDocument.querySelector("[data-reasoning-slider-fill]"); + const gradient = track?.style.getPropertyValue("--reasoning-progressive-gradient") ?? ""; + expect(gradient).toContain("linear-gradient"); + expect(gradient).toContain("52 211 153"); + expect(gradient).toContain("34 211 238"); + expect(gradient).toContain("96 165 250"); + expect(gradient).toContain("167 139 250"); + expect(gradient).toContain("192 132 252"); + expect(fill?.className).toContain("ade-reasoning-slider-fill-max"); + }); + + it("keeps keyboard tier changes open", async () => { + const user = userEvent.setup(); + const onChange = vi.fn(); + render( + , + ); + const trigger = screen.getByRole("button", { name: /Reasoning effort/i }); + await user.click(trigger); + const active = screen.getAllByRole("radio").find((radio) => radio.getAttribute("aria-checked") === "true"); + expect(active).toBeTruthy(); + + fireEvent.keyDown(active!, { key: "ArrowRight" }); + + expect(onChange).toHaveBeenCalledWith("high"); + expect(trigger.getAttribute("aria-expanded")).toBe("true"); }); it("clears the effort when the active tier is clicked again", async () => { diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx index 95ed60524..34209dcd0 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx @@ -1,4 +1,4 @@ -import { forwardRef, memo, useCallback, useEffect, useMemo, useState } from "react"; +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as Popover from "@radix-ui/react-popover"; import { CaretDown, Question } from "@phosphor-icons/react"; import type { ModelDescriptor } from "../../../../shared/modelRegistry"; @@ -18,86 +18,33 @@ export type ReasoningEffortPickerProps = { triggerClassName?: string; }; -export function reasoningChipLabel(effort: string | null | undefined): string | null { +export function reasoningChipLabel( + effort: string | null | undefined, + useCodex56Labels = false, +): string | null { if (!effort) return null; const lower = effort.trim().toLowerCase(); if (!lower) return null; if (lower === "minimal") return "MIN"; - if (lower === "low") return "LOW"; + if (lower === "low") return useCodex56Labels ? "LIGHT" : "LOW"; if (lower === "medium") return "MED"; if (lower === "high") return "HI"; if (lower === "xhigh") return "XH"; if (lower === "max") return "MAX"; + if (lower === "ultra") return "ULTRA"; if (lower === "ultracode") return "ULTRA"; return lower.slice(0, 3).toUpperCase(); } -function tierLabel(tier: string): string { +function tierLabel(tier: string, useCodex56Labels = false): string { + if (useCodex56Labels && tier === "low") return "Light"; if (tier === "xhigh") return "Extra High"; if (tier === "max") return "Max"; + if (tier === "ultra") return "Ultra"; if (tier === "ultracode") return "Ultracode"; return tier.charAt(0).toUpperCase() + tier.slice(1); } -const reasoningSliderStyleId = "ade-reasoning-slider-effects"; - -function ensureReasoningSliderStyles(): void { - if (typeof document === "undefined") return; - if (document.getElementById(reasoningSliderStyleId)) return; - const sheet = document.createElement("style"); - sheet.id = reasoningSliderStyleId; - sheet.textContent = ` - @keyframes ade-reasoning-effort-word-pop { - 0% { opacity: 0; transform: translateY(3px); filter: blur(3px); } - 100% { opacity: 1; transform: translateY(0); filter: blur(0); } - } - @keyframes ade-reasoning-fill-sheen { - 0% { transform: translateX(-80%); opacity: 0; } - 35% { opacity: 0.55; } - 100% { transform: translateX(80%); opacity: 0; } - } - @keyframes ade-reasoning-grid-drift { - 0% { background-position: 0 0, 0 0; } - 100% { background-position: -18px 0, 0 0; } - } - .ade-reasoning-effort-word { - animation: ade-reasoning-effort-word-pop 180ms cubic-bezier(0.2, 0.8, 0.2, 1); - } - .ade-reasoning-slider-fill { - background: linear-gradient(90deg, rgba(var(--reasoning-fill-rgb), 0.38), rgba(var(--reasoning-fill-rgb), 0.82)); - box-shadow: 0 0 18px rgba(var(--reasoning-fill-rgb), 0.24); - transition: width 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms ease, box-shadow 220ms ease; - } - .ade-reasoning-slider-fill::after { - content: ""; - position: absolute; - inset: 0; - pointer-events: none; - background: linear-gradient(100deg, transparent 20%, rgba(255,255,255,0.22) 50%, transparent 78%); - animation: ade-reasoning-fill-sheen 1.8s ease-in-out infinite; - } - .ade-reasoning-slider-fill-max { - background: - radial-gradient(circle at center, rgba(255,255,255,0.20) 0 0.8px, transparent 1px), - linear-gradient(90deg, rgba(124,58,237,0.34), rgba(216,180,254,0.86)); - background-size: 4px 4px, 100% 100%; - animation: ade-reasoning-grid-drift 1.15s linear infinite; - box-shadow: 0 0 24px rgba(167,139,250,0.34); - } - @media (prefers-reduced-motion: reduce) { - .ade-reasoning-effort-word, - .ade-reasoning-slider-fill::after, - .ade-reasoning-slider-fill-max { - animation: none; - } - .ade-reasoning-slider-fill { - transition: none; - } - } - `; - document.head.appendChild(sheet); -} - type ReasoningToneKey = "auto" | "low" | "steady" | "smart" | "deep" | "max"; const REASONING_TONE_STYLES: Record< @@ -177,6 +124,48 @@ function tierPercent(index: number, total: number): number { return (index / (total - 1)) * 100; } +function pointerPercent(clientX: number, rect: DOMRect): number { + const ratio = rect.width > 0 ? (clientX - rect.left) / rect.width : 0; + return Math.max(0, Math.min(100, ratio * 100)); +} + +function nearestTierIndex(percent: number, total: number): number { + if (total <= 1) return 0; + return Math.round((percent / 100) * (total - 1)); +} + +function sliderThumbPosition(percent: number): string { + const clamped = Math.max(0, Math.min(100, percent)); + return `calc(${clamped}% + ${8 - (clamped / 100) * 16}px)`; +} + +function setSliderTrackPosition(track: HTMLDivElement, percent: number, hasFill = true): void { + const position = sliderThumbPosition(percent); + track.style.setProperty("--reasoning-slider-thumb-position", position); + track.style.setProperty("--reasoning-slider-fill-position", hasFill ? position : "0%"); +} + +function reasoningProgressiveGradient(total: number): string { + if (total <= 0) return "linear-gradient(90deg, transparent, transparent)"; + const stops = Array.from({ length: total }, (_, index) => { + const tone = REASONING_TONE_STYLES[reasoningToneKeyForIndex(index, total)]; + const percent = tierPercent(index, total); + const opacity = 0.58 + (index / Math.max(1, total - 1)) * 0.34; + return `rgba(${tone.rgb} / ${opacity.toFixed(2)}) ${percent}%`; + }); + return `linear-gradient(90deg, ${stops.join(", ")})`; +} + +const POINTER_DRAG_THRESHOLD_PX = 3; + +type ActivePointerGesture = { + pointerId: number; + startClientX: number; + startedOnRidge: boolean; + moved: boolean; + captured: boolean; +}; + export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ modelId, reasoningEffort, @@ -188,9 +177,23 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ triggerClassName, }: ReasoningEffortPickerProps) { const [open, setOpen] = useState(false); + const activePointerGestureRef = useRef(null); + const suppressTierClickRef = useRef(false); + const suppressTierClickTimerRef = useRef(null); const { rememberReasoning, getReasoningForFamily } = useReasoningByFamily(); useEffect(() => { - ensureReasoningSliderStyles(); + if (open) return; + activePointerGestureRef.current = null; + suppressTierClickRef.current = false; + if (suppressTierClickTimerRef.current != null) { + window.clearTimeout(suppressTierClickTimerRef.current); + suppressTierClickTimerRef.current = null; + } + }, [open]); + useEffect(() => () => { + if (suppressTierClickTimerRef.current != null) { + window.clearTimeout(suppressTierClickTimerRef.current); + } }, []); const descriptor = useMemo( @@ -198,23 +201,33 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ [modelId], ); - const tiers = descriptor?.reasoningTiers ?? []; + const tiers = useMemo( + () => descriptor?.reasoningTiers ?? [], + [descriptor?.reasoningTiers], + ); const family = descriptor?.family; + const useCodex56Labels = /^gpt-5\.6-(?:sol|terra|luna)$/i.test(descriptor?.providerModelId ?? ""); const displayedEffort = useMemo(() => { - if (reasoningEffort) return reasoningEffort; - if (useFamilyDefaults && family) return getReasoningForFamily(family); + const modelDefault = descriptor?.defaultReasoningEffort; + const validModelDefault = modelDefault && tiers.includes(modelDefault) ? modelDefault : null; + if (reasoningEffort) { + return tiers.includes(reasoningEffort) ? reasoningEffort : validModelDefault; + } + if (useFamilyDefaults && family) { + const remembered = getReasoningForFamily(family); + if (remembered) return tiers.includes(remembered) ? remembered : validModelDefault; + } return null; - }, [reasoningEffort, family, getReasoningForFamily, useFamilyDefaults]); + }, [descriptor?.defaultReasoningEffort, family, getReasoningForFamily, reasoningEffort, tiers, useFamilyDefaults]); const activeIndex = displayedEffort ? tiers.findIndex((tier) => tier === displayedEffort) : -1; const activeTone = REASONING_TONE_STYLES[reasoningToneKeyForIndex(activeIndex, tiers.length)]; - const activeLabel = activeIndex >= 0 ? tierLabel(tiers[activeIndex]!) : "Auto"; - const fillPercent = activeIndex < 0 ? 0 : tiers.length <= 1 ? 100 : tierPercent(activeIndex, tiers.length); + const activeLabel = activeIndex >= 0 ? tierLabel(tiers[activeIndex]!, useCodex56Labels) : "Auto"; const thumbPercent = activeIndex < 0 ? 0 : tierPercent(activeIndex, tiers.length); - const thumbLeft = activeIndex < 0 || tiers.length <= 1 - ? activeIndex < 0 ? "8px" : "50%" - : `calc(${thumbPercent}% + ${8 - (thumbPercent / 100) * 16}px)`; + const thumbLeft = activeIndex < 0 ? "8px" : sliderThumbPosition(thumbPercent); + const fillPosition = activeIndex < 0 ? "0%" : thumbLeft; + const progressiveGradient = useMemo(() => reasoningProgressiveGradient(tiers.length), [tiers.length]); const commitEffort = useCallback( (tier: string | null) => { @@ -228,23 +241,138 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ const handleSelect = useCallback( (tier: string) => { commitEffort(displayedEffort === tier ? null : tier); - setOpen(false); }, [commitEffort, displayedEffort], ); - const handleTrackPointer = useCallback( + const restoreTrackPosition = useCallback( + (track: HTMLDivElement) => { + track.removeAttribute("data-dragging"); + track.style.setProperty("--reasoning-slider-thumb-position", thumbLeft); + track.style.setProperty("--reasoning-slider-fill-position", fillPosition); + }, + [fillPosition, thumbLeft], + ); + + const releasePointerCapture = useCallback((track: HTMLDivElement, pointerId: number) => { + if (typeof track.releasePointerCapture !== "function") return; + if (typeof track.hasPointerCapture === "function" && !track.hasPointerCapture(pointerId)) return; + track.releasePointerCapture(pointerId); + }, []); + + const suppressTrailingTierClick = useCallback(() => { + suppressTierClickRef.current = true; + if (suppressTierClickTimerRef.current != null) { + window.clearTimeout(suppressTierClickTimerRef.current); + } + suppressTierClickTimerRef.current = window.setTimeout(() => { + suppressTierClickRef.current = false; + suppressTierClickTimerRef.current = null; + }, 0); + }, []); + + const handleTrackClickCapture = useCallback((event: React.MouseEvent) => { + if (!suppressTierClickRef.current) return; + suppressTierClickRef.current = false; + if (suppressTierClickTimerRef.current != null) { + window.clearTimeout(suppressTierClickTimerRef.current); + suppressTierClickTimerRef.current = null; + } + event.preventDefault(); + event.stopPropagation(); + }, []); + + const handleTrackPointerDown = useCallback( (event: React.PointerEvent) => { if (disabled || tiers.length === 0) return; const target = event.target as Element | null; - if (target?.closest?.("[data-reasoning-slider-ridge-button]")) return; - const rect = event.currentTarget.getBoundingClientRect(); - const ratio = rect.width > 0 ? (event.clientX - rect.left) / rect.width : 0; - const clamped = Math.max(0, Math.min(1, ratio)); - const nextIndex = tiers.length <= 1 ? 0 : Math.round(clamped * (tiers.length - 1)); + const startedOnRidge = Boolean(target?.closest?.("[data-reasoning-slider-ridge-button]")); + if (event.button !== 0 || activePointerGestureRef.current != null) return; + const gesture: ActivePointerGesture = { + pointerId: event.pointerId, + startClientX: event.clientX, + startedOnRidge, + moved: false, + captured: false, + }; + activePointerGestureRef.current = gesture; + if (startedOnRidge) return; + event.preventDefault(); + event.currentTarget.dataset.dragging = "true"; + if (typeof event.currentTarget.setPointerCapture === "function") { + event.currentTarget.setPointerCapture(event.pointerId); + gesture.captured = true; + } + setSliderTrackPosition( + event.currentTarget, + pointerPercent(event.clientX, event.currentTarget.getBoundingClientRect()), + ); + }, + [disabled, tiers.length], + ); + + const handleTrackPointerMove = useCallback((event: React.PointerEvent) => { + const gesture = activePointerGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + if (!gesture.moved) { + if (Math.abs(event.clientX - gesture.startClientX) < POINTER_DRAG_THRESHOLD_PX) return; + gesture.moved = true; + } + event.preventDefault(); + event.currentTarget.dataset.dragging = "true"; + if (!gesture.captured && typeof event.currentTarget.setPointerCapture === "function") { + event.currentTarget.setPointerCapture(event.pointerId); + gesture.captured = true; + } + setSliderTrackPosition( + event.currentTarget, + pointerPercent(event.clientX, event.currentTarget.getBoundingClientRect()), + ); + }, []); + + const handleTrackPointerUp = useCallback( + (event: React.PointerEvent) => { + const gesture = activePointerGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + const moved = gesture.moved + || Math.abs(event.clientX - gesture.startClientX) >= POINTER_DRAG_THRESHOLD_PX; + activePointerGestureRef.current = null; + if (gesture.startedOnRidge && !moved) { + restoreTrackPosition(event.currentTarget); + return; + } + event.preventDefault(); + const percent = pointerPercent(event.clientX, event.currentTarget.getBoundingClientRect()); + const nextIndex = nearestTierIndex(percent, tiers.length); + const snappedPercent = tierPercent(nextIndex, tiers.length); + event.currentTarget.removeAttribute("data-dragging"); + setSliderTrackPosition(event.currentTarget, snappedPercent); + if (gesture.captured) releasePointerCapture(event.currentTarget, event.pointerId); + if (gesture.startedOnRidge) suppressTrailingTierClick(); commitEffort(tiers[nextIndex] ?? null); }, - [commitEffort, disabled, tiers], + [commitEffort, releasePointerCapture, restoreTrackPosition, suppressTrailingTierClick, tiers], + ); + + const handleTrackPointerCancel = useCallback( + (event: React.PointerEvent) => { + const gesture = activePointerGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + activePointerGestureRef.current = null; + restoreTrackPosition(event.currentTarget); + if (gesture.captured) releasePointerCapture(event.currentTarget, event.pointerId); + }, + [releasePointerCapture, restoreTrackPosition], + ); + + const handleTrackLostPointerCapture = useCallback( + (event: React.PointerEvent) => { + const gesture = activePointerGestureRef.current; + if (!gesture || gesture.pointerId !== event.pointerId) return; + activePointerGestureRef.current = null; + restoreTrackPosition(event.currentTarget); + }, + [restoreTrackPosition], ); const handleSliderKeyDown = useCallback( @@ -269,7 +397,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ if (tiers.length === 0) return null; - const label = reasoningChipLabel(displayedEffort) ?? "AUTO"; + const label = reasoningChipLabel(displayedEffort, useCodex56Labels) ?? "AUTO"; return ( Faster Smarter + {displayedEffort === "ultra" ? ( +

+ Automatically delegates work to multiple agents and can use limits faster. +

+ ) : null}
= 0 && "ade-reasoning-slider-fill-max", )} - style={{ width: `${fillPercent}%` }} aria-hidden /> {tiers.map((tier, index) => { @@ -357,7 +501,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ type="button" role="radio" aria-checked={isActive} - aria-label={tierLabel(tier)} + aria-label={tierLabel(tier, useCodex56Labels)} data-reasoning-slider-ridge-button onClick={(event) => { event.stopPropagation(); @@ -367,7 +511,7 @@ export const ReasoningEffortPicker = memo(function ReasoningEffortPicker({ className="absolute top-1/2 flex h-7 w-7 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full outline-none focus-visible:ring-2 focus-visible:ring-white/40" style={{ left: `${percent}%` }} > - {tierLabel(tier)} + {tierLabel(tier, useCodex56Labels)}
diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts index b90611aa6..e11de8689 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.test.ts @@ -94,6 +94,7 @@ describe("mergeSelectorModels", () => { isDefault: true, isAvailable: true, reasoningEfforts: [{ effort: "high", description: "High" }], + defaultReasoningEffort: "high", serviceTiers: ["fast"], supportsReasoning: true, supportsTools: true, @@ -111,6 +112,7 @@ describe("mergeSelectorModels", () => { expect(result.models[0]).toMatchObject({ id: "cursor/composer-2", reasoningTiers: ["high"], + defaultReasoningEffort: "high", serviceTiers: ["fast"], capabilities: expect.objectContaining({ reasoning: true, tools: true }), }); diff --git a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts index 2336237e4..f0f952f1f 100644 --- a/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts +++ b/apps/desktop/src/renderer/components/shared/ModelPicker/modelCatalog.ts @@ -202,9 +202,10 @@ export function descriptorsFromAgentChatModelCatalog( for (const model of subsection.models ?? []) { const base = resolveModelDescriptor(model.id) ?? createUnknownModelPlaceholder(model.id); const family = pickerFamilyForCatalogGroup(String(model.groupKey || group.key), model.family); + const isGpt56CodexModel = /^gpt-5\.6-(?:sol|terra|luna)$/i.test(base.providerModelId); const runtimeReasoningTiers = model.reasoningEfforts ?.map((entry) => entry.effort.trim().toLowerCase()) - .filter(Boolean); + .filter((effort) => Boolean(effort) && (!isGpt56CodexModel || effort !== "max")); const serviceTiers = model.serviceTiers ?.map((entry) => entry.trim().toLowerCase()) .filter(Boolean); @@ -224,6 +225,11 @@ export function descriptorsFromAgentChatModelCatalog( ...(model.aliases?.length ? { aliases: model.aliases } : base.aliases?.length ? { aliases: base.aliases } : {}), capabilities, ...(runtimeReasoningTiers?.length ? { reasoningTiers: runtimeReasoningTiers } : {}), + ...(model.defaultReasoningEffort + ? { defaultReasoningEffort: model.defaultReasoningEffort.trim().toLowerCase() } + : base.defaultReasoningEffort + ? { defaultReasoningEffort: base.defaultReasoningEffort } + : {}), ...(model.serviceTiers !== undefined ? { serviceTiers } : base.serviceTiers?.length diff --git a/apps/desktop/src/renderer/components/shared/ModelSelector.tsx b/apps/desktop/src/renderer/components/shared/ModelSelector.tsx index 84febd481..f8421d2ba 100644 --- a/apps/desktop/src/renderer/components/shared/ModelSelector.tsx +++ b/apps/desktop/src/renderer/components/shared/ModelSelector.tsx @@ -1,6 +1,6 @@ import { useCallback, useMemo } from "react"; import type { ModelConfig, ModelProvider, ThinkingLevel } from "../../../shared/types"; -import { getModelById, resolveModelDescriptor } from "../../../shared/modelRegistry"; +import { getModelById, resolveModelDescriptor, selectSupportedReasoningEffort } from "../../../shared/modelRegistry"; import { ModelPicker } from "./ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "./ModelPicker/ReasoningEffortPicker"; @@ -51,9 +51,16 @@ export function ModelSelector({ const handleModelChange = useCallback((modelId: string) => { const normalizedId = normalizeModelId(modelId); const provider = providerFromFamily(normalizedId); + const descriptor = getModelById(normalizedId); + const tiers = descriptor?.reasoningTiers ?? []; + const currentThinking = toThinkingLevel(selectSupportedReasoningEffort({ + tiers, + preferred: value.thinkingLevel, + advertisedDefault: descriptor?.defaultReasoningEffort, + })); onChange({ modelId: normalizedId, - thinkingLevel: value.thinkingLevel ?? "medium", + ...(currentThinking ? { thinkingLevel: currentThinking } : {}), ...(provider ? { provider } : {}), }); }, [onChange, value.thinkingLevel]); diff --git a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.test.tsx b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.test.tsx new file mode 100644 index 000000000..f06ba4e4b --- /dev/null +++ b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.test.tsx @@ -0,0 +1,47 @@ +/* @vitest-environment jsdom */ + +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { ReviewLaunchModelControls } from "./ReviewLaunchModelControls"; + +vi.mock("./ModelPicker/ModelPicker", () => ({ + ModelPicker: ({ onChange }: { onChange: (modelId: string) => void }) => ( + + ), +})); + +vi.mock("./ModelPicker/ReasoningEffortPicker", () => ({ + ReasoningEffortPicker: ({ reasoningEffort }: { reasoningEffort: string | null }) => ( + {reasoningEffort} + ), +})); + +afterEach(() => { + cleanup(); + delete (window as unknown as { ade?: unknown }).ade; +}); + +describe("ReviewLaunchModelControls", () => { + it("clamps Sol Ultra to Luna's medium default when the model changes", () => { + (window as unknown as { ade: unknown }).ade = { + ai: { getStatus: vi.fn().mockResolvedValue(null) }, + }; + const onModelChange = vi.fn(); + const onReasoningEffortChange = vi.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole("button", { name: "Pick Luna" })); + + expect(onModelChange).toHaveBeenCalledTimes(1); + expect(onModelChange).toHaveBeenCalledWith("openai/gpt-5.6-luna"); + expect(onReasoningEffortChange).toHaveBeenCalledTimes(1); + expect(onReasoningEffortChange).toHaveBeenCalledWith("medium"); + }); +}); diff --git a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx index 758802445..15a38eef4 100644 --- a/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx +++ b/apps/desktop/src/renderer/components/shared/ReviewLaunchModelControls.tsx @@ -1,5 +1,6 @@ import React from "react"; import type { AiSettingsStatus } from "../../../shared/types"; +import { resolveModelDescriptor, selectSupportedReasoningEffort } from "../../../shared/modelRegistry"; import { deriveConfiguredModelIds } from "../../lib/modelOptions"; import { ModelPicker } from "./ModelPicker/ModelPicker"; import { ReasoningEffortPicker } from "./ModelPicker/ReasoningEffortPicker"; @@ -28,6 +29,18 @@ export function ReviewLaunchModelControls({ }: ReviewLaunchModelControlsProps) { const [availableModelIds, setAvailableModelIds] = React.useState([]); + const handleModelChange = React.useCallback((nextModelId: string) => { + const descriptor = resolveModelDescriptor(nextModelId); + const tiers = descriptor?.reasoningTiers ?? []; + const nextReasoning = selectSupportedReasoningEffort({ + tiers, + preferred: reasoningEffort, + advertisedDefault: descriptor?.defaultReasoningEffort, + }) ?? ""; + onModelChange(nextModelId); + if (nextReasoning !== reasoningEffort) onReasoningEffortChange(nextReasoning); + }, [onModelChange, onReasoningEffortChange, reasoningEffort]); + React.useEffect(() => { let cancelled = false; const aiBridge = (window as Window & { @@ -59,7 +72,7 @@ export function ReviewLaunchModelControls({
{ } }); + it("injects the signed Computer Use MCP client only when main selects it", () => { + const command = "/Users/test/.codex/computer-use/Codex Computer Use.app/Contents/SharedSupport/SkyComputerUseClient.app/Contents/MacOS/SkyComputerUseClient"; + const launch = buildTrackedCliLaunchCommand({ + provider: "codex", + permissionMode: "default", + codexComputerUse: { command, args: ["mcp"] }, + }); + expect(launch.args).toEqual(expect.arrayContaining([ + "-c", + `mcp_servers.computer_use.command=${JSON.stringify(command)}`, + "-c", + 'mcp_servers.computer_use.args=["mcp"]', + "-c", + "mcp_servers.computer_use.enabled=true", + ])); + expect(launch.startupCommand).toContain("mcp_servers.computer_use.command"); + expect(launch.startupCommand).not.toContain("mcp_servers.computer-use"); + + const resumed = buildTrackedCliResumeCommand({ + provider: "codex", + targetKind: "thread", + targetId: "thread-cu", + launch: { permissionMode: "default" }, + }, { codexComputerUse: { command, args: ["mcp"] } }); + expect(resumed).toContain("mcp_servers.computer_use.command"); + expect(resumed).toContain("resume thread-cu"); + }); + it("keeps empty Codex CLI launches waiting for the next user task", () => { const launch = buildTrackedCliLaunchCommand({ provider: "codex", permissionMode: "default" }); diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index dd56743e1..605abd02c 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -4093,6 +4093,63 @@ button:active, [role="button"]:active { animation: ade-toast-in 180ms cubic-bezier(0.34, 1.56, 0.64, 1); } +/* Model reasoning slider: progressive fill, active-tier pulse, and drag state. */ +@keyframes ade-reasoning-effort-word-pop { + 0% { opacity: 0; transform: translateY(3px); filter: blur(3px); } + 100% { opacity: 1; transform: translateY(0); filter: blur(0); } +} + +@keyframes ade-reasoning-fill-sheen { + 0% { transform: translateX(-80%); opacity: 0; } + 35% { opacity: 0.55; } + 100% { transform: translateX(80%); opacity: 0; } +} + +@keyframes ade-reasoning-grid-drift { + 0% { background-position: 0 0, 0 0; } + 100% { background-position: -18px 0, 0 0; } +} + +.ade-reasoning-effort-word { + animation: ade-reasoning-effort-word-pop 180ms cubic-bezier(0.2, 0.8, 0.2, 1); +} + +.ade-reasoning-slider-fill { + background: var(--reasoning-progressive-gradient); + clip-path: inset(0 calc(100% - var(--reasoning-slider-fill-position)) 0 0); + box-shadow: 0 0 18px rgba(var(--reasoning-fill-rgb), 0.24); + transition: clip-path 220ms cubic-bezier(0.2, 0.8, 0.2, 1), background 220ms ease, box-shadow 220ms ease; +} + +.ade-reasoning-slider-fill::after { + content: ""; + position: absolute; + inset: 0; + pointer-events: none; + background: linear-gradient(100deg, transparent 20%, rgba(255, 255, 255, 0.22) 50%, transparent 78%); + animation: ade-reasoning-fill-sheen 1.8s ease-in-out infinite; +} + +.ade-reasoning-slider-fill-max { + background: + radial-gradient(circle at center, rgba(255, 255, 255, 0.20) 0 0.8px, transparent 1px), + var(--reasoning-progressive-gradient); + background-size: 4px 4px, 100% 100%; + animation: ade-reasoning-grid-drift 1.15s linear infinite; + box-shadow: 0 0 24px rgba(167, 139, 250, 0.34); +} + +.ade-reasoning-slider-track[data-dragging="true"] .ade-reasoning-slider-fill, +.ade-reasoning-slider-track[data-dragging="true"] .ade-reasoning-slider-thumb { + transition: none; +} + @media (prefers-reduced-motion: reduce) { .ade-toast-enter { animation: none; } + .ade-reasoning-effort-word, + .ade-reasoning-slider-fill::after, + .ade-reasoning-slider-fill-max { + animation: none; + } + .ade-reasoning-slider-fill { transition: none; } } diff --git a/apps/desktop/src/shared/adeCliGuidance.ts b/apps/desktop/src/shared/adeCliGuidance.ts index 8abd95154..f37af9dc8 100644 --- a/apps/desktop/src/shared/adeCliGuidance.ts +++ b/apps/desktop/src/shared/adeCliGuidance.ts @@ -48,6 +48,7 @@ export function buildAdeBootstrapGuidance( "Your ADE capabilities ship as Agent Skills. When a task touches an ADE area (lanes/git, PRs, proof & screenshots, the built-in browser, iOS simulator, app control, Linear, deeplinks, or searching across everything in ADE), read the matching `ade-*` skill before acting; otherwise ignore them.", `Skills: ${adeBundledAgentSkills.map((name) => `\`${name}\``).join(", ")}.`, formatAdeAgentSkillRootsForPrompt(skillRoots), + "If the direct `mcp__computer_use` tools are present, use them for Codex Computer Use and honor their per-app approvals; do not initialize `@oai/sky` through `node_repl` as a substitute.", "Ground truth for any `ade` invocation is `ade help ` and `ade actions list --text`; prefer typed commands with `--text`. Project secrets are available through `ade secrets`; read only the named secret the user asks you to use and avoid printing secret values. Track and clean up processes you start.", ].join("\n"); } diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index d72500de4..a80e0e22b 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -29,6 +29,11 @@ export type TrackedCliLaunchCommand = { env?: Record; }; +export type CodexComputerUseCliConfig = { + command: string; + args?: readonly string[]; +}; + export type CleanShellLaunchFields = { command: string; args: string[]; @@ -306,6 +311,22 @@ export function defaultTrackedCliStartupCommand(provider: CliProvider): string { return "claude"; } +export function codexComputerUseMcpFlags( + config: CodexComputerUseCliConfig | null | undefined, +): string[] { + const command = config?.command?.trim(); + if (!command) return []; + const args = config?.args?.length ? [...config.args] : ["mcp"]; + return [ + "-c", + `mcp_servers.computer_use.command=${JSON.stringify(command)}`, + "-c", + `mcp_servers.computer_use.args=${JSON.stringify(args)}`, + "-c", + "mcp_servers.computer_use.enabled=true", + ]; +} + function workTabCliPreamblePrompt(skillRoots: readonly string[], hasInitialPrompt = false): string { const launchInstruction = hasInitialPrompt ? [ @@ -355,6 +376,8 @@ export function buildTrackedCliStartupCommand(args: { initialPrompt?: string | null; /** Active lane worktree used to make ADE skill roots lane-aware. */ laneWorktreePath?: string | null; + /** Signed standalone Computer Use MCP client selected by ADE's main process. */ + codexComputerUse?: CodexComputerUseCliConfig | null; }): string { return buildTrackedCliLaunchCommand(args).startupCommand; } @@ -375,6 +398,8 @@ export function buildTrackedCliLaunchCommand(args: { initialPrompt?: string | null; /** Active lane worktree used to make ADE skill roots lane-aware. */ laneWorktreePath?: string | null; + /** Signed standalone Computer Use MCP client selected by ADE's main process. */ + codexComputerUse?: CodexComputerUseCliConfig | null; }): TrackedCliLaunchCommand { const permissionMode = effectiveOrchestrationPermissionMode(args); validateLaunchProfilePermissionMode(args.provider, permissionMode); @@ -424,6 +449,7 @@ export function buildTrackedCliLaunchCommand(args: { ...modelToCliFlag(codexModel), ...codexReasoningEffortFlags(args.reasoningEffort), ...codexServiceTierFlags(args.fastMode), + ...codexComputerUseMcpFlags(args.codexComputerUse), ...permissionModeToCodexFlags(permissionMode), ]; const usePromptArg = codexModel === "gpt-5.3-codex"; @@ -824,6 +850,7 @@ export function buildTrackedCliResumeCommand( fastMode?: boolean | null; permissionMode?: AgentChatPermissionMode | null; prompt?: string | null; + codexComputerUse?: CodexComputerUseCliConfig | null; } = {}, ): string { const permissionMode = overrides.permissionMode ?? metadata.launch.permissionMode; @@ -857,6 +884,7 @@ export function buildTrackedCliResumeCommand( ...modelToCliFlag(model), ...codexReasoningEffortFlags(reasoningEffort), ...codexServiceTierFlags(fastMode), + ...codexComputerUseMcpFlags(overrides.codexComputerUse), ...permissionModeToCodexFlags(permissionMode), ]; parts.push("resume"); diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index 8695dfb84..a61c2c89b 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -196,6 +196,7 @@ export const IPC = { agentChatDispatchSteer: "ade.agentChat.dispatchSteer", agentChatCancelDispatchedSteer: "ade.agentChat.cancelDispatchedSteer", agentChatInterrupt: "ade.agentChat.interrupt", + agentChatRecoverCodexTurn: "ade.agentChat.recoverCodexTurn", agentChatApprove: "ade.agentChat.approve", agentChatRespondToInput: "ade.agentChat.respondToInput", agentChatModels: "ade.agentChat.models", diff --git a/apps/desktop/src/shared/modelProfiles.test.ts b/apps/desktop/src/shared/modelProfiles.test.ts index 23b07431f..9e7e8cbbf 100644 --- a/apps/desktop/src/shared/modelProfiles.test.ts +++ b/apps/desktop/src/shared/modelProfiles.test.ts @@ -126,9 +126,15 @@ describe("thinking levels", () => { ]); }); - it("CODEX_THINKING_LEVELS has four levels including xhigh", () => { - expect(CODEX_THINKING_LEVELS).toHaveLength(4); - expect(CODEX_THINKING_LEVELS.map((t) => t.value)).toEqual(["low", "medium", "high", "xhigh"]); + it("CODEX_THINKING_LEVELS matches the product-facing 5.6 ladder", () => { + expect(CODEX_THINKING_LEVELS).toHaveLength(5); + expect(CODEX_THINKING_LEVELS).toEqual([ + { value: "low", label: "Light" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High" }, + { value: "xhigh", label: "Extra High" }, + { value: "ultra", label: "Ultra" }, + ]); }); it("getThinkingLevels returns claude levels for 'claude'", () => { @@ -206,6 +212,10 @@ describe("thinkingLevelToReasoningEffort", () => { expect(thinkingLevelToReasoningEffort("ultracode")).toBe("ultracode"); }); + it("maps Codex 'ultra' to the exact runtime effort", () => { + expect(thinkingLevelToReasoningEffort("ultra")).toBe("ultra"); + }); + it("returns 'low' for null", () => { expect(thinkingLevelToReasoningEffort(null)).toBe("low"); }); diff --git a/apps/desktop/src/shared/modelProfiles.ts b/apps/desktop/src/shared/modelProfiles.ts index 15af72474..86508dd84 100644 --- a/apps/desktop/src/shared/modelProfiles.ts +++ b/apps/desktop/src/shared/modelProfiles.ts @@ -44,7 +44,7 @@ function descriptorToEntry(d: ModelDescriptor, overrides?: { recommended?: boole } const DEFAULT_CLAUDE_MODEL_ID = getDefaultModelDescriptor("claude")?.id ?? "anthropic/claude-sonnet-5"; -const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.5"; +const DEFAULT_CODEX_MODEL_ID = getDefaultModelDescriptor("codex")?.id ?? "openai/gpt-5.6-sol"; // CLI-wrapped Anthropic models (claude provider) export const CLAUDE_MODELS: ModelEntry[] = MODEL_REGISTRY @@ -89,10 +89,11 @@ export const CLAUDE_THINKING_LEVELS: ThinkingOption[] = [ ]; export const CODEX_THINKING_LEVELS: ThinkingOption[] = [ - { value: "low", label: "Low" }, + { value: "low", label: "Light" }, { value: "medium", label: "Medium" }, { value: "high", label: "High" }, { value: "xhigh", label: "Extra High" }, + { value: "ultra", label: "Ultra" }, ]; export function getThinkingLevels(provider: ModelProvider): ThinkingOption[] { diff --git a/apps/desktop/src/shared/modelRegistry.test.ts b/apps/desktop/src/shared/modelRegistry.test.ts index 66f3b2406..94947f010 100644 --- a/apps/desktop/src/shared/modelRegistry.test.ts +++ b/apps/desktop/src/shared/modelRegistry.test.ts @@ -19,11 +19,21 @@ import { resolveModelDescriptor, resolveModelDescriptorForProvider, resolveModelSlug, + selectSupportedReasoningEffort, } from "./modelRegistry"; import type { ModelDescriptor, ProviderFamily } from "./modelRegistry"; import { describeModelSource } from "../renderer/lib/modelOptions"; describe("modelRegistry", () => { + it("selects only supported reasoning preferences and defaults", () => { + const tiers = ["low", "medium", "high"]; + expect(selectSupportedReasoningEffort({ tiers, preferred: "high", advertisedDefault: "low" })).toBe("high"); + expect(selectSupportedReasoningEffort({ tiers, preferred: "ultra", advertisedDefault: "low" })).toBe("low"); + expect(selectSupportedReasoningEffort({ tiers, advertisedDefault: "ultra", fallback: "high" })).toBe("high"); + expect(selectSupportedReasoningEffort({ tiers: ["low", "high"] })).toBe("low"); + expect(selectSupportedReasoningEffort({ tiers: [] })).toBeNull(); + }); + it("round-trips OpenCode registry ids with slashes inside model ids", () => { const id = encodeOpenCodeRegistryId("lmstudio", "openai/gpt-oss-20b"); expect(id).toMatch(/^opencode\/lmstudio\//); @@ -81,6 +91,7 @@ describe("modelRegistry", () => { expect(byId).toBe("anthropic/claude-opus-4-8"); expect(resolveModelSlug("gpt-5.4")).toBe("openai/gpt-5.4"); expect(resolveModelSlug("gpt-5.5")).toBe("openai/gpt-5.5"); + expect(resolveModelSlug("sol", "codex")).toBe("openai/gpt-5.6-sol"); expect(resolveModelSlug("gpt-5.4", "codex")).toBe("openai/gpt-5.4"); expect(resolveModelSlug("gpt-5.5", "codex")).toBe("openai/gpt-5.5"); expect(resolveModelSlug("")).toBeUndefined(); @@ -109,6 +120,9 @@ describe("modelRegistry", () => { it("keeps only the allowed OpenAI chat models in the registry defaults", () => { expect(listModelDescriptorsForProvider("codex").map((model) => model.id)).toEqual([ + "openai/gpt-5.6-sol", + "openai/gpt-5.6-terra", + "openai/gpt-5.6-luna", "openai/gpt-5.5", "openai/gpt-5.4", "openai/gpt-5.4-mini", @@ -120,7 +134,37 @@ describe("modelRegistry", () => { // API-key OpenAI models are now discovered dynamically through OpenCode, // so the static registry yields no hits for api-key auth alone. expect(getAvailableModels([{ type: "api-key", provider: "openai" }]).map((model) => model.id)).toEqual([]); - expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.5"); + expect(getDefaultModelDescriptor("codex")?.id).toBe("openai/gpt-5.6-sol"); + }); + + it("exposes the exact GPT-5.6 Codex effort ladders and defaults", () => { + expect(getModelById("openai/gpt-5.6-sol")).toMatchObject({ + displayName: "GPT-5.6 Sol", + providerModelId: "gpt-5.6-sol", + contextWindow: 372_000, + reasoningTiers: ["low", "medium", "high", "xhigh", "ultra"], + defaultReasoningEffort: "low", + serviceTiers: ["fast"], + }); + expect(getModelById("openai/gpt-5.6-terra")).toMatchObject({ + displayName: "GPT-5.6 Terra", + providerModelId: "gpt-5.6-terra", + contextWindow: 372_000, + reasoningTiers: ["low", "medium", "high", "xhigh", "ultra"], + defaultReasoningEffort: "medium", + serviceTiers: ["fast"], + }); + expect(getModelById("openai/gpt-5.6-luna")).toMatchObject({ + displayName: "GPT-5.6 Luna", + providerModelId: "gpt-5.6-luna", + contextWindow: 372_000, + reasoningTiers: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + serviceTiers: ["fast"], + }); + expect(resolveModelAlias("sol")?.id).toBe("openai/gpt-5.6-sol"); + expect(resolveModelAlias("terra")?.id).toBe("openai/gpt-5.6-terra"); + expect(resolveModelAlias("luna")?.id).toBe("openai/gpt-5.6-luna"); }); it("exposes GPT-5.5 with the real OpenAI model id and expected reasoning tiers", () => { @@ -225,6 +269,14 @@ describe("modelRegistry", () => { expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe("gpt-5.5"); }); + it("returns the exact Codex app-server runtime names for GPT-5.6", () => { + for (const slug of ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"]) { + const descriptor = getModelById(`openai/${slug}`); + expect(descriptor).toBeTruthy(); + expect(getRuntimeModelRefForDescriptor(descriptor!, "codex")).toBe(slug); + } + }); + it("resolves Cursor CLI abstract picker controls to concrete model ids", () => { const descriptor = { id: "cursor/claude-opus-4-7-thinking", diff --git a/apps/desktop/src/shared/modelRegistry.ts b/apps/desktop/src/shared/modelRegistry.ts index d91439f2b..b2753d60b 100644 --- a/apps/desktop/src/shared/modelRegistry.ts +++ b/apps/desktop/src/shared/modelRegistry.ts @@ -53,6 +53,8 @@ export type ModelDescriptor = { maxOutputTokens: number; capabilities: ModelCapabilities; reasoningTiers?: string[]; + /** Runtime-recommended effort when the user has not chosen one explicitly. */ + defaultReasoningEffort?: string; serviceTiers?: string[]; color: string; providerRoute: string; @@ -96,6 +98,22 @@ export type DynamicLocalModelDescriptorOptions = { export type WorkerExecutionPath = "cli" | "api" | "local"; export type ModelProviderGroup = "claude" | "codex" | "opencode" | "cursor" | "droid"; +/** Select a valid reasoning tier without duplicating fallback policy in each UI. */ +export function selectSupportedReasoningEffort(args: { + tiers: readonly string[]; + preferred?: string | null; + advertisedDefault?: string | null; + fallback?: string | null; +}): string | null { + if (!args.tiers.length) return null; + if (args.preferred && args.tiers.includes(args.preferred)) return args.preferred; + if (args.advertisedDefault && args.tiers.includes(args.advertisedDefault)) { + return args.advertisedDefault; + } + if (args.fallback && args.tiers.includes(args.fallback)) return args.fallback; + return args.tiers.includes("medium") ? "medium" : args.tiers[0] ?? null; +} + export function isModelProviderGroup(value: string | null | undefined): value is ModelProviderGroup { return value === "claude" || value === "codex" || value === "opencode" || value === "cursor" || value === "droid"; } @@ -378,6 +396,74 @@ export const MODEL_REGISTRY: ModelDescriptor[] = [ // ADE codex chat surfaces use real OpenAI model ids as the canonical // registry ids; older ADE-internal "-codex" wrapper ids remain aliases so // persisted sessions continue to resolve. + { + id: "openai/gpt-5.6-sol", + shortId: "gpt-5.6-sol", + aliases: ["sol", "gpt-5.6-sol"], + displayName: "GPT-5.6 Sol", + family: "openai", + authTypes: ["cli-subscription"], + contextWindow: 372_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + // Codex keeps `max` behind an opt-in model-feature flag. ADE aligns with + // the default product-facing ladder while exposing Ultra orchestration. + reasoningTiers: ["low", "medium", "high", "xhigh", "ultra"], + defaultReasoningEffort: "low", + serviceTiers: ["fast"], + color: "#10A37F", + providerRoute: "codex-cli", + providerModelId: "gpt-5.6-sol", + cliCommand: "codex", + isCliWrapped: true, + inputPricePer1M: 5, + outputPricePer1M: 30, + costTier: "high", + }, + { + id: "openai/gpt-5.6-terra", + shortId: "gpt-5.6-terra", + aliases: ["terra", "gpt-5.6-terra"], + displayName: "GPT-5.6 Terra", + family: "openai", + authTypes: ["cli-subscription"], + contextWindow: 372_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh", "ultra"], + defaultReasoningEffort: "medium", + serviceTiers: ["fast"], + color: "#22B88A", + providerRoute: "codex-cli", + providerModelId: "gpt-5.6-terra", + cliCommand: "codex", + isCliWrapped: true, + inputPricePer1M: 2.5, + outputPricePer1M: 15, + costTier: "medium", + }, + { + id: "openai/gpt-5.6-luna", + shortId: "gpt-5.6-luna", + aliases: ["luna", "gpt-5.6-luna"], + displayName: "GPT-5.6 Luna", + family: "openai", + authTypes: ["cli-subscription"], + contextWindow: 372_000, + maxOutputTokens: 128_000, + capabilities: ALL_CAPS, + reasoningTiers: ["low", "medium", "high", "xhigh"], + defaultReasoningEffort: "medium", + serviceTiers: ["fast"], + color: "#34D399", + providerRoute: "codex-cli", + providerModelId: "gpt-5.6-luna", + cliCommand: "codex", + isCliWrapped: true, + inputPricePer1M: 1, + outputPricePer1M: 6, + costTier: "low", + }, { id: "openai/gpt-5.5", shortId: "gpt-5.5", @@ -1570,6 +1656,13 @@ function pickDefaultClaudeModel(models: ModelDescriptor[]): ModelDescriptor | un } function pickDefaultCodexModel(models: ModelDescriptor[]): ModelDescriptor | undefined { + const sol = models + .filter((model) => /gpt-\d+(?:\.\d+)*-sol$/i.test(model.providerModelId)) + .sort((left, right) => compareVersionSegmentsDesc( + parseVersionSegments(left.providerModelId), + parseVersionSegments(right.providerModelId), + )); + if (sol[0]) return sol[0]; const standard = models .filter((model) => /gpt-\d+(?:\.\d+)*(?:-codex)?$/i.test(model.id) || /gpt-\d+(?:\.\d+)*$/i.test(model.providerModelId)) .sort((left, right) => { diff --git a/apps/desktop/src/shared/types/chat.test.ts b/apps/desktop/src/shared/types/chat.test.ts index 717bec779..21d46f443 100644 --- a/apps/desktop/src/shared/types/chat.test.ts +++ b/apps/desktop/src/shared/types/chat.test.ts @@ -1,5 +1,17 @@ import { describe, expect, it } from "vitest"; -import { inferAttachmentType, mergeAttachments, type AgentChatFileRef } from "./chat"; +import { + inferAttachmentType, + mergeAttachments, + type AgentChatFileRef, + type AgentChatModelsArgs, +} from "./chat"; + +describe("AgentChatModelsArgs", () => { + it("allows typed callers to request the aggregated model catalog", () => { + const args: AgentChatModelsArgs = {}; + expect(args).toEqual({}); + }); +}); describe("inferAttachmentType", () => { it("returns 'image' for image MIME types", () => { diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 277b2d1aa..bc906e76c 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -281,6 +281,24 @@ export type CodexWebSearchAction = { snippet?: string; }; +export type AgentChatMcpAppContext = { + connectorId?: string; + linkId?: string; + resourceUri?: string; + appName?: string; + templateId?: string; + actionName?: string; +}; + +/** Provider-neutral source identity retained from structured MCP tool events. */ +export type AgentChatMcpToolSource = { + server: string; + tool: string; + pluginId?: string; + resourceUri?: string; + appContext?: AgentChatMcpAppContext; +}; + export type CodexSafetyBufferingState = { threadId?: string | null; turnId?: string | null; @@ -425,6 +443,7 @@ export type AgentChatEvent = type: "tool_call"; tool: string; args: unknown; + mcp?: AgentChatMcpToolSource; itemId: string; logicalItemId?: string; parentItemId?: string; @@ -435,6 +454,7 @@ export type AgentChatEvent = type: "tool_result"; tool: string; result: unknown; + mcp?: AgentChatMcpToolSource; resultOriginalBytes?: number; resultOmittedBytes?: number; itemId: string; @@ -873,6 +893,9 @@ export type AgentChatEvent = result?: string | null; /** Local filesystem path if Codex saved the image to disk; null when the result is purely a URL/data URI. */ savedPath?: string | null; + /** Metadata when a large inline data URI was omitted from durable history or mobile sync. */ + resultOriginalBytes?: number; + resultOmittedBytes?: number; status: "running" | "completed" | "failed"; } | { @@ -882,6 +905,9 @@ export type AgentChatEvent = path?: string | null; url?: string | null; title?: string | null; + /** Metadata when a large inline data URI was omitted from durable history or mobile sync. */ + urlOriginalBytes?: number; + urlOmittedBytes?: number; status: "running" | "completed" | "failed"; } | { @@ -1430,6 +1456,7 @@ export type AgentChatModelInfo = { description?: string | null; isDefault: boolean; reasoningEfforts?: Array<{ effort: string; description: string }>; + defaultReasoningEffort?: string | null; serviceTiers?: string[]; aliases?: string[]; maxThinkingTokens?: number | null; @@ -1807,6 +1834,24 @@ export type AgentChatInterruptArgs = { sessionId: string; }; +export type AgentChatCodexRecoveryAction = + | "wait" + | "steer" + | "interrupt_retry_same_thread" + | "restart_resume_thread"; + +export type AgentChatRecoverCodexTurnArgs = { + sessionId: string; + turnId: string; + action: AgentChatCodexRecoveryAction; +}; + +export type AgentChatRecoverCodexTurnResult = { + action: AgentChatCodexRecoveryAction; + turnId: string; + status: "waiting" | "nudged" | "retrying" | "resumed"; +}; + export type AgentChatCodexGetGoalArgs = { sessionId: string; }; @@ -1841,7 +1886,7 @@ export type AgentChatRespondToInputArgs = { }; export type AgentChatModelsArgs = { - provider: AgentChatProvider; + provider?: AgentChatProvider; activateRuntime?: boolean; cursorSource?: AgentChatCursorModelSource; }; diff --git a/apps/desktop/src/shared/types/models.ts b/apps/desktop/src/shared/types/models.ts index 4b4b63dbd..c6f4bb467 100644 --- a/apps/desktop/src/shared/types/models.ts +++ b/apps/desktop/src/shared/types/models.ts @@ -4,7 +4,7 @@ export type ModelProvider = "claude" | "codex" | "cursor" | "droid" | "opencode" | (string & {}); -export type ThinkingLevel = "none" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh" | "ultracode"; +export type ThinkingLevel = "none" | "minimal" | "low" | "medium" | "high" | "max" | "xhigh" | "ultra" | "ultracode"; export type ModelConfig = { /** Optional provider hint; routing is resolved from modelId via model registry. */ diff --git a/apps/desktop/src/shared/types/sync.ts b/apps/desktop/src/shared/types/sync.ts index bb943a6bc..608c76f85 100644 --- a/apps/desktop/src/shared/types/sync.ts +++ b/apps/desktop/src/shared/types/sync.ts @@ -1009,6 +1009,7 @@ export type SyncRemoteCommandAction = | "chat.create" | "chat.send" | "chat.interrupt" + | "chat.recoverCodexTurn" | "chat.steer" | "chat.cancelSteer" | "chat.editSteer" diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index afcb26acf..5b04165d8 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1730,6 +1730,31 @@ struct CodexWebSearchAction: Codable, Equatable { var snippet: String? } +struct AgentChatMcpAppContext: Codable, Equatable { + var connectorId: String? + var linkId: String? + var resourceUri: String? + var appName: String? + var templateId: String? + var actionName: String? +} + +struct AgentChatMcpToolSource: Codable, Equatable { + var server: String + var tool: String + var pluginId: String? + var resourceUri: String? + var appContext: AgentChatMcpAppContext? + + func displayToolName(fallback: String) -> String { + let appName = appContext?.appName?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let sourceName = appName.isEmpty ? server.trimmingCharacters(in: .whitespacesAndNewlines) : appName + let resolvedTool = tool.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sourceName.isEmpty, !resolvedTool.isEmpty else { return fallback } + return "\(sourceName):\(resolvedTool)" + } +} + struct CodexSafetyBufferingState: Codable, Equatable { var threadId: String? var turnId: String? @@ -1874,11 +1899,13 @@ enum AgentChatEvent: Decodable, Equatable { case codexSafetyBuffering(state: CodexSafetyBufferingState, turnId: String?) case codexModerationMetadata(metadata: CodexModerationMetadata, turnId: String?) case codexSleep(itemId: String, turnId: String?, durationMs: Int?, status: String) - case codexTurnStalled(turnId: String, threadId: String?, reason: String, message: String, recoveryOptions: [String]?) + case codexTurnStalled(turnId: String, threadId: String?, reason: String, message: String, recoveryOptions: [String]?, sourceSessionId: String?) case codexThreadDeleted(threadId: String, turnId: String?) case systemNotice(noticeKind: AgentChatNoticeKind, message: String, detail: RemoteJSONValue?, turnId: String?, steerId: String?) case completionReport(report: ChatCompletionReport, turnId: String?) case webSearch(query: String, action: String?, actions: [CodexWebSearchAction]?, itemId: String, logicalItemId: String?, turnId: String?, status: String) + case codexImageGeneration(itemId: String, turnId: String?, prompt: String?, revisedPrompt: String?, result: String?, savedPath: String?, resultOriginalBytes: Int?, resultOmittedBytes: Int?, status: String) + case codexImageView(itemId: String, turnId: String?, path: String?, url: String?, title: String?, urlOriginalBytes: Int?, urlOmittedBytes: Int?, status: String) case autoApprovalReview(targetItemId: String, reviewStatus: AgentChatAutoApprovalReviewStatus, action: String?, review: String?, turnId: String?) case promptSuggestion(suggestion: String, turnId: String?) case planText(text: String, turnId: String?, itemId: String?) @@ -1900,6 +1927,7 @@ extension AgentChatEvent { case logicalItemId case parentItemId case tool + case mcp case args case result case path @@ -1970,6 +1998,7 @@ extension AgentChatEvent { case state case reasons case recoveryOptions + case sourceSessionId case threadId case metadata case noticeKind @@ -1982,6 +2011,13 @@ extension AgentChatEvent { case suggestion case targetItemId case resolution + case revisedPrompt + case savedPath + case url + case resultOriginalBytes + case resultOmittedBytes + case urlOriginalBytes + case urlOmittedBytes } init(from decoder: Decoder) throws { @@ -2016,8 +2052,10 @@ extension AgentChatEvent { itemId: try container.decodeIfPresent(String.self, forKey: .itemId) ) case "tool_call": + let rawTool = try container.decode(String.self, forKey: .tool) + let mcp = try? container.decodeIfPresent(AgentChatMcpToolSource.self, forKey: .mcp) self = .toolCall( - tool: try container.decode(String.self, forKey: .tool), + tool: mcp?.displayToolName(fallback: rawTool) ?? rawTool, args: try container.decode(RemoteJSONValue.self, forKey: .args), itemId: try container.decode(String.self, forKey: .itemId), logicalItemId: try container.decodeIfPresent(String.self, forKey: .logicalItemId), @@ -2025,8 +2063,10 @@ extension AgentChatEvent { turnId: try container.decodeIfPresent(String.self, forKey: .turnId) ) case "tool_result": + let rawTool = try container.decode(String.self, forKey: .tool) + let mcp = try? container.decodeIfPresent(AgentChatMcpToolSource.self, forKey: .mcp) self = .toolResult( - tool: try container.decode(String.self, forKey: .tool), + tool: mcp?.displayToolName(fallback: rawTool) ?? rawTool, result: try container.decode(RemoteJSONValue.self, forKey: .result), itemId: try container.decode(String.self, forKey: .itemId), logicalItemId: try container.decodeIfPresent(String.self, forKey: .logicalItemId), @@ -2327,7 +2367,8 @@ extension AgentChatEvent { threadId: threadId, reason: reason, message: message, - recoveryOptions: recoveryOptions + recoveryOptions: recoveryOptions, + sourceSessionId: try container.decodeIfPresent(String.self, forKey: .sourceSessionId) ) case "codex_thread_deleted": self = .codexThreadDeleted( @@ -2357,6 +2398,29 @@ extension AgentChatEvent { turnId: try container.decodeIfPresent(String.self, forKey: .turnId), status: try container.decode(String.self, forKey: .status) ) + case "codex_image_generation", "image_generation": + self = .codexImageGeneration( + itemId: try container.decode(String.self, forKey: .itemId), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId), + prompt: try container.decodeIfPresent(String.self, forKey: .prompt), + revisedPrompt: try container.decodeIfPresent(String.self, forKey: .revisedPrompt), + result: try container.decodeIfPresent(String.self, forKey: .result), + savedPath: try container.decodeIfPresent(String.self, forKey: .savedPath), + resultOriginalBytes: try container.decodeIfPresent(Int.self, forKey: .resultOriginalBytes), + resultOmittedBytes: try container.decodeIfPresent(Int.self, forKey: .resultOmittedBytes), + status: try container.decode(String.self, forKey: .status) + ) + case "codex_image_view", "image_view": + self = .codexImageView( + itemId: try container.decode(String.self, forKey: .itemId), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId), + path: try container.decodeIfPresent(String.self, forKey: .path), + url: try container.decodeIfPresent(String.self, forKey: .url), + title: try container.decodeIfPresent(String.self, forKey: .title), + urlOriginalBytes: try container.decodeIfPresent(Int.self, forKey: .urlOriginalBytes), + urlOmittedBytes: try container.decodeIfPresent(Int.self, forKey: .urlOmittedBytes), + status: try container.decode(String.self, forKey: .status) + ) case "auto_approval_review": self = .autoApprovalReview( targetItemId: try container.decode(String.self, forKey: .targetItemId), @@ -2419,6 +2483,8 @@ extension AgentChatEvent { case .systemNotice: return "system_notice" case .completionReport: return "completion_report" case .webSearch: return "web_search" + case .codexImageGeneration: return "codex_image_generation" + case .codexImageView: return "codex_image_view" case .autoApprovalReview: return "auto_approval_review" case .promptSuggestion: return "prompt_suggestion" case .planText: return "plan_text" @@ -2483,6 +2549,18 @@ struct AgentChatInterruptRequest: Codable, Equatable { var sessionId: String } +struct AgentChatRecoverCodexTurnRequest: Codable, Equatable { + var sessionId: String + var turnId: String + var action: String +} + +struct AgentChatRecoverCodexTurnResult: Codable, Equatable { + var action: String + var turnId: String + var status: String +} + struct AgentChatSessionIdRequest: Codable, Equatable { var sessionId: String } @@ -2557,6 +2635,7 @@ struct AgentChatModelInfo: Codable, Equatable, Identifiable { var description: String? var isDefault: Bool var reasoningEfforts: [AgentChatModelReasoningEffort]? + var defaultReasoningEffort: String? = nil var serviceTiers: [String]? var aliases: [String]? = nil var maxThinkingTokens: Int? @@ -2590,6 +2669,7 @@ struct AgentChatModelCatalogModel: Codable, Equatable, Identifiable { var description: String? var isDefault: Bool var reasoningEfforts: [AgentChatModelReasoningEffort]? + var defaultReasoningEffort: String? = nil var serviceTiers: [String]? var aliases: [String]? = nil var maxThinkingTokens: Int? diff --git a/apps/ios/ADE/Resources/VoiceGlossary.json b/apps/ios/ADE/Resources/VoiceGlossary.json index 5ca494dd4..c650c9077 100644 --- a/apps/ios/ADE/Resources/VoiceGlossary.json +++ b/apps/ios/ADE/Resources/VoiceGlossary.json @@ -16,6 +16,9 @@ "Claude Sonnet 5", "Claude Haiku 4.5", "Claude Fable 5", + "GPT-5.6 Sol", + "GPT-5.6 Terra", + "GPT-5.6 Luna", "GPT-5.5", "GPT-5.4", "GPT-5.3", @@ -48,10 +51,7 @@ "attached lane", "stack", "pack", - "worker", - "integration", "compaction", - "workspace", "brain", "daemon", "runtime", diff --git a/apps/ios/ADE/Services/SyncService.swift b/apps/ios/ADE/Services/SyncService.swift index 541d5c881..59bf0b846 100644 --- a/apps/ios/ADE/Services/SyncService.swift +++ b/apps/ios/ADE/Services/SyncService.swift @@ -7074,6 +7074,45 @@ final class SyncService: ObservableObject { ) } + func recoverCodexTurn( + sessionId: String, + turnId: String, + action: String + ) async throws -> AgentChatRecoverCodexTurnResult { + let supportedActions = Set([ + "wait", + "steer", + "interrupt_retry_same_thread", + "restart_resume_thread", + ]) + guard supportedActions.contains(action) else { + throw NSError( + domain: "ADE", + code: 24, + userInfo: [NSLocalizedDescriptionKey: "This Codex recovery action is not supported."] + ) + } + guard supportsRemoteAction("chat.recoverCodexTurn") else { + throw NSError( + domain: "ADE", + code: 17, + userInfo: [NSLocalizedDescriptionKey: "Recovery actions are not available on this machine version. Update ADE on the machine and reconnect."] + ) + } + let scope = chatCommandScope(for: sessionId) + return try await sendDecodableChatCommand( + action: "chat.recoverCodexTurn", + payload: AgentChatRecoverCodexTurnRequest( + sessionId: sessionId, + turnId: turnId, + action: action + ), + targetProjectId: scope.projectId, + targetProjectRootPath: scope.rootPath, + as: AgentChatRecoverCodexTurnResult.self + ) + } + @discardableResult func steerChatSession( sessionId: String, diff --git a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift index 79acbe684..9d0cfb2a0 100644 --- a/apps/ios/ADE/Views/Components/ADEDesignSystem.swift +++ b/apps/ios/ADE/Views/Components/ADEDesignSystem.swift @@ -134,6 +134,15 @@ enum ADEColor { "anthropic/claude-haiku-4-5": 0x06B6D4, "haiku": 0x06B6D4, // OpenAI / Codex + "openai/gpt-5.6-sol": 0x10A37F, + "gpt-5.6-sol": 0x10A37F, + "sol": 0x10A37F, + "openai/gpt-5.6-terra": 0x22B88A, + "gpt-5.6-terra": 0x22B88A, + "terra": 0x22B88A, + "openai/gpt-5.6-luna": 0x34D399, + "gpt-5.6-luna": 0x34D399, + "luna": 0x34D399, "openai/gpt-5.5": 0x10A37F, "gpt-5.5": 0x10A37F, "openai/gpt-5.4": 0x10A37F, @@ -167,6 +176,18 @@ enum ADEColor { } switch lower { + case "sol", "gpt-5.6-sol", "openai/gpt-5.6-sol": + append("openai/gpt-5.6-sol") + append("gpt-5.6-sol") + append("sol") + case "terra", "gpt-5.6-terra", "openai/gpt-5.6-terra": + append("openai/gpt-5.6-terra") + append("gpt-5.6-terra") + append("terra") + case "luna", "gpt-5.6-luna", "openai/gpt-5.6-luna": + append("openai/gpt-5.6-luna") + append("gpt-5.6-luna") + append("luna") case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": append("openai/gpt-5.5") append("gpt-5.5") @@ -256,6 +277,15 @@ enum ADEColor { "sonnet": ["low", "medium", "high", "max"], // Claude Haiku intentionally absent — no reasoning tiers. // OpenAI / Codex + "openai/gpt-5.6-sol": ["low", "medium", "high", "xhigh", "ultra"], + "gpt-5.6-sol": ["low", "medium", "high", "xhigh", "ultra"], + "sol": ["low", "medium", "high", "xhigh", "ultra"], + "openai/gpt-5.6-terra": ["low", "medium", "high", "xhigh", "ultra"], + "gpt-5.6-terra": ["low", "medium", "high", "xhigh", "ultra"], + "terra": ["low", "medium", "high", "xhigh", "ultra"], + "openai/gpt-5.6-luna": ["low", "medium", "high", "xhigh"], + "gpt-5.6-luna": ["low", "medium", "high", "xhigh"], + "luna": ["low", "medium", "high", "xhigh"], "openai/gpt-5.5": ["low", "medium", "high", "xhigh"], "gpt-5.5": ["low", "medium", "high", "xhigh"], "openai/gpt-5.4": ["low", "medium", "high", "xhigh"], diff --git a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift index 4458e2bba..ffa451635 100644 --- a/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift +++ b/apps/ios/ADE/Views/Hub/HubComposerDrawer.swift @@ -1096,7 +1096,7 @@ private func hubDefaultChatModelId(provider: String) -> String { return defaultModel } switch hubNormalizedChatProvider(provider) { - case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.5" + case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.6-sol" case "cursor": return "auto" case "opencode": return "opencode/anthropic/claude-sonnet-5" default: return "claude-sonnet-5" diff --git a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift index 3f108c195..111b219ab 100644 --- a/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift +++ b/apps/ios/ADE/Views/Lanes/LaneChatLaunchSheet.swift @@ -125,7 +125,8 @@ struct LaneChatLaunchSheet: View { } } - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + let reasoningEfforts = workVisibleReasoningEfforts(for: selectedModel) + if !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], alignment: .leading, spacing: 8) { @@ -139,7 +140,7 @@ struct LaneChatLaunchSheet: View { } ForEach(reasoningEfforts) { effort in LaneOptionButton( - title: effort.effort.capitalized, + title: workReasoningEffortDisplayName(effort.effort), subtitle: effort.description, systemImage: "brain", isSelected: selectedReasoningEffort == effort.effort @@ -202,7 +203,8 @@ struct LaneChatLaunchSheet: View { } } .onChange(of: selectedModelId) { _, _ in - if let efforts = selectedModel?.reasoningEfforts, !efforts.isEmpty { + let efforts = workVisibleReasoningEfforts(for: selectedModel) + if !efforts.isEmpty { if !efforts.contains(where: { $0.effort == selectedReasoningEffort }) { selectedReasoningEffort = "" } @@ -238,7 +240,10 @@ struct LaneChatLaunchSheet: View { private func loadModels(resetSelection: Bool) async { let requestedProvider = provider do { - let loadedModels = try await syncService.listChatModels(provider: requestedProvider) + let loadedModels = workPrioritizeGPT56ChatModels( + try await syncService.listChatModels(provider: requestedProvider), + provider: requestedProvider + ) guard provider == requestedProvider else { return } models = loadedModels if resetSelection || loadedModels.contains(where: { $0.id == selectedModelId }) == false { @@ -271,7 +276,7 @@ struct LaneChatLaunchSheet: View { model: selectedModelId, reasoningEffort: { guard !selectedReasoningEffort.isEmpty else { return nil } - guard selectedModel?.reasoningEfforts?.contains(where: { $0.effort == selectedReasoningEffort }) == true else { return nil } + guard workVisibleReasoningEfforts(for: selectedModel).contains(where: { $0.effort == selectedReasoningEffort }) else { return nil } return selectedReasoningEffort }() ) diff --git a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift index 6be020481..9bdfdbabd 100644 --- a/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatComposerAndInputViews.swift @@ -92,11 +92,25 @@ func workReasoningChipLabel(_ effort: String?) -> String? { case "high": return "HI" case "xhigh", "extra-high", "extra_high", "extra high": return "XH" case "max": return "MAX" - case "ultracode": return "ULTRA" + case "ultra", "ultracode": return "ULTRA" default: return String(lower.prefix(3)).uppercased() } } +func workReasoningEffortDisplayName(_ effort: String) -> String { + switch effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "minimal": return "Minimal" + case "low": return "Light" + case "medium": return "Medium" + case "high": return "High" + case "xhigh", "extra-high", "extra_high", "extra high": return "Extra High" + case "max": return "Max" + case "ultra": return "Ultra" + case "ultracode": return "Ultracode" + default: return effort.capitalized + } +} + func workChatComposerSupportsFastMode(_ summary: AgentChatSessionSummary) -> Bool { workComposerFastModeSupported( modelId: summary.modelId ?? summary.model, @@ -191,6 +205,9 @@ private func workModelRefsLookFastCapable(_ rawRefs: [String?]) -> Bool { .compactMap { $0?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() } .filter { !$0.isEmpty } let fastRefs: Set = [ + "sol", "gpt-5.6-sol", "openai/gpt-5.6-sol", + "terra", "gpt-5.6-terra", "openai/gpt-5.6-terra", + "luna", "gpt-5.6-luna", "openai/gpt-5.6-luna", "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex", "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex", "opencode/openai/gpt-5.4", diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index bb297e427..9b18cd52a 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -1234,6 +1234,137 @@ struct WorkEventCardView: View { } } +struct WorkCodexRecoveryCardView: View { + let card: WorkEventCardModel + let sessionId: String + let enabled: Bool + let onRecover: (@MainActor (String, String, String) async throws -> String)? + + @State private var pendingAction: String? + @State private var feedbackMessage: String? + @State private var errorMessage: String? + + private let labels: [String: String] = [ + "wait": "Wait", + "steer": "Nudge", + "interrupt_retry_same_thread": "Retry", + "restart_resume_thread": "Resume", + ] + + private var visibleOptions: [String] { + var seen = Set() + return card.recoveryOptions.filter { labels[$0] != nil && seen.insert($0).inserted }.prefix(4).map { $0 } + } + + private var canRecover: Bool { + enabled + && onRecover != nil + && !sessionId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + && !(card.recoveryTurnId?.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ?? true) + } + + var body: some View { + VStack(alignment: .leading, spacing: 9) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.warning) + Text("RECOVERY") + .font(.caption2.monospaced().weight(.bold)) + .tracking(1.2) + .foregroundStyle(ADEColor.warning.opacity(0.8)) + Text("Codex paused unexpectedly") + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(1) + } + + if let body = card.body, !body.isEmpty { + Text(body) + .font(.caption) + .foregroundStyle(ADEColor.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + + if !visibleOptions.isEmpty { + ViewThatFits(in: .horizontal) { + HStack(spacing: 7) { recoveryButtons } + LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 7) { recoveryButtons } + } + } + + if !canRecover, !visibleOptions.isEmpty { + Text(enabled ? "Update or reconnect the paired machine to use recovery." : "Reconnect to the paired machine to use recovery.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } + if let feedbackMessage { + Label(feedbackMessage, systemImage: "checkmark.circle.fill") + .font(.caption2) + .foregroundStyle(ADEColor.success) + .accessibilityAddTraits(.isStaticText) + } + if let errorMessage { + Label(errorMessage, systemImage: "xmark.circle.fill") + .font(.caption2) + .foregroundStyle(ADEColor.danger) + .accessibilityAddTraits(.isStaticText) + } + } + .padding(12) + .background(ADEColor.warning.opacity(0.055), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.warning.opacity(0.18), lineWidth: 1) + ) + .accessibilityElement(children: .contain) + } + + @ViewBuilder + private var recoveryButtons: some View { + ForEach(visibleOptions, id: \.self) { option in + let label = labels[option] ?? option + Button { + Task { await recover(option) } + } label: { + Text(pendingAction == option ? "\(label)…" : label) + .font(.caption.monospaced().weight(.semibold)) + .foregroundStyle(ADEColor.warning) + .frame(maxWidth: .infinity, minHeight: 44) + .padding(.horizontal, 10) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(ADEColor.warning.opacity(0.07), in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 10, style: .continuous) + .stroke(ADEColor.warning.opacity(0.18), lineWidth: 1) + ) + .disabled(!canRecover || pendingAction != nil) + .accessibilityLabel("\(label) Codex recovery") + .accessibilityHint("Runs this recovery action for the stalled Codex turn.") + } + } + + @MainActor + private func recover(_ action: String) async { + guard canRecover, + pendingAction == nil, + let turnId = card.recoveryTurnId, + let onRecover else { return } + pendingAction = action + feedbackMessage = nil + errorMessage = nil + defer { pendingAction = nil } + do { + feedbackMessage = try await onRecover(sessionId, turnId, action) + } catch { + ADEHaptics.error() + errorMessage = error.localizedDescription + } + } +} + /// Resolved / historical structured-question card shown in the transcript once /// a question is no longer pending. Replaces the flat "Question asked · Input /// requested" event card: renders the asking provider's logo + "{Provider} diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift index 76512878a..b464b9316 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView+Timeline.swift @@ -242,6 +242,13 @@ extension WorkChatSessionView { WorkResolvedPlanCard(card: card, fallbackProvider: chatSummaryContext.provider) } else if card.kind == "approval" { WorkResolvedApprovalChip(card: card, fallbackProvider: chatSummaryContext.provider) + } else if card.kind == "codexRecovery" { + WorkCodexRecoveryCardView( + card: card, + sessionId: card.recoverySessionId ?? session.id, + enabled: isLive, + onRecover: onRecoverCodexTurn + ) } else { WorkEventCardView( card: card, diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index f8471731d..c4e4108fb 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -213,6 +213,10 @@ struct WorkChatSessionView: View { var personalAttachmentsAvailable: Bool = true var personalModelCatalogAvailable: Bool = true var personalSessionUpdatesAvailable: Bool = true + /// Present only when the paired host advertises `chat.recoverCodexTurn`. + /// Keeping this optional prevents a new phone build from offering controls + /// that an older ADE brain cannot execute. + var onRecoverCodexTurn: (@MainActor (String, String, String) async throws -> String)? = nil @State var steerEditDrafts: [String: String] = [:] @State var modelPickerPresented = false diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index b418187d8..2f2841816 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1565,7 +1565,8 @@ func isQuestionInputToolName(_ tool: String) -> Bool { } private func normalizedWorkToolIdentity(_ tool: String) -> String { - tool + let unqualified = tool.split(separator: ":", omittingEmptySubsequences: true).last.map(String.init) ?? tool + return unqualified .trimmingCharacters(in: .whitespacesAndNewlines) .lowercased() .replacingOccurrences(of: "-", with: "_") @@ -1796,8 +1797,8 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { return ["web_search", turnId ?? "", itemId, query, actionKey, status.rawValue].joined(separator: "|") case .codexState(let title, let message, _, let turnId): return ["codex_state", turnId ?? "", title, message].joined(separator: "|") - case .codexTurnStalled(let message, _, let turnId): - return ["codex_turn_stalled", turnId ?? "", message].joined(separator: "|") + case .codexTurnStalled(let message, _, let turnId, let sourceSessionId): + return ["codex_turn_stalled", sourceSessionId ?? "", turnId ?? "", message].joined(separator: "|") case .planText(let text, let turnId): return ["plan_text", turnId ?? "", text].joined(separator: "|") case .toolUseSummary(let text, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index c7ffd18f3..a8c8038c2 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -16,6 +16,132 @@ func workStableTimelineItemId(itemId: String?, logicalItemId: String?) -> String return itemId } +private func workNonEmptyImageField(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +} + +private func workCompactImageResult(_ value: String?) -> String? { + guard let value = workNonEmptyImageField(value) else { return nil } + if value.lowercased().hasPrefix("data:") { + return "Generated image data" + } + return value +} + +private func workCompactImageReference(_ value: String?) -> String? { + guard let value = workNonEmptyImageField(value) else { return nil } + return value.lowercased().hasPrefix("data:") ? "Inline image data" : value +} + +private func workOmittedImageDetail(originalBytes: Int?, omittedBytes: Int?) -> String? { + guard let omittedBytes, omittedBytes > 0 else { return nil } + let bytes = max(originalBytes ?? omittedBytes, omittedBytes) + let count: String + if bytes >= 1024 * 1024 { + count = String(format: "%.1f MB", Double(bytes) / Double(1024 * 1024)) + } else if bytes >= 1024 { + count = "\((bytes + 512) / 1024) KB" + } else { + count = "\(bytes) bytes" + } + return "Inline preview omitted from mobile sync (\(count))" +} + +func workCodexImageGenerationEvent( + itemId: String, + turnId: String?, + prompt: String?, + revisedPrompt: String?, + result: String?, + savedPath: String?, + resultOriginalBytes: Int? = nil, + resultOmittedBytes: Int? = nil, + status rawStatus: String +) -> WorkChatEvent { + let status = toolStatus(from: rawStatus) + let effectivePrompt = workNonEmptyImageField(revisedPrompt) ?? workNonEmptyImageField(prompt) + if status == .running { + return .toolCall( + tool: "image_generation", + argsText: effectivePrompt ?? "Generating image", + itemId: itemId, + parentItemId: nil, + turnId: turnId + ) + } + + var lines: [String] = [] + if let savedPath = workNonEmptyImageField(savedPath) { + lines.append(savedPath) + } + if let result = workCompactImageResult(result), !lines.contains(result) { + lines.append(result) + } + if let omittedDetail = workOmittedImageDetail( + originalBytes: resultOriginalBytes, + omittedBytes: resultOmittedBytes + ) { + lines.append(omittedDetail) + } + if let effectivePrompt { + lines.append("Prompt: \(effectivePrompt)") + } + if lines.isEmpty { + lines.append(status == .failed ? "Image generation failed" : "Image generated") + } + return .toolResult( + tool: "image_generation", + resultText: lines.joined(separator: "\n"), + itemId: itemId, + parentItemId: nil, + turnId: turnId, + status: status + ) +} + +func workCodexImageViewEvent( + itemId: String, + turnId: String?, + path: String?, + url: String?, + title: String?, + urlOriginalBytes: Int? = nil, + urlOmittedBytes: Int? = nil, + status rawStatus: String +) -> WorkChatEvent { + let status = toolStatus(from: rawStatus) + var fields = [ + workNonEmptyImageField(title), + workCompactImageReference(path), + workCompactImageReference(url), + ].compactMap { $0 } + if let omittedDetail = workOmittedImageDetail( + originalBytes: urlOriginalBytes, + omittedBytes: urlOmittedBytes + ) { + fields.append(omittedDetail) + } + let detail = fields.isEmpty ? "Image" : fields.joined(separator: "\n") + if status == .running { + return .toolCall( + tool: "image_view", + argsText: detail, + itemId: itemId, + parentItemId: nil, + turnId: turnId + ) + } + return .toolResult( + tool: "image_view", + resultText: detail, + itemId: itemId, + parentItemId: nil, + turnId: turnId, + status: status + ) +} + private final class WorkANSIAttributedStringCacheBox: NSObject { let value: AttributedString @@ -307,6 +433,29 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { return .autoApprovalReview(summary: summary, turnId: turnId) case .webSearch(let query, let action, let actions, let itemId, let logicalItemId, let turnId, let status): return .webSearch(query: query, action: action, actions: actions, status: toolStatus(from: status), itemId: workStableTimelineItemId(itemId: itemId, logicalItemId: logicalItemId), turnId: turnId) + case .codexImageGeneration(let itemId, let turnId, let prompt, let revisedPrompt, let result, let savedPath, let resultOriginalBytes, let resultOmittedBytes, let status): + return workCodexImageGenerationEvent( + itemId: itemId, + turnId: turnId, + prompt: prompt, + revisedPrompt: revisedPrompt, + result: result, + savedPath: savedPath, + resultOriginalBytes: resultOriginalBytes, + resultOmittedBytes: resultOmittedBytes, + status: status + ) + case .codexImageView(let itemId, let turnId, let path, let url, let title, let urlOriginalBytes, let urlOmittedBytes, let status): + return workCodexImageViewEvent( + itemId: itemId, + turnId: turnId, + path: path, + url: url, + title: title, + urlOriginalBytes: urlOriginalBytes, + urlOmittedBytes: urlOmittedBytes, + status: status + ) case .codexSafetyBuffering(let state, let turnId): let detail = state.fasterModel.map { "Buffering, \($0) ready" } ?? "Buffering" return .codexState(title: "Safety", message: detail, icon: "shield.checkered", turnId: turnId ?? state.turnId) @@ -317,8 +466,13 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { return .codexState(title: "Wait", message: duration.map { "Sleeping \($0)" } ?? "Sleeping", icon: "hourglass", turnId: turnId) case .codexThreadDeleted(_, let turnId): return .codexState(title: "Thread", message: "Deleted upstream. Next message starts fresh.", icon: "exclamationmark.triangle", turnId: turnId) - case .codexTurnStalled(let turnId, _, _, let message, let recoveryOptions): - return .codexTurnStalled(message: message, recoveryOptions: recoveryOptions ?? [], turnId: turnId) + case .codexTurnStalled(let turnId, _, _, let message, let recoveryOptions, let sourceSessionId): + return .codexTurnStalled( + message: message, + recoveryOptions: recoveryOptions ?? [], + turnId: turnId, + sourceSessionId: sourceSessionId + ) case .planText(let text, let turnId, _): return .planText(text: text, turnId: turnId) case .toolUseSummary(let summary, _, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift index d0b9f6776..a8bcf1b74 100644 --- a/apps/ios/ADE/Views/Work/WorkModelCatalog.swift +++ b/apps/ios/ADE/Views/Work/WorkModelCatalog.swift @@ -3,9 +3,8 @@ import SwiftUI /// A single model entry that can be displayed in the mobile model picker. /// Mirrors a subset of the desktop `ModelDescriptor` — the mobile picker -/// doesn't need pricing/tier/reasoning metadata yet, just enough to render a -/// branded row (logo + display name + tier hint) and send a `modelId` to -/// `SyncService.updateChatSession`. +/// keeps the picker-facing tier, reasoning, and service metadata needed to +/// render a branded row and send a valid selection to the paired runtime. struct WorkModelOption: Identifiable, Hashable { /// Stable sync-contract id the host accepts (e.g. "claude-opus-4-8"). let id: String @@ -20,6 +19,8 @@ struct WorkModelOption: Identifiable, Hashable { /// Reasoning efforts supplied by the paired desktop host. Empty means the /// host did not advertise a selectable reasoning control for this model. let reasoningEfforts: [AgentChatModelReasoningEffort] + /// Runtime-recommended reasoning effort for a newly selected model. + let defaultReasoningEffort: String? let serviceTiers: [String] let cursorAvailability: CursorModelAvailability? let isAvailable: Bool @@ -31,6 +32,7 @@ struct WorkModelOption: Identifiable, Hashable { tagline: String, provider: String, reasoningEfforts: [AgentChatModelReasoningEffort] = [], + defaultReasoningEffort: String? = nil, serviceTiers: [String] = [], cursorAvailability: CursorModelAvailability? = nil, isAvailable: Bool = true @@ -41,6 +43,7 @@ struct WorkModelOption: Identifiable, Hashable { self.tagline = tagline self.provider = provider self.reasoningEfforts = reasoningEfforts + self.defaultReasoningEffort = defaultReasoningEffort self.serviceTiers = serviceTiers self.cursorAvailability = cursorAvailability self.isAvailable = isAvailable @@ -97,6 +100,33 @@ func workFilterChatModelsForCursorAvailability( models.filter { workAgentChatModelSupportsCursorAvailabilityMode($0, mode: mode) } } +/// Preserve the host's flat model catalog while pinning the GPT-5.6 family to +/// the required Codex order. This never injects a model an older host omitted. +func workPrioritizeGPT56ChatModels( + _ models: [AgentChatModelInfo], + provider: String +) -> [AgentChatModelInfo] { + guard providerFamilyKey(provider) == "codex" else { return models } + + func priority(_ model: AgentChatModelInfo) -> Int { + let canonicalId = model.modelId.flatMap(workCanonicalCodexRegistryId(for:)) + ?? workCanonicalCodexRegistryId(for: model.id) + switch canonicalId { + case "openai/gpt-5.6-sol": return 0 + case "openai/gpt-5.6-terra": return 1 + case "openai/gpt-5.6-luna": return 2 + default: return 3 + } + } + + return models.enumerated().sorted { lhs, rhs in + let lhsPriority = priority(lhs.element) + let rhsPriority = priority(rhs.element) + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.offset < rhs.offset + }.map(\.element) +} + /// Maps a picked model to the tracked CLI runtime provider, mirroring desktop /// `resolveCliProviderForModel` + `resolveProviderGroupForModel`. func workResolveCliProvider(for modelId: String, provider: String) -> String { @@ -200,6 +230,88 @@ struct WorkModelCatalogGroupLegacyView: Identifiable, Hashable { private let workModelGroupOrder = ["claude", "codex", "cursor", "droid", "opencode", "ollama", "lmstudio"] +private func workCodex56ReasoningEfforts(includeUltra: Bool) -> [AgentChatModelReasoningEffort] { + var efforts = [ + AgentChatModelReasoningEffort(effort: "low", description: "Fast answers with light reasoning"), + AgentChatModelReasoningEffort(effort: "medium", description: "Balanced speed and reasoning"), + AgentChatModelReasoningEffort(effort: "high", description: "Deeper reasoning for complex work"), + AgentChatModelReasoningEffort(effort: "xhigh", description: "Extended reasoning for difficult work"), + ] + if includeUltra { + efforts.append(AgentChatModelReasoningEffort( + effort: "ultra", + description: "Automatic multi-agent delegation; can use limits faster" + )) + } + return efforts +} + +private func workVisibleReasoningEfforts( + modelId: String, + advertised: [AgentChatModelReasoningEffort]?, + fallback: [AgentChatModelReasoningEffort] = [] +) -> [AgentChatModelReasoningEffort] { + let canonicalId = workCanonicalCodexRegistryId(for: modelId) + guard canonicalId == "openai/gpt-5.6-sol" + || canonicalId == "openai/gpt-5.6-terra" + || canonicalId == "openai/gpt-5.6-luna" else { + return advertised ?? fallback + } + let visibleAdvertised = advertised?.filter { + $0.effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "max" + } ?? [] + if !visibleAdvertised.isEmpty { + return visibleAdvertised + } + let visibleFallback = fallback.filter { + $0.effort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() != "max" + } + if !visibleFallback.isEmpty { + return visibleFallback + } + switch canonicalId { + case "openai/gpt-5.6-sol", "openai/gpt-5.6-terra": + return workCodex56ReasoningEfforts(includeUltra: true) + case "openai/gpt-5.6-luna": + return workCodex56ReasoningEfforts(includeUltra: false) + default: + return [] + } +} + +private func workVisibleDefaultReasoningEffort( + modelId: String, + advertised: String?, + fallback: String? +) -> String? { + let canonicalId = workCanonicalCodexRegistryId(for: modelId) + guard canonicalId == "openai/gpt-5.6-sol" + || canonicalId == "openai/gpt-5.6-terra" + || canonicalId == "openai/gpt-5.6-luna" else { + return advertised ?? fallback + } + let normalizedAdvertised = advertised?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let normalizedAdvertised, !normalizedAdvertised.isEmpty, normalizedAdvertised != "max" { + return normalizedAdvertised + } + let normalizedFallback = fallback?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + if let normalizedFallback, !normalizedFallback.isEmpty, normalizedFallback != "max" { + return normalizedFallback + } + switch canonicalId { + case "openai/gpt-5.6-sol": return "low" + case "openai/gpt-5.6-terra", "openai/gpt-5.6-luna": return "medium" + default: return nil + } +} + +func workVisibleReasoningEfforts(for model: AgentChatModelInfo?) -> [AgentChatModelReasoningEffort] { + guard let model else { return [] } + let reasoningModelId = model.modelId.flatMap(workCanonicalCodexRegistryId(for:)) + ?? model.id + return workVisibleReasoningEfforts(modelId: reasoningModelId, advertised: model.reasoningEfforts) +} + /// Flat view of the curated catalog: every model in a single provider tab so /// legacy call sites keep functioning. Prefer `workModelCatalogGroups` for /// the desktop-shaped hierarchical picker. @@ -258,6 +370,36 @@ private func workCuratedModelCatalogGroups() -> [WorkModelCatalogGroup] { key: "openai", displayName: "OpenAI", models: [ + WorkModelOption( + id: "gpt-5.6-sol", + displayName: "GPT-5.6 Sol", + tier: .flagship, + tagline: "Flagship · 372k context", + provider: "codex", + reasoningEfforts: workCodex56ReasoningEfforts(includeUltra: true), + defaultReasoningEffort: "low", + serviceTiers: ["fast"] + ), + WorkModelOption( + id: "gpt-5.6-terra", + displayName: "GPT-5.6 Terra", + tier: .balanced, + tagline: "Balanced · 372k context", + provider: "codex", + reasoningEfforts: workCodex56ReasoningEfforts(includeUltra: true), + defaultReasoningEffort: "medium", + serviceTiers: ["fast"] + ), + WorkModelOption( + id: "gpt-5.6-luna", + displayName: "GPT-5.6 Luna", + tier: .fast, + tagline: "Fastest · 372k context", + provider: "codex", + reasoningEfforts: workCodex56ReasoningEfforts(includeUltra: false), + defaultReasoningEffort: "medium", + serviceTiers: ["fast"] + ), WorkModelOption(id: "gpt-5.5", displayName: "GPT-5.5", tier: .flagship, tagline: "Flagship · 1M context", provider: "codex", serviceTiers: ["fast"]), WorkModelOption(id: "gpt-5.4", displayName: "GPT-5.4", tier: .flagship, tagline: "Affordable · 1M context", provider: "codex", serviceTiers: ["fast"]), WorkModelOption(id: "gpt-5.4-mini", displayName: "GPT-5.4-Mini", tier: .fast, tagline: "Cheaper 1M-context variant", provider: "codex"), @@ -458,7 +600,7 @@ func workModelCatalogGroups( return WorkModelProvider( key: providerKey, displayName: workProviderDisplayName(groupKey: groupKey, providerKey: providerKey, curatedGroups: curatedGroups), - models: sortedModels + models: workPrioritizeCodex56Models(sortedModels, groupKey: groupKey, providerKey: providerKey) ) } @@ -496,7 +638,11 @@ func workModelCatalogGroups( return WorkModelProvider( key: provider.key, displayName: provider.displayName, - models: workDeduplicatedModelOptions(models) + models: workPrioritizeCodex56Models( + workDeduplicatedModelOptions(models), + groupKey: group.key, + providerKey: provider.key + ) ) } .filter { !$0.models.isEmpty } @@ -516,6 +662,9 @@ private func workCatalogModelOption( topLevelProvider: String, providerKey: String ) -> WorkModelOption { + let reasoningModelId = workCanonicalCodexRegistryId(for: model.id) + ?? workCanonicalCodexRegistryId(for: model.runtimeModelId) + ?? model.id let displayName = model.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? model.id : model.displayName @@ -545,7 +694,12 @@ private func workCatalogModelOption( tier: workDynamicModelTier(for: model.id), tagline: tagline, provider: workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey), - reasoningEfforts: model.reasoningEfforts ?? [], + reasoningEfforts: workVisibleReasoningEfforts(modelId: reasoningModelId, advertised: model.reasoningEfforts), + defaultReasoningEffort: workVisibleDefaultReasoningEffort( + modelId: reasoningModelId, + advertised: model.defaultReasoningEffort, + fallback: nil + ), serviceTiers: model.serviceTiers ?? [], cursorAvailability: model.cursorAvailability, isAvailable: model.isAvailable @@ -666,6 +820,12 @@ private func workClaudeRuntimeModelId(for raw: String) -> String? { private func workCanonicalCodexRegistryId(for raw: String) -> String? { switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "sol", "gpt-5.6-sol", "openai/gpt-5.6-sol": + return "openai/gpt-5.6-sol" + case "terra", "gpt-5.6-terra", "openai/gpt-5.6-terra": + return "openai/gpt-5.6-terra" + case "luna", "gpt-5.6-luna", "openai/gpt-5.6-luna": + return "openai/gpt-5.6-luna" case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": return "openai/gpt-5.5" case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": @@ -685,6 +845,12 @@ private func workCanonicalCodexRegistryId(for raw: String) -> String? { private func workCodexRuntimeModelId(for raw: String) -> String? { switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "sol", "gpt-5.6-sol", "openai/gpt-5.6-sol": + return "gpt-5.6-sol" + case "terra", "gpt-5.6-terra", "openai/gpt-5.6-terra": + return "gpt-5.6-terra" + case "luna", "gpt-5.6-luna", "openai/gpt-5.6-luna": + return "gpt-5.6-luna" case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": return "gpt-5.5" case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": @@ -727,6 +893,12 @@ func workKnownModelDisplayName(_ raw: String?) -> String? { return "Claude Sonnet 5" case "haiku", "anthropic/claude-haiku-4-5", "claude-haiku-4-5": return "Claude Haiku 4.5" + case "sol", "gpt-5.6-sol", "openai/gpt-5.6-sol": + return "GPT-5.6 Sol" + case "terra", "gpt-5.6-terra", "openai/gpt-5.6-terra": + return "GPT-5.6 Terra" + case "luna", "gpt-5.6-luna", "openai/gpt-5.6-luna": + return "GPT-5.6 Luna" case "gpt-5.5", "gpt-5.5-codex", "openai/gpt-5.5", "openai/gpt-5.5-codex": return "GPT-5.5" case "gpt-5.4", "gpt-5.4-codex", "openai/gpt-5.4", "openai/gpt-5.4-codex": @@ -880,6 +1052,8 @@ private func workDynamicModelOption( providerKey: String, curated: WorkModelOption? ) -> WorkModelOption { + let reasoningModelId = model.modelId.flatMap(workCanonicalCodexRegistryId(for:)) + ?? model.id let displayName = model.displayName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty ? (curated?.displayName ?? model.id) : model.displayName @@ -909,8 +1083,17 @@ private func workDynamicModelOption( tier: workDynamicModelTier(for: model.id, curated: curated), tagline: tagline, provider: curated?.provider ?? workModelBrandKey(topLevelProvider: topLevelProvider, providerKey: providerKey), - reasoningEfforts: model.reasoningEfforts ?? [], - serviceTiers: model.serviceTiers ?? [], + reasoningEfforts: workVisibleReasoningEfforts( + modelId: reasoningModelId, + advertised: model.reasoningEfforts, + fallback: curated?.reasoningEfforts ?? [] + ), + defaultReasoningEffort: workVisibleDefaultReasoningEffort( + modelId: reasoningModelId, + advertised: model.defaultReasoningEffort, + fallback: curated?.defaultReasoningEffort + ), + serviceTiers: model.serviceTiers ?? curated?.serviceTiers ?? [], cursorAvailability: model.cursorAvailability ) } @@ -924,12 +1107,43 @@ private func workDynamicModelTier(for modelId: String, curated: WorkModelOption? if normalized.contains("mini") || normalized.contains("spark") || normalized.contains("flash") || normalized == "auto" || normalized.contains("haiku") { return .fast } - if normalized.contains("fable") || normalized.contains("opus") || normalized.contains("gpt-5.5") || normalized == "gpt-5" { + if normalized.contains("fable") || normalized.contains("opus") || normalized.contains("gpt-5.6-sol") || normalized.contains("gpt-5.5") || normalized == "gpt-5" { return .flagship } + if normalized.contains("gpt-5.6-luna") { + return .fast + } return .balanced } +/// Keep the GPT-5.6 family at the top of every host-driven Codex/OpenAI list, +/// even when an older or authenticated host catalog returns a different order. +private func workPrioritizeCodex56Models( + _ models: [WorkModelOption], + groupKey: String, + providerKey: String +) -> [WorkModelOption] { + guard groupKey.lowercased() == "codex", providerKey.lowercased() == "openai" else { + return models + } + + func priority(_ modelId: String) -> Int { + switch workCanonicalCodexRegistryId(for: modelId) { + case "openai/gpt-5.6-sol": return 0 + case "openai/gpt-5.6-terra": return 1 + case "openai/gpt-5.6-luna": return 2 + default: return 3 + } + } + + return models.enumerated().sorted { lhs, rhs in + let lhsPriority = priority(lhs.element.id) + let rhsPriority = priority(rhs.element.id) + if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } + return lhs.offset < rhs.offset + }.map(\.element) +} + private func workDeduplicatedModelOptions(_ models: [WorkModelOption]) -> [WorkModelOption] { var seen = Set() var deduplicated: [WorkModelOption] = [] @@ -1029,6 +1243,9 @@ func workModelCatalogGroupKey(for currentModelId: String, currentProvider: Strin if modelId.hasPrefix("opencode/") || provider == "opencode" { return "opencode" } + if workCanonicalCodexRegistryId(for: modelId) != nil { + return "codex" + } if provider == "anthropic" || provider == "claude" || modelId.hasPrefix("anthropic/") || modelId.contains("claude") || modelId.contains("fable") || modelId.contains("sonnet") || modelId.contains("opus") || modelId.contains("haiku") { return "claude" } diff --git a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift index 40902b582..25d2784ce 100644 --- a/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkModelPickerSheet.swift @@ -625,6 +625,12 @@ struct WorkModelPickerSheet: View { private func defaultReasoningEffort(for model: WorkModelOption) -> String? { let tiers = supportedReasoningTiers(for: model) guard !tiers.isEmpty else { return nil } + let advertisedDefault = model.defaultReasoningEffort? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased() + if let advertisedDefault, tiers.contains(advertisedDefault) { + return advertisedDefault + } if tiers.contains("medium") { return "medium" } return tiers[tiers.count / 2] } @@ -1191,6 +1197,9 @@ struct ModelPickerListRow: View { VStack(alignment: .leading, spacing: 8) { if !supportedTiers.isEmpty { reasoningPills(tiers: supportedTiers) + if selectedReasoningEffort.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "ultra" { + ultraReasoningWarning + } } if model.supportsCodexFastMode { fastModeToggle @@ -1371,13 +1380,21 @@ struct ModelPickerListRow: View { .accessibilityLabel("Fast mode \(selectedCodexFastMode ? "on" : "off")") } - private func reasoningLabel(for tier: String) -> String { - switch tier.lowercased() { - case "xhigh": return "XHigh" - case "max": return "Max" - case "ultracode": return "Ultracode" - default: return tier.capitalized + private var ultraReasoningWarning: some View { + Label { + Text("Ultra delegates to multiple agents and can use limits faster.") + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + } icon: { + Image(systemName: "person.3.fill") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.warning) } + .accessibilityLabel("Ultra delegates to multiple agents and can use limits faster") + } + + private func reasoningLabel(for tier: String) -> String { + workReasoningEffortDisplayName(tier) } } diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 83b7708f4..f860135b1 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -695,6 +695,11 @@ struct WorkEventCardModel: Identifiable, Equatable { /// and plan cards fold the resolved state inline and drop the separate /// floating "Input resolved · Accepted" ribbon. let resolution: String? + /// Action context for a live `codex_turn_stalled` recovery card. The source + /// session can differ from the visible parent chat when a child agent stalls. + let recoveryOptions: [String] + let recoveryTurnId: String? + let recoverySessionId: String? init( id: String, @@ -710,7 +715,10 @@ struct WorkEventCardModel: Identifiable, Equatable { isInProgress: Bool = false, questionModel: WorkPendingQuestionModel? = nil, planApprovalModel: WorkPendingPlanApprovalModel? = nil, - resolution: String? = nil + resolution: String? = nil, + recoveryOptions: [String] = [], + recoveryTurnId: String? = nil, + recoverySessionId: String? = nil ) { self.id = id self.kind = kind @@ -726,6 +734,9 @@ struct WorkEventCardModel: Identifiable, Equatable { self.questionModel = questionModel self.planApprovalModel = planApprovalModel self.resolution = resolution + self.recoveryOptions = recoveryOptions + self.recoveryTurnId = recoveryTurnId + self.recoverySessionId = recoverySessionId } } @@ -836,7 +847,7 @@ enum WorkChatEvent: Equatable { case autoApprovalReview(summary: String, turnId: String?) case webSearch(query: String, action: String?, actions: [CodexWebSearchAction]?, status: WorkToolCardStatus, itemId: String, turnId: String?) case codexState(title: String, message: String, icon: String, turnId: String?) - case codexTurnStalled(message: String, recoveryOptions: [String], turnId: String?) + case codexTurnStalled(message: String, recoveryOptions: [String], turnId: String?, sourceSessionId: String?) case planText(text: String, turnId: String?) case toolUseSummary(text: String, turnId: String?) case status(turnStatus: String, message: String?, turnId: String?) diff --git a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift index 10583fcdd..33b69de8c 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatScreen.swift @@ -1266,7 +1266,7 @@ private func workDefaultNewChatModelId(provider: String) -> String { return defaultModel } switch workNormalizedNewChatProvider(provider) { - case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.5" + case "codex": return workDefaultCatalogModelId(provider: "codex") ?? "gpt-5.6-sol" case "cursor": return "auto" case "opencode": return "opencode/anthropic/claude-sonnet-5" default: return "claude-sonnet-5" diff --git a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift index ccbe47f14..d2df2de94 100644 --- a/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkNewChatSheet.swift @@ -210,7 +210,8 @@ struct WorkNewChatSheet: View { } } - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + let reasoningEfforts = workVisibleReasoningEfforts(for: selectedModel) + if !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { ADEOptionButton( @@ -224,7 +225,7 @@ struct WorkNewChatSheet: View { ForEach(reasoningEfforts) { effort in ADEOptionButton( - title: effort.effort.capitalized, + title: workReasoningEffortDisplayName(effort.effort), subtitle: effort.description, systemImage: "brain.head.profile", isSelected: selectedReasoningEffort == effort.effort @@ -393,7 +394,8 @@ struct WorkNewChatSheet: View { } } .onChange(of: selectedModelId) { _, _ in - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + let reasoningEfforts = workVisibleReasoningEfforts(for: selectedModel) + if !reasoningEfforts.isEmpty { if !reasoningEfforts.contains(where: { $0.effort == selectedReasoningEffort }) { selectedReasoningEffort = "" } @@ -424,7 +426,10 @@ struct WorkNewChatSheet: View { func loadModels(resetSelection: Bool) async { let requestedProvider = provider do { - let loadedModels = try await syncService.listChatModels(provider: requestedProvider) + let loadedModels = workPrioritizeGPT56ChatModels( + try await syncService.listChatModels(provider: requestedProvider), + provider: requestedProvider + ) guard provider == requestedProvider else { return } let scopedModels = requestedProvider == "cursor" ? workFilterChatModelsForCursorAvailability(loadedModels, mode: .chat) @@ -469,7 +474,7 @@ struct WorkNewChatSheet: View { model: selectedModelId, reasoningEffort: { guard !selectedReasoningEffort.isEmpty else { return nil } - guard selectedModel?.reasoningEfforts?.contains(where: { $0.effort == selectedReasoningEffort }) == true else { return nil } + guard workVisibleReasoningEfforts(for: selectedModel).contains(where: { $0.effort == selectedReasoningEffort }) else { return nil } return selectedReasoningEffort }() ) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift index 109bd9eca..8e17a30e1 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView+Actions.swift @@ -115,6 +115,24 @@ extension WorkSessionDestinationView { } } + @MainActor + func recoverCodexTurn(sessionId targetSessionId: String, turnId: String, action: String) async throws -> String { + let result = try await syncService.recoverCodexTurn( + sessionId: targetSessionId, + turnId: turnId, + action: action + ) + await refreshChatStateAfterAction(forceRemote: true) + errorMessage = nil + switch result.status { + case "waiting": return "Waiting for Codex output…" + case "nudged": return "Status nudge sent." + case "retrying": return "Retry started in this thread." + case "resumed": return "Codex app server restarted and the thread resumed." + default: return "Recovery action sent." + } + } + @MainActor func approveRequest(itemId: String, decision: AgentChatApprovalDecision, responseText: String? = nil) async { do { diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index 0581fd047..d91c19173 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -49,6 +49,15 @@ func workChatSendWillQueueMessage( isLive && !hostReachable && chatSendQueueable } +/// Child recovery cards carry their own source session id, so viewing a +/// subagent transcript must not hide actions that the paired host supports. +func workChatCodexRecoveryAvailable( + hostSupportsRecovery: Bool, + viewingSubagent _: Bool +) -> Bool { + hostSupportsRecovery +} + func workChatLiveObservationKey(sessionId: String, chatEventRevision: Int) -> String { "\(sessionId)-\(chatEventRevision)" } @@ -1136,7 +1145,11 @@ struct WorkSessionDestinationView: View { personalModelCatalogAvailable: !personalChat || syncService.canInvokeRemoteAction("personalChats.modelCatalog"), personalSessionUpdatesAvailable: !personalChat - || syncService.canInvokeRemoteAction("personalChats.updateSession") + || syncService.canInvokeRemoteAction("personalChats.updateSession"), + onRecoverCodexTurn: workChatCodexRecoveryAvailable( + hostSupportsRecovery: syncService.supportsRemoteAction("chat.recoverCodexTurn"), + viewingSubagent: viewingSubagent + ) ? recoverCodexTurn : nil ) } diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift index 14e945917..79539cdfc 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet+Actions.swift @@ -6,7 +6,10 @@ extension WorkSessionSettingsSheet { @MainActor func loadModels() async { do { - let loadedModels = try await syncService.listChatModels(provider: summary.provider) + let loadedModels = workPrioritizeGPT56ChatModels( + try await syncService.listChatModels(provider: summary.provider), + provider: summary.provider + ) let scopedModels = summary.provider == "cursor" ? workFilterChatModelsForCursorAvailability(loadedModels, mode: .chat) : loadedModels @@ -22,9 +25,7 @@ extension WorkSessionSettingsSheet { ?? "" selectedModelId = matchedModelId - if let selectedModel, - let reasoningEfforts = selectedModel.reasoningEfforts, - reasoningEfforts.contains(where: { $0.effort == selectedReasoningEffort }) == false { + if !workVisibleReasoningEfforts(for: selectedModel).contains(where: { $0.effort == selectedReasoningEffort }) { selectedReasoningEffort = "" } errorMessage = nil diff --git a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift index 9d72ac1d9..c6769de6e 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionSettingsSheet.swift @@ -190,7 +190,8 @@ struct WorkSessionSettingsSheet: View { } } - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + let reasoningEfforts = workVisibleReasoningEfforts(for: selectedModel) + if !reasoningEfforts.isEmpty { GlassSection(title: "Reasoning") { VStack(alignment: .leading, spacing: 12) { ADEOptionButton( @@ -204,7 +205,7 @@ struct WorkSessionSettingsSheet: View { ForEach(reasoningEfforts) { effort in ADEOptionButton( - title: effort.effort.capitalized, + title: workReasoningEffortDisplayName(effort.effort), subtitle: effort.description, systemImage: "brain.head.profile", isSelected: selectedReasoningEffort == effort.effort @@ -293,7 +294,8 @@ struct WorkSessionSettingsSheet: View { } } .onChange(of: selectedModelId) { _, _ in - if let reasoningEfforts = selectedModel?.reasoningEfforts, !reasoningEfforts.isEmpty { + let reasoningEfforts = workVisibleReasoningEfforts(for: selectedModel) + if !reasoningEfforts.isEmpty { if !reasoningEfforts.contains(where: { $0.effort == selectedReasoningEffort }) { selectedReasoningEffort = "" } diff --git a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift index c0563e330..3c76055f2 100644 --- a/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkStatusAndFormattingHelpers.swift @@ -1177,9 +1177,21 @@ func noticeTint(for kind: String) -> ColorToken { func toolDisplayName(_ tool: String) -> String { let trimmed = tool.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return "Tool" } + switch trimmed.lowercased() { + case "image_generation": return "Image generation" + case "image_view": return "Image viewed" + default: break + } if trimmed.hasPrefix("functions.") { return String(trimmed.split(separator: ".").last ?? Substring(trimmed)) } + if let separator = trimmed.firstIndex(of: ":") { + let source = trimmed[.. String? { .autoApprovalReview(_, let turnId), .webSearch(_, _, _, _, _, let turnId), .codexState(_, _, _, let turnId), - .codexTurnStalled(_, _, let turnId), + .codexTurnStalled(_, _, let turnId, _), .planText(_, let turnId), .toolUseSummary(_, let turnId), .status(_, _, let turnId), diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index 015be436f..c3e7f8b3a 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -19,6 +19,35 @@ func optionalWorkInt(_ value: Any?) -> Int? { return nil } +private func parseCodexWebSearchActions(from value: Any?) -> [CodexWebSearchAction]? { + guard let rawActions = value as? [[String: Any]] else { return nil } + let actions = rawActions.compactMap { action -> CodexWebSearchAction? in + guard let type = optionalString(action["type"]) else { return nil } + let queries = (action["queries"] as? [Any])?.compactMap(optionalString) + return CodexWebSearchAction( + type: type, + status: optionalString(action["status"]), + query: optionalString(action["query"]), + queries: queries?.isEmpty == true ? nil : queries, + url: optionalString(action["url"]), + title: optionalString(action["title"]), + snippet: optionalString(action["snippet"]) + ) + } + return actions.isEmpty ? nil : actions +} + +private func workTranscriptToolName(from eventDict: [String: Any]) -> String { + let fallback = stringValue(eventDict["tool"]) + guard let mcp = eventDict["mcp"] as? [String: Any] else { return fallback } + let appContext = mcp["appContext"] as? [String: Any] + let source = optionalString(appContext?["appName"]) + ?? optionalString(mcp["server"]) + let action = optionalString(mcp["tool"]) + guard let source, let action else { return fallback } + return "\(source):\(action)" +} + private func formatWorkCompactTokenCount(_ value: Int) -> String { if value >= 1_000_000 { return String(format: "%.1fM", Double(value) / 1_000_000.0) } if value >= 10_000 { return "\(Int((Double(value) / 1_000.0).rounded()))k" } @@ -112,7 +141,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { ) case "tool_call": event = .toolCall( - tool: stringValue(eventDict["tool"]), + tool: workTranscriptToolName(from: eventDict), argsText: prettyPrintedJSONString(eventDict["args"]), itemId: stableToolItemId ?? workFallbackItemID( sessionId: sessionId, @@ -132,7 +161,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { ) case "tool_result": event = .toolResult( - tool: stringValue(eventDict["tool"]), + tool: workTranscriptToolName(from: eventDict), resultText: prettyPrintedJSONString(eventDict["result"]), itemId: stableToolItemId ?? workFallbackItemID( sessionId: sessionId, @@ -404,6 +433,13 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { turnId: turnId ?? optionalString(usageDict["turnId"]) ?? "", itemId: nil ) + case "codex_turn_stalled": + event = .codexTurnStalled( + message: stringValue(eventDict["message"]), + recoveryOptions: eventDict["recoveryOptions"] as? [String] ?? [], + turnId: turnId, + sourceSessionId: optionalString(eventDict["sourceSessionId"]) + ) case "completion_report": let report = eventDict["report"] as? [String: Any] ?? [:] let artifacts = (report["artifacts"] as? [[String: Any]] ?? []).map { artifact in @@ -447,7 +483,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { event = .webSearch( query: stringValue(eventDict["query"]), action: optionalString(eventDict["action"]), - actions: nil, + actions: parseCodexWebSearchActions(from: eventDict["actions"]), status: toolStatus(from: stringValue(eventDict["status"])), itemId: stableToolItemId ?? workFallbackItemID( sessionId: sessionId, @@ -464,6 +500,41 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { ), turnId: turnId ) + case "codex_image_generation", "image_generation": + event = workCodexImageGenerationEvent( + itemId: itemId ?? workFallbackItemID( + sessionId: sessionId, + timestamp: timestamp, + sequence: sequence, + type: type, + seed: ["image_generation", turnId ?? "", optionalString(eventDict["prompt"]) ?? ""].joined(separator: "|") + ), + turnId: turnId, + prompt: optionalString(eventDict["prompt"]), + revisedPrompt: optionalString(eventDict["revisedPrompt"] ?? eventDict["revised_prompt"]), + result: optionalString(eventDict["result"]), + savedPath: optionalString(eventDict["savedPath"] ?? eventDict["saved_path"]), + resultOriginalBytes: optionalWorkInt(eventDict["resultOriginalBytes"] ?? eventDict["result_original_bytes"]), + resultOmittedBytes: optionalWorkInt(eventDict["resultOmittedBytes"] ?? eventDict["result_omitted_bytes"]), + status: stringValue(eventDict["status"]) + ) + case "codex_image_view", "image_view": + event = workCodexImageViewEvent( + itemId: itemId ?? workFallbackItemID( + sessionId: sessionId, + timestamp: timestamp, + sequence: sequence, + type: type, + seed: ["image_view", turnId ?? "", optionalString(eventDict["path"]) ?? "", optionalString(eventDict["url"]) ?? ""].joined(separator: "|") + ), + turnId: turnId, + path: optionalString(eventDict["path"]), + url: optionalString(eventDict["url"]), + title: optionalString(eventDict["title"]), + urlOriginalBytes: optionalWorkInt(eventDict["urlOriginalBytes"] ?? eventDict["url_original_bytes"]), + urlOmittedBytes: optionalWorkInt(eventDict["urlOmittedBytes"] ?? eventDict["url_omitted_bytes"]), + status: stringValue(eventDict["status"]) + ) case "plan_text": event = .planText(text: stringValue(eventDict["text"]), turnId: turnId) case "tool_use_summary": diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 277603704..77c55c98e 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -250,6 +250,15 @@ final class ADETests: XCTestCase { XCTAssertEqual(cleaned, "SSeta") } + func testBundledVoiceGlossaryKeepsContextualTermsWithinSpeechAnalyzerLimit() { + let terms = VoiceGlossary.shared.contextualTerms + + XCTAssertLessThanOrEqual(terms.count, 100) + XCTAssertTrue(terms.contains("GPT-5.6 Sol")) + XCTAssertTrue(terms.contains("GPT-5.6 Terra")) + XCTAssertTrue(terms.contains("GPT-5.6 Luna")) + } + func testDictationBufferConverterRecreatesWhenInputFormatChanges() throws { let converter = DictationBufferConverter() let outputFormat = try XCTUnwrap(AVAudioFormat( @@ -7031,6 +7040,31 @@ final class ADETests: XCTestCase { XCTAssertEqual(service.pendingOperationCount, 0) } + @MainActor + func testCodexRecoveryIsGatedWhenLegacyHostDoesNotAdvertiseAction() async throws { + let remoteCommandDescriptorsKey = "ade.sync.remoteCommandDescriptors" + UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) + defer { UserDefaults.standard.removeObject(forKey: remoteCommandDescriptorsKey) } + + let database = makeDatabase(baseURL: makeTemporaryDirectory()) + defer { database.close() } + let service = SyncService(database: database) + service.disconnect() + + do { + _ = try await service.recoverCodexTurn( + sessionId: "chat-legacy", + turnId: "turn-1", + action: "wait" + ) + XCTFail("Expected recovery to be rejected before an unsupported command is sent.") + } catch { + XCTAssertTrue(error.localizedDescription.contains("not available on this machine version")) + } + XCTAssertFalse(service.supportsRemoteAction("chat.recoverCodexTurn")) + XCTAssertEqual(service.pendingOperationCount, 0) + } + @MainActor func testIntentCommandRegistryQueuesCommandsUntilBridgeRegisters() async { ADEIntentCommandRegistry.resetForTesting() @@ -9787,8 +9821,8 @@ final class ADETests: XCTestCase { func testWorkSubagentSnapshotsPreserveAgentIdAndRunningCount() { let raw = """ - {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","parentAgentId":"parent-agent-1","description":"Docs helper","background":true,"turnId":"turn-1"}} - {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"subagent_progress","taskId":"task-1","agentId":"agent-1","summary":"Reading README.md","lastToolName":"functions.Read","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","parentAgentId":"parent-agent-1","description":"Docs helper","background":true,"label":"Researcher","model":"gpt-5.6-luna","reasoningEffort":"xhigh","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"subagent_progress","taskId":"task-1","agentId":"agent-1","summary":"Reading README.md","lastToolName":"functions.Read","label":"Researcher","model":"gpt-5.6-luna","reasoningEffort":"xhigh","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"subagent_started","taskId":"task-2","agentId":"agent-2","description":"Done helper","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"subagent_result","taskId":"task-2","agentId":"agent-2","status":"completed","summary":"Done","turnId":"turn-1"}} """ @@ -9800,6 +9834,9 @@ final class ADETests: XCTestCase { XCTAssertEqual(snapshots.first?.agentId, "agent-1") XCTAssertEqual(snapshots.first?.parentToolUseId, "parent-agent-1") XCTAssertEqual(snapshots.first?.background, true) + XCTAssertEqual(snapshots.first?.label, "Researcher") + XCTAssertEqual(snapshots.first?.model, "gpt-5.6-luna") + XCTAssertEqual(snapshots.first?.reasoningEffort, "xhigh") XCTAssertEqual(snapshots.first?.lastToolName, "functions.Read") XCTAssertEqual(snapshots.first?.startedAt, "2026-03-25T00:00:01.000Z") XCTAssertEqual(snapshots.first?.updatedAt, "2026-03-25T00:00:02.000Z") @@ -11216,8 +11253,14 @@ final class ADETests: XCTestCase { XCTAssertEqual(workReasoningChipLabel("xhigh"), "XH") XCTAssertEqual(workReasoningChipLabel("extra-high"), "XH") XCTAssertEqual(workReasoningChipLabel("max"), "MAX") + XCTAssertEqual(workReasoningChipLabel("ultra"), "ULTRA") XCTAssertEqual(workReasoningChipLabel("ultracode"), "ULTRA") XCTAssertEqual(workReasoningChipLabel("custom-effort"), "CUS") + XCTAssertEqual(workReasoningEffortDisplayName("low"), "Light") + XCTAssertEqual(workReasoningEffortDisplayName("medium"), "Medium") + XCTAssertEqual(workReasoningEffortDisplayName("high"), "High") + XCTAssertEqual(workReasoningEffortDisplayName("xhigh"), "Extra High") + XCTAssertEqual(workReasoningEffortDisplayName("ultra"), "Ultra") } func testWorkChatComposerShowsFastModeForCodexSessionsWithoutPersistedFlag() { @@ -11782,6 +11825,170 @@ final class ADETests: XCTestCase { XCTAssertEqual(noticeSteerId, "steer-1") } + func testParseWorkChatTranscriptPreservesWebSearchSourceActions() { + let raw = #"{"sessionId":"chat-1","timestamp":"2026-07-09T00:00:01.000Z","sequence":1,"event":{"type":"web_search","query":"GPT-5.6 Sol","action":"open_page","actions":[{"type":"search","status":"completed","queries":["GPT-5.6 Sol","GPT-5.6 Terra"]},{"type":"open_page","status":"completed","url":"https://openai.com/index/previewing-gpt-5-6-sol/","title":"Previewing GPT-5.6 Sol","snippet":"A new model family."}],"itemId":"search-1","turnId":"turn-1","status":"completed"}}"# + + let transcript = parseWorkChatTranscript(raw) + XCTAssertEqual(transcript.count, 1) + guard case .webSearch(let query, let action, let actions, let status, let itemId, let turnId) = transcript[0].event else { + return XCTFail("Expected web_search event.") + } + XCTAssertEqual(query, "GPT-5.6 Sol") + XCTAssertEqual(action, "open_page") + XCTAssertEqual(status, .completed) + XCTAssertEqual(itemId, "search-1") + XCTAssertEqual(turnId, "turn-1") + XCTAssertEqual(actions?.count, 2) + XCTAssertEqual(actions?.first?.queries, ["GPT-5.6 Sol", "GPT-5.6 Terra"]) + XCTAssertEqual(actions?.last?.url, "https://openai.com/index/previewing-gpt-5-6-sol/") + XCTAssertEqual(actions?.last?.title, "Previewing GPT-5.6 Sol") + } + + func testImageActivityBecomesCompactToolCardsWithoutEmbeddingImageData() throws { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:01.000Z","sequence":1,"event":{"type":"codex_image_generation","itemId":"image-1","turnId":"turn-1","prompt":"Draw a moonlit terminal","status":"running"}} + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:02.000Z","sequence":2,"event":{"type":"codex_image_generation","itemId":"image-1","turnId":"turn-1","prompt":"Draw a moonlit terminal","revisedPrompt":"A clean moonlit terminal illustration","result":null,"savedPath":"/tmp/moon.png","resultOriginalBytes":81920,"resultOmittedBytes":81920,"status":"completed"}} + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:03.000Z","sequence":3,"event":{"type":"codex_image_view","itemId":"image-view-1","turnId":"turn-1","path":"/tmp/moon.png","url":"data:image/png;base64,AAAA","title":"Moon preview","status":"completed"}} + """ + + let transcript = parseWorkChatTranscript(raw) + let cards = buildWorkToolCards(from: transcript) + + XCTAssertEqual(cards.map(\.toolName), ["image_generation", "image_view"]) + XCTAssertEqual(cards.first?.status, .completed) + XCTAssertEqual(cards.first?.argsText, "Draw a moonlit terminal") + XCTAssertTrue(cards.first?.resultText?.contains("/tmp/moon.png") == true) + XCTAssertTrue(cards.first?.resultText?.contains("A clean moonlit terminal illustration") == true) + XCTAssertTrue(cards.first?.resultText?.contains("Inline preview omitted from mobile sync (80 KB)") == true) + XCTAssertEqual(toolDisplayName(cards[0].toolName), "Image generation") + XCTAssertEqual(toolDisplayName(cards[1].toolName), "Image viewed") + XCTAssertTrue(cards[1].resultText?.contains("Inline image data") == true) + XCTAssertFalse(cards[1].resultText?.contains("base64,AAAA") == true) + + let decoded = try AgentChatEvent.decode(from: [ + "type": "codex_image_generation", + "itemId": "decoded-image", + "turnId": "turn-2", + "prompt": "Draw a clean icon", + "result": "https://example.com/icon.png", + "status": "completed", + ]) + guard case .toolResult(let tool, let result, let itemId, _, let turnId, let status) = makeWorkChatEvent(from: decoded) else { + return XCTFail("Expected decoded image generation to map to a compact tool result.") + } + XCTAssertEqual(tool, "image_generation") + XCTAssertEqual(itemId, "decoded-image") + XCTAssertEqual(turnId, "turn-2") + XCTAssertEqual(status, .completed) + XCTAssertTrue(result.contains("https://example.com/icon.png")) + } + + func testCodexRecoveryPreservesChildSessionAndAdvertisedActions() throws { + let eventObject: [String: Any] = [ + "type": "codex_turn_stalled", + "turnId": "turn-child", + "threadId": "thread-child", + "reason": "no_output", + "message": "Codex accepted the turn but has not streamed output yet.", + "recoveryOptions": ["wait", "steer", "interrupt_retry_same_thread", "restart_resume_thread"], + "sourceSessionId": "chat-child", + ] + let decoded = try AgentChatEvent.decode(from: eventObject) + guard case .codexTurnStalled(let turnId, let threadId, let reason, let message, let options, let sourceSessionId) = decoded else { + return XCTFail("Expected a Codex stalled-turn event.") + } + XCTAssertEqual(turnId, "turn-child") + XCTAssertEqual(threadId, "thread-child") + XCTAssertEqual(reason, "no_output") + XCTAssertEqual(sourceSessionId, "chat-child") + XCTAssertEqual(options, ["wait", "steer", "interrupt_retry_same_thread", "restart_resume_thread"]) + + let mapped = makeWorkChatEvent(from: decoded) + let envelope = WorkChatEnvelope( + sessionId: "chat-parent", + timestamp: "2026-07-09T00:00:01.000Z", + sequence: 1, + event: mapped + ) + let card = try XCTUnwrap(buildWorkEventCards(from: [envelope]).first) + XCTAssertEqual(card.kind, "codexRecovery") + XCTAssertEqual(card.body, message) + XCTAssertEqual(card.recoverySessionId, "chat-child") + XCTAssertEqual(card.recoveryTurnId, "turn-child") + XCTAssertEqual(card.recoveryOptions, options) + + let raw = #"{"sessionId":"chat-parent","timestamp":"2026-07-09T00:00:01.000Z","sequence":1,"event":{"type":"codex_turn_stalled","turnId":"turn-child","threadId":"thread-child","reason":"no_output","message":"Codex accepted the turn but has not streamed output yet.","recoveryOptions":["wait","steer","interrupt_retry_same_thread","restart_resume_thread"],"sourceSessionId":"chat-child"}}"# + guard case .codexTurnStalled(_, let parsedOptions, let parsedTurnId, let parsedSourceSessionId) = parseWorkChatTranscript(raw).first?.event else { + return XCTFail("Expected transcript fallback to preserve the recovery event.") + } + XCTAssertEqual(parsedOptions, options) + XCTAssertEqual(parsedTurnId, "turn-child") + XCTAssertEqual(parsedSourceSessionId, "chat-child") + } + + func testCodexRecoveryRemainsAvailableInSubagentTranscriptWhenHostSupportsIt() { + XCTAssertTrue(workChatCodexRecoveryAvailable( + hostSupportsRecovery: true, + viewingSubagent: true + )) + XCTAssertFalse(workChatCodexRecoveryAvailable( + hostSupportsRecovery: false, + viewingSubagent: true + )) + } + + func testMcpConnectorIdentitySurvivesDecodedAndFallbackToolCards() throws { + let eventObject: [String: Any] = [ + "type": "tool_call", + "tool": "google_drive:search_files", + "args": ["query": "roadmap"], + "mcp": [ + "server": "google_drive", + "tool": "search_files", + "appContext": ["appName": "Google Drive", "actionName": "Search files"], + ], + "itemId": "mcp-1", + "turnId": "turn-1", + ] + let decoded = try AgentChatEvent.decode(from: eventObject) + guard case .toolCall(let decodedTool, _, _, _, _, _) = decoded else { + return XCTFail("Expected decoded MCP tool call.") + } + XCTAssertEqual(decodedTool, "Google Drive:search_files") + + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:01.000Z","sequence":1,"event":{"type":"tool_call","tool":"google_drive:search_files","args":{"query":"roadmap"},"mcp":{"server":"google_drive","tool":"search_files","appContext":{"appName":"Google Drive","actionName":"Search files"}},"itemId":"mcp-1","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:02.000Z","sequence":2,"event":{"type":"tool_result","tool":"google_drive:search_files","result":"2 files","mcp":{"server":"google_drive","tool":"search_files","appContext":{"appName":"Google Drive","actionName":"Search files"}},"itemId":"mcp-1","turnId":"turn-1","status":"completed"}} + """ + let cards = buildWorkToolCards(from: parseWorkChatTranscript(raw)) + XCTAssertEqual(cards.first?.toolName, "Google Drive:search_files") + XCTAssertEqual(toolDisplayName(cards.first?.toolName ?? ""), "Google Drive · search files") + XCTAssertEqual(cards.first?.resultText, "2 files") + XCTAssertTrue(isRequestUserInputToolName("ADE:request_user_input")) + } + + func testMalformedMcpMetadataFallsBackToRawToolForCallAndResult() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:01.000Z","sequence":1,"event":{"type":"tool_call","tool":"google_drive:search_files","args":{"query":"roadmap"},"mcp":{"server":"google_drive"},"itemId":"mcp-1","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-09T00:00:02.000Z","sequence":2,"event":{"type":"tool_result","tool":"google_drive:search_files","result":"2 files","mcp":{"tool":"search_files"},"itemId":"mcp-1","turnId":"turn-1","status":"completed"}} + """ + + let transcript = parseWorkChatTranscript(raw) + XCTAssertEqual(transcript.count, 2) + + guard transcript.count == 2, + case .toolCall(let callTool, _, let callItemId, _, _) = transcript[0].event, + case .toolResult(let resultTool, let resultText, let resultItemId, _, _, let status) = transcript[1].event else { + return XCTFail("Expected malformed MCP metadata to preserve the raw tool call and result.") + } + XCTAssertEqual(callTool, "google_drive:search_files") + XCTAssertEqual(callItemId, "mcp-1") + XCTAssertEqual(resultTool, "google_drive:search_files") + XCTAssertEqual(resultText, "2 files") + XCTAssertEqual(resultItemId, "mcp-1") + XCTAssertEqual(status, .completed) + } + func testParseWorkChatTranscriptPrefersUserMessageDisplayText() { let raw = """ {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"user_message","text":"INTERNAL_RUNTIME_PROMPT","displayText":"ADE coordinator start: initialize the session.","turnId":"turn-1"}} @@ -12513,6 +12720,46 @@ final class ADETests: XCTestCase { XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.5"), ["low", "medium", "high", "xhigh"]) } + func testWorkModelCatalogKeepsGPT56TiersFirstWithExactReasoningDefaults() { + let codexModels = workModelCatalogGroups(currentModelId: "", currentProvider: "codex") + .first(where: { $0.key == "codex" })? + .providers + .first(where: { $0.key == "openai" })? + .models + + XCTAssertEqual(codexModels?.prefix(3).map(\.id), [ + "gpt-5.6-sol", + "gpt-5.6-terra", + "gpt-5.6-luna", + ]) + XCTAssertEqual(workDefaultCatalogModelId(provider: "codex"), "gpt-5.6-sol") + + let sol = codexModels?.first(where: { $0.id == "gpt-5.6-sol" }) + XCTAssertEqual(sol?.displayName, "GPT-5.6 Sol") + XCTAssertEqual(sol?.tier, .flagship) + XCTAssertEqual(sol?.tagline, "Flagship · 372k context") + XCTAssertEqual(sol?.reasoningEfforts.map(\.effort), ["low", "medium", "high", "xhigh", "ultra"]) + XCTAssertEqual(sol?.defaultReasoningEffort, "low") + XCTAssertTrue(sol?.supportsCodexFastMode == true) + + let terra = codexModels?.first(where: { $0.id == "gpt-5.6-terra" }) + XCTAssertEqual(terra?.tier, .balanced) + XCTAssertEqual(terra?.reasoningEfforts.map(\.effort), ["low", "medium", "high", "xhigh", "ultra"]) + XCTAssertEqual(terra?.defaultReasoningEffort, "medium") + + let luna = codexModels?.first(where: { $0.id == "gpt-5.6-luna" }) + XCTAssertEqual(luna?.tier, .fast) + XCTAssertEqual(luna?.reasoningEfforts.map(\.effort), ["low", "medium", "high", "xhigh"]) + XCTAssertEqual(luna?.defaultReasoningEffort, "medium") + + XCTAssertTrue(workModelIdsEquivalent("sol", "openai/gpt-5.6-sol")) + XCTAssertTrue(workModelIdsEquivalent("terra", "gpt-5.6-terra")) + XCTAssertTrue(workModelIdsEquivalent("luna", "openai/gpt-5.6-luna")) + XCTAssertEqual(workModelCatalogGroupKey(for: "sol", currentProvider: ""), "codex") + XCTAssertEqual(workKnownModelDisplayName("openai/gpt-5.6-terra"), "GPT-5.6 Terra") + XCTAssertNotNil(ADEColor.modelBrand(for: "luna")) + } + func testMobileComposerReasoningTiersMirrorDesktopRegistry() { XCTAssertEqual(ADEColor.reasoningTiers(for: "anthropic/claude-fable-5"), ["low", "medium", "high", "xhigh", "max", "ultracode"]) XCTAssertEqual(ADEColor.reasoningTiers(for: "anthropic/claude-fable-5-api"), ["low", "medium", "high", "xhigh", "max", "ultracode"]) @@ -12526,6 +12773,9 @@ final class ADETests: XCTestCase { XCTAssertEqual(ADEColor.reasoningTiers(for: "opus[1m]"), ["low", "medium", "high", "xhigh", "max"]) XCTAssertEqual(ADEColor.reasoningTiers(for: "anthropic/claude-sonnet-5"), ["low", "medium", "high", "max"]) XCTAssertNil(ADEColor.reasoningTiers(for: "claude-haiku-4-5")) + XCTAssertEqual(ADEColor.reasoningTiers(for: "sol"), ["low", "medium", "high", "xhigh", "ultra"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "openai/gpt-5.6-terra"), ["low", "medium", "high", "xhigh", "ultra"]) + XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.6-luna"), ["low", "medium", "high", "xhigh"]) XCTAssertEqual(ADEColor.reasoningTiers(for: "openai/gpt-5.3-codex-spark"), ["low", "medium", "high", "xhigh"]) XCTAssertEqual(ADEColor.reasoningTiers(for: "gpt-5.2"), ["low", "medium", "high", "xhigh"]) } @@ -12609,6 +12859,71 @@ final class ADETests: XCTestCase { XCTAssertEqual(cursorGroup?.providers.first?.models.first?.provider, "claude") } + func testHostModelCatalogDefensivelyPrioritizesGPT56AndCarriesDefaultEffort() throws { + let payload: [String: Any] = [ + "groups": [[ + "key": "codex", + "displayName": "Codex", + "providers": [[ + "key": "openai", + "displayName": "OpenAI", + "badgeColor": "#10A37F", + "modelCount": 4, + "subsections": [[ + "key": "models", + "label": "Models", + "models": [ + ["id": "gpt-5.5", "runtimeModelId": "gpt-5.5", "provider": "codex", "providerKey": "openai", "groupKey": "codex", "displayName": "GPT-5.5", "isDefault": false, "isAvailable": true], + ["id": "gpt-5.6-luna", "runtimeModelId": "gpt-5.6-luna", "provider": "codex", "providerKey": "openai", "groupKey": "codex", "displayName": "GPT-5.6 Luna", "isDefault": false, "defaultReasoningEffort": "medium", "isAvailable": true], + ["id": "gpt-5.6-sol", "runtimeModelId": "gpt-5.6-sol", "provider": "codex", "providerKey": "openai", "groupKey": "codex", "displayName": "GPT-5.6 Sol", "isDefault": true, "defaultReasoningEffort": "low", "reasoningEfforts": [["effort": "low", "description": "fast"], ["effort": "medium", "description": "balanced"], ["effort": "high", "description": "deep"], ["effort": "xhigh", "description": "extended"], ["effort": "max", "description": "optional"], ["effort": "ultra", "description": "delegates"]], "isAvailable": true], + ["id": "gpt-5.6-terra", "runtimeModelId": "gpt-5.6-terra", "provider": "codex", "providerKey": "openai", "groupKey": "codex", "displayName": "GPT-5.6 Terra", "isDefault": false, "defaultReasoningEffort": "medium", "isAvailable": true], + ], + ]], + ]], + ]], + "fetchedAt": "2026-07-09T00:00:00Z", + ] + let data = try JSONSerialization.data(withJSONObject: payload) + let hostCatalog = try JSONDecoder().decode(AgentChatModelCatalog.self, from: data) + let models = workModelCatalogGroups( + hostCatalog: hostCatalog, + currentModelId: "", + currentProvider: "codex" + ).first?.providers.first?.models + + XCTAssertEqual(models?.map(\.id), ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"]) + XCTAssertEqual(models?.first?.defaultReasoningEffort, "low") + XCTAssertEqual(models?.first?.reasoningEfforts.map(\.effort), ["low", "medium", "high", "xhigh", "ultra"]) + + let legacyListData = try JSONSerialization.data(withJSONObject: [ + "id": "gpt-5.6-luna", + "displayName": "GPT-5.6 Luna", + "isDefault": false, + "reasoningEfforts": [ + ["effort": "low", "description": "light"], + ["effort": "medium", "description": "balanced"], + ["effort": "high", "description": "deep"], + ["effort": "xhigh", "description": "extended"], + ["effort": "max", "description": "optional"], + ], + ]) + let legacyListModel = try JSONDecoder().decode(AgentChatModelInfo.self, from: legacyListData) + XCTAssertEqual(workVisibleReasoningEfforts(for: legacyListModel).map(\.effort), ["low", "medium", "high", "xhigh"]) + + let flatListData = try JSONSerialization.data(withJSONObject: [ + ["id": "gpt-5.5", "displayName": "GPT-5.5", "isDefault": false], + ["id": "gpt-5.6-luna", "displayName": "GPT-5.6 Luna", "isDefault": false], + ["id": "gpt-5.6-sol", "displayName": "GPT-5.6 Sol", "isDefault": true], + ["id": "gpt-5.6-terra", "displayName": "GPT-5.6 Terra", "isDefault": false], + ]) + let flatList = try JSONDecoder().decode([AgentChatModelInfo].self, from: flatListData) + XCTAssertEqual( + workPrioritizeGPT56ChatModels(flatList, provider: "codex").map(\.id), + ["gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna", "gpt-5.5"] + ) + XCTAssertEqual(workPrioritizeGPT56ChatModels(flatList, provider: "claude").map(\.id), flatList.map(\.id)) + } + func testCuratedCursorSonnetUsesCursorRuntimeDescriptorId() { let cursorGroup = workModelCatalogGroups(currentModelId: "", currentProvider: "cursor") .first(where: { $0.key == "cursor" }) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 7096da8ad..0c34142c8 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -222,6 +222,14 @@ rendered in desktop also appears in ADE Code. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. +Model setup shares the desktop registry: GPT-5.6 Sol/Terra/Luna lead the Codex +list, Sol is the default, and host-advertised reasoning defaults are honored +while the GPT-5.6 raw Max tier stays hidden. Transcript aggregation also +preserves MCP app/action labels and collapses web/image lifecycle updates. In +CLI interface mode, attached and embedded runtime actions use the same signed, +explicit-opt-in `computer_use` MCP resolver as desktop when launching or +resuming Codex. + ### 2.4 iOS client (`apps/ios/`) Native SwiftUI app acting as a controller. It pairs with an ADE machine over WebSocket and reads live state from a local cr-sqlite-backed SQLite database that mirrors the project's `ade.db`. The phone never runs agents. @@ -384,9 +392,9 @@ Service entry points live under `apps/desktop/src/main/services/ai/`. The subsys ### 4.1 Provider routing - **Router** — `aiIntegrationService.ts` resolves a task → model → provider class and dispatches. -- **Model registry** — `apps/desktop/src/shared/modelRegistry.ts` is the single source of truth. Each `ModelDescriptor` carries identity (`id`, `shortId`, `providerRoute`, `providerModelId`), capabilities, pricing, context sizing, auth type (`cli-subscription`, `api-key`, `openrouter`, `local`), and optional `harnessProfile`/`discoverySource` for safety metadata. +- **Model registry** — `apps/desktop/src/shared/modelRegistry.ts` is the single source of truth. Each `ModelDescriptor` carries identity (`id`, `shortId`, `providerRoute`, `providerModelId`), capabilities, pricing, context sizing, auth type (`cli-subscription`, `api-key`, `openrouter`, `local`), optional reasoning tiers plus `defaultReasoningEffort`, and optional `harnessProfile`/`discoverySource` for safety metadata. - **Classes**: - - **CLI-wrapped** (Claude via `@anthropic-ai/claude-agent-sdk`, Codex CLI via `@openai/codex-sdk`) — spawned as subprocesses; Claude uses the SDK `query()` stream with ADE's async input pump and bundled Claude Code binary, while Codex uses its app-server JSON-RPC bridge. Authentication inherits from the user's own CLI login. ADE context is exposed through environment variables, and agents can call back into ADE with the `ade` CLI. + - **CLI-wrapped** (Claude via `@anthropic-ai/claude-agent-sdk`, Codex via the pinned `@openai/codex` package) — spawned as subprocesses; Claude uses the SDK `query()` stream with ADE's async input pump and bundled Claude Code binary, while Codex uses its app-server JSON-RPC bridge. Desktop and runtime packages pin Codex `0.144.0`, including the matching native app-server binary. Authentication inherits from the user's own CLI login. ADE context is exposed through environment variables, and agents can call back into ADE with the `ade` CLI. - **API-key / OpenRouter** (Anthropic, OpenAI, Google, Mistral, DeepSeek, xAI, Groq, Together AI, OpenRouter) — routed through the **OpenCode server** (`opencode` binary, user-installed or bundled). Discovery via `openCodeInventory.ts`; replaces dynamic portion of the registry. - **Local** (Ollama, LM Studio, vLLM) — OpenAI-compatible local endpoints through OpenCode. Discovery via `localModelDiscovery.ts`. - **Detection pipeline**: @@ -432,7 +440,7 @@ Agent tools are split by domain: `apps/desktop/src/shared/modelRegistry.ts` + `apps/desktop/src/shared/modelProfiles.ts`: -- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. The Claude block is ordered for every picker as Fable 5, Opus 4.8 1M, Sonnet 5, Haiku 4.5, then Opus 4.7 1M. Sonnet 5 uses provider model `claude-sonnet-5` (1,000,000 context / 128,000 max output); removed Sonnet 4.6 and basic Opus 4.7 ids resolve forward as compatibility aliases but do not appear as selectable rows. Opus 4.7 1M remains available as `anthropic/claude-opus-4-7-1m` with aliases `opus[1m]` / `claude-opus-4-7[1m]`. `ModelDescriptor.serviceTiers?: string[]` advertises optional service tiers (today: `"fast"`, set on Fable/Opus, Codex GPT 5.4 / 5.5 entries, and dynamic Cursor SDK/CLI rows) that the UI's Fast Mode toggle keys off. Codex maps it to the JSON-RPC `serviceTier` argument; Cursor SDK maps it through discovered model parameters, and Cursor CLI launches use the matching fast model alias when present. +- `MODEL_REGISTRY` — static CLI-wrapped entries + dynamically populated API-key/local entries. The OpenAI/Codex block is ordered GPT-5.6 Sol, Terra, Luna, then the retained GPT-5.5 and older rows; `pickDefaultCodexModel` chooses the newest Sol row, so new Codex sessions default to GPT-5.6 Sol. All three GPT-5.6 descriptors have a 372k context window and advertise Fast. Sol and Terra expose `low | medium | high | xhigh | ultra`; Luna stops at `xhigh`. The product labels those tiers Light, Medium, High, Extra High, and Ultra. Raw app-server catalogs can report `max`, but ADE hides that feature-gated tier for GPT-5.6. `defaultReasoningEffort` is `low` for Sol and `medium` for Terra/Luna and is honored by desktop, TUI, iOS, CTO, review, and handoff pickers. The Claude block is ordered for every picker as Fable 5, Opus 4.8 1M, Sonnet 5, Haiku 4.5, then Opus 4.7 1M. Sonnet 5 uses provider model `claude-sonnet-5` (1,000,000 context / 128,000 max output); removed Sonnet 4.6 and basic Opus 4.7 ids resolve forward as compatibility aliases but do not appear as selectable rows. Opus 4.7 1M remains available as `anthropic/claude-opus-4-7-1m` with aliases `opus[1m]` / `claude-opus-4-7[1m]`. `ModelDescriptor.serviceTiers?: string[]` advertises optional service tiers (today: `"fast"`, set on Fable/Opus, GPT-5.6 and older fast-capable Codex entries, and dynamic Cursor SDK/CLI rows) that the UI's Fast Mode toggle keys off. Codex maps it to the JSON-RPC `serviceTier` argument; Cursor SDK maps it through discovered model parameters, and Cursor CLI launches use the matching fast model alias when present. - `ModelProviderGroup` = `"claude" | "codex" | "opencode" | "cursor" | "droid"`. Cursor and Droid each have their own top-level provider group used by the model picker, identity routing, and tracked CLI provider catalog. - Helpers: `getModelById`, `getModelPricing`, `updateModelPricingInRegistry`, `replaceDynamicOpenCodeModelDescriptors`, `resolveProviderGroupForModel`, `resolveModelDescriptorForProvider`, `getRuntimeModelRefForDescriptor`, `modelSupportsServiceTier(descriptor, tier)` / `modelSupportsFastMode(descriptor)`. - Reasoning tier passthrough (`providerOptions.ts`) maps tier strings directly to each provider's native config (`thinking.type`, `reasoningEffort`, `thinkingConfig.thinkingLevel`, etc.) — no arbitrary token budgets. Claude Fable and Opus 4.8 rows advertise `low | medium | high | xhigh | max | ultracode`; Sonnet 5 advertises `low | medium | high | max`. @@ -503,7 +511,9 @@ ade.agentChat.* # agent chat sessions, model inventory, parallel la # used by desktop + TUI + iOS ModelPickers; accepts # `{ mode: "cached"|"refresh-stale"|"force", refreshProvider?: "opencode"|"cursor"|"droid"|"lmstudio"|"ollama" }`) # and ade.agentChat.codex.* goal controls backed by - # Codex app-server thread/goal RPCs. + # Codex app-server thread/goal RPCs, plus + # recoverCodexTurn for guarded Wait/Nudge/Retry/Resume + # handling of the currently active stalled turn. ade.personalChats.* # preload namespace over machine RPC: allowlisted personal-chat # call + cursor event stream. Local/no-project windows target the # local brain; a remote-bound project window targets that remote brain. @@ -584,8 +594,9 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts` | In-app web browser owned by the main process. Allocates `WebContentsView` tabs against persistent project-profile partitions derived from the active project root (fallback `persist:ade-browser`, cap 10 tabs per window/project service), positions them over a renderer-supplied bounds rect, drives navigation / tabs / reload / back / forward, attaches the Chrome DevTools Protocol debugger for inspect-mode hit tests and background input dispatch, captures screenshots, handles Electron download requests with sanitized unique filenames in the user's Downloads folder, and emits `BuiltInBrowserEventPayload`s to subscribers. Tabs carry per-tab owner metadata (`ownerLaneId`, `ownerChatSessionId`, `ownerClaimedAt`, `ownerLeaseExpiresAt`), and observe/click/type/key/scroll/fill/clear/wait/screenshot/select/reload/back/forward/stop can target a hidden or non-active tab by `tabId` or by a lightweight browser session id. Browser sessions bind one agent workflow to one tab, remember owner plus last observation/trace ids, support `ade browser session ` aliases, and end explicitly or when the tab closes. Browser agent observations are scratch PNG/JSON files under `.ade/cache/browser-observations/` with a bounded DOM element list for selector/text/index targeting, optional numbered element-map screenshots, console/network diagnostics, pending request count, and short-lived element handles that preserve same-origin iframe/open-shadow-root context. Click/fill/clear/press/wait can resolve `selector`, `text`, `testId`, `elementIndex`, or `handle`, scroll the target into view, focus as needed, and reject disabled targets before dispatching CDP input. Waits wake from browser/network/page activity events with a timeout fallback; `network-idle` requires complete ready state, zero pending browser requests, and a configurable idle window. Per-tab action traces record action, target metadata, before/after URL, duration, observation id, session id, and errors; typed/fill values are represented by length rather than raw text. Scratch observations prune to the latest 3 observations per tab by default and sweep stale cache files; `ade browser proof` promotes a fresh observation through the computer-use artifact broker. Desktop bridge calls from `ade browser` carry the runtime project root and are routed only to an ADE window/project tab for that root, so a command from one project cannot mutate another project's browser. Navigation normalization and protocol allowlisting live in `builtInBrowserNavigation.ts`; Google-auth permission policy lives in `builtInBrowserPermissions.ts`; WebAuthn account selection is registered per Electron storage session in `builtInBrowserWebAuthn.ts`. Consumed by `ChatBuiltInBrowserPanel` (Work sidebar Browser tab), which hides the native view while ADE overlays intersect the browser rectangle, and by `openUrlInAdeBrowser()` in the renderer so renderer-side link clicks open inside ADE rather than the system browser. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. | -| `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | +| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `claudeStructuredActivity.ts`, `openCodeStructuredActivity.ts`, `codexMcpElicitation.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSdkErrors.ts`), `droidSdkEventMapper.ts`, `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool with official local-store persistence, formalizes the cross-runtime event vocabulary, normalizes provider-native web/MCP/image activity into compact shared events, handles Codex app-server MCP elicitations and stalled-turn recovery, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. | +| `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, and macOS capture capability descriptor. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. Direct Codex Computer Use executable resolution lives outside this folder in `main/utils/codexComputerUse.ts` because it configures provider runtimes rather than ingesting proof. | +| `proof/` | `agentBrowserArtifactAdapter.ts` | Parses agent-browser payloads into broker inputs. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | | `cto/` | `ctoStateService.ts`, `ctoMemoryService.ts`, `ctoPromptContent.ts`, `linearClient.ts`, `linearIssueTracker.ts`, `linearCredentialService.ts`, `linearOAuthService.ts`, `linearTokenRefresh.ts`, `linearLaneCardService.ts`, `linearLiveStatusService.ts` | CTO identity, the smart-memory file store, session logs, and the Linear read/credential/OAuth surface. `linearLaneCardService` posts the Linear attachment card and builds the cross-machine ADE deeplink that backs the card's URL; `linearLiveStatusService` is the optional launch/PR/merge status round-trip. | @@ -901,7 +912,7 @@ ADE does not generate PRD or architecture bootstrap documents. Agent prompts tel ### 12.1 Principle -Proof is **intentional**. Agents run computer use through whatever tool they already have — Claude's `computer_use`, Codex shell, a scripted browser, a headless Playwright run, a local screenshot. ADE stays out of that loop. When the agent reaches a checkpoint worth showing, it files an artifact through the broker (directly or via `ade proof capture` / `attach`), optionally with a caption. That record is what the drawer UI renders and what reviewers see. +Proof is **intentional**. Agents run computer use through their provider tool — including direct Codex Computer Use, Claude's `computer_use`, a scripted browser, a headless Playwright run, or a local screenshot. ADE may provision the provider tool, but it does not passively promote every tool result into proof. When the agent reaches a checkpoint worth showing, it files an artifact through the broker (directly or via `ade proof capture` / `attach`), optionally with a caption. That record is what the drawer UI renders and what reviewers see. The previous control-plane model — `ComputerUsePolicy` (`off`/`auto`/`enabled`, `allowLocalFallback`, `retainProof`, `preferredBackend`), passive `proofObserver` ingestion from chat `tool_result` events, and the Settings > Computer Use panel — was removed. There is **one path** now: intentional ingest via the broker. @@ -915,8 +926,16 @@ Supporting files in the same directory: - `controlPlane.ts` — builds `ComputerUseOwnerSnapshot` (recent artifacts + activity) and `ComputerUseSettingsSnapshot` (backend readiness, capabilities) over the broker. - `localComputerUse.ts` — exports `getLocalProofCaptureCapabilities()`, a macOS-only descriptor reporting whether `screencapture`, app launch, and GUI-interaction commands are available. -- `agentBrowserArtifactAdapter.ts` — parses agent-browser payloads into `ComputerUseArtifactInput[]`. -- `syntheticToolResult.ts` — produces tool-result stubs during Claude compaction so a previously-executed tool response can be re-surfaced without re-running the tool. +- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — parses agent-browser payloads into `ComputerUseArtifactInput[]`. + +Direct Codex Computer Use is a separate execution path. On macOS, +`apps/desktop/src/main/utils/codexComputerUse.ts` requires an explicit +`computer-use@openai-bundled` plugin or `mcp_servers.computer_use` opt-in, +finds the standalone `SkyComputerUseClient`, verifies the full code signature +plus OpenAI team/bundle identifiers, and injects it as the canonical +`computer_use` MCP server into native Work chats and tracked Codex CLI +start/resume commands. It does not replace the proof broker or bypass MCP +elicitation prompts. ### 12.3 Artifact record diff --git a/docs/PRD.md b/docs/PRD.md index 9daf7dfd1..a3ad5975f 100644 --- a/docs/PRD.md +++ b/docs/PRD.md @@ -21,9 +21,9 @@ The clients of that brain are equal: The primary unit of work inside any project is a **lane**: an isolated git worktree + per-lane process pool + agent session. Many lanes run concurrently — each with its own chat, its own processes, its own PR. Lanes compose into **stacks** (dependency chains) and can be driven by automation rules when the work needs durable routing. Layered on top, all owned by the brain: -- **Agents** — lane-bound chat, machine-owned personal chat, plus the persistent CTO operator. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware. +- **Agents** — lane-bound chat, machine-owned personal chat, plus the persistent CTO operator. Multi-provider (Anthropic, OpenAI, Claude Code CLI, Codex, OpenCode, Cursor). Tool-aware; Codex defaults to GPT-5.6 Sol with Terra/Luna beside it. - **Automations** — rule-based background workflows triggered by events, cron, webhooks. -- **Computer use** — control plane that fans out to Computer Use, agent-browser, or local fallback for UI automation proofs. +- **Computer use** — direct, signed Codex Computer Use MCP wiring on macOS plus the provider-neutral proof broker for intentional screenshots, videos, traces, and verification artifacts. - **ADE browser** — built-in browser with persistent isolated project profiles plus an explicit global profile for personal chat, tab/session ownership, hidden-tab agent actions, diagnostics, traces, and explicit proof promotion. - **Linear** — issue read/search plus a developer lane/PR flow and an optional live-status round-trip. - **Multi-device sync** — cr-sqlite CRDT replication, owned by the sync service inside the brain. The iOS app and any controller desktops connect through the same sync service. @@ -102,7 +102,7 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec ### Agents and chat - [**Agents**](./features/agents/README.md) — Two surfaces: lane-bound chat and the persistent CTO operator. Identity, capability modes, tool tiers, and the CTO memory system. -- [**Chat**](./features/chat/README.md) — Multi-provider, streaming, tool-aware. Transcript and turns, tool system (universal/workflow/coordinator), agent routing, composer + derived panels, and parallel multi-model lane launch. Terminal client: [ADE Code](./features/ade-code/README.md). +- [**Chat**](./features/chat/README.md) — Multi-provider, streaming, tool-aware. Transcript and turns, compact web/MCP/image/subagent activity, Codex Sources and stalled-turn recovery, tool system (universal/workflow/coordinator), agent routing, composer + derived panels, and parallel multi-model lane launch. Terminal client: [ADE Code](./features/ade-code/README.md). - [**Personal Chats**](./features/personal-chats/README.md) — General-purpose, machine-owned AI conversations with the same model catalog but no project, lane, Git, or PR binding. Available from desktop, hosted web, mobile Hub, and the ADE CLI. - [**History**](./features/history/README.md) — Two surfaces sharing one page: a GitKraken-style commit graph for the focused lane (per-commit branch/lane/tag/cherry-pick/revert/reset and lane-level head-change undo+redo), and a unified activity feed that merges operations with chat sessions and CTO sessions. Every recorded service follows the same `runTrackedOperation` pattern. @@ -122,7 +122,7 @@ ADE is the control plane. It owns ADE Browser automation for its built-in projec ### Integrations - [**Linear Integration**](./features/linear-integration/README.md) — Issue read/search, lane/commit/PR attachment flow, batch launch, session-scoped attachment, and an optional live-status round-trip. -- [**Computer Use**](./features/computer-use/README.md) — Intentional proof capture plus active App Control. Canonical artifact model, ownership-linked storage. +- [**Computer Use**](./features/computer-use/README.md) — Direct signed Codex Computer Use, intentional proof capture, and active App Control. Canonical artifact model, ownership-linked storage. - [**iOS Simulator**](./features/ios-simulator/README.md) — Chat-side macOS-only drawer that builds, launches, mirrors, inspects, and controls a booted iOS Simulator. ADEInspector publishes per-frame SwiftUI element metadata so taps become source-anchored chat context. - [**Sync and Multi-Device**](./features/sync-and-multi-device/README.md) — cr-sqlite CRDT (desktop native ext, iOS pure-SQL emulation). Host/controller model. WebSocket envelope. Remote commands. diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 411ca56dd..affe413f5 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -35,12 +35,12 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/remoteBridge.ts` | Remote transport shim used by `remoteLauncher.ts`: starts `ade rpc --stdio` over SSH, performs process-backed JSON-RPC for selection/listing, then exposes a local one-connection bridge socket (Unix socket on POSIX, loopback TCP on Windows) to the regular TUI. Owns stderr tails, 16 MB RPC frame limits, child-process cleanup, and bridge-socket teardown. | | `apps/ade-cli/src/tuiClient/commands.ts` / `linearCommands.ts` | Slash command catalog and routing. `commands.ts` ships `/lane delete` (right-pane confirmation form that destroys the active lane), `/effort` (reasoning-effort-only picker, a narrower companion to `/model`), provider-agnostic `/skills` for Agent Skill discovery, and provider-agnostic `/secrets` for masked project-secret listing/copying. `linearCommands.ts` requires a sub-command — bare `/linear` returns the usage hint instead of silently picking `workflows`. It also routes the session/lane attachment verbs (`attach` / `detach` / `issues` → `lane` domain session-scoped or lane-scoped actions) and the issue write-bridge verbs (`comment` / `set-state` / `assign` / `label` → `linear_issue_tracker` domain), reusing `--issue-id` / `--linear-issue-json` / attachment flags (`source`, `includeInPr`, `closeOnMerge`, `role`) parsing shared with the typed `ade linear` CLI commands in `cli.ts`. | | `apps/ade-cli/src/tuiClient/providerMetadata.ts` | Provider labels, family labels, token normalization, and provider lookup helpers shared by setup rows and the model picker. Keeps Anthropic/OpenAI/Factory aliases mapped onto the TUI's provider ids and decides which providers support runtime catalog refresh. | -| `apps/ade-cli/src/tuiClient/modelState.ts` | Pure model/setup state for draft chats and `/model`: initial provider/model defaults, Chat vs CLI interface mode, Cursor chat-vs-CLI availability reconciliation, Codex preset/approval/sandbox mapping, provider-specific permission summaries, reasoning effort derivation, Fast Mode support, and the `SetupPaneRow` list rendered in setup panes. | +| `apps/ade-cli/src/tuiClient/modelState.ts` | Pure model/setup state for draft chats and `/model`: GPT-5.6 Sol default plus Sol/Terra/Luna ordering, Chat vs CLI interface mode, Cursor chat-vs-CLI availability reconciliation, Codex preset/approval/sandbox mapping, provider-specific permission summaries, host-aware reasoning defaults/visible tiers, Fast Mode support, and the `SetupPaneRow` list rendered in setup panes. GPT-5.6 labels `low` as Light, `xhigh` as Extra High, exposes Ultra on Sol/Terra, and filters the feature-gated raw Max tier. | | `apps/ade-cli/src/tuiClient/modelPickerController.ts` | Small adapter between right-pane model-picker state and `modelPickerLayout.ts`: supplies active model/reasoning/interface, favorites/recents, AI status, footer focus, lane label, and provider refresh routing. | | `apps/ade-cli/src/tuiClient/rightPaneFormatters.ts` | Pure formatters for right-pane result panes (PR summary / review / checks / comments, Linear status, system details). Keeps `app.tsx` free of ad-hoc rendering helpers. | | `apps/ade-cli/src/tuiClient/format.ts` | Transcript rendering helpers for the TUI. | | `apps/ade-cli/src/tuiClient/displayWidth.ts` | Grapheme-aware terminal-cell helpers using `string-width`: code-unit ↔ display-cell mapping, display-cell slicing, truncation, wrapping, and selection splitting for Unicode-safe chat/model/right-pane rendering. | -| `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, tool-calls / files-changed / plan / compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. | +| `apps/ade-cli/src/tuiClient/aggregate.ts` | Pure derivations on top of the chat event stream. Produces `AggregatedBlock`s (assistant text, connector-aware tool calls, files changed, web/image/plan/compaction groups, runtime-activity rows for subagent and activity envelopes, queued steers) and `derivePendingSteers`, consumed by `ChatView` and the right-pane steer view. MCP app/server identity replaces generic tool labels and image lifecycle updates collapse by item id. | | `apps/ade-cli/src/tuiClient/bracketedPaste.ts` | Bracketed-paste parser/formatter for terminal-control mode and multi-line forwarded input. Normalizes pasted newlines and wraps multi-line user input in bracketed-paste markers before writing it into provider CLI PTYs. | | `apps/ade-cli/src/tuiClient/closedCliSessions.ts` | Converts ended tracked CLI terminal sessions into chat-like drawer rows, filters them out of open chat lists, derives resumability/provider metadata, and maps user-initiated closes (0/130/143) to the neutral idle glyph instead of a failure state. | | `apps/ade-cli/src/tuiClient/drawerSelection.ts` | Pure selectors for the lane / chat drawer (active row, expanded groups, keyboard navigation). | @@ -71,6 +71,7 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/services/modelPickerStore.ts` | Cross-surface (desktop + TUI + iOS) favorites and recents stored in the per-project `ade.db` tables `model_picker_favorites` and `model_picker_recents`, with `~/.ade/modelPicker.json` imported once as a legacy migration source. `MAX_RECENTS` caps the recents list in app code because the CRR tables are primary-key-only. Exposed through the top-level `modelPicker.getFavorites` / `setFavorites` / `toggleFavorite` / `getRecents` / `pushRecent` JSON-RPC methods on `adeRpcServer` and through matching iOS sync commands. | | `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds, scheduled-work/retraction events, `AgentChatModelCatalog*`). Imported per-module so ade-cli typecheck stays scoped. | | `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | +| `apps/desktop/src/main/utils/codexComputerUse.ts`, `apps/desktop/src/shared/cliLaunch.ts` | Explicit-opt-in, OpenAI-signature-verified Computer Use MCP resolution plus the Codex CLI start/resume flags reused by attached and embedded ADE Code runtime actions. | | `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | ## Modes @@ -143,7 +144,7 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Header** — project name, active lane, branch, and the terminal client frame. - **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. -- **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), `web_search` events group with tool calls and include the first provider action query/title/URL when available, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, file-change groups collapse to one summary row and expand to typed file rows whose `diff` action opens the turn diff in the right pane, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. +- **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), MCP events prefer the app/plugin/server name plus action instead of a generic `mcp` label, `web_search` events group with tool calls and include the first provider action query/title/URL when available, generated/viewed-image lifecycle updates collapse to one concise notice per item, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, file-change groups collapse to one summary row and expand to typed file rows whose `diff` action opens the turn diff in the right pane, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. - **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`. - **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — **TASKS** (latest `todo_update` snapshot, desktop ChatTasksPanel parity), **SCHEDULE** (Claude wakeups/cron/background work from `scheduled_work_update`, desktop Chat Info parity), and **PR** (the lane's PR state + checks rollup with `/pr` hand-off hints, desktop ChatPrPane parity). PR rows refresh from runtime PR update notifications when available and still keep the 30s poll as a fallback. Codex goal state comes from the shared chat event stream and is normalized so provider token budgets do not show as ADE-side limits. Selecting a subagent row with `↵` first probes for a usable subagent transcript; if one is available the centre transcript swaps to it, otherwise the local reconstruction stays visible with a notice. `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), optional **SETUP** (lane setup progress or retryable failure; press `r` on a failed setup to retry), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. - **External session browser** — a RightPane flow backed by @@ -335,6 +336,8 @@ After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli - `+ new chat` opens a draft setup view (`new-chat-setup`) in the right pane; it does not create a backend chat until the first prompt is sent from the middle composer. - The draft setup and `/model` panes carry an **Interface: Chat | CLI** row (immediately after Provider), matching the desktop/iOS Chat/CLI switcher. **Chat** creates an SDK chat via `chat.createSession` (all providers, including Claude); **CLI** starts a tracked provider CLI terminal via the `start_cli_session` action (Claude, Codex, Cursor, Droid, or OpenCode). New drafts default to the last explicitly selected Interface for the project, persisted in `~/.ade/ade-code-state.json` with legacy fallback; programmatic draft resets do not overwrite it. The row is editable while the chat is a draft and becomes read-only once a session exists (its value then reflects the session type). First-prompt submit branches on it: draft `CLI` starts a tracked terminal; otherwise an SDK chat is created. Ollama / LM Studio have no CLI and stay Chat-only. - `/model` opens the model setup view (`model-setup`) in the right pane. It can switch provider, model, reasoning, Fast Mode for fast-capable descriptors, and permission settings, refresh provider readiness through `ai.getStatus`, and open desktop Settings > AI Providers for full configuration. Cursor rows come from the same provider-grouped catalog as desktop, including `cursorAvailability`, so SDK chat models and Cursor CLI launch models stay separated consistently — and the picker gates Cursor availability on the selected Interface (Chat disables CLI-only Cursor models and vice versa). +- The Codex/OpenAI list always begins GPT-5.6 Sol, Terra, Luna; Sol is the new-chat default and GPT-5.5 remains below the family. Sol/Terra expose Light, Medium, High, Extra High, Ultra; Luna stops at Extra High. ADE Code uses each descriptor/host row's `defaultReasoningEffort` (`low` for Sol, `medium` for Terra/Luna) and filters raw `max` from these three models. +- On macOS, both Chat and CLI Codex drafts inherit the same direct Computer Use integration as desktop. If the bundled plugin or canonical MCP server is explicitly enabled in Codex config and the standalone OpenAI client passes strict signature checks, native chats receive it on app-server thread start/resume and CLI launches receive `mcp_servers.computer_use` overrides. Disabled/unverified/missing clients add no flags. MCP app consent still appears as pending input; Full Auto does not bypass it. - `/login` delegates only to provider CLIs that can authenticate in the current terminal: Claude (`claude auth login`), Codex (`codex login`), and OpenCode (`opencode auth login`). After a successful login for the active provider, an auth-failed latest prompt is restored into the composer with a "logged in — press Enter to resend" notice. Cursor chat is `@cursor/sdk` and needs `CURSOR_API_KEY` or desktop Settings > AI Providers. Droid chat runs Factory Droid over ACP and needs `FACTORY_API_KEY` or Factory's interactive `droid` login. - The middle composer shows the selected provider, model, reasoning, and permission mode under the prompt so draft changes on the right are visible before the chat starts. diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index ef88611c6..3d87c25d3 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -13,7 +13,8 @@ The former worker/hiring agents were removed. There is one persistent identity | `apps/desktop/src/main/services/ai/tools/ctoOperatorTools.ts` | CTO operator tools for chat spawning, lanes/PRs/git/tests, Linear reads/writes, and the `saveMemory` / `searchMemory` / `readMemory` memory tools. | | `apps/desktop/src/main/services/agentTools/agentToolsService.ts` | Detects external CLI tools on PATH. | | `apps/ade-cli/src/cli.ts` | Agent-focused `ade` command surface and text/JSON output formatters. Includes the `ade ios-sim` (alias `ade ios`, `ade simulator`) family — see [iOS Simulator feature](../ios-simulator/README.md), the `ade --socket app-control ...` driver for live Electron apps, and the `ade --socket browser ...` driver for the in-app browser. `ade secrets list|get|set|delete` is the typed surface for encrypted project-scoped ADE secrets that agents may read when the user names a secret. `ade new chat --mode chat|cli --lane --provider codex --model --reasoning-effort --no-fast --permissions full-auto --prompt "..."` mirrors the desktop New Chat toggle. `ade chat create` / `ade new --mode chat` default `orchestrationParentSessionId` from `ADE_CHAT_SESSION_ID` so agent-spawned chats link back to the spawning chat (`--parent ` overrides, `--no-parent` opts out). `ade chat read --text` reads recent transcript messages. `ade chat ... --personal` lists, creates, reads, sends to, interrupts, archives, and deletes machine-owned projectless chats through the running brain. `ade lanes link-linear-issue --linear-issue-json '{...}'` (aliases `link-linear`, `linear-link`) links Linear issues to an existing lane. | -| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | +| `apps/ade-cli/src/adeRpcServer.ts` | Private ADE action RPC: registers actions, handles JSON-RPC, applies session-identity-based filtering, builds lane-scoped ADE guidance / `ADE_AGENT_SKILLS_DIRS` for CLI launches, injects an explicitly enabled and verified direct Computer Use MCP client into tracked Codex launches, and returns GitHub + ADE PR URLs from PR creation tools when available. | +| `apps/desktop/src/main/utils/codexComputerUse.ts` | Security boundary for direct Codex Computer Use: explicit config opt-in, stable/cache candidate resolution, executable check, and strict OpenAI code-signature identity verification. | | `apps/desktop/resources/agent-skills/ade-cli-control-plane/SKILL.md` | Agent-facing ADE CLI control-plane guidance. | | `apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md` | Agent-facing schema for Mosaic v1 interactive cards: an agent emits a fenced ` ```mosaic ` JSON block to ask the user for structured input (select / multiselect / number / input / approval / table) and the submitted answers return as the next user message. Discovered on demand rather than named in the `adeBundledAgentSkills` bootstrap list; parsing/rendering live in `apps/desktop/src/shared/chatMosaic.ts` (see [chat composer-and-ui.md](../chat/composer-and-ui.md)). | | `apps/desktop/src/main/services/cli/adeCliService.ts` | Desktop-side install / status / uninstall surface for the `ade` launcher. | @@ -31,6 +32,16 @@ One persistent project-level identity. The CTO carries a structured `CtoIdentity ### Regular chat agents Ephemeral sessions bound to a lane. They have no persistent identity document; the session state lives in the transcript and resumes across restarts. +New Codex agents default to GPT-5.6 Sol, with Terra and Luna immediately below +it and GPT-5.5 retained. Provider-native web, MCP/connector, image, and +subagent activity is normalized into compact transcript events so every client +can show what the agent did without dumping raw SDK/app-server envelopes. + +On macOS, Codex agents can use the canonical `mcp__computer_use` tool when the +user explicitly enabled the bundled Computer Use plugin or MCP server and the +standalone OpenAI helper passes signature verification. The same path is +installed for native Work chat and tracked Codex CLI start/resume; normal MCP +elicitation consent still applies. ### Personal chat agents diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 243b1988d..ecec8ecd7 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -37,6 +37,10 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/projectSlashCommandDiscovery.ts` | Unified project-wide slash command discovery. Merges commands from Claude, Codex, and Cursor discovery modules into a single deduplicated list, filtering `/login`. Used by the ADE Code TUI's `discoverProjectSlashCommands` so all providers see the same cross-provider command catalog. | | `apps/desktop/src/main/services/chat/slashCommandPromptExpansion.ts` | Provider-routed slash command prompt expansion. Given a provider, cwd, and slash command input, resolves the command's markdown body into prompt text through the appropriate provider-specific resolution path (Claude, Codex, or Cursor). Built-in and runtime-registered commands are skipped so the SDK handles them natively. Consumed by `agentChatService` to expand slash commands before dispatch. | | `apps/desktop/src/main/services/chat/chatTextBatching.ts` | Batches streaming assistant text fragments (100 ms) before emission to reduce renderer re-renders. | +| `apps/desktop/src/main/services/chat/claudeStructuredActivity.ts` | Normalizes Claude server-owned `web_search` / `web_fetch` and MCP content blocks into the same `web_search` and paired `tool_call` / `tool_result` events used by other providers, including deterministic turn-end closure for unfinished blocks. | +| `apps/desktop/src/main/services/chat/openCodeStructuredActivity.ts` | Maps OpenCode image `file` parts into compact image-generation events, preserving a local saved path when the URL points at a file. | +| `apps/desktop/src/main/services/chat/codexMcpElicitation.ts` | Converts Codex app-server MCP elicitation JSON Schemas into pending-input questions and coerces accepted form answers back to boolean/number/array/object values. Persistent consent is gated by request metadata. | +| `apps/desktop/src/main/utils/codexComputerUse.ts` | Resolves and strictly verifies the OpenAI-signed standalone Computer Use client after explicit user opt-in, then supplies the canonical `computer_use` MCP config to Work chat and tracked Codex CLI launch/resume paths. | | `apps/desktop/src/main/services/chat/sessionRecovery.ts` | Version-2 persisted-state reconstruction when sessions resume from disk. | | `apps/desktop/src/main/services/chat/cursorSdkPool.ts` | Cursor SDK adapter: spawns and pools `cursorSdkWorker.ts` Node workers per session, sends turns, brokers permission/hook callbacks, maps SDK events to chat events, and handles teardown. Worker env construction sanitizes ADE/runtime ownership variables while preserving packaged `NODE_PATH` entries through the shared runtime helper so workers copied under `Resources/ade-cli/` can still resolve unpacked app dependencies such as `@cursor/sdk`. The connection envelope carries stable durable-state keys, model parameters, worker request ids, SDK request ids, and structured `CursorSdkErrorDetail` so service logs can correlate ADE chat sessions with Cursor SDK/backend failures. | | `apps/desktop/src/main/services/chat/cursorSdkWorker.ts` | Node worker that hosts the official `@cursor/sdk` and bridges it to the main process via the JSON line protocol in `cursorSdkProtocol.ts`. It creates the SDK local agent platform with the lane workspace/state root, configures local agents to use HTTP/1 by default (`ADE_CURSOR_SDK_USE_HTTP1_FOR_AGENT=0` disables it), enables SDK local agent retries, passes ADE mode/idempotency keys on sends, and tolerates stream-iteration failures long enough to call `run.wait()` and emit a structured terminal result. | @@ -44,12 +48,12 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/cursorSdkProtocol.ts` | Shared types for the worker IPC: chat mode, approval policy, sandbox mode, hook decisions, hook requests, `CursorSdkModelParameterValue`, `CursorSdkWorkerInit`, local/cloud send payloads, SDK request ids, and `CursorSdkErrorDetail`. It exports Cursor-specific error classifiers for transport (`nghttp2`, dropped sockets, stream closures) and backoff/resource exhaustion (`resource_exhausted`, `rate_limited`, `NGHTTP2_ENHANCE_YOUR_CALM`, 429-style text) so UI/service paths present rate-limit and network failures consistently. | | `apps/desktop/src/main/services/chat/cursorSdkPolicy.ts` | Maps ADE permission modes onto Cursor SDK chat mode + approval policy + sandbox mode (`ade` / `cursor-native` / `off`); decides which tool calls auto-approve and which require a user prompt. | | `apps/desktop/src/main/services/chat/cursorSdkSystemPrompt.ts` | Builds the system prompt the Cursor worker injects (lane context, ADE CLI guidance, persona overlays). | -| `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. On a terminal `ERROR` status it reads the worker-injected `adeErrorCode` / `adeErrorDetail`, emits stable user-facing headlines for rate-limit and transport failures, preserves exact Cursor request ids/details in `detail`, and sets `errorInfo.category` to `rate_limit`, `network`, `busy`, or `auth` when classification is known. | +| `apps/desktop/src/main/services/chat/cursorSdkEventMapper.ts` | Translates `@cursor/sdk` stream events into the ADE `AgentChatEventEnvelope` shape consumed by the renderer. Cursor MCP calls retain provider/tool identity in `event.mcp`; generated-image tools become compact image-generation rows. On a terminal `ERROR` status it reads the worker-injected `adeErrorCode` / `adeErrorDetail`, emits stable user-facing headlines for rate-limit and transport failures, preserves exact Cursor request ids/details in `detail`, and sets `errorInfo.category` to `rate_limit`, `network`, `busy`, or `auth` when classification is known. | | `apps/desktop/src/main/services/chat/cursorModelsDiscovery.ts` | Probes the live `@cursor/sdk` and `cursor-agent` CLI model lists, merges their descriptors, and records `cursorAvailability` so chat sessions see SDK-capable models while Work CLI launches can include CLI-only models. Both JSON and text probes preserve aliases, descriptions, `parameters[]`, and `variants[]`; `*-fast` CLI rows are folded into their base model as `aliases` + `serviceTiers: ["fast"]` so the picker shows one model with a Fast toggle instead of duplicate "Fast" rows. Parameter and variant metadata is classified into `reasoningTiers` (`none`/`dynamic`/`minimal`/`low`/`medium`/`high`/`xhigh`/`max`/`thinking`) and `serviceTiers` (`fast`). `resolveCursorSdkModelSelectionParams` rebuilds the matching `CursorSdkModelParameterValue[]` so the SDK boot can target the right variant. The previous minimal `auto` / `composer-2` fallback list has been removed. **Cache resilience:** both the SDK and CLI caches are stale-while-revalidate — last-known-good rows are served well past the 120s freshness window (up to ~6h) and a background warm (at most one attempt per freshness window, so a broken CLI/SDK is not re-spawned on every passive read) refreshes them, so verified-provider models never blink out on passive status reads (`availableModelIds`, mobile, TUI). `markCursorModelCachesStale` ages the caches without dropping rows — generic readiness invalidation (forced status refresh, verifying any provider's key) calls it, while only a cursor key change does a full `clearCursorCliModelsCache`. Auth/SDK-resolution failures drop the SDK cache (a dead key/unusable module must not resurface phantom models); transient failures keep serving last-known-good. When the signed-in CLI reports "No models available" its cache is dropped and a provider runtime failure is surfaced (the stored login lost model access; re-auth via `cursor-agent logout`). | | `apps/desktop/src/main/services/chat/droidSdkPool.ts` | Droid SDK adapter. Forks `droidSdkWorker.cjs` per session, exposes `acquireDroidSdkConnection` / `releaseDroidSdkConnection`, and proxies prompt sends, settings updates, permission decisions, ask-user responses, and cancellation through the worker. Resolves the Droid SDK CLI executable via `resolveDroidExecutable` (PATH + bundle + configured install paths). | | `apps/desktop/src/main/services/chat/droidSdkWorker.ts` | Node worker that hosts `@factory/droid-sdk`. Streams SDK events back to the main process and forwards permission / ask-user prompts back through the JSON-line protocol. | | `apps/desktop/src/main/services/chat/droidSdkProtocol.ts` | Worker IPC types: `DroidSdkSessionSettings` (autonomy level, interaction mode, reasoning effort), `DroidSdkReasoningEffort`, `DroidSdkPermissionRequest`/`Decision`, `DroidSdkAskUserRequest`/`Response`, `DroidSdkReady` (handshake with `availableModels`), and `DroidSdkSendPrompt`. | -| `apps/desktop/src/main/services/chat/droidSdkEventMapper.ts` | Per-session `DroidSdkEventMapperState` + `mapDroidSdkMessageToChatEvents` / `mapDroidSdkRunResultToDoneEvent`. Tracks streaming text/thinking item ids, maps tool calls and results, and surfaces token usage. Replaces the deleted `droidAcpPool.ts` + `droidAcpEventMapper` path. | +| `apps/desktop/src/main/services/chat/droidSdkEventMapper.ts` | Per-session `DroidSdkEventMapperState` + `mapDroidSdkMessageToChatEvents` / `mapDroidSdkRunResultToDoneEvent`. Tracks streaming text/thinking/image item ids, maps tool calls and results, surfaces image content as compact generation rows, and reports token usage. Replaces the deleted `droidAcpPool.ts` + `droidAcpEventMapper` path. | | `apps/desktop/src/main/services/chat/droidModelsDiscovery.ts` | SDK-driven model probe (`listDroidModelsFromSdk`) plus the `~/.factory/config.json` custom-proxy merge. Normalizes retired factory Claude ids (Sonnet 4.6 -> Sonnet 5, basic Opus 4.7 -> Opus 4.8) before descriptors reach desktop, mobile, or TUI model pickers. Exposes `discoverDroidSdkModelDescriptors` (alias for the legacy `discoverDroidCliModelDescriptors` while callers migrate). | | `apps/desktop/src/main/services/opencode/openCodeBinaryManager.ts` | Resolves the OpenCode CLI: PATH first, then the bundled `node_modules/.bin/opencode`. Cache entries are re-validated with `canRunBinaryCandidate` on every lookup so user installs after launch are picked up; missing-binary lookups are intentionally not cached. `clearOpenCodeBinaryCache()` is wired into the AI integration's full cache reset. | | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | @@ -59,7 +63,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/chat/claudeWorkflowProgress.ts` | Defensive normalizer for the Claude Agent SDK's undocumented `workflow_progress` snapshot on `system:task_progress` (Workflow orchestration runs). Parses phases + per-agent entries (caps counts, clips previews, drops malformed entries, unknown states degrade to queued/running; unparseable snapshots return undefined so the generic task rendering is untouched), then `planClaudeWorkflowAgentTransitions` diffs each cumulative tick against per-task emit state to fan out `subagent_started/progress/result` events under a stable `::a` identity with the emitted agentId latched at first emission. Consumed by `agentChatService`'s `task_progress`/`task_notification` handlers and the interrupt path (which close still-running agents as `stopped`). | | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | -| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal control args, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `scheduled_work_update` captures Claude wake/cron/background lifecycle, while `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | +| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), provider-neutral `AgentChatMcpToolSource` / app context metadata, Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal/recovery control args, image generation/view events with large-inline-payload omission metadata, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `AgentChatMessageSession*` peer-message routing DTOs (`auto` / `queue` / `wake` / `interrupt-replace`), `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `AgentChatSubagentSnapshot.label` carries provider-assigned display labels such as Codex Agent #N. `scheduled_work_update` captures Claude wake/cron/background lifecycle, while `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx` | Reusable activity, token, code-movement, and client-mix charts. `AgentChatPane` mounts the compact variant directly below an empty Work draft composer (desktop and web only); it reads `usage.getAdeStats` through the active `window.ade` adapter and persists the selected chart and day/week/month/year range locally. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | @@ -67,7 +71,8 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Shared renderer helper for Work draft-launch job DTOs and pruning. Owns `NativeControlState`, `DraftLaunchSnapshot`, `PreparedDraftLaunch`, `DraftLaunchJobStatus`, `DraftLaunchJob`, `isDraftLaunchJobTerminal`, `isDraftLaunchJobStale`, and `pruneDraftLaunchJobs`; active jobs are kept ahead of terminal rows, with terminal rows filling the remaining retained slots and at least one terminal row retained alongside active jobs. Also owns the launch durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout(promise, label)` (rejects a launch step whose runtime call never settles; the underlying IPC is not cancellable, so on timeout it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Shared renderer helper for in-flight chat handoff placeholders. Defines the handoff job DTO, scope keying, status labels (`preparing-summary` -> `creating-chat` -> `sending-handoff`), search matching, and the stable placeholder id used by the Work session sidebar. | | `apps/desktop/src/renderer/state/appStore.ts` | Shared renderer state store. Besides project/lane/work selection, it persists user preferences such as `launchPromptClipboardEnabled` and `launchPromptClipboardNoticeEnabled`, mirrors them into per-project stores, and owns `draftLaunchJobsByScope` (+ `setDraftLaunchJobs`) for Work draft launch status strips plus `handoffLaunchJobsByScope` (+ `setHandoffLaunchJobs`) for Work sidebar handoff placeholders. These live in the **root** store (not the per-project store) on purpose: in-flight launches must survive a remote project switch that destroys the originating per-project store; `AgentChatPane` reads them via `useRootAppStore` / `rootAppStoreApi.getState()`. | -| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Activity bundles fold todo/scheduled/subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips, while `codex_turn_stalled` renders as a recovery card with the available recovery choices. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | +| `apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx` | Virtualized transcript renderer. Coalesces resize / measurement updates and, while sticky-to-bottom is active, follows height changes across multiple animation frames so streamed output and late row measurements do not leave the user above the newest message. Programmatic scroll writes are tracked by target scroll position, not a stale counter, so browser-coalesced scroll events do not swallow the next real user gesture. Activity bundles fold todo/scheduled/subagent updates into compact rows, dedupe placeholder subagent parents once the concrete child id is known, and open Chat Info / subagent detail instead of duplicating the drawer roster inline. Codex goal lifecycle events render as compact user-facing rows (`Goal set`, `Goal paused`, `Goal cleared`) instead of raw JSON-RPC/status wording. Codex runtime notices (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`) render as small transcript chips. `codex_turn_stalled` renders a live recovery card: Wait re-arms the watchdog, Nudge sends a status steer, Retry interrupts and replays work in the same thread, and Resume restarts app-server, resumes the thread, and retries. Handoff brief user messages with `metadata.hideFullPrompt` show only their `displayText` breadcrumb and do not expose or copy the internal prompt body. Error events whose `errorInfo.agentCli.category` is `"unauthenticated"` render as the calm `AgentCliAuthCard` (raw 401 behind a `Details` disclosure) rather than the red error block, so a recoverable logout reads as a re-login prompt, not a crash. | +| `apps/desktop/src/renderer/components/chat/ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Codex Chat Actions source inventory. Sources is the first available tab and derives a deduplicated list of attachments/files, web searches/results, MCP apps/tools, and external resource URLs from the current transcript. HTTP(S) rows open in ADE's built-in browser; internal `node_repl` plumbing and unsafe protocols are excluded. | | `apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx` | Git / PR quick-action toolbar above the composer. If the lane already has a linked PR, the PR button opens or toggles that PR; otherwise it routes to the PR workspace with a create-PR handoff (`create=1&sourceLaneId=&target=primary`). When the chat PR pane or compact PR menu opens, it asks `prReadCache.refreshLinkedPrCoalesced` for a targeted `prs.refresh({ prIds })` so the badge picks up merged/closed/check transitions without broad GitHub polling. | | `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | @@ -78,7 +83,8 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | | `apps/desktop/src/renderer/lib/claudeAuthPrompt.ts` | Renderer-side classifier for Claude logged-out / `/login`-required error text. Drives the header and sticky login CTAs; matches both Claude-first wording and ADE's own "Authentication failed for <model>" classified message. | | `apps/desktop/src/renderer/lib/openExternal.ts` | Renderer-side router for outbound URLs. Defines the `ADE_OPEN_BUILT_IN_BROWSER_EVENT` window event plus `openUrlInAdeBrowser(url)` and `openExternalUrl(url)`. `openUrlInAdeBrowser` dispatches the event (so any open `WorkSidebar` can flip to its Browser tab), then calls `window.ade.builtInBrowser.navigate({ url, newTab: true })`. Anything that is not a normal `http`/`https`/`about:blank` URL falls through to `window.ade.app.openExternal` (system browser). All in-renderer URL clicks (markdown links, lane-runtime open buttons, etc.) go through this helper so the user stays inside ADE. | -| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images read bytes through `ade.app.readClipboardImage` then write them through `ade.agentChat.saveTempAttachment` so remote-bound chats receive a runtime-readable attachment path. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. | +| `apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx` | Composer UI: single-session prompt entry, attachments, model/permission controls, slash commands, pending input answering, and parallel launch slot configuration. Permission popover rows keep only the mode title in the visible row (the explanation remains in the tooltip/title) for every provider-backed picker. Codex MCP elicitations show Allow once / Deny, conditionally show Always allow, and expose safe URL authorization through ADE's browser. Pasted/dropped image attachments show pending thumbnails while temp files save, and native Electron clipboard images read bytes through `ade.app.readClipboardImage` then write them through `ade.agentChat.saveTempAttachment` so remote-bound chats receive a runtime-readable attachment path. The launch-prompt clipboard helper is gated separately from prompt copying: `launchPromptClipboardEnabled` controls copying and `launchPromptClipboardNoticeEnabled` controls whether composer reminder text is shown. Orchestration model-selection pending inputs decode the full agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) so the picker can show what the lead is spawning without preselecting a recommended model. | +| `apps/desktop/src/renderer/components/shared/ModelPicker/ReasoningEffortPicker.tsx` | Shared reasoning slider. Supports pointer drag with nearest-tick snap, keyboard arrows/Home/End, a progressive filled gradient and active-tier pulse, GPT-5.6 labels (Light through Ultra), and an Ultra multi-agent usage note. Choosing or dragging to a tier leaves the popover open; outside click or Escape closes it. | | `apps/desktop/src/renderer/components/chat/ChatModelSelectionPendingCard.tsx` | Pending-input card used when ADE asks the user to choose a model for a new or rerouted agent. It renders the agent briefing, touched files, run-after dependencies, provider/model controls, cancel/confirm states, and leaves the model unset until the user chooses one. | | `apps/desktop/src/renderer/components/chat/ChatCursorCloudPanel.tsx` | Side panel for Cursor Cloud (background agents): lists existing cloud agents and runs for the lane, lets the user open an existing cloud chat in ADE, archive/unarchive/cancel, and stream run output. Backed by `ade.ai.cursorCloud.*` IPC. | | `apps/desktop/src/renderer/components/chat/CursorCloudInlineLaunch.tsx` | Inline composer affordance for "Send to Cursor Cloud": picks repo + branch + Cursor Cloud-eligible model, optionally targeting a detected PR, and dispatches the prompt to a fresh cloud agent. | @@ -582,8 +588,9 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.dispatchSteer` | invoke | Claude-only: deliver a queued steer immediately as `mode: "inline"` (folded into the active turn via SDK `shouldQuery: false` send) or `mode: "interrupt"` (interrupt the active turn so the steer runs next). Throws on Codex/OpenCode/Cursor. | | `ade.agentChat.cancelDispatchedSteer` | invoke | Claude-only: rescinds an inline-dispatched message before the model reads it (calls SDK `cancelAsyncMessage(uuid)`). No-op if the message has already been consumed. | | `ade.agentChat.interrupt` | invoke | Provider-specific interruption of the in-flight turn. | +| `ade.agentChat.recoverCodexTurn` | invoke | Execute one guarded recovery action for the currently active stalled Codex turn: `wait`, `steer`, `interrupt_retry_same_thread`, or `restart_resume_thread`. Calls are single-flight per session/turn and reject stale cards once that turn is no longer active. | | `ade.agentChat.approve` | invoke | Legacy approval channel (pre-pending-input). | -| `ade.agentChat.respondToInput` | invoke | Unified pending-input answer channel. | +| `ade.agentChat.respondToInput` | invoke | Unified pending-input answer channel, including Codex MCP elicitation form values and metadata-gated persistent consent. | | `ade.agentChat.delete` | invoke | Permanently remove a chat session: disposes the runtime if still running, cancels any pending turn collector, resolves outstanding input waiters, removes the persisted JSON + transcript, and deletes the `terminal_sessions` row. Renderer surfaces this as "Delete chat". | | `ade.agentChat.updateSession` | invoke | Mutate permission modes, `manuallyNamed`, capability mode, and the legacy-named `codexFastMode` Fast Mode toggle. | | `ade.agentChat.codex.goal.get` / `.set` / `.setStatus` / `.clear` | invoke | Codex-only IPC channels behind the preload API `window.ade.agentChat.codex.getGoal` / `.setGoal` / `.setGoalStatus` / `.clearGoal`. They call the app-server goal RPCs directly instead of sending `/goal` prompt text through the chat, preserve CLI/PTY sessions, validate objective length, persist goal state into session summaries, and keep ADE goals unlimited by clearing provider token budgets. | @@ -623,9 +630,28 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. a genuinely silent or unreadable turn emits `codex_turn_stalled`. The desktop transcript, `ade code`, and iOS should render that structured event as the recovery surface; do not hide it as a generic activity row. + Desktop recovery buttons call `recoverCodexTurn` and must remain scoped to + the active turn: Wait re-arms reconciliation, Nudge sends a progress steer, + Retry interrupts/finalizes the turn before retrying in the same thread, and + Resume additionally tears down app-server and resumes the persisted thread. + Only one action may run for a session/turn, and a late card must fail rather + than mutating a newer turn. App-server `thread/deleted` notifications must clear live turn state, stop active Codex subagents, and remove the persisted thread id so the next user message starts from a fresh thread. +- **Codex app-server requests must always resolve or remain visibly pending.** + Version 0.144.0 can request `currentTime/read` (reply in whole Unix seconds) + and `mcpServer/elicitation/request` (form or URL mode). Unsupported silence + stops the surrounding turn. `serverRequest/resolved` may arrive when a + request is completed outside ADE; remove the matching approval and emit + `pending_input_resolved` so a stale card cannot keep the composer locked. +- **Structured provider activity shares one compact event contract.** Claude + server web/MCP blocks, Codex `mcpToolCall` + app context, Cursor MCP/image + tools, OpenCode image file parts, and Droid image content must map to the + shared work-log/image events with stable item ids. Emit at most one start and + one terminal event per item; finish any still-open Claude server block at + turn completion. Keep large inline media out of durable/mobile transcript + frames and retain byte-omission metadata when content is compacted. - **Transcript read merges streaming text fragments.** The `MAX_TRANSCRIPT_READ_CHARS` budget is `120_000` (was `40_000`) and the transcript reader collapses consecutive assistant text events diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index 99a65cdf5..a43da7d37 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -8,10 +8,12 @@ where the machinery lives. | Path | Role | |---|---| -| `apps/desktop/src/shared/modelRegistry.ts` | Single source of truth for model descriptors. Defines `MODEL_REGISTRY`, `ModelDescriptor`, resolution helpers. | +| `apps/desktop/src/shared/modelRegistry.ts` | Single source of truth for model descriptors. Defines `MODEL_REGISTRY`, `ModelDescriptor`, model defaults (`defaultReasoningEffort`), and shared reasoning fallback/model-resolution helpers. | | `apps/desktop/src/shared/modelProfiles.ts` | Curated selection helpers (task routing, default pickers). | | `apps/desktop/src/shared/chatModelSwitching.ts` | `canSwitchChatSessionModel` / `filterChatModelIdsForSession` -- rules for mid-session model changes. | | `apps/desktop/src/main/services/chat/agentChatService.ts` | `handoffSession`, permission translation, per-provider adapter. | +| `apps/desktop/src/main/utils/codexComputerUse.ts` | macOS-only signed Codex Computer Use MCP resolver. Requires explicit Codex config opt-in and verifies the standalone OpenAI client before it can be injected into a chat or CLI runtime. | +| `apps/desktop/src/shared/cliLaunch.ts` | Tracked provider CLI start/resume builders, including model/reasoning/permission flags and the canonical `computer_use` MCP overrides for Codex. | | `apps/desktop/src/main/services/ai/providerRuntimeHealth.ts` | Tracks provider readiness/auth/network failures so the UI can surface degraded states. | | `apps/desktop/src/main/services/ai/providerOptions.ts` | Normalises provider-native options (Claude permission mode, Codex approval + sandbox, OpenCode permission). | | `apps/desktop/src/main/services/ai/authDetector.ts` | Discovers available credentials (CLI, API key, OAuth) and reports auth status. | @@ -29,7 +31,7 @@ for vendored runtimes without changing the union. | Provider | Runtime | Adapter location | |---|---|---| | `claude` | `@anthropic-ai/claude-agent-sdk` `query()` stream with an ADE async input pump, `startup()` warmup, bundled Claude Code binary, SDK sessions, hooks, output styles, plugins, context usage, rewind, and slash-command dispatch. | `agentChatService.ts` (inline; the file carries the full Claude adapter). | -| `codex` | `codex app-server` subprocess, JSON-RPC protocol. Spawn failures surface as error events. | `agentChatService.ts` (Codex adapter); config via `codexAppServerConfig.ts`. | +| `codex` | Pinned `@openai/codex` 0.144.0 `codex app-server` subprocess, JSON-RPC protocol. Spawn failures surface as error events. | `agentChatService.ts` (Codex adapter and thread config); executable resolution via `services/ai/codexExecutable.ts`. | | `opencode` | OpenCode server runtime: Anthropic/OpenAI/Google/Mistral/DeepSeek/xAI/Groq/Together AI API keys, OpenRouter, and local (Ollama, LM Studio, vLLM). | `agentChatService.ts` (OpenCode adapter); model discovery in `localModelDiscovery.ts` and `modelsDevService.ts`. | | `cursor` | Official `@cursor/sdk` running in a Node worker pool. ADE owns permissions, hooks, and the system prompt; the SDK owns the model + tool execution. Slash commands are discovered from `.cursor/commands/`, `.cursor/agents/`, built-in subagents, and Agent Skill roots via `cursorSlashCommandDiscovery.ts`. | `cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`, `cursorSlashCommandDiscovery.ts`. | | `droid` | Factory Droid models exposed as dynamic `droid/` descriptors and driven through the official `@factory/droid-sdk` running in a forked Node worker pool. The legacy ACP bridge (`droidAcpPool.ts`) has been retired. | `droidSdkPool.ts`, `droidSdkWorker.ts`, `droidSdkProtocol.ts`, `droidSdkEventMapper.ts`, `droidModelsDiscovery.ts`; model helpers in `modelRegistry.ts`. | @@ -50,6 +52,7 @@ type ModelDescriptor = { maxOutputTokens: number; capabilities: { tools, vision, reasoning, streaming }; reasoningTiers?: string[]; + defaultReasoningEffort?: string; // runtime-recommended initial tier serviceTiers?: string[]; // optional provider service tiers, currently "fast" color: string; providerRoute: string; @@ -102,6 +105,28 @@ to Opus 4.8, while `opus[1m]` / `opus-1m` still target Opus 4.7 1M. Passthrough to the provider config is unchanged (the tier string is forwarded directly to the CLI / SDK, with no synthesized token budgets). +### GPT-5.6 Codex models + +The OpenAI section is pinned in this order on every ADE model surface: + +1. `openai/gpt-5.6-sol` (`gpt-5.6-sol`) — default Codex model; 372k context; default effort `low`. +2. `openai/gpt-5.6-terra` (`gpt-5.6-terra`) — 372k context; default effort `medium`. +3. `openai/gpt-5.6-luna` (`gpt-5.6-luna`) — 372k context; default effort `medium`. + +GPT-5.5 remains selectable below them. Sol and Terra expose `low | medium | +high | xhigh | ultra`; Luna exposes `low | medium | high | xhigh`. Desktop, +ADE Code, and iOS label those values Light, Medium, High, Extra High, and +Ultra. `ultra` is the multi-agent tier and carries a usage warning. Although a +raw 0.144.0 app-server catalog may advertise `max`, Codex Desktop puts that +tier behind a separate feature opt-in, so ADE filters it from the GPT-5.6 +picker ladder rather than presenting it as an ordinary level. + +`selectSupportedReasoningEffort()` centralizes fallback order: keep a valid +explicit selection, then use the model's advertised default, then a valid +surface fallback, then Medium/first tier. All desktop launch surfaces and +handoffs use that helper; ADE Code and iOS mirror the same rule and prefer +host-advertised model metadata over their static compatibility catalogs. + ## Auth and credentials `authDetector.ts` (`detectAllAuth`) probes every provider: @@ -157,8 +182,8 @@ values into the Codex app-server wire format at the JSON-RPC boundary: `on-request` -> `onRequest`, `untrusted` -> `unlessTrusted`, `on-failure` -> `onFailure`, and `workspace-write` -> `workspaceWrite`. Every `thread/start` and `thread/resume` call passes `{ model, cwd, -reasoningEffort, ...codexPolicyArgs, ...codexServiceTierArgs(session), -persistExtendedHistory: true }`. The return envelope is consumed by +config: { model_reasoning_effort, mcp_servers? }, +...codexPolicyArgs, ...codexServiceTierArgs(session) }`. The return envelope is consumed by `applyCodexEffectiveThreadState`, which normalizes `approvalPolicy`, `sandbox` (including the camel-case aliases `readOnly` / `workspaceWrite` / `dangerFullAccess` that the server emits), and @@ -175,6 +200,23 @@ over a stale persisted pair. Turns use the Codex-native `effort` key (`turn/start({ threadId, input, effort?, serviceTier? })`) instead of the lifecycle `reasoningEffort` name. +The 0.144.0 server-request surface is handled in the same adapter. ADE answers +`currentTime/read` with `{ currentTimeAt: }`. +`mcpServer/elicitation/request` becomes the unified pending-input UI for form +and URL modes; primitive/enum/multiselect fields are coerced back into the +requested schema. `serverRequest/resolved` clears a pending card when the +server resolves it elsewhere. Elicitations are never silently approved by +full-auto mode, and `Always allow` is sent only when the request `_meta` +explicitly permits persistent consent. + +On macOS, an explicitly enabled Codex Computer Use plugin/config adds the +verified standalone `SkyComputerUseClient` under +`config.mcp_servers.computer_use`. The merge is applied on fresh thread start, +thread resume, recovery resume, and goal-control thread resume. Tracked Codex +CLI launches/resumes receive equivalent `-c mcp_servers.computer_use.*` +overrides. This bypasses host-process signing problems without weakening the +MCP elicitation boundary; see [Computer-Use Backends](../computer-use/backends.md#codex-computer-use-current). + For Codex and the other coding-agent paths, ADE's worktree guidance is write-scoped rather than read-scoped: the launched lane worktree is the only place the agent should edit files or run mutating commands, while @@ -214,7 +256,7 @@ Parallel-model rows track Fast mode per slot fast-capable runs side-by-side can mix Fast and Standard turns. Codex discovery populates `serviceTiers` from app-server-reported `additionalSpeedTiers` / `serviceTiers` rows; the static registry -pre-marks the GPT 5.4 / 5.5 Codex CLI entries. Cursor discovery +pre-marks GPT-5.6 and older fast-capable Codex CLI entries. Cursor discovery populates `serviceTiers` from SDK/CLI parameters and folds CLI `*-fast` rows into their base descriptors as aliases. OpenCode maps Fast to the provider variant `fast` for both chat and Work CLI launches. @@ -307,8 +349,9 @@ deny path made the model hesitate after a "denied" tool call.) 5. The service resolves the correct adapter and spawns the runtime. For Claude, `resolveClaudeCliModel()` translates the descriptor into -the CLI's expected model token. For Codex, `codexAppServerConfig.ts` -builds the app-server startup options. +the CLI's expected model token. For Codex, `agentChatService.ts` builds the +app-server startup and thread configuration while `codexExecutable.ts` resolves +the packaged/PATH binary. ## Model switching mid-session diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index ee81dcd44..09c2913f9 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -15,8 +15,9 @@ subagents, computer use). The pane derives all visible state from the | `apps/desktop/src/renderer/components/usage/UsageActivityCarousel.tsx` | Cross-client activity, token, code-movement, and client-mix charts. `AgentChatPane` mounts `WorkUsageActivityCarousel` beneath the empty Work draft composer when no app panel is open; the component persists the chosen chart and day/week/month/year range. | | `apps/desktop/src/renderer/lib/draftLaunchJobs.ts` | Pure helper for Work draft-launch job DTOs, terminal/stale-state detection, and pruning. The list keeps active rows ahead of terminal rows, fills remaining retained slots with terminal rows, and keeps at least one terminal row alongside active jobs. Also owns the durability constants/helpers: `DRAFT_LAUNCH_TIMEOUT_MS` (90 s) + `withDraftLaunchTimeout` (fails a step whose runtime call never settles; the underlying IPC is not cancellable, so it keeps running detached and the timeout only unwedges the renderer-side job) and `LAUNCH_PROJECT_CHANGED_MESSAGE` (the legacy/unpinned abort error used only when no originating project binding is available and the active project drifts mid-launch). | | `apps/desktop/src/renderer/lib/handoffLaunchJobs.ts` | Pure helper for handoff placeholder DTOs, scope keys, stable placeholder ids, status labels, and search matching. `AgentChatPane` writes these jobs into the root store while `TerminalsPage` passes matching jobs into the Work session sidebar. The handoff drawer can offer a brief summarized handoff or a full-history fork when source and target stay within Claude or within Codex; Codex forks use the app-server thread id returned by `thread/fork`. | -| `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, and keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details. | -| `AgentChatComposer.tsx` | Text input, attachments, model selector, permission controls, slash commands, pending-input answering, voice-dictation target registration, and parallel model-slot controls. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `AgentChatMessageList.tsx` | Virtualized message list (`@tanstack/react-virtual`). Renders transcript rows and turn dividers, and keeps sticky-bottom sessions pinned across streamed row growth and late virtual-height measurements. Plan-approval rows with non-empty body text render a scrollable markdown block (capped at `360px`) beneath the header so the user can review plan content inline. Codex goal lifecycle rows use user-facing text such as `Goal set`, `Goal paused`, and `Goal cleared`. A stalled Codex turn renders a clickable Wait / Nudge / Retry / Resume recovery card wired to `agentChat.recoverCodexTurn`. User messages marked `metadata.hideFullPrompt` render and copy only their `displayText`, keeping internal handoff briefs out of the visible transcript details. | +| `AgentChatComposer.tsx` | Text input, attachments, model selector, compact title-only permission controls, slash commands, pending-input answering (including Codex MCP form/URL elicitations), voice-dictation target registration, and parallel model-slot controls. Launch-prompt clipboard reminder text is controlled by `launchPromptClipboardNoticeEnabled`, separate from the `launchPromptClipboardEnabled` copy behavior. For orchestration model-selection pending inputs it decodes the agent briefing metadata (`workDescription`, `filesHint`, `dependsOn`) before rendering the selection card. | +| `ChatActionsDrawerPanel.tsx`, `ChatSourcesPanel.tsx`, `chatSources.ts` | Chat Actions tab shell plus Codex Sources view. The source derivation deduplicates files, web queries/results, MCP apps/tools, and external resource URLs from transcript events; safe web rows open in ADE's browser. | | `VoiceDictationButton.tsx`, `apps/desktop/src/renderer/services/globalVoiceRecorder.ts`, `apps/desktop/src/renderer/components/voice/*` | Desktop dictation UI and recorder. The module-level recorder owns mic capture across navigation, writes live state to the root app store, transcribes via `window.ade.transcription`, inserts cleaned text into the registered composer, and always copies the cleaned transcript to the clipboard. The header indicator and composer pill render the same recording state. | | `apps/desktop/src/main/services/transcription/*` | Electron main-process transcription service. Writes captured 16 kHz mono PCM to WAV, runs bundled whisper.cpp `base.en`, parses the JSON sidecar, and applies deterministic glossary cleanup. | | `apps/desktop/resources/voice/voice-glossary.json`, `apps/desktop/resources/whisper/README.md` | Shared dictation glossary and release notes for materialized whisper resources. The large model and binary are generated by `materialize-whisper-resources.mjs` and ignored by git. | @@ -48,7 +49,7 @@ subagents, computer use). The pane derives all visible state from the | `pendingInput.ts`, `chatExecutionSummary.ts`, `chatNavigation.ts`, `chatTranscriptRows.ts` | Pure state derivations consumed by the UI. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Prompt formatting for visual/tool context from attachments, iOS Simulator, App Control, and built-in browser selections. | | `apps/desktop/src/shared/types/chat.ts` | Shared composer/session DTOs, including `PARALLEL_CHAT_MAX_ATTACHMENTS`, parallel launch state types, the `AgentChatModelCatalog*` set, `AgentChatModelCatalogRefreshProvider` (`opencode` / `cursor` / `droid` / `lmstudio` / `ollama`), and `AgentChatModelCatalogArgs` (`mode`, `refreshProvider`). | -| `apps/desktop/src/renderer/components/shared/ModelPicker/` | Modular ModelPicker (see [ModelPicker structure](#modelpicker-structure)): `ModelPicker.tsx`, `ModelPickerContent.tsx`, `ModelPickerRail.tsx`, `ModelListRow.tsx`, `ReasoningEffortPicker.tsx`, `modelCatalog.ts`, `modelOrdering.ts`, `modelPickerSearch.ts`, `providerEmptyState.tsx`, `runtimeCatalogCache.ts`, plus the `useProviderAuthStatus` / `useAuthOnlyFilter` / `useModelFavorites` / `useModelRecents` / `usePerSurfaceModelDefaults` / `useReasoningByFamily` hooks. | +| `apps/desktop/src/renderer/components/shared/ModelPicker/` | Modular ModelPicker (see [ModelPicker structure](#modelpicker-structure)): `ModelPicker.tsx`, `ModelPickerContent.tsx`, `ModelPickerRail.tsx`, `ModelListRow.tsx`, `ReasoningEffortPicker.tsx` (draggable/snapping gradient slider that stays open on selection), `modelCatalog.ts`, `modelOrdering.ts`, `modelPickerSearch.ts`, `providerEmptyState.tsx`, `runtimeCatalogCache.ts`, plus the `useProviderAuthStatus` / `useAuthOnlyFilter` / `useModelFavorites` / `useModelRecents` / `usePerSurfaceModelDefaults` / `useReasoningByFamily` hooks. | ## Pane layout @@ -213,7 +214,15 @@ and a footer that contains the composer. from the model row) is rendered next to the model trigger when the active descriptor exposes `reasoningTiers`. The picker remembers the last-used effort per model family via the `useReasoningByFamily` - hook. + hook. The control is a real pointer slider: click or drag previews the + position and release snaps to the nearest model-supported tick; arrows, + Home, and End provide the same keyboard path. The filled portion uses a + progressive low-to-high gradient and the active tier keeps the existing + pulse/colour treatment. A tier choice does **not** close the popover — the + user can compare levels until clicking outside or pressing Escape. + GPT-5.6 displays Light / Medium / High / Extra High / Ultra; ordinary Max + is hidden for that family, and Ultra explains that it can delegate to + multiple agents and use limits faster. - **Voice dictation.** When voice input is enabled and the bundled model is installed, a mic button appears beside Send. Capture is owned by the app-global `globalVoiceRecorder`, so recording survives @@ -228,7 +237,7 @@ and a footer that contains the composer. toggles the legacy-named `codexFastMode` bit for the selected session. It renders whenever the selected descriptor advertises `serviceTiers: ["fast"]`, including dynamic Cursor SDK/CLI rows and - the Codex GPT 5.4 / 5.5 entries. Codex state flows into the next + GPT-5.6 and older fast-capable Codex entries. Codex state flows into the next `thread/start` / `turn/start` as `serviceTier: "fast"`; Cursor SDK state flows through the discovered model-parameter selection, and Work CLI launches resolve fast Cursor rows to the matching `*-fast` @@ -250,6 +259,10 @@ and a footer that contains the composer. - OpenCode permission mode selector. - Cursor mode snapshot + config options when on Cursor. + All shared permission dropdowns render only each mode's title in the row. + The longer explanation remains available as the row tooltip/title instead + of forcing the popover to become a wall of text. + Both the Claude and Codex popovers render via `createPortal` into `document.body` and are positioned with `getBoundingClientRect` + `window.innerHeight`. That keeps them visible when the composer is @@ -601,6 +614,27 @@ sources differ per runtime: service intentionally does NOT set `agentType` so the renderer falls back to that description for the row label. +## Chat Actions and Sources + +`ChatActionsDrawerPanel` assembles the contextual tabs for an active chat. +When the selected provider is Codex, **Sources** is the first tab (ahead of +Missions/Agents/Proof/Handoff/Run) and receives the current display event set. +`deriveChatSources()` builds four compact sections: + +- **Files** — user attachments and image URLs. +- **Apps & tools** — MCP servers/plugins/connectors, grouped by app identity + with the distinct actions summarized once. +- **Web** — search queries and result URLs/titles/snippets. +- **External resources** — HTTP(S) links and resource URIs found in MCP + metadata/results, including Linear issue context. + +The derivation is bounded and recursive only for JSON-shaped tool results, +deduplicates by canonical path/URL/source id, and excludes the internal +`node_repl` execution host. A row is clickable only for an allowed HTTP(S) +URL and opens through `openUrlInAdeBrowser`; local paths remain informational. +The main transcript separately keeps web, image, MCP/connector, and subagent +lifecycle compact across Codex, Claude, Cursor, Droid, and OpenCode adapters. + ## Terminal drawer `ChatTerminalDrawer` is a collapsible drawer at the bottom of the chat @@ -671,6 +705,16 @@ Legacy `ade.agentChat.approve` is still supported. Plan approval cards receive the plan text from the `ExitPlanMode` tool input so the UI shows meaningful content rather than a generic label. +Codex app-server `mcpServer/elicitation/request` uses the same contract. +Form-mode JSON Schema properties become paged questions (enum/oneOf, +boolean, freeform primitive, and multiselect array); answers are coerced back +to the schema before ADE replies. Approval/URL mode shows **Allow once** and +**Deny**, an **Open authorization** action only for safe web URLs, and +**Always allow** only when `_meta.persist` includes `always`. Provider +full-auto mode does not auto-answer these app/connector consent requests. +`serverRequest/resolved` emits the usual resolution event so a request +completed outside the card cannot leave the composer locked. + ### Cross-surface parity The card's data contract (`PendingInputRequest` / `PendingInputQuestion` @@ -819,6 +863,13 @@ These modules are pure and unit-testable: - **Model warmup on selection.** Selecting a Claude model triggers `ade.agentChat.warmupModel` to preload a V2 session. If the warmup promise is never awaited, the first turn incurs a 20 s latency. +- **Reasoning slider pointer ownership.** The track captures a drag only + after the small movement threshold, writes preview positions through CSS + custom properties, and commits exactly once on pointer-up. Ridge-button + clicks take the normal radio path; a drag that starts on a ridge suppresses + the trailing synthetic click so it cannot toggle the newly snapped tier + back to Auto. Do not close the Radix popover from `onChange` — outside click + and Escape are the intentional close actions. - **Stale slash commands.** SDK-provided slash commands are fetched once per session initialisation. If the user switches model mid-session, the pane re-fetches. Missing the refetch surfaces slash commands diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index f72b9884e..5b4599bf5 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -74,7 +74,7 @@ Two helpers summarise a parsed stream: | `text` | Streaming assistant text; identified by `messageId` (preferred) or turn/item identity. Fragments merge when `shouldMergeTextRows()` returns true. | | `transcript_retraction` | Provider-level retraction signal. Claude emits this for refusal fallback `retracted_message_uuids` and assistant `supersedes`; renderers remove prior assistant text rows whose `messageId` matches `messageIds`, optionally retaining `replacementMessageId` as the new provider message id. The persisted JSONL remains append-only. | | `reasoning` | Chain-of-thought or assistant-internal reasoning; surfaces as a distinct transcript row with a collapsible header. | -| `tool_call` / `tool_result` | Paired per tool invocation; rendered inside work-log groups. `tool_result.status` can be `running`, `completed`, `failed`, or `interrupted`. | +| `tool_call` / `tool_result` | Paired per tool invocation; rendered inside work-log groups. `tool_result.status` can be `running`, `completed`, `failed`, or `interrupted`. Provider-native MCP calls retain `mcp: AgentChatMcpToolSource` (`server`, `tool`, optional plugin/resource/app context) so transcript labels, the TUI/iOS, and Sources use the connector identity instead of a generic tool name. | | `file_change` | Emitted when the agent writes or deletes a file; carries `path`, `diff`, and `kind`. | | `command` | A shell command invocation; carries `cwd`, `output`, `exitCode`, `durationMs`. | | `plan` | Final plan payload (steps + explanation); replaces any earlier `plan_text` rows for that turn. | @@ -96,8 +96,9 @@ Two helpers summarise a parsed stream: | `turn_diff_summary` | Git-level before/after SHA + per-file stats for a completed turn. | | `delegation_state` | Delegated worker state updates. | | `context_compact` | Emitted before the provider compacts context (manual or auto). | -| `web_search` | Web-search tool lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Codex actions can carry `query`, `queries`, `title`, `url`, and `snippet`; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. | -| `codex_safety_buffering` / `codex_moderation_metadata` / `codex_sleep` / `codex_thread_deleted` / `codex_turn_stalled` | Codex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; `codex_thread_deleted` clears the stored upstream thread; `codex_turn_stalled` is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. | +| `web_search` | Provider-neutral web-search/fetch lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Actions can carry `query`, `queries`, `title`, `url`, and `snippet`; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. Codex emits native web-search items; `claudeStructuredActivity.ts` maps Claude server-tool blocks into the same event. | +| `codex_image_generation` / `codex_image_view` | Compact generated/viewed-image lifecycle used across providers despite the legacy type prefix. Codex emits native image items, Cursor maps `generateImage`, OpenCode maps image `file` parts, and Droid maps assistant image blocks. Large stored data URIs are removed with original/omitted byte metadata. | +| `codex_safety_buffering` / `codex_moderation_metadata` / `codex_sleep` / `codex_thread_deleted` / `codex_turn_stalled` | Codex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; `codex_thread_deleted` clears the stored upstream thread; `codex_turn_stalled` is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. Its actions are `wait`, `steer`, `interrupt_retry_same_thread`, and `restart_resume_thread`. | | `auto_approval_review` | When auto-approval policy kicks in, this event carries the review text. | | `prompt_suggestion` | Suggested follow-up prompts for the user. | @@ -117,6 +118,25 @@ subagent row beside it. `AgentChatPane` filters the dotted rows from the transcript display because they are coordination data, while subagent-specific panels can consume either shape during the transition. +## Structured activity normalization + +Adapters preserve provider richness while converging on compact event shapes: + +- Claude server `web_search` / `web_fetch` blocks become `web_search` start + and terminal events. Claude MCP blocks become paired tool events; unfinished + server activities are closed when the turn ends. +- Codex 0.144.0 `mcpToolCall` items retain plugin/app/resource metadata, while + native web/image/subagent items keep their specialized compact rows. +- Cursor MCP calls and generated images, OpenCode image file parts, and Droid + assistant image blocks reuse those same tool/image events. + +Every event uses the provider item id (plus turn id) as its lifecycle key. +Desktop `chatTranscriptRows`, ADE Code `aggregateChatBlocks`, and iOS +`WorkEventMapping`/`WorkTranscriptParser` therefore update one row instead of +printing repeated start/progress/result records. The Codex Sources tab derives +files, web results, connector apps/actions, and external URLs from this same +stream; it is a view, not a second persistence channel. + ## Render pipeline `apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts` @@ -266,12 +286,15 @@ or capped files do not hide compacted chat history. Persisted chat events keep the same public `AgentChatEvent` shape, but bulky payloads are compacted before storage for rows users rarely need in full after -the turn is over. Large command output, tool results, file diffs, and reasoning -text are replaced with a short head/tail preview plus original/omitted byte -metadata on the event (`outputOriginalBytes`, `resultOmittedBytes`, -`diffOmittedBytes`, `textOmittedBytes`, etc.). Live subscribers still receive -the full event payload while a turn is active; the renderer, iOS client, CLI, -and TUI see the persisted preview only when replaying stored history. +the turn is over. Large command output, tool results, file diffs, reasoning +text, and inline image data URIs are replaced with a short preview (or no inline +media) plus original/omitted-byte metadata on the event +(`outputOriginalBytes`, `resultOmittedBytes`, `diffOmittedBytes`, +`textOmittedBytes`, `urlOmittedBytes`, etc.). Desktop/runtime live subscribers +still receive the original event while a turn is active. The sync host +independently removes inline image data URIs over 64 KB from mobile live sends, +snapshots, and replay entries without mutating the desktop event. +Persisted-history consumers see the stored preview on replay. `sessionRecovery.ts` implements version-2 reconstruction: diff --git a/docs/features/computer-use/README.md b/docs/features/computer-use/README.md index 5ded1854c..ac3f52930 100644 --- a/docs/features/computer-use/README.md +++ b/docs/features/computer-use/README.md @@ -1,8 +1,13 @@ # Computer Use -ADE does not run computer-use itself. Agents drive computer use through whatever tool they already have — Claude's `computer_use`, Codex shell, a scripted browser, a headless Playwright run. ADE's only job is to **ingest** the resulting artifact (screenshot, video, trace, verification output, console log), link it to an owner (chat, lane, PR, Linear issue), and render it in the review drawer. +ADE has two intentionally separate computer-use responsibilities: -The previous control-plane model — policy modes (`off`/`auto`/`enabled`), readiness gates, per-phase evidence requirements, a passive proof observer — is gone. What remains is a thin broker backed by a single table. +1. **Provider execution wiring.** On macOS, opted-in Codex sessions receive the signed standalone Codex Computer Use client as the canonical `computer_use` MCP server. This works in native Work chats and tracked Codex CLI sessions, including resume/fork paths. +2. **Proof ingestion.** Any agent can intentionally register a screenshot, video, trace, verification output, or console log. ADE stores it, links it to an owner (chat, lane, PR, Linear issue), and renders it in the review drawer. + +Execution does not imply proof. ADE never passively promotes every Computer Use tool result into a durable artifact. + +The previous proof control-plane model — policy modes (`off`/`auto`/`enabled`), readiness gates, per-phase evidence requirements, a passive proof observer — is gone. The proof side is now a thin broker backed by a single table; direct Codex execution is the provider-native MCP path described below. See [`../proof.md`](../proof.md) for the user-facing CLI surface (`ade proof capture` / `attach` / `list`) and the drawer UI contract. @@ -22,8 +27,16 @@ The desktop renderer is a viewer: it edits review state, navigates owners, and d - `computerUseArtifactBrokerService.ts` — the broker. Canonical storage for `computer_use_artifacts` + `computer_use_artifact_links`. Ingestion (`ingestArtifacts`), listing (`listArtifacts`), review-state management (`reviewArtifact`), routing (`routeArtifact`), backend status (`getBackendStatus`). Uses `secureCopyFromDescriptor` (O_NOFOLLOW + atomic rename) for on-disk ingests and materializes inline text/JSON content via `createComputerUseArtifactPath` + `writeTextAtomic`. - `controlPlane.ts` — builds `ComputerUseOwnerSnapshot` (recent artifacts + activity) and `ComputerUseSettingsSnapshot` (backend readiness, capabilities). Pure assembly layer over the broker. - `localComputerUse.ts` — macOS-only capability descriptor (`LocalComputerUseCapabilities`). Reports whether `screencapture`, app launch, and GUI-interaction commands are available. `createComputerUseArtifactPath` + `toProjectArtifactUri` round out the storage helpers. -- `agentBrowserArtifactAdapter.ts` — parses agent-browser payload shapes (screenshots, videos, traces, verification, console logs) into `ComputerUseArtifactInput[]`. -- `syntheticToolResult.ts` — produces tool-result stubs during Claude compaction so a previously-executed tool response can be re-surfaced without re-running the tool. + +### Proof adapters + +- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — parses agent-browser payload shapes (screenshots, videos, traces, verification, console logs) into `ComputerUseArtifactInput[]`. + +### Direct Codex Computer Use + +- `apps/desktop/src/main/utils/codexComputerUse.ts` — resolves the standalone `SkyComputerUseClient`, requires explicit user opt-in, verifies its strict macOS code signature plus OpenAI team/bundle identifiers, and returns the MCP launch config. +- `apps/desktop/src/main/services/chat/agentChatService.ts` — merges the resolved `computer_use` server into every Codex `thread/start` and `thread/resume` config and handles MCP tool/source events plus elicitation requests. +- `apps/desktop/src/shared/cliLaunch.ts` — emits the equivalent `-c mcp_servers.computer_use.*` flags for tracked Codex CLI start/resume commands. `agentChatCliLaunch.ts`, `ptyService.ts`, and `externalSessionsService.ts` resolve the config at each launch/resume so a newly installed or disabled plugin is respected. Computer-use services that used to exist and were deleted on this branch: @@ -107,7 +120,7 @@ Other paths are rejected. - Per-phase `evidenceRequirements` math and preflight coverage/readiness gates. - Settings > Computer Use panel. - Ghost OS-specific readiness probes (`ghost status` / `ghost doctor` shelling and regex parsing). -- Separate tool delivery for computer use. +- The old ADE-defined universal computer-use tool delivery. Codex now uses its provider-native MCP client instead. ## App Control bridge @@ -124,8 +137,8 @@ See [`app-control.md`](./app-control.md) for the full surface (service, IPC, ren - [`app-control.md`](./app-control.md) — current App Control bridge for Electron apps (CDP launch/connect, snapshot, click/type, source matching, ADE CLI `app-control` and `terminal` surfaces). -The three remaining detail docs describe the pre-rebuild control plane (Ghost OS readiness probe, policy matrix, phase-based coverage). They are retained for historical context but do not reflect the current shipping system. +The backend doc begins with the current direct Codex integration, then retains the pre-rebuild Ghost OS / local-fallback catalog for historical context. The settings/readiness doc is historical. -- [`backends.md`](./backends.md) — pre-rebuild backend catalog. -- [`artifact-broker.md`](./artifact-broker.md) — pre-rebuild broker model, including the passive observer. +- [`backends.md`](./backends.md) — direct Codex Computer Use execution plus the historical proof-backend catalog. +- [`artifact-broker.md`](./artifact-broker.md) — current broker, storage, and ownership model, with the retired passive observer called out for context. - [`settings-and-readiness.md`](./settings-and-readiness.md) — pre-rebuild Settings > Computer Use panel. diff --git a/docs/features/computer-use/artifact-broker.md b/docs/features/computer-use/artifact-broker.md index 012c34f44..e9e2323c9 100644 --- a/docs/features/computer-use/artifact-broker.md +++ b/docs/features/computer-use/artifact-broker.md @@ -7,9 +7,8 @@ The broker runs inside the ADE runtime (`ade serve`) that owns the project. Arti ## Source file map - `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — the service. `createComputerUseArtifactBrokerService(args)` is the entry point. Loaded by both the ADE runtime's project scope and the desktop's local-project services. -- `apps/desktop/src/main/services/computerUse/agentBrowserArtifactAdapter.ts` — payload parser for agent-browser output. +- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — payload parser for agent-browser output. - `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — storage helpers (`createComputerUseArtifactPath`, `toProjectArtifactUri`). -- `apps/desktop/src/main/services/computerUse/syntheticToolResult.ts` — Claude-compaction tool-result stubs. - `apps/desktop/src/shared/types/computerUseArtifacts.ts` (via `shared/types`) — `ComputerUseArtifactRecord`, `ComputerUseArtifactLink`, `ComputerUseArtifactInput`, `ComputerUseArtifactOwner`, `ComputerUseArtifactReviewState`, `ComputerUseArtifactWorkflowState`, `ComputerUseEventPayload`. - `apps/desktop/src/shared/proofArtifacts.ts` — `normalizeComputerUseArtifactKind`, `resolveReportArtifactKind`. diff --git a/docs/features/computer-use/backends.md b/docs/features/computer-use/backends.md index 9874668cc..b609d0b3a 100644 --- a/docs/features/computer-use/backends.md +++ b/docs/features/computer-use/backends.md @@ -1,15 +1,57 @@ # Computer-Use Backends -Backend discovery runs on the runtime machine that owns the project (the ADE runtime's `commandExists("ghost")` / `commandExists("agent-browser")` checks reflect that machine's `PATH`). ADE's job is to discover backends locally to that runtime, report their readiness, and ingest their output. ADE does not wrap or replace the backends themselves. +The current active provider integration is **Codex Computer Use**: ADE securely resolves OpenAI's standalone signed client and presents it to Codex as an MCP server. Proof capture remains a separate broker workflow. -This doc describes the historical Ghost OS / agent-browser / local-fallback model. The current shipping broker (`computerUseArtifactBrokerService.getBackendStatus`) reports the same backend names but the policy + readiness machinery (`buildComputerUseSettingsSnapshot`, `buildGhostOsCheck`, capability matrix) was retired with the proof rebuild and the Settings > Computer Use panel was folded into Integrations. Use this doc for context on backend semantics, not for the current operator UI. +The later sections describe the historical Ghost OS / agent-browser / local-fallback model. The current shipping broker (`computerUseArtifactBrokerService.getBackendStatus`) reports the same proof-backend names, but the policy + readiness machinery (`buildComputerUseSettingsSnapshot`, `buildGhostOsCheck`, capability matrix) was retired with the proof rebuild and the Settings > Computer Use panel was folded into Integrations. Use those sections for context on proof-backend semantics, not for the current operator UI. ## Source file map - `apps/desktop/src/main/services/computerUse/controlPlane.ts` — pre-rebuild `buildComputerUseOwnerSnapshot` + capability/Ghost-OS helpers. The current build keeps the snapshot assembly path; the policy/Ghost-OS readiness helpers are vestigial. - `apps/desktop/src/main/services/computerUse/localComputerUse.ts` — `getLocalComputerUseCapabilities`, `createComputerUseArtifactPath`, `toProjectArtifactUri`. Capability detection (`screencapture`, `open`, `swift`, `osascript`) reflects the runtime host's environment. -- `apps/desktop/src/main/services/computerUse/agentBrowserArtifactAdapter.ts` — `parseAgentBrowserArtifactPayload`, `loadAgentBrowserArtifactPayloadFromFile`. Parses agent-browser output manifests on the runtime host. +- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` — `parseAgentBrowserArtifactPayload`, `loadAgentBrowserArtifactPayloadFromFile`. Parses agent-browser output manifests on the runtime host. - `apps/desktop/src/main/services/computerUse/computerUseArtifactBrokerService.ts` — `getBackendStatus` (emits `ComputerUseBackendStatus`), `secureCopyFromDescriptor` (symlink-safe path-based ingest), backend enumeration. +- `apps/desktop/src/main/utils/codexComputerUse.ts` — current direct Codex client resolver and signature/opt-in boundary. +- `apps/desktop/src/main/services/chat/agentChatService.ts` — injects the MCP config into app-server threads and maps Computer Use MCP calls/elicitations into chat events. +- `apps/desktop/src/shared/cliLaunch.ts` — injects the same MCP server into tracked Codex CLI start/resume argv. + +## Codex Computer Use (current) + +**Transport:** provider-native MCP server named `computer_use`, exposed to the model as `mcp__computer_use`. + +ADE enables this path only on macOS and only when the user's Codex config has one of these explicit opt-ins: + +- `[plugins."computer-use@openai-bundled"]` with `enabled = true`; or +- `[mcp_servers.computer_use]` unless that section explicitly sets `enabled = false`. + +`resolveCodexComputerUseMcpConfig()` searches the stable install under +`$CODEX_HOME/computer-use/` first, then version-sorted bundled plugin-cache +directories. A candidate must be executable and pass both +`codesign --verify --strict` and identity checks for OpenAI team `2DC432GLL2` and client bundle +identifier `com.openai.sky.CUAService.cli`. ADE never executes an arbitrary +same-named cache binary. + +The standalone helper is important: starting Computer Use through a generic +`node_repl`/host wrapper makes the operating-system entitlement depend on the +host process. ADE instead launches the OpenAI-signed helper directly, preserving +the Computer Use entitlement when Codex runs inside ADE. + +Native Work chats merge the config into the app-server `thread/start` and +`thread/resume` config alongside the selected reasoning effort. Existing user +MCP servers remain configured; ADE supplies only the canonical `computer_use` +entry. Tracked Codex CLI sessions receive the equivalent `-c` overrides on +initial launch, resume, imported-session resume, and fork. The resolver runs at +those boundaries rather than persisting an executable path in chat state. + +Computer Use follows normal MCP consent. `mcpServer/elicitation/request` becomes +an ADE pending-input card with Allow once / Deny and, only when the server's +`_meta.persist` permits it, Always allow. URL-mode requests can open the +authorization link in ADE's built-in browser. Full-auto chat permissions do not +silently approve a per-app elicitation. + +## Historical proof backends + +The remaining sections document the retired readiness/policy model. + ## Ghost OS **Transport:** external CLI. ADE detects `ghost` on `PATH` and reads `ghost status` / `ghost doctor` for readiness. diff --git a/docs/features/proof.md b/docs/features/proof.md index 839df8d32..535007302 100644 --- a/docs/features/proof.md +++ b/docs/features/proof.md @@ -190,7 +190,6 @@ The broker (`apps/desktop/src/main/services/computerUse/computerUseArtifactBroke - `controlPlane.ts` builds owner snapshots + backend status for the UI. - `localComputerUse.ts` reports macOS-only proof-capture capabilities (`screencapture`, app launch, GUI interaction). Reflects the runtime host's environment, not the desktop machine's. -- `agentBrowserArtifactAdapter.ts` parses agent-browser output into `ComputerUseArtifactInput[]`. -- `syntheticToolResult.ts` produces tool-result stubs for the Claude compaction path. +- `apps/desktop/src/main/services/proof/agentBrowserArtifactAdapter.ts` parses agent-browser output into `ComputerUseArtifactInput[]`. -Every piece upstream of the CLI is the agent's own business. Every piece downstream is a thin line to disk, a broker insert, and the drawer. No backend abstraction, no policy engine, no observer — the proof observer was deleted with this rebuild, along with `ComputerUsePolicy` and the Settings > Computer Use panel. +Provider execution can be provisioned by ADE (for example the signed direct Codex Computer Use MCP client), but proof remains explicit. Every piece downstream of `ade proof` is a thin line to disk, a broker insert, and the drawer. No passive observer promotes provider tool calls automatically — the proof observer was deleted with this rebuild, along with `ComputerUsePolicy` and the Settings > Computer Use panel. diff --git a/docs/features/sync-and-multi-device/README.md b/docs/features/sync-and-multi-device/README.md index 5780ce24a..f2346451a 100644 --- a/docs/features/sync-and-multi-device/README.md +++ b/docs/features/sync-and-multi-device/README.md @@ -216,7 +216,10 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `crdt-model.md`), chat-first scheduling (chat events are pumped before background changesets, and peers with active chat subscriptions get smaller background batches / backpressure deferral when the WebSocket - send buffer is already backed up), the mobile changeset diet + send buffer is already backed up), mobile-chat inline-image compaction + (`compactChatEventEnvelopeForSync`: data URIs above 64 KB are removed from + live sends, snapshots, and replay entries while the desktop event remains + unchanged and original/omitted byte counts are retained), the mobile changeset diet (`MOBILE_CHANGESET_EXCLUDED_TABLES`: high-churn tables the phone never reads — `attempt_transcripts`, `operations`, `ai_usage_log`, `budget_usage_records`, `automation_runs`, @@ -361,7 +364,11 @@ Canonical files (`apps/ade-cli/src/services/sync/`): `usage.getAdeStats`, `prs.getMobileSnapshot`, `lanes.presence.*`, `work.runQuickCommand`, `work.startCliSession`, `work.listExternalSessions`, - `work.importExternalSession`, `modelPicker.*`, …). Each registration + `work.importExternalSession`, `chat.recoverCodexTurn`, `modelPicker.*`, …). + Stalled-turn recovery is viewer-allowed but not queueable because it must + target the currently active Codex turn. Mobile/remote Codex CLI launches + also resolve the explicitly opted-in, verified standalone Computer Use MCP + client and add it through the shared launch builder. Each registration carries a `SyncRemoteCommandDescriptor` with a **scope** label of `"runtime"` or `"project"`. The runtime rejects a `project`-scoped command when no project is open or when the caller did not bundle a diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index b9b64c19e..fae248fca 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -262,6 +262,20 @@ parsing, model catalog, session grouping) to keep individual files under a few hundred lines. This split is the primary reason the Work tab grew from one ~3,000-line file to ~30 focused files. +The Work model/activity parity path is concentrated in these files: + +- `ADE/Models/RemoteModels.swift` — tolerant chat-event decoding, MCP app/tool + source metadata, image events/omission metadata, Codex recovery DTOs, and + host model rows including `defaultReasoningEffort`. +- `ADE/Views/Work/WorkModelCatalog.swift` and `WorkModelPickerSheet.swift` — + host-first model catalog merge, GPT-5.6 ordering/defaults/visible tiers, Fast, + and the Ultra usage warning. +- `ADE/Views/Work/WorkEventMapping.swift`, `WorkTranscriptParser.swift`, and + `WorkStatusAndFormattingHelpers.swift` — compact web/MCP/image mapping for + both live Codable events and persisted JSONL fallback. +- `ADE/Services/SyncService.swift` and `ADE/Views/Work/WorkSessionDestinationView+Actions.swift` + — host-advertised `chat.recoverCodexTurn` dispatch for stalled-turn buttons. + Deployment target: iOS 26+. iPhone and iPad (adaptive layouts planned for Phase 7). @@ -1418,10 +1432,17 @@ does not duplicate the full desktop Stats page. `codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, and `codex_turn_stalled` in `RemoteModels.swift`, mapping them through `WorkEventMapping.swift`, - and rendering compact timeline cards. Web-search events also carry + and rendering compact timeline cards. Stalled rows preserve the + `sourceSessionId` from child chats and expose Wait / Nudge / Retry / Resume + only when the host advertises `chat.recoverCodexTurn`. Web-search events also carry provider action metadata (`query` / `queries`, `title`, `url`, `snippet`); Work keeps those in the enriched web-search tool card so URLs are visible without duplicating the same event as a second row. + MCP tool events decode provider-neutral app/server/action metadata so cards + name the connected app instead of `mcp`. Generated/viewed-image events from + Codex and other adapters reuse compact tool cards; data URIs are never printed + into the timeline, and stored/mobile compaction byte counts become a short + "preview omitted" detail. - **Subagent lifecycle is rendered as chat structure, not event spam.** `RemoteModels.swift` accepts both legacy `subagent_started` / `subagent_progress` / `subagent_result` events and the canonical dotted @@ -1472,7 +1493,12 @@ does not duplicate the full desktop Stats page. together; the Claude picker order mirrors desktop (Fable 5, Opus 4.8 1M, Sonnet 5, Haiku 4.5, Opus 4.7 1M) and legacy Sonnet 4.6 / basic Opus 4.7 selections normalize forward instead of appearing as - rows. `shell` remains valid runtime-side but the phone no longer + rows. The OpenAI picker always promotes GPT-5.6 Sol, Terra, Luna in that + order even when a host returns another order; Sol is the fallback default + and GPT-5.5 remains below them. The phone prefers host-advertised reasoning + tiers/defaults, filters raw `max` for GPT-5.6, and falls back to Light / + Medium / High / Extra High / Ultra on Sol/Terra and through Extra High on + Luna (`low` for Sol; `medium` for Terra/Luna). `shell` remains valid runtime-side but the phone no longer offers a plain-shell launch. `SyncStartCliSessionArgs` also carries an optional `reasoningEffort` field that the runtime forwards to `buildTrackedCliLaunchCommand`, so the phone can launch a Codex / diff --git a/docs/features/sync-and-multi-device/remote-commands.md b/docs/features/sync-and-multi-device/remote-commands.md index 29abbd0b6..7abf4e9e7 100644 --- a/docs/features/sync-and-multi-device/remote-commands.md +++ b/docs/features/sync-and-multi-device/remote-commands.md @@ -426,6 +426,10 @@ A handful have more logic: — the same module the desktop Work tab uses — so the runtime owns the startup-command shape and a phone cannot smuggle in a free-form shell command (the `shell` provider takes no startup payload at all). + For Codex on macOS, the runtime resolves the explicitly opted-in and + OpenAI-signature-verified standalone Computer Use client at launch time and + adds only the canonical `mcp_servers.computer_use` config overrides; a + missing/disabled/unverified client adds nothing. The runtime resolves the requested lane worktree before building that launch payload, so ADE guidance and `ADE_AGENT_SKILLS_DIRS` prefer lane-local `.claude` / `.agents` / `.ade` / `.codex` skill dirs and @@ -466,6 +470,10 @@ A handful have more logic: - **`chat.create`** — resolves a missing `model` to the first available provider model via `agentChatService.getAvailableModels` before forwarding. +- **`chat.recoverCodexTurn`** — validates one of `wait`, `steer`, + `interrupt_retry_same_thread`, or `restart_resume_thread` and forwards to + the chat service. It is viewer-allowed but deliberately non-queueable: the + session/turn pair must still be the active stalled turn when handled. - **`lanes.suggestName`** — background lane naming for the mobile auto-create flow (desktop parity with `agentChatService.suggestLaneNameFromPrompt`). Takes `{ prompt, @@ -657,7 +665,9 @@ first fetch instead of returning an empty passive cache. - **`chat.models` returns the brain's model catalog.** A controller must not hardcode model IDs. The brain is authoritative about which models are wired up, which providers have credentials, and - what the default model is. + what the default model and `defaultReasoningEffort` are. Compatibility + catalogs may keep fallback rows for older hosts, but host-advertised tiers + and defaults win. - **`lanes.delete` and `lanes.archive` are queueable.** A disconnected controller can enqueue deletes that replay on reconnect. Be aware when reasoning about "why did this lane