diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 87b7c10d9..da8fa374e 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -10,12 +10,12 @@ "license": "AGPL-3.0", "dependencies": { "@anthropic-ai/claude-agent-sdk": "0.3.220", - "@cursor/sdk": "^1.0.27", + "@cursor/sdk": "1.0.27", "@factory/droid-sdk": "0.2.0", "@linear/sdk": "^84.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex": "0.144.5", - "@opencode-ai/sdk": "1.18.14", + "@opencode-ai/sdk": "1.18.21", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "bonjour-service": "^1.3.0", @@ -24,7 +24,7 @@ "graceful-fs": "^4.2.11", "node-cron": "^3.0.3", "node-pty": "^1.1.0", - "opencode-ai": "1.18.14", + "opencode-ai": "1.18.21", "ssh2": "^1.17.0", "ws": "^8.19.0", "yaml": "^2.8.2", @@ -3500,9 +3500,9 @@ } }, "node_modules/@opencode-ai/sdk": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.14.tgz", - "integrity": "sha512-Yd8vPDT5DW++Hs2904Q/Vvk/m1U79aKFSRusgAfBQ+AGhfoT40LdeL5eiJtVy0xBUIZF/8DEZjkWaeof9Y59PA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.18.21.tgz", + "integrity": "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA==", "license": "MIT", "dependencies": { "cross-spawn": "7.0.6" @@ -16758,9 +16758,9 @@ } }, "node_modules/opencode-ai": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.14.tgz", - "integrity": "sha512-E5son8EQkh+cQ3bYkeGCOSNTcx8wmgsHBZ3bJjY0rOA84JaRb3VvqkfT41A8ChgEzwbSNe7uzEHDKqJOkNMynQ==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-ai/-/opencode-ai-1.18.21.tgz", + "integrity": "sha512-BxQyxpD0y2X0sXJUKLOooXVmi9QIoeKPtdH68r7QRiqXJ/YulK1MQvSe8KyA8183zoPV0G6JAtgz1OqmE3OGUw==", "cpu": [ "arm64", "x64" @@ -16776,24 +16776,24 @@ "opencode": "bin/opencode.exe" }, "optionalDependencies": { - "opencode-darwin-arm64": "1.18.14", - "opencode-darwin-x64": "1.18.14", - "opencode-darwin-x64-baseline": "1.18.14", - "opencode-linux-arm64": "1.18.14", - "opencode-linux-arm64-musl": "1.18.14", - "opencode-linux-x64": "1.18.14", - "opencode-linux-x64-baseline": "1.18.14", - "opencode-linux-x64-baseline-musl": "1.18.14", - "opencode-linux-x64-musl": "1.18.14", - "opencode-windows-arm64": "1.18.14", - "opencode-windows-x64": "1.18.14", - "opencode-windows-x64-baseline": "1.18.14" + "opencode-darwin-arm64": "1.18.21", + "opencode-darwin-x64": "1.18.21", + "opencode-darwin-x64-baseline": "1.18.21", + "opencode-linux-arm64": "1.18.21", + "opencode-linux-arm64-musl": "1.18.21", + "opencode-linux-x64": "1.18.21", + "opencode-linux-x64-baseline": "1.18.21", + "opencode-linux-x64-baseline-musl": "1.18.21", + "opencode-linux-x64-musl": "1.18.21", + "opencode-windows-arm64": "1.18.21", + "opencode-windows-x64": "1.18.21", + "opencode-windows-x64-baseline": "1.18.21" } }, "node_modules/opencode-darwin-arm64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.14.tgz", - "integrity": "sha512-xlzzkkvIE5mnB/s9xYbrY+N7d4K4iFclNp6hT9YIWEOgfJSESkPhCrmfy3qMrAGV+Ufof+QaZPSCMyngt/741Q==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-darwin-arm64/-/opencode-darwin-arm64-1.18.21.tgz", + "integrity": "sha512-VEeFMsNJ4C4T3+hJiMcNVK0kGMUr6X+p4NJoxpvYwSfSBkVsUpxb9pZPNjj0ZNxvEkuydDtm1D5UE+Dj61+0Ew==", "cpu": [ "arm64" ], @@ -16803,9 +16803,9 @@ ] }, "node_modules/opencode-darwin-x64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.14.tgz", - "integrity": "sha512-HHPgOBWT3/1axMLU45z9p4g6STErwzSKJmH8w4h2RiFCii8F8cGCljZbFyLeWWQ1y3YHWl62r3ZFq1T69DasVg==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64/-/opencode-darwin-x64-1.18.21.tgz", + "integrity": "sha512-giHRU28MknuFjkaKyFb58YlAgre2tzTj22TjNY4uJqgkZnaCfwnlXfGjs1lvkBBPKRA8BZfhj+osIhPXPz7VLw==", "cpu": [ "x64" ], @@ -16815,9 +16815,9 @@ ] }, "node_modules/opencode-darwin-x64-baseline": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.14.tgz", - "integrity": "sha512-XE9hjQPztLrfcQsML0mi87rBrBii+BrK6HBz2cBCpCLRbDb0UtPX1YQqAPA4qCSZl6v6VEyolj6KvyjhGSpZUg==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-darwin-x64-baseline/-/opencode-darwin-x64-baseline-1.18.21.tgz", + "integrity": "sha512-PkUuiQHk/+NhJXzrg6eRZjz26zfo6tlDjVTsvUKSUzuAOxqVJAg74uVO5jRPMM93C4ftf8wcxRhaTkNr4v1Ovw==", "cpu": [ "x64" ], @@ -16827,9 +16827,9 @@ ] }, "node_modules/opencode-linux-arm64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.14.tgz", - "integrity": "sha512-LSILNOuBO5Lotcky2XcvpAVxj/abRcWynKdTcX1QX0+bVOCEsY/fb0tGUuJFafVzWsmjFS7QF3vyD9mnrMC7Qg==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64/-/opencode-linux-arm64-1.18.21.tgz", + "integrity": "sha512-9mzZd/4mHG/rngbSToCQg7W82OKBay920k3l0v+ojajllp8JVGJqQmDmATvNpsoUVOa201nqkfiQI0GvpCofHA==", "cpu": [ "arm64" ], @@ -16839,21 +16839,24 @@ ] }, "node_modules/opencode-linux-arm64-musl": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.14.tgz", - "integrity": "sha512-kc4c+jw97MLFkoLEnSSQjol9QbrHwYagk9IWSuH8GhF5dhJ5PtIT0ghQ3QT6PcWAxaUSh3aP3pt9hg6YRNVoow==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-arm64-musl/-/opencode-linux-arm64-musl-1.18.21.tgz", + "integrity": "sha512-mWjELwOQm8PhcqVyAmyuocJQP2Vsl8mw8BG13eZ0Izs8k0Kq0/i8YXdxU/pdyeXJM6+r9baJ20EuLSrsDTTuyw==", "cpu": [ "arm64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-linux-x64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.14.tgz", - "integrity": "sha512-SMZj5jyOoOsZ6Cp2jD8hZbxRpw71OkyMDq02ZIfEFj/MxzX5xulIeC3XedFaATvAjKMlnfq5A9MFssExcrwegg==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-x64/-/opencode-linux-x64-1.18.21.tgz", + "integrity": "sha512-e7AegOOCksBHTuQ2YUg6J7UxYKMReN21wck52YbEUl2RJvcAnfAyl4sfB6y9QosAILXFl3853EXBbYj/8BljxQ==", "cpu": [ "x64" ], @@ -16863,9 +16866,9 @@ ] }, "node_modules/opencode-linux-x64-baseline": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.14.tgz", - "integrity": "sha512-80cqcKMNWS1PQb+g0fud6H/j+0+MNv8OzyQwDDm+ZphFZW5v/LnfzXtCiWyqxp+vJeqlWjIn+I2p/Qr8YhbugA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline/-/opencode-linux-x64-baseline-1.18.21.tgz", + "integrity": "sha512-eTvSmVVgPEi6KWooCbQsTRh/Tp97hX9pGhY62DnvZ6Ejpyav23jNBwWhnPx8XHpCdQPUEfPnpfdqvm/f8CGocA==", "cpu": [ "x64" ], @@ -16875,33 +16878,39 @@ ] }, "node_modules/opencode-linux-x64-baseline-musl": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.14.tgz", - "integrity": "sha512-8cSbF24sqAtlHmW0ndtuGPlg2/hquyTpSOWlOtdgdFHPBrzRIwE2gq4U9Ce1fbPNNEy09r/ZsEDjYaVQuDeMkA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-baseline-musl/-/opencode-linux-x64-baseline-musl-1.18.21.tgz", + "integrity": "sha512-t6kvICk+wH1A/fFCqRNS4CrJzZqBcBGCPxrab4XxtGTYnxzzYiz5gQxb/iMhWKcQSwGhu03Gi0jeDo9MfQKR2A==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-linux-x64-musl": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.14.tgz", - "integrity": "sha512-PIJZzMMvfkweEl0S6ZE2SND9E8vZYz8jFIlTUmdioGl56tV3Z/dAsMhShC6/aBef61wxoGghQHo4/I4tqKQz4g==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-linux-x64-musl/-/opencode-linux-x64-musl-1.18.21.tgz", + "integrity": "sha512-6K2g+kjyH9LMPhCJxz42CYaZoxVGZXkAslFeWzlyn/+xkRSJHVn3kGUObMS2hE/UZ7CQVTffm7kVlttJDWgPwg==", "cpu": [ "x64" ], + "libc": [ + "musl" + ], "optional": true, "os": [ "linux" ] }, "node_modules/opencode-windows-arm64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.14.tgz", - "integrity": "sha512-lLf1fMhrBg0tdQOBvWGpwbMCXS+EEqAYdikhjGZz16SVMGAQNNemdPEvqi2M2BMEEUpvVneBS546TvXB+N52DA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-windows-arm64/-/opencode-windows-arm64-1.18.21.tgz", + "integrity": "sha512-tAyjoN9z2YChe6TguHudU6njEznsoBraTvWRJxcVkxtQMZrFB89x/OlKJJ3m5GxlceI4ReeBGP309biK6VJE7w==", "cpu": [ "arm64" ], @@ -16911,9 +16920,9 @@ ] }, "node_modules/opencode-windows-x64": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.14.tgz", - "integrity": "sha512-hhsDHc4dZcuxSUi4yhGxzrFPdV6fCKNrQVp6nRzxcVstcIyJ3Xl+wKcxw5JagKOgpNFJ4mVoNh9lJ+7hrsKPZA==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-windows-x64/-/opencode-windows-x64-1.18.21.tgz", + "integrity": "sha512-eLrIAczcDUy0F32JU7wKD4bB0L6U75H2ryoz84I5ydtboRG1Muk8pO88HcL9ty92Zrx71HC9mLT5tSQy6ETvbw==", "cpu": [ "x64" ], @@ -16923,9 +16932,9 @@ ] }, "node_modules/opencode-windows-x64-baseline": { - "version": "1.18.14", - "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.14.tgz", - "integrity": "sha512-39Y0oLPpzVBdyILqWnWHl15ruQpsMH7muI6IA/LIbJRwMaSimsMKt2v/ItefFXz3tRHqdxVTPf2RsYClyWBLng==", + "version": "1.18.21", + "resolved": "https://registry.npmjs.org/opencode-windows-x64-baseline/-/opencode-windows-x64-baseline-1.18.21.tgz", + "integrity": "sha512-KqFmdEqHEBfv4sPpXLqeUaCv42OBlCwyQz6FmHDg169ZrafvPLiTds5wTYtFWOUhRCDYjQvokSNILqFHomyc4w==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 09aa439fb..531ad8e0f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -78,7 +78,7 @@ "@linear/sdk": "^84.0.0", "@modelcontextprotocol/sdk": "^1.29.0", "@openai/codex": "0.144.5", - "@opencode-ai/sdk": "1.18.14", + "@opencode-ai/sdk": "1.18.21", "@xterm/addon-serialize": "^0.14.0", "@xterm/headless": "^6.0.0", "bonjour-service": "^1.3.0", @@ -87,7 +87,7 @@ "graceful-fs": "^4.2.11", "node-cron": "^3.0.3", "node-pty": "^1.1.0", - "opencode-ai": "1.18.14", + "opencode-ai": "1.18.21", "ssh2": "^1.17.0", "ws": "^8.19.0", "yaml": "^2.8.2", diff --git a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts index a3a6c4e43..c4ded495c 100644 --- a/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatCliLaunch.test.ts @@ -263,16 +263,15 @@ describe("launchAgentChatCli OpenCode fast mode", () => { ); const createArg = deps.create.mock.calls[0]?.[0] as PtyCreateArgs; + // The root TUI is the only launch surface; it has no --variant flag, so + // fast mode stays a chat-runtime feature rather than a CLI flag. expect(createArg.command).toBe("opencode"); expect(createArg.args).toEqual(expect.arrayContaining([ - "run", - "--interactive", "--model", "openai/gpt-5.4", - "--variant", - "fast", ])); - expect(createArg.startupCommand).toContain("--variant fast"); + expect(createArg.args).not.toContain("--variant"); + expect(createArg.startupCommand).not.toContain("run --interactive"); }); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 20305a36f..4cd7a40da 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -9,6 +9,7 @@ import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { codexComputerUseClientCandidates } from "../../utils/codexComputerUse"; import { buildOpenCodePromptParts, + openCodeEventStream, resolveOpenCodeExecutablePath, startOpenCodeSession, } from "../opencode/openCodeRuntime"; @@ -407,6 +408,13 @@ vi.mock("../opencode/openCodeRuntime", async () => { const result = streamText({} as any) as { fullStream?: AsyncIterable>; }; + // Mirror the real wire order: OpenCode announces every message + // (with its role) before its parts arrive. + const assistantMessageId = `message-${sessionId}`; + pushEvent({ + type: "message.updated", + properties: { info: { id: assistantMessageId, role: "assistant", sessionID: sessionId } }, + }); let text = ""; for await (const part of result.fullStream ?? []) { if (state.aborted) break; @@ -425,7 +433,7 @@ vi.mock("../opencode/openCodeRuntime", async () => { pushEvent({ type: "message.part.updated", properties: { - part: { id: `text-${sessionId}`, sessionID: sessionId, type: "text", text }, + part: { id: `text-${sessionId}`, type: "text", text, messageID: assistantMessageId, sessionID: sessionId }, delta: String(part.textDelta ?? ""), }, }); @@ -37037,7 +37045,233 @@ describe("createAgentChatService", () => { await sendPromise; }); - it("fails a cleanly ended OpenCode event stream and clears active child sessions", async () => { + it("never renders OpenCode user-message parts as assistant output", async () => { + // Regression: `message.part.updated` carries user-message parts too (the + // prompt echo, and historically the synthetic system-prompt part). Without + // an assistant-role gate, whatever rode in the user message echoed into the + // transcript as a left-side agent bubble before the agent answered. + 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: "Resolve the failing test.", + }); + const started = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" && event.event.turnStatus === "started", + ); + + const state = [...mockState.openCodeSessions.values()][0]!; + const pushEvents = (...nextEvents: any[]): void => { + state.events.push(...nextEvents); + const waiters = [...state.waiters]; + state.waiters.length = 0; + waiters.forEach((waiter) => waiter()); + }; + + pushEvents( + // The user message echo: role announced first, then its plain text part + // with no synthetic/ignored flags — exactly what OpenCode streams. + { type: "message.updated", properties: { info: { id: "msg-user-1", role: "user", sessionID: "opencode-session-1" } } }, + { + type: "message.part.updated", + properties: { + part: { + id: "prt-user-1", + type: "text", + text: "Resolve the failing test.", + messageID: "msg-user-1", + sessionID: "opencode-session-1", + }, + }, + }, + // A part whose message role is not yet known must stay unrendered too. + { + type: "message.part.updated", + properties: { + part: { + id: "prt-unknown-1", + type: "text", + text: "orphan part before its message.updated", + messageID: "msg-unannounced", + sessionID: "opencode-session-1", + }, + }, + }, + // The real answer. + { type: "message.updated", properties: { info: { id: "msg-asst-1", role: "assistant", sessionID: "opencode-session-1" } } }, + { + type: "message.part.updated", + properties: { + part: { + id: "prt-asst-1", + type: "text", + text: "Fixed it.", + messageID: "msg-asst-1", + sessionID: "opencode-session-1", + }, + }, + }, + { type: "session.idle", properties: { sessionID: "opencode-session-1" } }, + ); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "done" && event.event.turnId === started.event.turnId, + ); + + const textPayloads = events + .filter((event) => event.event.type === "text") + .map((event) => (event.event as { text: string }).text) + .join(""); + expect(textPayloads).toContain("Fixed it."); + expect(textPayloads).not.toContain("Resolve the failing test."); + expect(textPayloads).not.toContain("orphan part"); + + releaseStream(); + await sendPromise; + }); + + it("does not adopt OpenCode placeholder session titles", async () => { + // OpenCode mints "New session - " (and child variants) until its own + // titler runs; adopting one would flash timestamp soup as the chat title + // and block auto-titling. + 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: "Name this thread properly.", + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" && event.event.turnStatus === "started", + ); + + const state = [...mockState.openCodeSessions.values()][0]!; + const pushEvents = (...nextEvents: any[]): void => { + state.events.push(...nextEvents); + const waiters = [...state.waiters]; + state.waiters.length = 0; + waiters.forEach((waiter) => waiter()); + }; + + pushEvents( + { + type: "session.created", + properties: { info: { id: "opencode-session-1", title: "New session - 2026-08-22T15:25:28.226Z" } }, + }, + { + type: "session.updated", + properties: { info: { id: "opencode-session-1", title: "Child session - 2026-08-22T15:26:00.000Z" } }, + }, + ); + await new Promise((resolve) => setTimeout(resolve, 20)); + const placeholderAdoptions = events.filter( + (event) => + event.event.type === "session_meta_updated" + && /[12]:\d{2}:\d{2}\.\d{3}Z$/.test((event.event as { title?: string }).title ?? ""), + ); + expect(placeholderAdoptions).toEqual([]); + + pushEvents({ + type: "session.updated", + properties: { info: { id: "opencode-session-1", title: "Lane status sweep" } }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & + { event: Extract } => + event.event.type === "session_meta_updated" && event.event.title === "Lane status sweep", + ); + + releaseStream(); + await sendPromise; + }); + + it("subscribes to the OpenCode event stream before dispatching the prompt", async () => { + // The SSE stream is live-only. Dispatching first races the subscription: + // if the server publishes the assistant message.updated before /event is + // connected, the role announcement is lost and the role gate would drop + // every part of that message. + const observations: string[] = []; + vi.mocked(streamText).mockReturnValue({ + fullStream: (async function* () {})(), + } as any); + vi.mocked(openCodeEventStream).mockImplementationOnce((async () => { + // Snapshot how many prompts have been dispatched at subscribe time. + const state = [...mockState.openCodeSessions.values()][0]; + observations.push(`promptBodiesAtSubscribe=${state ? state.promptBodies.length : -1}`); + // Ends immediately: the turn fails cleanly, which is all this test needs. + return (async function* () {})() as AsyncGenerator; + }) as unknown as typeof openCodeEventStream); + + const events: AgentChatEventEnvelope[] = []; + 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: "Check dispatch order.", + }); + // The turn dispatches asynchronously; wait for the subscription snapshot. + await vi.waitFor(() => { + expect(observations.length).toBeGreaterThan(0); + }); + // Zero means the SSE subscription was live before any prompt was dispatched. + expect(observations).toEqual(["promptBodiesAtSubscribe=0"]); + // Let the failed turn settle (its failure is emitted as an error event). + await sendPromise.catch(() => undefined); + }); + +it("fails a cleanly ended OpenCode event stream and clears active child sessions", async () => { const events: AgentChatEventEnvelope[] = []; let releaseStream!: () => void; const streamGate = new Promise((resolve) => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 2676675f7..85bfae862 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -4775,7 +4775,7 @@ function sanitizeAutoTitle(raw: string, maxChars = AUTO_TITLE_MAX_CHARS): string const collapsed = normalized.toLowerCase().replace(/[^\p{L}\p{N}]+/gu, " ").trim(); if (REJECTED_TITLES.has(collapsed)) return null; - if (/^(new session|new chat|untitled chat|untitled)\b/u.test(collapsed)) return null; + if (/^(new session|new chat|child session|untitled chat|untitled)\b/u.test(collapsed)) return null; if (/^(completed?|done|finished|resolved|success)\b/u.test(collapsed)) { const remainder = collapsed.replace(/^(completed?|done|finished|resolved|success)\b/u, "").trim(); @@ -23315,6 +23315,17 @@ export function createAgentChatService(args: { }), }; + // Subscribe BEFORE dispatching. The event stream is live-only — it never + // replays events published before the connection lands — so a prompt + // request that wins this race would have its assistant `message.updated` + // role announcement (and first parts) lost, and the role gate below + // would then drop every part of that message. + const eventStream = await openCodeEventStream({ + client: runtime.handle.client, + directory: runtime.handle.directory, + signal: abortController.signal, + }); + const promptAccepted = runtime.handle.client.session.promptAsync({ path: { id: runtime.handle.sessionId }, query: { directory: runtime.handle.directory }, @@ -23322,19 +23333,20 @@ export function createAgentChatService(args: { body: openCodePromptBody, }); - const eventStream = await openCodeEventStream({ - client: runtime.handle.client, - directory: runtime.handle.directory, - signal: abortController.signal, - }); - await promptAccepted; if (args.onBackendDispatched) { args.onBackendDispatched(); } let stepNumber = 0; - const openCodeAssistantMessageIds = new Set(); + // Role of every message OpenCode tells us about, keyed by message id. + // Part events alone carry no role, and user-message parts (including + // synthetic/ignored prompt context) ride the same `message.part.updated` + // stream as assistant output — so content emission must be gated on a + // known assistant role, never on the part shape alone. OpenCode publishes + // `message.updated` before the first part of a message, so the role is + // always resolved by the time its parts arrive. + const openCodeMessageRoleById = new Map(); const emittedOpenCodeImagePartIds = new Set(); const emitOpenCodeImagePart = (part: unknown): void => { const imageEvent = mapOpenCodeImagePart({ @@ -23514,10 +23526,8 @@ export function createAgentChatService(args: { } if (event.type === "message.updated") { - if (event.properties.info.role === "assistant") { - openCodeAssistantMessageIds.add(event.properties.info.id); - } else { - openCodeAssistantMessageIds.delete(event.properties.info.id); + if (event.properties.info.role === "assistant" || event.properties.info.role === "user") { + openCodeMessageRoleById.set(event.properties.info.id, event.properties.info.role); } continue; } @@ -23591,12 +23601,23 @@ export function createAgentChatService(args: { continue; } + // Only assistant messages produce rendered content. User-message text + // parts stream through the same event; without this role gate the + // user's own prompt (or injected system context) would echo into the + // transcript as an assistant bubble. OpenCode announces every message + // (with its role) before its parts, so an unknown role means "not + // announced yet" — those stay unrendered too. + const openCodePartMessageRole = openCodeMessageRoleById.get(part.messageID); + if (part.type === "text") { // Skip synthetic/ignored prompt parts (e.g. ADE launch directives // injected as system context) — they should not be rendered in chat. if ((part as { synthetic?: boolean }).synthetic || (part as { ignored?: boolean }).ignored) { continue; } + if (openCodePartMessageRole !== "assistant") { + continue; + } const previous = runtime.textByPartId.get(part.id) ?? ""; const nextText = part.text; const nextDelta = typeof delta === "string" @@ -23618,6 +23639,9 @@ export function createAgentChatService(args: { } if (part.type === "reasoning") { + if (openCodePartMessageRole !== "assistant") { + continue; + } const previous = runtime.reasoningByPartId.get(part.id) ?? ""; const nextText = part.text; const nextDelta = typeof delta === "string" @@ -23646,7 +23670,7 @@ export function createAgentChatService(args: { 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)) { + if (openCodePartMessageRole === "assistant") { emitOpenCodeImagePart(part); } continue; diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts index 60d71441a..e60d688a0 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.test.ts @@ -115,7 +115,9 @@ vi.mock("./openCodeServerManager", () => ({ import { __resetOpenCodeRuntimeDiagnosticsForTests, buildOpenCodeConfig, + buildOpenCodePromptParts, getOpenCodeRuntimeSnapshot, + isOpenCodeNotFoundError, refreshOpenCodeSessionToolSelection, runOpenCodeTextPrompt, startOpenCodeSession, @@ -238,6 +240,160 @@ describe("openCodeRuntime", () => { expect(snapshot.dedicatedCount).toBe(0); expect(Object.keys(snapshot).sort()).toEqual(["dedicatedCount", "entries", "sharedCount"]); }); + + it("sends a provided system prompt through the body system field, not a text part", async () => { + await runOpenCodeTextPrompt({ + directory: "/repo", + title: "System prompt transport", + modelDescriptor: { + id: "opencode/openai/gpt-5-mini", + family: "openai", + providerRoute: "opencode", + providerModelId: "openai/gpt-5-mini", + openCodeProviderId: "openai", + openCodeModelId: "gpt-5-mini", + } as any, + prompt: "ping", + system: "You are ADE's naming agent.", + projectConfig: { ai: {} }, + }); + + const lastPromptCall = mockState.promptAsync.mock.calls.at(-1) as unknown as + [{ body: Record } | undefined]; + const body = lastPromptCall?.[0]?.body ?? {}; + expect(body.system).toBe("You are ADE's naming agent."); + // The synthetic/ignored part injection is gone for good: OpenCode drops + // `ignored` parts from model context, so that transport never worked. + const parts = body.parts as Array>; + expect(parts.every((part) => !part.synthetic && !part.ignored)).toBe(true); + }); + + it("omits the body system field when no system prompt is provided", async () => { + await runOpenCodeTextPrompt({ + directory: "/repo", + title: "No system prompt", + modelDescriptor: { + id: "opencode/openai/gpt-5-mini", + family: "openai", + providerRoute: "opencode", + providerModelId: "openai/gpt-5-mini", + openCodeProviderId: "openai", + openCodeModelId: "gpt-5-mini", + } as any, + prompt: "ping", + projectConfig: { ai: {} }, + }); + + const lastPromptCall = mockState.promptAsync.mock.calls.at(-1) as unknown as + [{ body: Record } | undefined]; + const body = lastPromptCall?.[0]?.body ?? {}; + expect(body).not.toHaveProperty("system"); + }); + + it("builds prompt parts from the user text plus file attachments only", () => { + const parts = buildOpenCodePromptParts({ + prompt: "hello", + files: [{ path: "/tmp/pic.png", mime: "image/png", filename: "pic.png" }], + }); + + expect(parts).toHaveLength(2); + expect(parts[0]).toMatchObject({ type: "text", text: "hello" }); + expect(parts[1]).toMatchObject({ type: "file", mime: "image/png" }); + }); + + it("recreates a persisted session only on a confirmed miss, and rethrows anything else", async () => { + // Confirmed 404 → fall through to session.create. + mockState.getSession.mockImplementationOnce(async () => { + throw new Error("not found", { cause: { body: { name: "NotFoundError" }, status: 404 } }); + }); + const recreated = await startOpenCodeSession({ + directory: "/repo", + sessionId: "ses_gone", + leaseKind: "dedicated", + projectConfig: { ai: {} }, + ownerKind: "chat", + ownerId: "chat-404", + ownerKey: "chat:chat-404", + }); + expect(recreated.sessionId).toContain("opencode-session-"); + expect(mockState.createSession).toHaveBeenCalled(); + + // Transient failure (no response / non-404 status) must surface, not reset + // the thread onto a brand-new empty session. + mockState.getSession.mockImplementationOnce(async () => { + throw new Error("opencode server GET → 503", { cause: { body: {}, status: 503 } }); + }); + await expect(startOpenCodeSession({ + directory: "/repo", + sessionId: "ses_blip", + leaseKind: "dedicated", + projectConfig: { ai: {} }, + ownerKind: "chat", + ownerId: "chat-blip", + ownerKey: "chat:chat-blip", + })).rejects.toThrow(/503/); + expect(mockState.dedicatedLease.close).toHaveBeenCalledWith("error"); + + mockState.getSession.mockImplementationOnce(async () => { + throw new TypeError("fetch failed"); + }); + await expect(startOpenCodeSession({ + directory: "/repo", + sessionId: "ses_network", + leaseKind: "dedicated", + projectConfig: { ai: {} }, + ownerKind: "chat", + ownerId: "chat-net", + ownerKey: "chat:chat-network", + })).rejects.toThrow(/fetch failed/); + + // A live session is adopted as-is, with its title. + mockState.getSession.mockImplementationOnce((async () => ({ + data: { id: "ses_live", title: "Real thread" }, + })) as unknown as typeof mockState.getSession); + const adopted = await startOpenCodeSession({ + directory: "/repo", + sessionId: "ses_live", + leaseKind: "dedicated", + projectConfig: { ai: {} }, + ownerKind: "chat", + ownerId: "chat-live", + ownerKey: "chat:chat-live", + }); + expect(adopted.initialTitle).toBe("Real thread"); + expect(mockState.createSession).toHaveBeenCalledTimes(1); + }); + + it("classifies OpenCode missing-session errors precisely", () => { + expect(isOpenCodeNotFoundError(new Error("x", { cause: { body: { name: "NotFoundError" }, status: 404 } }))).toBe(true); + expect(isOpenCodeNotFoundError(new Error("x", { cause: { body: { name: "NotFoundError" } } }))).toBe(true); + expect(isOpenCodeNotFoundError({ status: 404 })).toBe(true); + expect(isOpenCodeNotFoundError({ name: "NotFoundError" })).toBe(true); + // Deeply nested but bounded walk still finds it. + expect(isOpenCodeNotFoundError(new Error("x", { cause: { error: { data: { statusCode: 404 } } } }))).toBe(true); + // Anything else is NOT a confirmed miss — transient blips included. + expect(isOpenCodeNotFoundError(new Error("session not found"))).toBe(false); + expect(isOpenCodeNotFoundError(new Error("x", { cause: { status: 500 } }))).toBe(false); + expect(isOpenCodeNotFoundError({ status: 400 })).toBe(false); + expect(isOpenCodeNotFoundError(undefined)).toBe(false); + expect(isOpenCodeNotFoundError("404")).toBe(false); + }); + + it("rejects a NotFoundError whose chain carries a non-404 status", () => { + // A shallow NotFoundError name must not outvote a deeper concrete status: + // re-creating the session after a transient 503 strands the live thread. + expect(isOpenCodeNotFoundError(new Error("x", { + cause: { body: { name: "NotFoundError" }, status: 503 }, + }))).toBe(false); + expect(isOpenCodeNotFoundError({ + name: "NotFoundError", + cause: { status: 500 }, + })).toBe(false); + // And a deep non-404 vetoes even when the name sits at the root. + expect(isOpenCodeNotFoundError(new Error("x", { + cause: { error: { data: { statusCode: 500, name: "NotFoundError" } } }, + }))).toBe(false); + }); }); describe("buildOpenCodeConfig provider injection", () => { diff --git a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts index 428a89b8b..62c441505 100644 --- a/apps/desktop/src/main/services/opencode/openCodeRuntime.ts +++ b/apps/desktop/src/main/services/opencode/openCodeRuntime.ts @@ -608,18 +608,9 @@ export function mapPermissionModeToOpenCodeAgent(mode: PermissionMode): OpenCode export function buildOpenCodePromptParts(args: { prompt: string; - system?: string; files?: OpenCodePromptFile[]; }): Array { const parts: Array = []; - if (args.system?.trim()) { - parts.push({ - type: "text", - text: args.system.trim(), - synthetic: true, - ignored: true, - }); - } parts.push({ type: "text", text: args.prompt, @@ -635,6 +626,49 @@ export function buildOpenCodePromptParts(args: { return parts; } +/** + * True only when an OpenCode server call failed with a confirmed + * "session does not exist" (HTTP 404 / `NotFoundError`). Anything else — a + * transport blip, timeout, HTML version-mismatch guard, auth hiccup — must NOT + * be treated as a missing session: the caller would silently start a fresh, + * empty session and strand the user's thread (t3code's #3604 silent context + * loss). Walks a bounded chain of `cause`/`body`/`error`/`data` properties so + * SDK wrapper shapes stay covered; any explicit non-404 status seals the walk. + */ +export function isOpenCodeNotFoundError(error: unknown): boolean { + // Two passes over the same bounded tree, so a deep non-404 status vetoes a + // shallow `NotFoundError` name: `{ name: "NotFoundError", cause: { status: + // 503 } }` must NOT re-create the session. Pass 1 seals on any explicit + // non-404 status anywhere; pass 2 affirms on a NotFoundError name or a 404. + const collect = (value: unknown, depth: number, visit: (record: Record) => void): void => { + if (!value || typeof value !== "object" || depth > 6) return; + const record = value as Record; + visit(record); + for (const key of ["cause", "body", "error", "data"] as const) { + const nested = record[key]; + if (nested === undefined || nested === null || Array.isArray(nested)) continue; + collect(nested, depth + 1, visit); + } + }; + let sealed = false; + let affirmed = false; + collect(error, 0, (record) => { + if (sealed) return; + for (const key of ["status", "statusCode"] as const) { + const candidate = record[key]; + if (typeof candidate === "number" && Number.isFinite(candidate)) { + if (candidate !== 404) { + sealed = true; + return; + } + affirmed = true; + } + } + if (record.name === "NotFoundError") affirmed = true; + }); + return !sealed && affirmed; +} + function createOpenCodeSessionHandle(args: { client: OpencodeClient; v2Client: OpenCodeV2Client; @@ -726,7 +760,14 @@ async function startOpenCodeSessionInternal( directory: args.directory, toolSelection: null, }); - } catch { + } catch (error) { + // Only a confirmed "session missing" may fall through to creation. Any + // other failure (transport, timeout, server restart mid-request) must + // surface — silently starting an empty session would strand the thread. + if (!isOpenCodeNotFoundError(error)) { + lease.close("error"); + throw error instanceof Error ? error : new Error(String(error)); + } // Fall through to session creation when the persisted session no longer exists. } } @@ -831,10 +872,14 @@ export async function runOpenCodeTextPrompt( body: { agent: args.agent ?? "ade-helper", model, + // First-class system prompt on the wire. Never inject it as a + // synthetic/ignored text part: OpenCode drops `ignored` parts from + // model context entirely, so the prompt would silently never reach + // the model. + ...(args.system?.trim() ? { system: args.system.trim() } : {}), ...(toolSelection ? { tools: toolSelection } : {}), parts: buildOpenCodePromptParts({ prompt: args.prompt, - system: args.system, files: args.files, }), }, diff --git a/apps/desktop/src/main/services/pty/ptyService.test.ts b/apps/desktop/src/main/services/pty/ptyService.test.ts index 489178923..e9e8046ec 100644 --- a/apps/desktop/src/main/services/pty/ptyService.test.ts +++ b/apps/desktop/src/main/services/pty/ptyService.test.ts @@ -39,8 +39,8 @@ const mocks = vi.hoisted(() => { return { isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }; }), realpathSync: Object.assign( - vi.fn((p: string) => p), - { native: vi.fn((p: string) => p) }, + vi.fn((p: string) => realpathOverrides.get(p as string) ?? p), + { native: vi.fn((p: string) => realpathOverrides.get(p as string) ?? p) }, ), statSync: vi.fn((p: string) => { if ((existsSyncResults.get(p) ?? true) === false) { @@ -5605,7 +5605,6 @@ describe("ptyService", () => { expect(spawnArgs).toContain(buildCanonicalOpenCodeReplayResumeCommand({ permissionMode: "plan", model: "opencode/lmstudio/openai%2Fgpt-oss-20b", - fastMode: true, resumeTarget: "ses_abc", prompt: "continue from the freeze frame", replayLimit: 40, @@ -5620,6 +5619,70 @@ describe("ptyService", () => { } }); + it("detects mini replay support from realistic root help output without overrides", async () => { + // Regression: \b can never match before "--" (both sides are non-word + // characters), so the old probe never recognized help output and replay + // resume stayed permanently dormant. + const previous = process.env.ADE_OPENCODE_REPLAY_RESUME; + delete process.env.ADE_OPENCODE_REPLAY_RESUME; + try { + const { service, sessionService, loadPty } = createHarness(); + mocks.spawnSync.mockImplementationOnce(() => ({ + status: 0, + stdout: [ + "Options:", + " -m, --model model to use in the format of provider/model", + " --mini start the minimal interactive interface [boolean] [default: false]", + " --no-replay disable mini session history replay on resume and after resize", + " --replay-limit cap visible mini replay to the newest N messages", + ].join("\n"), + stderr: "", + })); + sessionService.create({ + sessionId: "session-opencode-probe", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "OpenCode CLI", + startedAt: "2026-04-09T12:00:00.000Z", + transcriptPath: "/tmp/transcripts/session-opencode-probe.log", + toolType: "opencode", + resumeCommand: "opencode --session ses_probe", + resumeMetadata: { + provider: "opencode", + targetKind: "session", + targetId: "ses_probe", + launch: { permissionMode: "plan" }, + }, + }); + sessionService.end({ + sessionId: "session-opencode-probe", + endedAt: "2026-04-09T12:30:00.000Z", + exitCode: 0, + status: "completed", + }); + + const result = await service.sendToSession({ + sessionId: "session-opencode-probe", + text: "continue from the freeze frame", + permissionMode: "plan", + }); + + expect(result.resumed).toBe(true); + const spawn = (loadPty.mock.results[0]?.value as any).spawn; + const commandLine = String(spawn.mock.calls[0]?.[1]?.at(-1) ?? ""); + expect(commandLine).toContain("opencode --mini "); + expect(commandLine).toContain("--replay-limit 40"); + expect(commandLine).toContain("--session ses_probe"); + } finally { + if (previous === undefined) { + delete process.env.ADE_OPENCODE_REPLAY_RESUME; + } else { + process.env.ADE_OPENCODE_REPLAY_RESUME = previous; + } + } + }); + it("sendToSession single-flights concurrent resumes for the same session", async () => { const { service, sessionService, mockPty, loadPty } = createHarness(); sessionService.create({ @@ -8337,6 +8400,46 @@ describe("ptyService", () => { expect(sessionService.setResumeCommand).toHaveBeenCalledWith("session-opencode", "opencode --session ses_abc"); }); + it("matches OpenCode resume rows through symlink-resolved directories", async () => { + // macOS hands PTY cwds out as /tmp/... while OpenCode records the + // resolved /private/tmp/... spelling; the backfill must compare + // realpath-resolved keys or lane sessions under symlinked roots lose + // their own resume target. + const startedAt = "2026-04-15T21:30:00.000Z"; + const bundledOpenCode = "/Applications/ADE.app/Contents/Resources/app.asar.unpacked/node_modules/opencode-darwin-arm64/bin/opencode"; + mocks.resolveOpenCodeBinaryPath.mockReturnValue(bundledOpenCode); + mocks.spawnSync.mockReturnValueOnce({ + status: 0, + stdout: JSON.stringify([ + { + id: "ses_symlink", + directory: "/private/tmp/test-worktree", + created: Date.parse(startedAt), + updated: Date.parse(startedAt) + 1000, + }, + ]), + stderr: "", + }); + + const { service, sessionService } = createHarness(); + sessionService.readTranscriptTail.mockResolvedValueOnce("opencode\n"); + sessionService.create({ + sessionId: "session-opencode-symlink", + laneId: "lane-1", + ptyId: null, + tracked: true, + title: "OpenCode CLI", + startedAt, + transcriptPath: "/tmp/test-worktree/.ade/transcripts/session-opencode-symlink.log", + toolType: "opencode", + }); + mocks.realpathOverrides.set("/tmp/test-worktree", "/private/tmp/test-worktree"); + + await service.ensureResumeTargets(["session-opencode-symlink"]); + + expect(sessionService.setResumeCommand).toHaveBeenCalledWith("session-opencode-symlink", "opencode --session ses_symlink"); + }); + it("does not backfill OpenCode from session list without OpenCode transcript evidence", async () => { const startedAt = "2026-04-15T21:30:00.000Z"; mocks.spawnSync.mockReturnValueOnce({ diff --git a/apps/desktop/src/main/services/pty/ptyService.ts b/apps/desktop/src/main/services/pty/ptyService.ts index fc767c9df..eaa767371 100644 --- a/apps/desktop/src/main/services/pty/ptyService.ts +++ b/apps/desktop/src/main/services/pty/ptyService.ts @@ -51,7 +51,7 @@ import { shouldUseWindowsCmdWrapper, windowsTaskkillInvocation, } from "../shared/processExecution"; -import { pathsEqual } from "../shared/pathCompare"; +import { pathKey, pathsEqual } from "../shared/pathCompare"; import type { ResourceAttributionRoot, ResourceAttributionRootKind } from "./resourceUsageSampling"; import { augmentProcessPathWithShellAndKnownCliDirs, @@ -465,7 +465,10 @@ function openCodeSupportsReplayResume(): boolean { const env: NodeJS.ProcessEnv = { ...process.env, NO_COLOR: "1" }; delete env.FORCE_COLOR; const executable = resolveOpenCodeBinaryPath() ?? "opencode"; - const result = spawnSync(executable, ["run", "--help"], { + // Replay resume is a root `--mini` feature: `--replay-limit` requires + // --mini, and an explicit `--replay` flag is rejected outright on current + // OpenCode. Probe the root help, not `run --help`. + const result = spawnSync(executable, ["--help"], { encoding: "utf8", timeout: 3000, maxBuffer: 512 * 1024, @@ -473,9 +476,11 @@ function openCodeSupportsReplayResume(): boolean { windowsHide: true, }); const output = `${String(result.stdout ?? "")}\n${String(result.stderr ?? "")}`; + // Flag tokens start with "-", a non-word character, so \b can never match + // before them; anchor on whitespace/string edges instead. cachedOpenCodeReplayResumeSupport = result.status === 0 - && /\b--replay\b/.test(output) - && /\b--interactive\b/.test(output); + && /(^|\s)--mini(\s|$)/.test(output) + && /(^|\s)--replay-limit(\s|$)/.test(output); return cachedOpenCodeReplayResumeSupport; } catch { cachedOpenCodeReplayResumeSupport = false; @@ -3453,6 +3458,20 @@ export function createPtyService({ if (jsonStart < 0) return null; const rows = JSON.parse(stdout.slice(jsonStart)) as unknown; if (!Array.isArray(rows)) return null; + // macOS hands back /var/... while the session row records the resolved + // /private/var/... spelling (and Windows adds case drift). Compare + // realpath-resolved, platform-folded keys or every lane under a symlinked + // tmp root misses its own resume target. + const canonicalKey = (value: string): string => { + let resolved = value; + try { + resolved = fs.realpathSync(value); + } catch { + // Missing/deleted directory: compare the raw spelling. + } + return pathKey(resolved); + }; + const requestedKey = canonicalKey(args.cwd); const requestedStartedAtMs = Date.parse(args.startedAt ?? ""); const hasStartedAt = Number.isFinite(requestedStartedAtMs); let bestMatch: { id: string; score: number; updatedMs: number } | null = null; @@ -3460,7 +3479,7 @@ export function createPtyService({ const record = row && typeof row === "object" ? row as Record : null; const id = typeof record?.id === "string" ? record.id.trim() : ""; const directory = typeof record?.directory === "string" ? record.directory.trim() : ""; - if (!id || directory !== args.cwd) continue; + if (!id || !directory || canonicalKey(directory) !== requestedKey) continue; const createdMs = Number(record?.created); const updatedMs = Number(record?.updated); let referenceMs: number; @@ -6845,11 +6864,6 @@ export function createPtyService({ ?? resumableSession.resumeMetadata?.launch.permissionMode ?? null, model: overrides.model ?? launchMetadata?.model ?? null, - reasoningEffort: overrides.reasoningEffort ?? launchMetadata?.reasoningEffort ?? null, - fastMode: overrides.fastMode - ?? launchMetadata?.fastMode - ?? launchMetadata?.codexFastMode - ?? null, prompt: text, }; return process.platform === "win32" diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts index ac1616108..0c3fdc89e 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.test.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.test.ts @@ -214,7 +214,7 @@ describe("terminalSessionSignals", () => { targetKind: "session", targetId: "ses_1", launch: { permissionMode: "full-auto", fastMode: true }, - })).toBe("OPENCODE_CONFIG_CONTENT=\"{\\\"permission\\\":\\\"allow\\\"}\" opencode run --interactive --variant fast --session ses_1"); + })).toBe("OPENCODE_CONFIG_CONTENT=\"{\\\"permission\\\":\\\"allow\\\"}\" opencode --session ses_1"); }); it("applies resume-time model, reasoning, and permission overrides", () => { @@ -403,11 +403,10 @@ describe("terminalSessionSignals", () => { permissionMode: "edit", targetId: "ses_abc", model: "openai/gpt-5.4", - fastMode: true, prompt: "continue from here", }); - expect(command).toContain("opencode run --interactive --model \"openai/gpt-5.4\" --variant fast --session ses_abc --replay --replay-limit 40 -- \"continue from here\""); + expect(command).toContain("opencode --mini --model \"openai/gpt-5.4\" --session ses_abc --replay-limit 40 --prompt \"continue from here\""); expect(command).toContain("\\\"question\\\":\\\"allow\\\""); }); diff --git a/apps/desktop/src/main/utils/terminalSessionSignals.ts b/apps/desktop/src/main/utils/terminalSessionSignals.ts index c21657c33..d985dbccb 100644 --- a/apps/desktop/src/main/utils/terminalSessionSignals.ts +++ b/apps/desktop/src/main/utils/terminalSessionSignals.ts @@ -166,16 +166,12 @@ export function buildOpenCodeReplayResumeCommand(args: { permissionMode: AgentChatPermissionMode | null | undefined; targetId: string | null; model?: string | null; - reasoningEffort?: string | null; - fastMode?: boolean | null; prompt: string; replayLimit?: number | null; }): string { return buildCanonicalOpenCodeReplayResumeCommand({ permissionMode: args.permissionMode, model: args.model, - reasoningEffort: args.reasoningEffort, - fastMode: args.fastMode, prompt: args.prompt, resumeTarget: args.targetId, continueLast: !args.targetId, diff --git a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts index 71f220879..de019e898 100644 --- a/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts +++ b/apps/desktop/src/renderer/components/terminals/cliLaunch.test.ts @@ -1144,21 +1144,23 @@ describe("buildTrackedCliStartupCommand", () => { initialPrompt: "Use OpenCode fast mode.", }); expect(launch.command).toBe("opencode"); + // The root TUI is the only launch surface now: `run --interactive`'s + // bare split-footer read as "a plain terminal with no UI", and the root + // command has no --variant flag to branch on. expect(launch.args).toEqual(expect.arrayContaining([ - "run", - "--interactive", "--model", "openai/gpt-5.4", - "--variant", - "fast", ])); - expect(launch.args).not.toEqual(expect.arrayContaining(["--prompt"])); - expect(launch.startupCommand).toContain("opencode run --interactive"); - expect(launch.startupCommand).toContain("--variant fast"); + expect(launch.args).not.toContain("--variant"); + // The root command takes the kickoff through --prompt (the positional + // slot belongs to the project path); the prompt value carries ADE's + // session-guidance preamble with the user's text inside it. + expect(launch.args).toContain("--prompt"); + expect(launch.startupCommand).not.toContain("run --interactive"); expect(launch.startupCommand).toContain("Use OpenCode fast mode."); }); - it("launches OpenCode reasoning through the interactive run variant flag when fast is off", () => { + it("keeps reasoning effort out of OpenCode CLI launches (root TUI has no --variant)", () => { const launch = buildTrackedCliLaunchCommand({ provider: "opencode", permissionMode: "full-auto", @@ -1167,13 +1169,10 @@ describe("buildTrackedCliStartupCommand", () => { fastMode: false, initialPrompt: "Use OpenCode high reasoning.", }); - expect(launch.args).toEqual(expect.arrayContaining([ - "run", - "--interactive", - "--variant", - "high", - ])); - expect(launch.args).not.toEqual(expect.arrayContaining(["--variant", "fast"])); + // The root command silently drops unknown args, so passing --variant + // there would be a lie; reasoning effort stays a chat-runtime feature. + expect(launch.args).not.toContain("--variant"); + expect(launch.startupCommand).not.toContain("run --interactive"); }); it("normalizes ADE OpenCode registry model ids before launching the CLI", () => { @@ -1290,12 +1289,15 @@ describe("tracked CLI resume helpers", () => { }); expect(replay.command).toBe("opencode"); expect(replay.args).toEqual(expect.arrayContaining([ + "--mini", "--session", "ses_99", - "--replay", - "--", + "--replay-limit", + "40", + "--prompt", prompt, ])); + expect(replay.args).not.toContain("--replay"); expect(replay.env?.OPENCODE_CONFIG_CONTENT).toBeTruthy(); }); @@ -1565,21 +1567,20 @@ describe("tracked CLI resume helpers", () => { targetKind: "session", targetId: "ses_99", launch: { permissionMode: "plan", model: "opencode/openai/gpt-5.4", fastMode: true }, - })).toContain("opencode run --interactive --agent plan --model \"openai/gpt-5.4\" --variant fast --session ses_99"); + })).toContain("opencode --agent plan --model \"openai/gpt-5.4\" --session ses_99"); }); it("builds OpenCode interactive replay resume commands for freeze-frame continuation", () => { const command = buildOpenCodeReplayResumeCommand({ permissionMode: "plan", model: `opencode/openai/${encodeURIComponent("gpt-5.4")}`, - fastMode: true, resumeTarget: "ses_99", prompt: "continue from the snapshot", replayLimit: 12, }); expect(command).toContain("OPENCODE_CONFIG_CONTENT="); - expect(command).toContain("opencode run --interactive --agent plan --model \"openai/gpt-5.4\" --variant fast --session ses_99 --replay --replay-limit 12 --"); + expect(command).toContain("opencode --mini --agent plan --model \"openai/gpt-5.4\" --session ses_99 --replay-limit 12 --prompt"); expect(command).toContain("continue from the snapshot"); expect(command).toContain("\\\"question\\\":\\\"allow\\\""); }); diff --git a/apps/desktop/src/shared/cliLaunch.ts b/apps/desktop/src/shared/cliLaunch.ts index 7a487234d..3a318f353 100644 --- a/apps/desktop/src/shared/cliLaunch.ts +++ b/apps/desktop/src/shared/cliLaunch.ts @@ -830,8 +830,6 @@ export function buildTrackedCliLaunchCommand(args: { const opencode = buildOpenCodeCommandParts({ permissionMode, model: args.model, - reasoningEffort: args.reasoningEffort, - fastMode: args.fastMode, prompt: workTabCliPrompt(initialPrompt, skillRoots), }); const opencodeEnv = withAdeAgentSkillEnv(opencode.env, skillRoots); @@ -1183,42 +1181,44 @@ function normalizeOpenCodeCliModel(model: string | null | undefined): string | n return `${decoded.openCodeProviderId}/${decoded.openCodeModelId}`; } -function openCodeVariantForLaunch(args: { - reasoningEffort?: string | null; - fastMode?: boolean | null; -}): string | null { - // Fast mode takes priority: when enabled, the "fast" variant supersedes any reasoningEffort variant. - if (args.fastMode === true) return "fast"; - return normalizeCliFlagValue(args.reasoningEffort); -} - -function buildOpenCodeCommandParts(args: { +/// Shared OpenCode launch-argument core: permission agent, model, and the +/// resume/continue selector. Both the fresh-launch builder (root TUI) and the +/// replay-resume builder (`--mini`) wrap this so flag assembly cannot drift — +/// the old pair diverged once already (`--` positional vs `--prompt`). +function openCodeCoreCommandArgs(args: { permissionMode: AgentChatPermissionMode | null | undefined; model?: string | null; - reasoningEffort?: string | null; - fastMode?: boolean | null; - prompt?: string; resumeTarget?: string | null; continueLast?: boolean; -}): { args: string[]; startupCommand: string; env?: Record } { - const variant = openCodeVariantForLaunch(args); +}): string[] { const commandArgs = [ - ...(variant ? ["run", "--interactive"] : []), ...permissionModeToOpenCodeArgs(args.permissionMode), ]; commandArgs.push(...modelToCliFlag(normalizeOpenCodeCliModel(args.model))); - if (variant) commandArgs.push("--variant", variant); if (args.resumeTarget) { commandArgs.push("--session", args.resumeTarget); } else if (args.continueLast) { commandArgs.push("--continue"); } + return commandArgs; +} + +function buildOpenCodeCommandParts(args: { + permissionMode: AgentChatPermissionMode | null | undefined; + model?: string | null; + prompt?: string; + resumeTarget?: string | null; + continueLast?: boolean; +}): { args: string[]; startupCommand: string; env?: Record } { + // Always launch the full root TUI. The old shape branched into + // `opencode run --interactive` whenever a reasoning variant was set, which is + // OpenCode's bare split-footer mode — users read it as "a plain terminal with + // no UI". The root command has no --variant flag (it silently drops unknown + // args), so variants remain a chat-runtime feature; CLI launches keep the + // model and permission agent only. + const commandArgs = openCodeCoreCommandArgs(args); if (args.prompt) { - if (variant) { - commandArgs.push("--", args.prompt); - } else { - commandArgs.push("--prompt", args.prompt); - } + commandArgs.push("--prompt", args.prompt); } const config = openCodeConfigEnv(args.permissionMode); return { @@ -1233,8 +1233,6 @@ export const OPENCODE_RESUME_REPLAY_LIMIT = 40; type OpenCodeReplayResumeArgs = { permissionMode: AgentChatPermissionMode | null | undefined; model?: string | null; - reasoningEffort?: string | null; - fastMode?: boolean | null; prompt: string; resumeTarget?: string | null; continueLast?: boolean; @@ -1244,24 +1242,20 @@ type OpenCodeReplayResumeArgs = { export function buildOpenCodeReplayResumeLaunchCommand( args: OpenCodeReplayResumeArgs, ): TrackedCliLaunchCommand { - const variant = openCodeVariantForLaunch(args); + // Mini mode replays the newest messages on resume by default (upstream made + // an explicit --replay flag an error); --replay-limit caps how far back the + // freeze-frame reaches. const commandArgs = [ - "run", - "--interactive", - ...permissionModeToOpenCodeArgs(args.permissionMode), - ...modelToCliFlag(normalizeOpenCodeCliModel(args.model)), + "--mini", + ...openCodeCoreCommandArgs(args), ]; - if (variant) commandArgs.push("--variant", variant); - if (args.resumeTarget) { - commandArgs.push("--session", args.resumeTarget); - } else if (args.continueLast) { - commandArgs.push("--continue"); - } - commandArgs.push("--replay"); const replayLimit = Number.isFinite(args.replayLimit) ? Math.max(1, Math.floor(Number(args.replayLimit))) : OPENCODE_RESUME_REPLAY_LIMIT; - commandArgs.push("--replay-limit", String(replayLimit), "--", args.prompt); + commandArgs.push("--replay-limit", String(replayLimit)); + if (args.prompt) { + commandArgs.push("--prompt", args.prompt); + } const config = openCodeConfigEnv(args.permissionMode); return { command: "opencode", @@ -1456,8 +1450,6 @@ export function buildTrackedCliResumeLaunchCommand( const opencode = buildOpenCodeCommandParts({ permissionMode, model, - reasoningEffort, - fastMode, ...(prompt ? { prompt } : {}), resumeTarget: targetId || null, continueLast: !targetId, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index a951ed23b..facb7517b 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -22,7 +22,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/renderer/components/chat/CrossMachineHandoffModal.tsx`, `crossMachineHandoffPresentation.tsx` | **Send to machine** workflow in the Handoff tab: source Git readiness, eligible connected-machine selection, brief or full-history fork selection, the destination chat's model / reasoning effort / fast mode / permission mode (the shared `PermissionModePicker` and `ReasoningEffortPicker`, each self-hiding when the chosen model can't honor it), optional continuation note, destination project matching or confirmed clone, storage/auth/model/commit/lane checks, a **Fetch & fast-forward there** offer when the destination lane is clean and a strict ancestor of the source commit, transport disclosure, route-pinned final send, and recoverable source-marker completion. Source blockers are `BlockedActionReason` values rendered next to a `BlockedActionButton`, so no blocker can hide behind a disabled control. The modal takes a `runtimePin` naming the machine the **source** chat runs on (`null` = this tab's bound machine) and pins every source-side call to it — lane list, `git.getSyncStatus`, `git.getOriginRemote`, `git.push`, `git.pull`, `agentChat.prepareCrossMachineHandoff`, `validateCrossMachineSource`, `markCrossMachineHandoff` — while destination dispatch keeps routing by target id. The pin lives in a ref and is frozen once per operation, so every await inside one handoff reaches the same runtime; reading it fresh after an await could cross a lane-index change and split one handoff across two machines. Eligibility follows the same rule: the Handoff menu offers the cross-machine card based on the chat's own binding (`isRemoteChat`), so a local chat viewed from a remote-bound tab can still hand off, and a chat pinned to a remote machine cannot. `crossMachineHandoffPresentation.tsx` holds the pure half — stage/mode types, `SourceCheck`, branch/route/readiness copy, permission tone and icon maps, and `CheckRow` — so the copy and lookups that shipped wrong are directly testable. Cross-machine fork transports provider-native history for Claude, Codex, and OpenCode; Cursor and Droid use brief mode because their histories are not portable between machines (Droid's session index is machine-local, and Cursor's local fork is ADE-side context seeding that produces no provider artifact to send). A fork that can't be completed always degrades to a one-click brief rather than a dead end: an older destination that omits `forkHandoffSupport`, a history over the transport cap, or an unforkable provider file (e.g. a Codex `.zst` rollout) each surface a plain-language reason and a **send as brief** action that re-runs prepare + preflight in brief mode. The insecure-route consent line is fork-aware — a fork discloses that the full chat history is sent exactly as recorded, while a brief states only the summary is sent, never secrets. See [Cross-machine session handoff](../sync-and-multi-device/cross-machine-session-handoff.md). | | `apps/desktop/src/shared/crossMachineHandoff.ts` and `apps/desktop/src/shared/types/chat.ts` | Renderer-safe Git-origin normalization, portable remote sanitization, untrusted remote-response decoders, and the versioned capsule/preflight/accept DTOs shared across renderer, preload, Electron main, and the ADE runtime. `chat.ts` also owns the fork-handoff contract: `HANDOFF_FORK_PROVIDERS` (`claude`, `codex`, `opencode`, `droid`, `cursor`) + `providerSupportsHandoffFork()`, the companion `providerForkReplaysTranscript()` (true only for Cursor, whose fork is an ADE-side full-transcript replay onto a brand-new agent rather than a native provider fork, so UI copy must not promise a copied provider thread — it promises the conversation, bounded by the target model's context window), `AgentChatHandoffArgs.targetLaneId` (brief may retarget any lane in the project; fork must stay in the source lane), the cross-machine capsule's optional `mode: "brief" \| "fork"` with `forkTransport` (provider-native session files) and `transcriptEnvelopes` (gzipped ADE JSONL), and the preflight's optional `forkHandoffSupport` (absent = older destination the source must treat as fork-unsupported, so a fork never silently downgrades to a brief). Cross-machine fork has its own narrower list: `CROSS_MACHINE_HANDOFF_FORK_PROVIDERS` + `providerSupportsCrossMachineHandoffFork()`, derived from `HANDOFF_FORK_PROVIDERS` by filtering out Droid (its session index is machine-local) and every replay-forked provider (Cursor produces no transportable artifact at all), so the two lists cannot drift. `validateForkTransport` gates inbound capsules on the cross-machine helper rather than the local one, so a provider whose fork has nothing to package is refused by the provider check instead of by the transport-kind allowlist. The preflight also carries an optional `laneFastForward` (`laneId`, `laneName`, `behindBy`) — the destination's own assertion that its existing lane is clean and a strict ancestor of the source commit. `decodeCrossMachineDestinationPreflightResult` decodes `forkHandoffSupport` and `laneFastForward` only when present, and rejects a `behindBy` that is not a positive integer because the destination refuses a zero-distance fast-forward. `chat.ts` also owns `ACTIVE_TURN_DISPATCH_MODES` — THE per-provider active-turn delivery matrix, in menu order with the first entry as the provider's default (`claude`: `inline`, `queue`, `interrupt`; `cursor`: `interrupt`, `queue`; everything else queue-only) — read through `activeTurnDispatchModes()`, `defaultActiveTurnDispatchMode()` and `supportsActiveTurnDispatchMode()`, with the companion facts `activeTurnInterruptContinues()` (true only for Cursor, whose interrupt cancels and resends on the same thread instead of folding into the live query, so the affordance says "continue") and `unsupportedActiveTurnDispatchModeMessage()` (the one rejection string, templated off the table). Every surface reads it rather than restating the rules — the composer's split send button, the chat pane's dispatch wiring, `agentChatService`'s steer/dispatch guards, and the `ade code` TUI's `/steer` commands; iOS mirrors it by hand in `WorkActiveSendCapability` because it cannot import TS. `chat.ts` is also the canonical cross-client contract for context-usage state/sample metadata, Claude result provenance/error/correlation fields, queue-aware interrupt results, the bounded `queue_recovery` lifecycle, and the desktop prompt-stash DTOs plus `MAX_PROMPT_STASHES`. | | `apps/desktop/src/main/services/chat/crossMachineForkTransport.ts` | Node-only fork-transport plumbing shared by the source packaging and destination materialization paths. Owns the uncompressed limits (18 MiB provider main session file, 4 MiB total Claude sidecars, 3 MiB ADE transcript envelopes), the independent base64 bounds that reject oversized input before decoding, and `CROSS_MACHINE_FORK_ENCODED_BUDGET_BYTES` (20 MiB) — a whole-capsule encoded budget kept under the 25 MiB sync-envelope/WebSocket payload caps. `gzipToBase64` / `gunzipFromBase64` (the latter enforces a max output length) do the compression; `enforceCrossMachineForkEncodedBudget` drops the sidecar group first and only throws a "too large, send a brief" error when the main file plus transcript alone blow the budget; `crossMachineForkOversizeError` returns the typed `CROSS_MACHINE_FORK_OVERSIZE` failure; `runCliCapture` buffers `opencode export` / `import` stdout/stderr with a timeout; and `validateForkTransport` re-validates a received capsule's transport (cross-machine fork provider support, provider match, kind allowlist, base64 shape, path-traversal-safe side-file paths, per-file and total size caps) before any decode. It gates on `providerSupportsCrossMachineHandoffFork`, not the local-fork predicate, so a provider whose fork produces no transportable artifact (Droid's machine-local index, Cursor's context-only reseed) is refused by the provider check rather than incidentally by the kind allowlist. | -| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. Lane naming and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` → the model the chat was launched with → a model from another provider → a sibling on the leading provider), and only then fall back to a deterministic prompt-derived title/slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | +| `apps/desktop/src/main/services/chat/agentChatService.ts` | Main service: session lifecycle, external chat import orchestration (`importExternalChatSession` for Claude/Codex sessions discovered by the external-session service), turn dispatch, event emission, provider adapters, steer queue, handoff, auto-title, prompt-derived lane-name suggestions for auto-created / parallel lanes, event-history snapshots, durable chat transcript replay/storage compaction, slash-command discovery/merge (delegates to per-provider discovery modules and `slashCommandPromptExpansion` for unified prompt expansion), and active-workload detection used by project/window close guards. Codex non-retrying app-server failures are deduplicated by turn plus semantic error identity across the early `error` notification and terminal `turn/completed`; retrying notifications (`willRetry: true`) remain provider-health notices while the turn stays active. OpenCode stream rendering gates every rendered content type on the assistant message role: `message.part.updated` events carry no role and user-message parts (including synthetic/ignored prompt context) ride the same event stream as assistant output, so text/reasoning deltas emit only for parts whose message id `message.updated` announced as `assistant` (`openCodeMessageRoleById` — unknown ids stay unrendered because OpenCode announces every message before its parts), synthetic/ignored parts are skipped outright, and image `file` parts still emit only for assistant-owned messages. Lane naming and chat auto-titling both run through the session-intelligence prompt path over the shared candidate chain in `sessionNaming.ts` (configured `titleModelId` → the model the chat was launched with → a model from another provider → a sibling on the leading provider), and only then fall back to a deterministic prompt-derived title/slug; branch uniqueness is handled by the lane id suffix added by lane creation. Tracks Fast Mode with the legacy `codexFastMode: boolean` session field for every provider whose descriptor advertises `serviceTiers: ["fast"]`; Codex forwards it as `serviceTier: "fast" \| null` on every `thread/start` and `turn/start` JSON-RPC call, while Cursor SDK sessions resolve it through discovered model parameters (see [Agent Routing](agent-routing.md#provider-service-tiers-fast-mode)). Codex chat goals are managed through the app-server `thread/goal/get` / `set` / `clear` RPCs, persisted in session summaries, validated to the provider's 4,000-character objective limit, and normalized to ADE's unlimited-budget policy by sending `tokenBudget: null` and clearing provider-reported budgets. `applyCodexEffectiveThreadState` accepts a `requestedCodexPolicy` option and uses `shouldPreserveRequestedCodexPolicy` to keep ADE-controlled picker selections authoritative when the lifecycle response echoes an older thread policy (prevents a manual Plan→Edit switch from snapping back); it also syncs the abstract `permissionMode` via `syncLegacyPermissionMode` after every policy application. Whenever an `updateSession` touches any permission/interaction/mode field, the service also emits a transient `session_meta_updated` chat event carrying the recomputed mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, and the `cursorModeSnapshot`) so any other client viewing the same session — a desktop refreshing a session an iOS device just re-moded, or vice versa — updates its composer controls live. It is a direct state patch, emitted after the Cursor policy sync so `cursorModeSnapshot` reflects the recomputed mode, and is kept off the session-list refresh path. Builds ADE guidance from the active lane worktree so Agent Skill roots are lane-scoped in persistent system/developer prompts and provider fallback injection. `buildAgentRuntimeEnv(managed)` stamps every SDK-backed provider process with `ADE_CHAT_SESSION_ID`, `ADE_DEFAULT_ROLE=agent` (or `orchestrator` for a lead), `ADE_LANE_ID`, `ADE_PROJECT_ROOT`, and `ADE_WORKSPACE_ROOT`; the persistent guidance also names the concrete `--session ` argument for status commands so shared SDK servers do not depend on process-global env inheritance. `dismissPendingInputForSettlement` is the provider-neutral quieting boundary used by **Dismiss & settle**: it interrupts live Claude/Codex/OpenCode/Cursor/Droid turns best-effort, cancels local/provider waiters, removes Codex plan follow-ups, emits pending-input resolution, and persists an idle session before settle is written. When the session has Linear issues attached (`session_linear_issues`), `buildAgentRuntimeEnv` also materializes them into a per-session context file via `writeSessionLinearIssueContextFile` (`//linear-issues.json`, written atomically; stale files cleared when nothing is attached) and sets `ADE_LINEAR_ISSUE_IDS` (comma-joined identifiers) + `ADE_LINEAR_CONTEXT_FILE` so the agent reads its issue context without Linear credentials. Attaching a `linear_issue` context attachment at run time calls `laneService.attachLinearIssueToSession({ chatSessionId, issues, role: "worked", source: "chat_attach", includeInPr: true })` so the link is persisted even for standalone (laneless) chats; when the session has a lane it additionally runs `laneService.linkLinearIssues` for the lane/PR-card semantics. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). Claude SDK sessions also resolve the executable through `claudeCodeExecutable.ts` and pass `pathToClaudeCodeExecutable` so packaged builds can prefer the bundled native binary before PATH/auth fallbacks; interrupted Claude turns stop active subagents before emitting stopped `subagent_result`s, and every `subagent_result` is gated on a previously emitted `subagent_started` (tracked in `emittedSubagentStartIds`) so an interrupt can never emit a phantom stopped card for a subagent that never announced — terminal events clear both the taskId and agentId aliases. A plain Claude Code task run (`task_type` `other`, no agent metadata — e.g. "Re-run affected test files") is tracked for cleanup but never surfaces subagent rows. Claude resume paths run `claudeThinkingTranscriptRepair` before loading a transcript, and the runtime self-heals the same corruption after the Anthropic thinking-block 400 error. Plan-mode transitions run through `claudePlanMode.ts` and emit a plan-mode notice carrying the resulting access mode, so the renderer composer chip updates from an authoritative value even when the session refresh races with compaction. Cursor SDK setup records interrupts that arrive while the worker is still being acquired, releases the acquired generation if setup loses the race, and suppresses false provider-health failures for user-initiated setup interrupts. Every local Cursor turn is guarded by a 90 s first-event watchdog and at most one automatic recycle-and-resend (see [Cursor thread recycling and the first-event watchdog](#cursor-thread-recycling-and-the-first-event-watchdog)); an expired Cursor access token recycles the worker while resuming the *same* agent id, so the recovery is silent and the thread survives. Queued-steer settlement is claim-based: `settledSteerIds` is a per-session `WeakMap` of steer ids that have already had a delivered-or-cancelled notice emitted, claimed by every emitter that resolves a steer and re-opened whenever a steer goes back on the queue, so a runtime swap that detaches a queue the delivery attempt also drains cannot render two contradictory notices for one message. Cursor provider slash commands use a dedicated discovery path (`cursorSlashCommandDiscovery`) instead of falling through to the generic filesystem-backed list. Claude query startup is single-flight: concurrent `ensureClaudeQuery` callers latch onto one in-flight `queryStartPromise`, and a per-runtime `queryGeneration` token aborts and reaps a start that a reset or interrupt superseded, so a resumed session never spawns twin subprocesses; both reset and interrupt reap the SDK subprocess through `claudeSubprocessReaper` because a closed `query()` still leaves a live `claude --resume` child. `run_in_background` shell tasks (SDK `task_type` `local_bash`/`background`) survive turn boundaries — the query stays alive across turns and delivers their real completion — so interrupt, reset/dispose, a native subagent exit, or a host-restart rebind settle them as stopped; a reset that orphans still-open background tasks emits one `system_notice` that they were stopped without reporting completion, and background-task titles are sticky (the first spawn description is reused through the terminal row). A durable per-`(SDK message id, content index)` emitted-text record keeps a re-delivered assistant snapshot (after a stream-dedup reset from steer, message interleave, or idle handoff) from doubling the transcript. Claude `TaskCreate`/`TaskUpdate` tracking keys creates by tool-use id and remaps the harness's ordinal task id onto the Nth created task; an update for an id it cannot resolve or describe changes nothing rather than fabricating a todo row. `steer()` returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`); reasoning effort is normalized and applied at steer delivery, and an active Claude `interrupt-replace` uses SDK priority `now` without tearing down the query or its background work. When a spawned child chat ends, `reportChildSpawnEnded` reports its outcome to the spawner according to the child's `spawnKind`; an active Claude parent receives SDK `priority: "next"` delivery, an active Codex parent receives `turn/steer`, and idle or provider-fallback parents receive the normal message path, while scheduled work remains boundary-delivered (see [Spawn types and completion reporting](#spawn-types-and-completion-reporting)). Spawned agents also inherit `ADE_PARENT_CHAT_SESSION_ID` / `ADE_SPAWN_KIND` and a subagent self-report guidance line. Fork/import history seeding (`appendImportedChatEvents`) is chunked with event-loop yields, defers transcript flushes to chunk boundaries, and never publishes seeded historical envelopes to live event subscribers — readers load them via history APIs; live-publishing an entire source chat froze the app during fork handoff (ADE-122). The `chat.handoffSession` / `chat.prepareCrossMachineHandoff` runtime actions carry extended timeouts (120s daemon action, 150s IPC) because a brief handoff spans AI-brief generation plus first-message dispatch — the old 30s default fired a false timeout while the daemon-side handoff completed anyway. For orchestrator-lead sessions it builds the read-only capability services (`buildOrchestrationLeadReadServices` → `searchWorkspace` / `readLinearIssue` / `readPr` / `listProofArtifacts` / `mintDeeplink`), wiring each only when the backing service exists so a null service degrades to an omitted tool rather than a crash. Large service file. | | `apps/desktop/src/main/services/chat/chatRuntimeBudget.ts` | The process-wide warm-runtime budget. Owns `MAX_CONCURRENT_ACTIVE_RUNTIMES` (5) and `createChatRuntimeBudget()`, which chat services register with as `RuntimeBudgetParticipant`s (`countActiveRuntimes` + `listEvictableRuntimes`). `enforce(excludeSessionId)` releases at most one runtime per call — the globally least-recently-used releasable one across every registered participant — and yields when nothing is releasable. Constructed once per host (`main.ts`, `bootstrap.ts`) and passed to every project scope's `createAgentChatService`; a service constructed without one gets a private budget, which is the old per-service behaviour and the right answer for tests. Deliberately dependency-free of any runtime type so the LRU choice is testable without standing up a chat service. See [Session lifecycle](#session-lifecycle) below. | | `apps/desktop/src/main/services/chat/sessionNaming.ts` | Canonical home for everything the three naming callers share — automatic lane identity, chat auto-title, and the legacy lane-name suggestion — because each used to carry its own hand-copied chain that had already drifted. Owns the three system prompts and the lane-identity JSON schema, `MAX_NAMING_WORDS` (six words, handed to the model as a **guideline**: an over-long answer is clamped, never rejected, because a clamped real name beats a slug), `isProviderLevelNamingFailure` (a missing/unusable CLI, auth, quota, or an account that cannot run the model — including the "model is not supported when using X with a Y account" 400; it deliberately excludes "not supported for/on/by", which describes one model lacking a capability and must still retry a sibling), `buildNamingModelCandidates` (preferred ids → a model from a provider none of them belong to → a sibling on the leading provider, so a cross-provider candidate is always reachable), and `runNamingAcrossProviders` (walks the chain up to three attempts; a provider-level failure condemns every remaining model behind that provider, `run` returning null means "answered unusably" and the next candidate still gets a turn, and `shouldStop` abandons the chain when the user renames mid-flight). | | `apps/desktop/src/main/services/chat/spawnMissionOwnership.ts` | The single statement of who a spawned child chat is currently working for, so the policy is written and tested in one place instead of inline in `reportChildSpawnEnded`. Wake vs quiet is the child's persisted `spawnKind` (`subagent` always wakes; `peer` never does). `isHumanChildMessage` / `countHumanChildMessagesForTurn` / `formatHumanChildMessageAnnotation` name how many human messages landed in a finished turn so the next subagent wake can say `The user also sent N message(s) to this chat.` Parent dispatches, scheduled wakes, relays, host continuations, and any orchestration origin are not human messages. `HOST_AUTHORED_MESSAGE_PROVENANCE_KEYS` / `stripHostAuthoredMessageProvenance` export the same key list to every untrusted entry point (the ADE RPC edge, the automation action bridge) so provenance is always what the host observed, never what a caller asserted. | @@ -91,6 +91,7 @@ for its separate RPC, sync, storage, and UI contracts. | `apps/desktop/src/main/services/ai/piAuthService.ts` | In-app Pi sign-in on a dedicated inventory-only worker: provider enumeration, one flow per provider, prompt/notice fan-out through `addPiAuthStatusListener`, prompt answers, cancellation, and a 10-minute bound. A user-pressed cancel gives Pi `PI_LOGIN_CANCEL_GRACE_MS` to report a login it had already completed; a supersede (a replacement attempt) settles the outgoing flow at once and silently, so it cannot clear the card the newer attempt owns. Relays credentials, never retains them. | | `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 generic `opus` to Opus 5 with its `high` default reasoning effort and Fast capability; retired Sonnet 4.6 / basic Opus 4.7 ids still resolve to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. **Cross-launch persistence:** `persistOpenCodeInventory(projectRoot, providers)` writes each successful probe's provider list (keyed by project root, with `savedAt`) to `opencode-inventory-cache.json` under Electron `userData` (override via `ADE_OPENCODE_INVENTORY_CACHE_FILE`); on a cold start the Settings page reloads that persisted list flagged stale (`opencodeProvidersStale`) so the ~160-provider chip cloud renders immediately instead of blanking until the first live probe (stale-while-revalidate). Writes are best-effort and never break the probe. | +| `apps/desktop/src/main/services/opencode/openCodeRuntime.ts` | OpenCode server session runtime: session handles over shared/dedicated server leases, the `buildOpenCodeConfig` / `OPENCODE_CONFIG_CONTENT` permission config (`OpenCodePermissionKey`), prompt assembly (`buildOpenCodePromptParts`), and event-stream helpers. Two contracts are load-bearing. **Re-attach is 404-gated:** when a persisted session id fails `session.get`, only a confirmed "session does not exist" (`isOpenCodeNotFoundError`, HTTP 404 / `NotFoundError`, walking a bounded chain of `cause`/`body`/`error`/`data` wrapper shapes with any explicit non-404 status sealing the walk) may fall through to fresh-session creation; any other failure closes the server lease and surfaces, because silently starting an empty session would strand the user's thread. **System prompts ride the prompt body:** ADE's system prompt travels on the prompt body's first-class `system` field and is never injected as a synthetic/ignored text part — OpenCode drops `ignored` parts from model context entirely, so a part-shaped transport silently never reaches the model. | | `apps/desktop/src/main/services/opencode/openCodeAuthService.ts` | Drives the managed OpenCode server's auth API for subscription connect + API-key seeding, reusing the shared inventory server lease (never spawning its own process). `listAuthMethods` reads `GET /provider/auth`; `startOAuth` authorizes (`POST /provider/{id}/oauth/authorize`), opens the returned URL, and polls `provider.list().connected` every 2s until connected or a 5-min timeout, re-probing inventory on success; `cancelOAuth` stops the poller; `setProviderKey` does `PUT /auth/{id}` and mirrors the key into ADE's `apiKeyStore` so it is re-injected on future launches. One flow per `providerId` at a time (a new start supersedes the prior). Transitions are published through `addOpenCodeOAuthStatusListener` (`pending`/`connected`/`cancelled`/`timeout`/`failed`), a multi-sink fan-out so the same event reaches desktop windows and the remote/web runtime event buffer. Seeded credentials land in ADE's isolated managed OpenCode dir (XDG roots under `userData/opencode-runtime/xdg-v*`), never the user's `~/.local/share/opencode`. | | `apps/desktop/src/shared/chatTranscript.ts` | Pure JSON-lines parser for `AgentChatEventEnvelope` values. Used by both the main process and the renderer. | | `apps/desktop/src/shared/chatEventCompaction.ts` | The single compaction policy for heavy chat-event payloads, owned by the two consumers that must never disagree: the stored transcript (`compactChatEventForStorage`, called by `agentChatService`) and the mobile/web sync wire (`compactChatEventForWire`, called by the sync host's `compactChatEventEnvelopeForSync`). It owns the whole cap table — command output (4 KB running / 16 KB completed / 64 KB failed), `tool_result.result` (16 KB, 64 KB when failed or interrupted), `tool_result.structured` (8 KB), file diffs (32 KB), reasoning text (8 KB), and inline `data:image/*` URIs (64 KB) — plus `compactRunningCommandOutput`, exported as a whole operation so no caller re-derives the label/budget pairing. Shortened payloads keep original/omitted byte counts on the event (`outputOriginalBytes`, `resultOmittedBytes`, `diffOmittedBytes`, `textOmittedBytes`, `urlOmittedBytes`, …). The wire variant runs storage compaction first, then drops `structured` and `toolResultMeta` from `tool_result` entirely — no renderer, TUI, web, or iOS client decodes either field, so removing them needs no capability gate. | @@ -1895,6 +1896,21 @@ Provider connection management lives on the `ade.ai.*` surface (handled in `regi must never throw into the probe path. The in-memory `persistedInventoryMemo` and the `peekOpenCodeInventoryCache` passive-read cache are distinct; clearing one does not clear the other. +- **OpenCode runtime contracts: prompt-body system prompts, 404-gated + re-attach, and role-gated rendering.** Three traps share one runtime + boundary (`openCodeRuntime.ts` plus the service's OpenCode event pump). + ADE's system prompt goes to OpenCode on the prompt body's `system` field — + never as a `synthetic`/`ignored` text part, which OpenCode drops from model + context entirely, so the part-shaped injection silently never reached the + model. When a chat re-attaches to its persisted session id, only a confirmed + 404 (`isOpenCodeNotFoundError`) may fall through to fresh-session creation; + transport blips, timeouts, and server-restart failures must surface instead + of resetting the thread into an empty session. And because + `message.part.updated` carries no message role, the service keys every seen + message id on `message.updated` and renders text/reasoning/file parts only + when that map says `assistant` — user-message parts (including synthetic or + ignored prompt context) stream through the same channel and would otherwise + echo into the transcript as assistant bubbles. - **New `ai.*` config fields must be added to BOTH `coerceAiConfig` and `mergeAiConfig`.** In `projectConfigService.ts`, `coerceAiConfig` validates/parses a config field off disk and `mergeAiConfig` folds the diff --git a/docs/features/chat/agent-routing.md b/docs/features/chat/agent-routing.md index be94682ca..fd22e2c16 100644 --- a/docs/features/chat/agent-routing.md +++ b/docs/features/chat/agent-routing.md @@ -13,7 +13,7 @@ where the machinery lives. | `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/shared/cliLaunch.ts` | Tracked provider CLI start/resume builders, including model/reasoning/permission flags and the canonical `computer_use` MCP overrides for Codex. Reasoning/fast variants are per-provider: Claude/Codex/Droid/Pi keep their flags, but tracked OpenCode launches always run the root TUI (`opencode [-m model] [--agent plan] [--prompt …]`) — no `run --interactive` branch and no `--variant`, because the root command silently drops unknown args; variants remain a chat-runtime feature. | | `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/shared/providerConfigHomes.ts` | Where each provider CLI keeps its user-level config (`claudeConfigHome`, `codexConfigHome`, `factoryConfigHome`), and the canonical statement of the config-ownership rule below. Every adapter that reads or writes a provider config path goes through it. | @@ -422,6 +422,15 @@ not agents the user should meet in Tab-cycle or `@`-autocomplete; without a `mode` they default to `"all"` and show up in the picker. `ade-helper` uses `steps: 1` (`maxSteps` is the deprecated spelling). +ADE's system prompt is neither config nor a message part: every turn carries it +on the chat request body's first-class `system` field. It must never be injected +as a `synthetic`/`ignored` text part — OpenCode drops `ignored` parts from model +context entirely, so a part-shaped injection silently never reaches the model. +Session re-attach follows the same fail-loud spirit: when a persisted session id +fails `session.get`, only a confirmed 404 (`isOpenCodeNotFoundError`) falls +through to fresh-session creation; any other error surfaces rather than +silently resetting the thread into a new empty session. + ## Permission modes Permission controls are provider-native. The session carries an abstract @@ -581,7 +590,9 @@ discovery populates `serviceTiers` from app-server-reported 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. +to the provider variant `fast` in chat prompt bodies; Work CLI launches +carry no variant at all — the root TUI has no `--variant` flag, so tracked +launches keep only the permission agent and model. Droid preserves Factory's concrete fast model IDs when they are reported, and its canonical Anthropic normalization also publishes `serviceTiers: ["fast"]` for fast-capable rows such as Opus 5. The former launch as the diff --git a/docs/features/terminals-and-sessions/README.md b/docs/features/terminals-and-sessions/README.md index 49f78f14c..ab9cea98e 100644 --- a/docs/features/terminals-and-sessions/README.md +++ b/docs/features/terminals-and-sessions/README.md @@ -1239,8 +1239,12 @@ Renderer surfaces: it waits for Cursor's interactive prompt and submits the ADE guidance plus user text through PTY input instead of argv. Droid materializes a temp `--settings` JSON keyed off the active - permission mode, and OpenCode passes its inline permission policy - through the `OPENCODE_CONFIG_CONTENT` env var. ADE session guidance is + permission mode, and OpenCode passes its inline permission policy + through the `OPENCODE_CONFIG_CONTENT` env var and always launches the + root TUI (`opencode [-m model] [--agent plan] [--prompt …]`) — tracked + launches have no `run --interactive` branch and no reasoning/fast + variant flag (the root command silently drops unknown args), so + variants remain a chat-runtime feature. ADE session guidance is injected on every launch with skill roots resolved from the active lane worktree when known: Claude gets `buildAdeCliAgentGuidance(...)` through `--append-system-prompt`; Codex, Droid, and OpenCode receive diff --git a/docs/features/terminals-and-sessions/pty-and-sessions.md b/docs/features/terminals-and-sessions/pty-and-sessions.md index 962cc8aef..0e17be49b 100644 --- a/docs/features/terminals-and-sessions/pty-and-sessions.md +++ b/docs/features/terminals-and-sessions/pty-and-sessions.md @@ -574,7 +574,11 @@ write paths into one call: Cursor is the exception: its continuation command stays prompt-free, then the text is submitted after the resumed CLI is input-ready. OpenCode uses its replay-resume command when the installed CLI - supports it. If the code has to reuse an already-started resume + supports it — root `--mini` with `--replay-limit` (an explicit + `--replay` flag is an upstream error), gated by probing root + `opencode --help` for both flags; without that support it falls back + to the plain root-TUI resume with the prompt embedded. If the code + has to reuse an already-started resume flight, it writes `text` after the PTY is attached. The return shape is `{ ptyId, sessionId, pid, session, resumed: true, reusedExistingRuntime: false }`. @@ -754,6 +758,13 @@ Strategies, in order: in the lane cwd. Sessions whose `directory` matches are scored by `created`/`updated` against `startedAt` with the same 10-minute drift window. The recovered id becomes `opencode --session `. + The directory match compares realpath-resolved, platform-folded + path keys on both sides (`fs.realpathSync` + `pathKey`): macOS hands + `/var/...` back from the CLI while session rows record the resolved + `/private/var/...` spelling, and Windows adds case drift, so raw + string equality made every lane under a symlinked tmp root miss its + own resume target. A directory that cannot be realpath-resolved + (deleted worktree) falls back to comparing the raw spelling. The Droid storage scan and the OpenCode `session list` invocation only fire on the `close` / `dispose` reasons and explicit on-demand diff --git a/docs/features/terminals-and-sessions/ui-surfaces.md b/docs/features/terminals-and-sessions/ui-surfaces.md index 0dbde53dd..8f4ef5adb 100644 --- a/docs/features/terminals-and-sessions/ui-surfaces.md +++ b/docs/features/terminals-and-sessions/ui-surfaces.md @@ -912,7 +912,12 @@ Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: - **OpenCode** → an inline JSON permission policy passed via the `OPENCODE_CONFIG_CONTENT` env var (`config-toml` mode skips the env so OpenCode reads `opencode.json` instead). Plan mode adds `--agent - plan`. + plan`. Fresh launches always start the full root TUI — + `opencode [-m model] [--agent plan] [--prompt ]`. + There is deliberately no `run --interactive` branch and no + reasoning/fast `--variant`: the root command silently drops unknown + args, so variants remain a chat-runtime feature and tracked launches + carry only the model and permission agent. Every provider also receives ADE CLI guidance — Claude through `--append-system-prompt`, Codex/Droid/OpenCode as a leading prompt argument, and Cursor through PTY `initialInput` only when there is an @@ -929,6 +934,17 @@ Launch commands are built by `apps/desktop/src/shared/cliLaunch.ts`: `prompt` override is used by `sendToSession` for the first ended-session follow-up; `resumeSession` rebuilds the same command without a prompt. +- `buildOpenCodeReplayResumeLaunchCommand` — OpenCode continuations that + carry the first follow-up prompt use this shape when the installed CLI + supports replay resume: root `--mini` mode replays the newest messages on + resume by default, and `--replay-limit ` (`OPENCODE_RESUME_REPLAY_LIMIT`, + 40) caps how far back the freeze-frame reaches; the prompt travels via + `--prompt`. An explicit `--replay` flag is an upstream error on current + OpenCode, so nothing sends one. The support gate probes root + `opencode --help` (not `run --help`) for both `--mini` and + `--replay-limit`, with an env override + (`ADE_OPENCODE_REPLAY_RESUME=1|0`) forcing either way; without support ADE + falls back to the plain root-TUI resume above. ## Context menu: `SessionContextMenu.tsx`