diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 30c81613d..82def88cd 100644 --- a/apps/ade-cli/package-lock.json +++ b/apps/ade-cli/package-lock.json @@ -8,7 +8,7 @@ "name": "ade-cli", "version": "0.0.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", @@ -96,22 +96,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "integrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -120,9 +120,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.201.tgz", - "integrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", + "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.201.tgz", - "integrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", + "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", "cpu": [ "x64" ], @@ -146,9 +146,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.201.tgz", - "integrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", + "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", "cpu": [ "arm64" ], @@ -162,9 +162,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.201.tgz", - "integrity": "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", + "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", "cpu": [ "arm64" ], @@ -178,9 +178,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.201.tgz", - "integrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", + "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", "cpu": [ "x64" ], @@ -194,9 +194,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.201.tgz", - "integrity": "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", + "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", "cpu": [ "x64" ], @@ -210,9 +210,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.201.tgz", - "integrity": "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", + "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.201.tgz", - "integrity": "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", + "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", "cpu": [ "x64" ], @@ -6656,66 +6656,66 @@ } }, "@anthropic-ai/claude-agent-sdk": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "integrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", "requires": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" } }, "@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.201.tgz", - "integrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", + "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.201.tgz", - "integrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", + "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.201.tgz", - "integrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", + "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.201.tgz", - "integrity": "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", + "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.201.tgz", - "integrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", + "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.201.tgz", - "integrity": "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", + "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.201.tgz", - "integrity": "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", + "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.201.tgz", - "integrity": "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", + "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", "optional": true }, "@anthropic-ai/sdk": { diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 33030595b..596c4a5f2 100644 --- a/apps/ade-cli/package.json +++ b/apps/ade-cli/package.json @@ -24,7 +24,7 @@ "test": "vitest run" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx index 34639a37b..29607c003 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/RightPane.test.tsx @@ -55,6 +55,7 @@ function chatInfo(overrides: Partial = {}): ChatInfoSnapshot { planExplanation: null, planStreamingText: null, todos: [], + scheduledWork: [], pr: null, resumableTerminal: false, ...overrides, @@ -254,6 +255,82 @@ describe("RightPane chat info", () => { expect(frame).toContain("/pr for details"); }); + it("renders scheduled Claude wakeups in chat info without mixing them into the subagent roster", () => { + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + + expect(frame).toContain("SCHEDULE"); + expect(frame).toContain("Check the nightly build"); + expect(frame).toContain("wakeup · scheduled"); + expect(frame).toContain("after CI starts"); + expect(frame).toContain("CHATS"); + expect(frame).toContain("no subagents yet"); + }); + + it("caps scheduled work rows in narrow chat info panes", () => { + const scheduledWork: ChatInfoSnapshot["scheduledWork"] = Array.from({ length: 7 }, (_, index) => { + const ordinal = String(index + 1).padStart(2, "0"); + return { + id: `wake-${ordinal}`, + kind: "wakeup", + status: "scheduled", + origin: "schedule_wakeup", + title: `Wakeup ${ordinal} with a very long title that must fit`, + summary: "A long summary that should be clipped before it can widen the pane", + reason: "scheduled review", + recurring: false, + durable: true, + createdAt: "2026-07-07T12:00:00.000Z", + updatedAt: `2026-07-07T12:0${index}:00.000Z`, + }; + }); + const result = render( + , + ); + const frame = stripAnsi(result.lastFrame() ?? ""); + const longestLine = Math.max(...frame.split("\n").map((line) => line.length)); + + expect(frame).toContain("SCHEDULE"); + expect(frame).toContain("Wakeup 01"); + expect(frame).toContain("Wakeup 05"); + expect(frame).not.toContain("Wakeup 06"); + expect(frame).toContain("↓ 2 more"); + expect(longestLine).toBeLessThanOrEqual(44); + }); + it("renders the plan explanation under the plan steps when present", () => { const result = render( { expect(isChatSessionAnimating({ status: "idle", awaitingInput: false, idleSinceAt: null })).toBe(false); }); + it("treats chat-info snapshot events as immediate auto-open edges", () => { + expect(isChatFlushEdge("scheduled_work_update")).toBe(true); + expect(isChatFlushEdge("todo_update")).toBe(true); + expect(isChatFlushEdge("subagent_progress")).toBe(false); + expect(shouldAutoOpenChatInfoForEvent({ + eventType: "scheduled_work_update", + isActiveSessionEvent: true, + activePane: "chat", + userDismissedRightPane: false, + })).toBe(true); + expect(shouldAutoOpenChatInfoForEvent({ + eventType: "scheduled_work_update", + isActiveSessionEvent: true, + activePane: "drawer", + userDismissedRightPane: false, + })).toBe(false); + expect(shouldAutoOpenChatInfoForEvent({ + eventType: "scheduled_work_update", + isActiveSessionEvent: true, + activePane: "chat", + userDismissedRightPane: true, + })).toBe(false); + expect(shouldAutoOpenChatInfoForEvent({ + eventType: "scheduled_work_update", + isActiveSessionEvent: false, + activePane: "chat", + userDismissedRightPane: false, + })).toBe(false); + }); + it("routes prompt submission to an existing chat before starting a provider-specific fallback", () => { expect(resolvePromptChatSubmitTarget({ draftChatActive: false, @@ -636,6 +668,7 @@ describe("right pane context defaults", () => { planExplanation: null, planStreamingText: null, todos: [], + scheduledWork: [], pr: null, snapshots: [], inspectedSubagentId: null, diff --git a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts index 4f6d95266..e48d1dac1 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/chatInfo.test.ts @@ -214,4 +214,49 @@ describe("deriveChatInfoSnapshot", () => { ]); expect(snapshot.pr).toEqual({ number: 7, state: "open", checksPassed: 1, checksTotal: 2 }); }); + + it("derives the latest scheduled work rows from Claude SDK wakeup events", () => { + const snapshot = deriveChatInfoSnapshot({ + events: [ + env("2026-07-07T12:00:00.000Z", { + type: "scheduled_work_update", + id: "wake-1", + kind: "wakeup", + status: "scheduled", + title: "Check the build", + nextRunAt: "2026-07-07T12:15:00.000Z", + reason: "after CI starts", + }, 1), + env("2026-07-07T12:16:00.000Z", { + type: "scheduled_work_update", + id: "wake-1", + kind: "wakeup", + status: "running", + title: "Check the build", + lastRunAt: "2026-07-07T12:16:00.000Z", + reason: "after CI starts", + }, 2), + ], + activeSession: session({ provider: "claude" }), + provider: "claude", + modelLabel: "claude-opus-4-7", + laneLabel: "lane", + snapshots: [], + tokenStats: null, + goal: null, + streaming: false, + }); + + expect(snapshot.scheduledWork).toEqual([ + expect.objectContaining({ + id: "wake-1", + kind: "wakeup", + status: "running", + title: "Check the build", + reason: "after CI starts", + createdAt: "2026-07-07T12:00:00.000Z", + updatedAt: "2026-07-07T12:16:00.000Z", + }), + ]); + }); }); diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index a1be9e09f..0f07224af 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -898,6 +898,43 @@ describe("renderChatLines", () => { expect(lines[2]?.header).toBeUndefined(); }); + it("removes assistant lines superseded by transcript retractions", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 1, + event: { type: "text", text: "Original answer", messageId: "msg-old", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 2, + event: { + type: "transcript_retraction", + messageIds: ["msg-old"], + reason: "assistant_supersedes", + replacementMessageId: "msg-new", + turnId: "turn-1", + }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:03.000Z", + sequence: 3, + event: { type: "text", text: "Replacement answer", messageId: "msg-new", turnId: "turn-1" }, + }, + ], + }); + + expect(lines.filter((line) => line.tone === "assistant").map((line) => line.body)).toEqual([ + "Replacement answer", + ]); + }); + it("renders expanded failed tool output when requested", () => { const events = [{ sessionId: "s1", diff --git a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts index a1e3b6ed8..f2df034ed 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/subagentPane.test.ts @@ -38,9 +38,10 @@ function rosterContent(): Extract { goal: null, plan: null, planExplanation: null, - planStreamingText: null, - todos: [], - pr: null, + planStreamingText: null, + todos: [], + scheduledWork: [], + pr: null, streaming: false, inspectedSubagentId: null, capability: resolveSubagentCapability("codex"), diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index cc85e6c77..a8ce5f73b 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -371,7 +371,30 @@ const PTY_ATTACHED_FLUSH_MS = 16; const PTY_PREVIEW_FLUSH_MS = 32; const TERMINAL_PREVIEW_POLL_MS = 1_000; -function isChatFlushEdge(eventType: string): boolean { +function isChatInfoAutoOpenEvent(eventType: string): boolean { + return ( + eventType === "subagent_started" + || eventType === "subagent.started" + || eventType === "todo_update" + || eventType === "scheduled_work_update" + ); +} + +export function shouldAutoOpenChatInfoForEvent(args: { + eventType: string; + isActiveSessionEvent: boolean; + activePane: string; + userDismissedRightPane: boolean; +}): boolean { + return ( + isChatInfoAutoOpenEvent(args.eventType) + && args.isActiveSessionEvent + && args.activePane !== "drawer" + && !args.userDismissedRightPane + ); +} + +export function isChatFlushEdge(eventType: string): boolean { // Only the event types that drive an immediate side-effect below (spinner / // interrupt / right-pane) force a synchronous flush. Notably NOT the broad // "subagent" prefix — subagent_progress is high-frequency and would defeat @@ -381,8 +404,7 @@ function isChatFlushEdge(eventType: string): boolean { || eventType === "done" || eventType === "user_message" || eventType === "error" - || eventType === "subagent_started" - || eventType === "subagent.started" + || isChatInfoAutoOpenEvent(eventType) ); } @@ -2786,6 +2808,8 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, const [rightChatsClosedExpanded, setRightChatsClosedExpanded] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false); const [rightOpen, setRightOpen] = useState(false); + const rightOpenRef = useRef(false); + rightOpenRef.current = rightOpen; const [activePane, setActivePane] = useState("chat"); const [prompt, setPrompt] = useState(""); const [promptCursor, setPromptCursor] = useState(0); @@ -3861,10 +3885,12 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, subagentPaneCommandAvailable, ]); // Auto-open the chat-info pane the first time a subagent appears for a session - // (once per session), mirroring the desktop subagent auto-open. Unlike a - // manual open, this does NOT steal focus from the composer — the pane simply - // appears alongside the chat. Respects an explicit user dismissal and never - // stomps a different pane the user opened. + // (once per session), mirroring the desktop subagent auto-open. Scheduled + // work and task snapshots also use the immediate event path below, keeping + // this derivation cheap while preserving the same "new info appeared" feel. + // Unlike a manual open, this does NOT steal focus from the composer — the + // pane simply appears alongside the chat. Respects an explicit user dismissal + // and never stomps a different pane the user opened. useEffect(() => { const sessionId = activeSessionId; if (!sessionId || !subagentPaneCommandAvailable) return; @@ -7368,14 +7394,27 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setSessionInterrupted(envelope.sessionId, false); if (isActiveSessionEvent) setInterrupted(false); } - if (event.type === "subagent_started" || event.type === "subagent.started") { + if (shouldAutoOpenChatInfoForEvent({ + eventType, + isActiveSessionEvent, + activePane: activePaneRef.current, + userDismissedRightPane: userDismissedRightPaneRef.current, + })) { // Auto-open chat info only when the user is in the chat surface. // Drawer navigation keeps lane details in the right pane. - if (!isActiveSessionEvent || activePaneRef.current === "drawer") return; setRightPane((prev) => { - if (prev.kind === "chat-info") return { kind: "chat-info", info: buildChatInfoSnapshotRef.current() }; + if (prev.kind === "chat-info") { + if (!rightOpenRef.current) { + rightOpenRef.current = true; + setRightOpen(true); + } + subagentAutoOpenedSessionsRef.current.add(envelope.sessionId); + return { kind: "chat-info", info: buildChatInfoSnapshotRef.current() }; + } if (prev.kind !== "empty" && prev.kind !== "lane-details") return prev; setRightOpen(true); + rightOpenRef.current = true; + subagentAutoOpenedSessionsRef.current.add(envelope.sessionId); return { kind: "chat-info", info: buildChatInfoSnapshotRef.current(), diff --git a/apps/ade-cli/src/tuiClient/chatInfo.ts b/apps/ade-cli/src/tuiClient/chatInfo.ts index 8bfc16c5e..98f6b2256 100644 --- a/apps/ade-cli/src/tuiClient/chatInfo.ts +++ b/apps/ade-cli/src/tuiClient/chatInfo.ts @@ -4,6 +4,7 @@ import type { AgentChatSessionSummary, } from "../../../desktop/src/shared/types/chat"; import { latestPlan } from "../../../desktop/src/shared/chatSubagents"; +import { deriveScheduledWorkSnapshots } from "../../../desktop/src/shared/chatScheduledWork"; import { resolveSubagentCapability } from "../../../desktop/src/shared/subagentCapabilities"; import { deriveMissionSnapshot } from "../../../desktop/src/renderer/components/chat/chatMission"; import { deriveTodoItems } from "../../../desktop/src/renderer/components/chat/chatExecutionSummary"; @@ -78,6 +79,7 @@ export function deriveChatInfoSnapshot(args: { planExplanation: trimmedOrNull(planEventRecord?.explanation), planStreamingText: trimmedOrNull(planEventRecord?.streamingText), todos: deriveTodoItems(args.events), + scheduledWork: deriveScheduledWorkSnapshots(args.events), pr: args.pr ?? null, snapshots: args.snapshots, inspectedSubagentId: args.inspectedSubagentId ?? null, diff --git a/apps/ade-cli/src/tuiClient/components/RightPane.tsx b/apps/ade-cli/src/tuiClient/components/RightPane.tsx index 2fc103763..dbe4298e4 100644 --- a/apps/ade-cli/src/tuiClient/components/RightPane.tsx +++ b/apps/ade-cli/src/tuiClient/components/RightPane.tsx @@ -5,6 +5,7 @@ import type { AdeCodeProvider, ChatInfoPlanStep, ChatInfoSnapshot, + ChatScheduledWorkSnapshot, RightPaneContent, SubagentSnapshot, } from "../types"; @@ -1014,6 +1015,64 @@ function ChatInfoMissionBlock({ mission, width, brandColor }: { mission: Mission // Desktop ChatTasksPanel parity: latest todo_update snapshot. Rendered BELOW // the roster (like Mission) so the roster's click line-math stays intact. const TASKS_VISIBLE_CAP = 6; +const SCHEDULE_VISIBLE_CAP = 5; + +function scheduleStatusColor(status: ChatScheduledWorkSnapshot["status"]): string { + if (status === "running" || status === "fired") return theme.color.running; + if (status === "failed" || status === "missed") return theme.color.error; + if (status === "completed") return theme.color.done; + if (status === "cancelled" || status === "stopped") return theme.color.t4; + return theme.color.info; +} + +function scheduleStatusGlyph(status: ChatScheduledWorkSnapshot["status"]): string { + if (status === "running" || status === "fired") return "●"; + if (status === "failed" || status === "missed") return "×"; + if (status === "completed") return "✓"; + if (status === "cancelled" || status === "stopped") return "○"; + return "◷"; +} + +function scheduleKindLabel(kind: ChatScheduledWorkSnapshot["kind"]): string { + if (kind === "remote_trigger") return "trigger"; + if (kind === "background_task") return "background"; + return kind; +} + +function scheduleLineDetail(item: ChatScheduledWorkSnapshot): string { + return item.cron ?? item.reason ?? item.summary ?? item.prompt ?? ""; +} + +function ChatInfoScheduleBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { + if (!info.scheduledWork.length) return null; + const inner = Math.max(10, width - 4); + const visible = info.scheduledWork.slice(0, SCHEDULE_VISIBLE_CAP); + const hiddenAfter = info.scheduledWork.length - visible.length; + return ( + + + {visible.map((item) => { + const detail = scheduleLineDetail(item); + const label = `${scheduleKindLabel(item.kind)} · ${item.status}`; + const titleBudget = Math.max(6, inner - label.length - 4); + return ( + + + {scheduleStatusGlyph(item.status)} {endTruncate(item.title, titleBudget)} {label} + + {detail ? ( + + {" "}{endTruncate(detail, inner - 2)} + + ) : null} + + ); + })} + {hiddenAfter > 0 ? {` ↓ ${hiddenAfter} more`} : null} + + ); +} + function ChatInfoTasksBlock({ info, brandColor, width }: { info: ChatInfoSnapshot; brandColor: string; width: number }) { if (!info.todos.length) return null; const inner = Math.max(10, width - 4); @@ -1092,6 +1151,7 @@ function ChatInfoPane({ + ); @@ -2036,16 +2096,16 @@ function RightPaneComponent({ /** Reports the model-picker's measured content origin for click hit-testing. */ onModelPickerMeasureOrigin?: (origin: { x: number; y: number; width: number }) => void; /** Data passed in by app.tsx for the model-picker content kind. */ - modelPickerInputs?: { - models: AgentChatModelInfo[]; - catalog?: AgentChatModelCatalog | null; - favorites: string[]; - recents: string[]; - activeModelId: string | null; - activeReasoningEffort?: string | null; - aiStatus?: AiSettingsStatus | null; - interfaceMode?: AdeCodeInterfaceMode; - }; + modelPickerInputs?: { + models: AgentChatModelInfo[]; + catalog?: AgentChatModelCatalog | null; + favorites: string[]; + recents: string[]; + activeModelId: string | null; + activeReasoningEffort?: string | null; + aiStatus?: AiSettingsStatus | null; + interfaceMode?: AdeCodeInterfaceMode; + }; }) { const { title, hint, branch } = paneTitle(content); const paneWidth = Math.max(30, width); @@ -2103,12 +2163,12 @@ function RightPaneComponent({ return content.rows.length ? visibleRows.map((row, visibleIndex) => { const index = listStart + visibleIndex; return ( - - {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} - + + {content.action ? `${index === selectedIndex ? theme.rail : " "} ${row}` : row} + ); }) : {content.emptyText ?? "No data."}; })()} @@ -2192,22 +2252,22 @@ function RightPaneComponent({ {content.kind === "model-picker" && modelPickerInputs ? ( ) : null} - {content.fields.map((field, index) => { + {content.fields.map((field, index) => { const value = formValues[field.name]?.trim(); const displayValue = endTruncate( (value || field.placeholder || "").replace(/\s+/g, " "), Math.max(8, paneWidth - field.label.length - 8), ); - return ( - + return ( + {index === activeFormField ? theme.rail : " "} {field.label} {field.required ? " *" : ""}: {displayValue} diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index 058e1c14d..f833ffe00 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -547,6 +547,17 @@ export function renderChatLines(args: { }); continue; } + if (event.type === "transcript_retraction") { + const retractedIds = new Set(event.messageIds.map((messageId) => messageId.trim()).filter(Boolean)); + if (!retractedIds.size) continue; + for (let lineIndex = lines.length - 1; lineIndex >= 0; lineIndex -= 1) { + const line = lines[lineIndex]; + if (line?.tone === "assistant" && line.messageId && retractedIds.has(line.messageId)) { + lines.splice(lineIndex, 1); + } + } + continue; + } if (event.type === "text") { // Codex subagent child content is namespaced `codex-subagent:` and belongs // in the subagent transcript, not the parent chat — mirror desktop, which diff --git a/apps/ade-cli/src/tuiClient/types.ts b/apps/ade-cli/src/tuiClient/types.ts index 42fe3e95e..e84236d97 100644 --- a/apps/ade-cli/src/tuiClient/types.ts +++ b/apps/ade-cli/src/tuiClient/types.ts @@ -148,7 +148,9 @@ export type SetupPaneRow = { }; export type { SubagentSnapshot, ChatInfoPlan, ChatInfoPlanStep } from "../../../desktop/src/shared/chatSubagents"; +export type { ChatScheduledWorkSnapshot } from "../../../desktop/src/shared/chatScheduledWork"; import type { SubagentSnapshot, ChatInfoPlan } from "../../../desktop/src/shared/chatSubagents"; +import type { ChatScheduledWorkSnapshot } from "../../../desktop/src/shared/chatScheduledWork"; import type { SubagentCapability } from "../../../desktop/src/shared/subagentCapabilities"; import type { MissionSnapshot } from "../../../desktop/src/renderer/components/chat/chatMission"; @@ -182,6 +184,8 @@ export type ChatInfoSnapshot = { planStreamingText: string | null; /** Latest todo_update snapshot (desktop ChatTasksPanel parity). */ todos: ChatInfoTodoItem[]; + /** Latest scheduled/background wakeups surfaced by Claude Agent SDK events. */ + scheduledWork: ChatScheduledWorkSnapshot[]; /** Open/merged/closed PR on the chat's lane (desktop ChatPrPane parity). */ pr: ChatInfoPrSummary | null; snapshots: SubagentSnapshot[]; diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index f5877098f..5546731f9 100644 --- a/apps/desktop/package-lock.json +++ b/apps/desktop/package-lock.json @@ -9,7 +9,7 @@ "version": "1.0.0-beta.1", "license": "AGPL-3.0", "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", @@ -247,22 +247,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.201.tgz", - "integrity": "sha512-InT1XLmf2QpldWdtznKDWEoGJT4p+sXh24yxbeBQ++lMJCzMrI0W27MEmmmDWx0otpa+ubdHCF5YQ6oiNt7cmg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.202.tgz", + "integrity": "sha512-LnaLxDtsZP7J6g++xRSnnpTX7CHNe4v+cvBRIlD2ar+N+xi0aqY2YDaCsxPsl+haVUB9kqlUMd0zosmwsfTGjQ==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.201", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.201" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.202", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.202" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -271,9 +271,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.201.tgz", - "integrity": "sha512-8Mcb3BDyKUGfJWFFTWwt+at37lbDH3ZwVtUNPWGG1toZ75RDCJry5U4kXRvQ2xokvJQlA0E+eNp6keWe5ZH22Q==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.202.tgz", + "integrity": "sha512-ujR3zDthDPkZs+AxW95iHpqLT5cuwGImsS3mVxLt1DlDij4qeTnihLX8+EpQTK+oNW9jjvFA86yKwa84fa1KYA==", "cpu": [ "arm64" ], @@ -284,9 +284,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.201.tgz", - "integrity": "sha512-TFR2bu0+ml3RHoMrtsgD0qDK5Oknw8kYGBV7qpQHn+IWmE96gnHhogG1LpJwpHtni08XkJIjfWk1DdlsUYtRkQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.202.tgz", + "integrity": "sha512-s/RVSGgkVmIMfyt1ndR8braLLu82bARoijmt1kk8d4IptUZ0Sc+zNUWKoFXwR9XqDBu6rBbBF9RIzD02raT57w==", "cpu": [ "x64" ], @@ -297,9 +297,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.201.tgz", - "integrity": "sha512-mShTo3MwF0gkN4dDw78wWJiB6aBDVRkl81cnApvoBofpdyUBYgm9Gw16CCjDTgelMKeBFqN6ErJpwjI3wbP00A==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.202.tgz", + "integrity": "sha512-a4YtRkgGYt3ogePJDW8Ts6bNW690jb9LHyZaiWXsi+zT53xCNqJB2zKPyRc7hXWOqzIk4nCfwJpjmhLzMu3WIg==", "cpu": [ "arm64" ], @@ -313,9 +313,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.201.tgz", - "integrity": "sha512-EiqbpfJIpChfkn+8Uj061Qjyw0eaRcOXtdrvVuHANyj8ZErVOr8HlH6op9PSeIUa9TX0m2+tNgKPQvOGseQckA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.202.tgz", + "integrity": "sha512-abSb3Gah45kUNyOeKjmQ/dd1KZ4CaQz5JAr9YQxRDXoOwx8wJVx6huBIpDxjms9wyS9X5Rqxn0Lx7zFP+wV2zQ==", "cpu": [ "arm64" ], @@ -329,9 +329,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.201.tgz", - "integrity": "sha512-jrJBrRWrSuoFKIgjyqxHqmfd6Pb3Bs5Bvakg0knXCTC4fbUXGnC9Q6u7gdDwgXohUNP6/DD+s8U7bivvvVv0dg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.202.tgz", + "integrity": "sha512-XIvhdCWAAT4OdOA82fOJII+WH0Tf8pFckckEbJMMmOgQBKOnHT+609Pd3Ehw6zGcA9iFrhG5mY8Ncuckeo1aMw==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.201.tgz", - "integrity": "sha512-IbxnzO5UCbqbm2TnzCHkSyJorAFw2isdKdIsFCTxJJjSs3ZC+v3LC1QSUiVCx0qi+CV6w3MKx6mLI11mrvhbbQ==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.202.tgz", + "integrity": "sha512-fze5nAQL1ErcMCQNB10ILaWdM0QbJSaTQzBz8NVAy0FGW8ZL0t4Wf/VgFkfzXbfkaxmPuM1C27Dn5HiU7UDEHQ==", "cpu": [ "x64" ], @@ -361,9 +361,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.201.tgz", - "integrity": "sha512-UsoytRJ/037uHpb3ATrIoe+AgwTf+PwKuFLGjddHAV/11wERJs0hlrnSmcnp43kf0PFxoSNinngme96YYASmQg==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.202.tgz", + "integrity": "sha512-N1J0HRvC+8a69bqNY7+ENIYQzR0i7s+rOIGH5XtuLxvLqOnZO8LHxWEZOe8ezabGq5eZqphSCgL6vQnQQpNh+A==", "cpu": [ "arm64" ], @@ -374,9 +374,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.201", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.201.tgz", - "integrity": "sha512-PhalN/0cWcqDfbx7iwoLNR2gurjTiqhBk1G6K+NRScxEcQjWuu5xKXCcdbX8ePVpT+nbEMmFEFpn2y+8V8hIdA==", + "version": "0.3.202", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.202.tgz", + "integrity": "sha512-ytLGEC1fjTSiVSoXukS+j9G+06Mi20NSzxxzlG6uE75SEB0+17tHdWUaHqd8PhH/6GPzcYx81czxWQl1MVbq4Q==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a9771bce6..9ab63686f 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -58,7 +58,7 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.201", + "@anthropic-ai/claude-agent-sdk": "^0.3.202", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 4af2e5df8..10a35718e 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -351,6 +351,14 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(getAdeActionInputContract("chat", "readTranscript")).toMatchObject({ input: expect.stringContaining("limit"), }); + expect(getAdeActionInputContract("chat", "getChatEventHistory")).toMatchObject({ + description: expect.stringContaining("scheduled work"), + input: expect.stringContaining("maxEvents"), + }); + expect(getAdeActionInputContract("chat", "getChatEventHistoryPage")).toMatchObject({ + description: expect.stringContaining("older raw chat events"), + input: expect.stringContaining("beforeOffset"), + }); expect(getAdeActionInputContract("chat", "sendMessage")).toMatchObject({ description: expect.stringContaining("asynchronously"), }); @@ -363,6 +371,14 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { const readTranscript = vi.fn(async (sessionId: string, limit?: number, since?: string) => ([ { role: "user", text: sessionId, timestamp: since ?? "now", limit }, ])); + const getChatEventHistory = vi.fn((sessionId: string, options?: { maxEvents?: number; maxBytes?: number }) => ({ + sessionId, + options, + })); + const getChatEventHistoryPage = vi.fn((sessionId: string, options: { beforeOffset: number; maxBytes?: number }) => ({ + sessionId, + options, + })); const sendMessage = vi.fn(async () => undefined); const runtime = { agentChatService: { @@ -370,6 +386,8 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { getAvailableModels, getSessionSummary, readTranscript, + getChatEventHistory, + getChatEventHistoryPage, sendMessage, }, } as unknown as Parameters[0]; @@ -379,6 +397,8 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { getAvailableModels?: (args?: unknown) => Promise; getSessionSummary?: (args?: unknown) => Promise; readTranscript?: (args?: unknown) => Promise; + getChatEventHistory?: (args?: unknown) => Promise; + getChatEventHistoryPage?: (args?: unknown) => Promise; sendMessage?: (args?: unknown) => Promise; }; @@ -422,6 +442,32 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { ]); expect(readTranscript).toHaveBeenCalledWith("chat-1", 25, "2026-06-29T00:00:00.000Z"); + await expect(Promise.resolve(chat.getChatEventHistory?.({ + sessionId: " chat-1 ", + maxEvents: "128", + maxBytes: 65536, + }))).resolves.toEqual({ + sessionId: "chat-1", + options: { maxEvents: 128, maxBytes: 65536 }, + }); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-1", { maxEvents: 128, maxBytes: 65536 }); + + await expect(Promise.resolve(chat.getChatEventHistory?.([" chat-2 ", { maxEvents: 32 }]))).resolves.toEqual({ + sessionId: "chat-2", + options: { maxEvents: 32 }, + }); + expect(getChatEventHistory).toHaveBeenCalledWith("chat-2", { maxEvents: 32 }); + + await expect(Promise.resolve(chat.getChatEventHistoryPage?.({ + sessionId: " chat-1 ", + beforeOffset: "4096", + maxBytes: "8192", + }))).resolves.toEqual({ + sessionId: "chat-1", + options: { beforeOffset: 4096, maxBytes: 8192 }, + }); + expect(getChatEventHistoryPage).toHaveBeenCalledWith("chat-1", { beforeOffset: 4096, maxBytes: 8192 }); + await expect(chat.sendMessage?.({ sessionId: " chat-1 ", text: "next", diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index 9779b410e..8110fd738 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -730,6 +730,16 @@ const ADE_ACTION_INPUT_CONTRACTS: Partial agentChatService.getSessionSummary(readStringActionArg(args, "sessionId")); } + if (typeof base.getChatEventHistory === "function") { + service.getChatEventHistory = (args?: unknown) => { + const { sessionId, options } = readChatHistoryActionArgs(args, "chat.getChatEventHistory"); + const maxEvents = readOptionalIntegerActionField(options.maxEvents, "maxEvents"); + const maxBytes = readOptionalIntegerActionField(options.maxBytes, "maxBytes"); + return agentChatService.getChatEventHistory(sessionId, { + ...(maxEvents !== undefined ? { maxEvents } : {}), + ...(maxBytes !== undefined ? { maxBytes } : {}), + }); + }; + } + if (typeof base.getChatEventHistoryPage === "function") { + service.getChatEventHistoryPage = (args?: unknown) => { + const { sessionId, options } = readChatHistoryActionArgs(args, "chat.getChatEventHistoryPage"); + const beforeOffset = readOptionalIntegerActionField(options.beforeOffset, "beforeOffset"); + if (beforeOffset === undefined) { + throw new Error("Expected 'beforeOffset' to be a finite number."); + } + const maxBytes = readOptionalIntegerActionField(options.maxBytes, "maxBytes"); + return agentChatService.getChatEventHistoryPage(sessionId, { + beforeOffset, + ...(maxBytes !== undefined ? { maxBytes } : {}), + }); + }; + } if (typeof agentChatService.getModelCatalog === "function") { service.modelCatalog = (args?: unknown) => agentChatService.getModelCatalog(readObjectActionArg(args, "chat.modelCatalog") as never); @@ -2005,6 +2040,42 @@ function readObjectActionArg(value: unknown, actionName: string): Record } { + if (Array.isArray(value)) { + return { + sessionId: requireNonEmptyString(value[0], "sessionId"), + options: asActionRecord(value[1]), + }; + } + if (typeof value === "string") { + return { + sessionId: requireNonEmptyString(value, "sessionId"), + options: {}, + }; + } + const record = readObjectActionArg(value, actionName); + return { + sessionId: requireNonEmptyString(record.sessionId, "sessionId"), + options: record, + }; +} + function readRuntimeFileWatchSenderId(args: Record): number { const raw = args[RUNTIME_FILE_WATCH_CLIENT_ID_FIELD]; const numeric = typeof raw === "number" diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index baf0434fe..ff07403fb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -796,6 +796,7 @@ import { detectAllAuth } from "../ai/authDetector"; import { buildCodingAgentSystemPrompt } from "../ai/tools/systemPrompt"; import { createOrchestrationService } from "../orchestration/orchestrationService"; import { runGit } from "../git/git"; +import { deriveScheduledWorkSnapshots } from "../../../shared/chatScheduledWork"; import { parseAgentChatTranscript } from "../../../shared/chatTranscript"; import { mapPermissionToCodex } from "./permissionMapping"; import { acquireCursorSdkConnection, releaseCursorSdkConnection } from "./cursorSdkPool"; @@ -860,7 +861,9 @@ function legacyClaudeSendPayload(message: unknown): unknown { } function beginClaudeStartupWarmup(session: any) { - if (!session) return; + if (!session) { + throw new Error("beginClaudeStartupWarmup requires a session fixture"); + } void (async () => { try { if (typeof session.send === "function") { @@ -8827,6 +8830,1269 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); + + it("keeps the Claude SDK stream alive for scheduled wakeups after a foreground result", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startBackground!: () => void; + let finishBackground!: () => void; + const startBackgroundPromise = new Promise((resolve) => { startBackground = resolve; }); + const finishBackgroundPromise = new Promise((resolve) => { finishBackground = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-wakeup", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-wakeup", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startBackgroundPromise; + yield { + type: "assistant", + uuid: "assistant-idle-tool", + message: { + id: "msg-idle-tool", + content: [ + { + type: "tool_use", + id: "tool-wakeup-1", + name: "ScheduleWakeup", + input: { + reason: "CI was still running", + prompt: "Check CI again and report back.", + }, + }, + ], + usage: { input_tokens: 2, output_tokens: 3 }, + }, + }; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-idle-wakeup", + task_id: "cron-task-1", + task_type: "cron", + parent_tool_use_id: "tool-wakeup-1", + description: "Check CI again", + agent_id: "agent-child-1", + parent_agent_id: "agent-parent-1", + }; + + await finishBackgroundPromise; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-idle-wakeup", + task_id: "cron-task-1", + task_type: "cron", + parent_tool_use_id: "tool-wakeup-1", + patch: { status: "completed" }, + summary: "CI passed.", + agent_id: "agent-child-1", + parent_agent_id: "agent-parent-1", + }; + yield { + type: "assistant", + uuid: "assistant-idle-text", + message: { + id: "msg-idle-text", + content: [{ type: "text", text: "CI passed." }], + usage: { input_tokens: 2, output_tokens: 4 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-wakeup", + usage: { input_tokens: 2, output_tokens: 4 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-idle-wakeup", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Run CI and wake up when it finishes.", + }); + expect(service.hasActiveWorkloads()).toBe(false); + const wakeupId = `wakeup:${session.id}`; + + vi.mocked(runGit).mockClear(); + startBackground(); + await vi.waitFor(() => { + expect(runGit).toHaveBeenCalledWith(["rev-parse", "HEAD"], expect.objectContaining({ timeoutMs: 8_000 })); + }); + const wakeupEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === wakeupId, + ); + expect(wakeupEvent.event).toMatchObject({ + kind: "wakeup", + status: "scheduled", + origin: "schedule_wakeup", + reason: "CI was still running", + prompt: "Check CI again and report back.", + }); + expect(wakeupEvent.event.turnId).toMatch(/^claude-idle-/); + expect(service.hasActiveWorkloads()).toBe(true); + + const cronRunningEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === wakeupId + && event.event.status === "running", + ); + expect(cronRunningEvent.event).toMatchObject({ + kind: "wakeup", + origin: "cron", + title: "Check CI again", + sourceToolUseId: "tool-wakeup-1", + sourceTaskId: "cron-task-1", + }); + + finishBackground(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === wakeupId + && event.event.status === "completed", + ); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "done" + && event.event.turnId.startsWith("claude-idle-") + && event.event.status === "completed", + ); + expect(service.hasActiveWorkloads()).toBe(false); + }); + + it("keeps idle skip_transcript tasks out of visible chat info", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startAmbient!: () => void; + let ambientDrained = false; + const startAmbientPromise = new Promise((resolve) => { startAmbient = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-skip-transcript", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-skip-transcript", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startAmbientPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-idle-skip-transcript", + task_id: "task-ambient-idle-1", + description: "Generate session title", + task_type: "other", + skip_transcript: true, + }; + yield { + type: "system", + subtype: "task_progress", + session_id: "sdk-idle-skip-transcript", + task_id: "task-ambient-idle-1", + summary: "thinking", + }; + yield { + type: "system", + subtype: "task_notification", + session_id: "sdk-idle-skip-transcript", + task_id: "task-ambient-idle-1", + status: "completed", + summary: "Done", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-skip-transcript", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + ambientDrained = true; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-idle-skip-transcript", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Complete a visible turn, then let idle housekeeping run.", + }); + + startAmbient(); + await vi.waitFor(() => { + expect(ambientDrained).toBe(true); + }); + + expect(events.filter((event) => + event.sessionId === session.id + && (event.event.type === "subagent_started" + || event.event.type === "subagent_progress" + || event.event.type === "subagent_result") + && (event.event as { taskId?: string }).taskId === "task-ambient-idle-1", + )).toEqual([]); + expect(events.some((event) => + event.sessionId === session.id + && event.event.type === "status" + && event.event.turnId?.startsWith("claude-idle-") === true, + )).toBe(false); + expect(service.hasActiveWorkloads()).toBe(false); + }); + + it("delivers queued steers after an idle Claude turn completes", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startBackground!: () => void; + let finishBackground!: () => void; + const startBackgroundPromise = new Promise((resolve) => { startBackground = resolve; }); + const finishBackgroundPromise = new Promise((resolve) => { finishBackground = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-queued-steer", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-queued-steer", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startBackgroundPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-idle-queued-steer", + task_id: "cron-task-queued-steer", + task_type: "cron", + description: "Check queued steer", + }; + + await finishBackgroundPromise; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-idle-queued-steer", + task_id: "cron-task-queued-steer", + task_type: "cron", + patch: { status: "completed" }, + summary: "Background task completed.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-queued-steer", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + + yield { + type: "assistant", + uuid: "assistant-queued-steer", + message: { + id: "msg-queued-steer", + content: [{ type: "text", text: "Queued steer delivered." }], + usage: { input_tokens: 3, output_tokens: 4 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-queued-steer", + usage: { input_tokens: 3, output_tokens: 4 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-idle-queued-steer", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Start a scheduled check.", + }); + + startBackground(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.status === "running", + ); + + const steerResult = await service.steer({ + sessionId: session.id, + text: "Follow up after the background task.", + }); + expect(steerResult.queued).toBe(true); + + finishBackground(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "text" + && event.event.text.includes("Queued steer delivered."), + ); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Follow up after the background task.")); + expect(service.hasActiveWorkloads()).toBe(false); + }); + + it("keys Claude cron create and delete events by the provider cron id", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-id", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + uuid: "assistant-cron-tools", + message: { + id: "msg-cron-tools", + content: [ + { + type: "tool_use", + id: "tool-cron-create", + name: "CronCreate", + input: { + id: "cron-sdk-1", + cron: "*/15 * * * *", + prompt: "Check CI status.", + }, + }, + { + type: "tool_use", + id: "tool-cron-delete", + name: "CronDelete", + input: { + id: "cron-sdk-1", + }, + }, + ], + usage: { input_tokens: 2, output_tokens: 3 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-id", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-id", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule and then cancel a CI cron.", + }); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual(["cron-sdk-1", "cron-sdk-1"]); + expect(scheduledEvents.map((event) => event.event.status)).toEqual(["scheduled", "cancelled"]); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "cron-sdk-1", + status: "cancelled", + cron: "*/15 * * * *", + prompt: "Check CI status.", + sourceToolUseId: "tool-cron-delete", + }); + }); + + it("waits for the provider cron id before creating a CronCreate scheduled row", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-provider-id", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + uuid: "assistant-cron-create-no-id", + message: { + id: "msg-cron-create-no-id", + content: [ + { + type: "tool_use", + id: "tool-cron-create-no-id", + name: "CronCreate", + input: { + cron: "*/15 * * * *", + prompt: "Check CI status.", + }, + }, + ], + usage: { input_tokens: 2, output_tokens: 3 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-provider-id", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-provider-id", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule a CI cron.", + }); + + expect(events.filter((event) => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron", + )).toEqual([]); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [{ + id: "cron-provider-1", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }], + }); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "cron-provider-1", + status: "scheduled", + cron: "*/15 * * * *", + prompt: "Check CI status.", + }); + }); + + it("coalesces one-shot wakeup hook snapshots with the scheduled wakeup row", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let cancelWakeup!: () => void; + const cancelWakeupPromise = new Promise((resolve) => { cancelWakeup = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-wakeup-provider-id", + slash_commands: [], + }; + return; + } + + yield { + type: "assistant", + uuid: "assistant-wakeup-provider-id", + message: { + id: "msg-wakeup-provider-id", + content: [ + { + type: "tool_use", + id: "tool-wakeup-provider-id", + name: "ScheduleWakeup", + input: { + reason: "CI was still running", + prompt: "Check CI again.", + }, + }, + ], + usage: { input_tokens: 2, output_tokens: 3 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-wakeup-provider-id", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + + await cancelWakeupPromise; + yield { + type: "assistant", + uuid: "assistant-wakeup-provider-delete", + message: { + id: "msg-wakeup-provider-delete", + content: [ + { + type: "tool_use", + id: "tool-wakeup-provider-delete", + name: "CronDelete", + input: { + id: "wakeup-provider-1", + }, + }, + ], + usage: { input_tokens: 2, output_tokens: 3 }, + }, + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-wakeup-provider-id", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-wakeup-provider-id", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + const wakeupId = `wakeup:${session.id}`; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule a one-shot CI wakeup.", + }); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [{ + id: "wakeup-provider-1", + schedule: "once", + prompt: "Check CI again.", + recurring: false, + }], + }); + + cancelWakeup(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === wakeupId + && event.event.status === "cancelled", + ); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "wakeup"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual([wakeupId, wakeupId, wakeupId]); + expect(scheduledEvents.map((event) => event.event.status)).toEqual(["scheduled", "scheduled", "cancelled"]); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: wakeupId, + kind: "wakeup", + status: "cancelled", + prompt: "Check CI again.", + sourceTaskId: "wakeup-provider-1", + }); + }); + + it("coalesces parentless recurring cron run events with the provider cron row", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startCronRun!: () => void; + const startCronRunPromise = new Promise((resolve) => { startCronRun = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-parentless-run", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-run", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startCronRunPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-cron-parentless-run", + task_id: "cron-run-task-1", + task_type: "cron", + description: "Check CI status.", + }; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-cron-parentless-run", + task_id: "cron-run-task-1", + task_type: "cron", + patch: { status: "completed" }, + summary: "CI passed.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-run", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-parentless-run", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule a recurring CI cron.", + }); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [{ + id: "cron-provider-parentless-1", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }], + }); + + startCronRun(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === "cron-provider-parentless-1" + && event.event.status === "completed", + ); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual([ + "cron-provider-parentless-1", + "cron-provider-parentless-1", + "cron-provider-parentless-1", + ]); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "cron-provider-parentless-1", + kind: "cron", + status: "completed", + sourceTaskId: "cron-run-task-1", + }); + }); + + it("matches parentless recurring cron runs by prompt when multiple provider crons are active", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startCronRun!: () => void; + const startCronRunPromise = new Promise((resolve) => { startCronRun = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-parentless-multiple", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-multiple", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startCronRunPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-cron-parentless-multiple", + task_id: "cron-run-task-multiple", + task_type: "cron", + description: "Review issue comments.", + }; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-cron-parentless-multiple", + task_id: "cron-run-task-multiple", + task_type: "cron", + patch: { status: "completed" }, + summary: "One new review comment was found.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-multiple", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-parentless-multiple", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule two recurring crons.", + }); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [ + { + id: "cron-provider-multiple-ci", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }, + { + id: "cron-provider-multiple-review", + schedule: "*/20 * * * *", + prompt: "Review issue comments.", + recurring: true, + }, + ], + }); + + startCronRun(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.id === "cron-provider-multiple-review" + && event.event.status === "completed", + ); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual([ + "cron-provider-multiple-ci", + "cron-provider-multiple-review", + "cron-provider-multiple-review", + "cron-provider-multiple-review", + ]); + expect(scheduledEvents.map((event) => event.event.id)).not.toContain("cron-run-task-multiple"); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(2); + expect(snapshots.find((snapshot) => snapshot.id === "cron-provider-multiple-ci")).toMatchObject({ + id: "cron-provider-multiple-ci", + kind: "cron", + status: "scheduled", + }); + expect(snapshots.find((snapshot) => snapshot.id === "cron-provider-multiple-review")).toMatchObject({ + id: "cron-provider-multiple-review", + kind: "cron", + status: "completed", + sourceTaskId: "cron-run-task-multiple", + }); + }); + + it("does not create task-id scheduled rows for ambiguous parentless cron runs", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startCronRun!: () => void; + const startCronRunPromise = new Promise((resolve) => { startCronRun = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-parentless-ambiguous", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-ambiguous", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startCronRunPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-cron-parentless-ambiguous", + task_id: "cron-run-task-ambiguous", + task_type: "cron", + description: "Run scheduled maintenance.", + }; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-cron-parentless-ambiguous", + task_id: "cron-run-task-ambiguous", + task_type: "cron", + patch: { status: "completed" }, + summary: "Finished scheduled maintenance.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-parentless-ambiguous", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-parentless-ambiguous", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Schedule two recurring crons.", + }); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [ + { + id: "cron-provider-ambiguous-ci", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }, + { + id: "cron-provider-ambiguous-review", + schedule: "*/20 * * * *", + prompt: "Review issue comments.", + recurring: true, + }, + ], + }); + + startCronRun(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "subagent_result" + && event.event.taskId === "cron-run-task-ambiguous", + ); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual([ + "cron-provider-ambiguous-ci", + "cron-provider-ambiguous-review", + ]); + expect(scheduledEvents.map((event) => event.event.id)).not.toContain("cron-run-task-ambiguous"); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(2); + expect(snapshots.map((snapshot) => snapshot.status).sort()).toEqual(["scheduled", "scheduled"]); + }); + + it("does not create task-id scheduled rows for parentless cron runs when aliases are empty", async () => { + const events: AgentChatEventEnvelope[] = []; + const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let startCronRun!: () => void; + const startCronRunPromise = new Promise((resolve) => { startCronRun = resolve; }); + + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-cron-empty-aliases", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-empty-aliases", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await startCronRunPromise; + yield { + type: "system", + subtype: "task_started", + session_id: "sdk-cron-empty-aliases", + task_id: "cron-run-task-empty-aliases", + task_type: "cron", + description: "Check CI status.", + }; + yield { + type: "system", + subtype: "task_updated", + session_id: "sdk-cron-empty-aliases", + task_id: "cron-run-task-empty-aliases", + task_type: "cron", + patch: { status: "completed" }, + summary: "CI passed.", + }; + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-cron-empty-aliases", + usage: { input_tokens: 2, output_tokens: 3 }, + }; + })()); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-cron-empty-aliases", + setPermissionMode, + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + hooks?: Record Promise> }>>; + } | undefined; + const stopHook = opts?.hooks?.Stop?.[0]?.hooks[0]; + + await service.runSessionTurn({ + sessionId: session.id, + text: "Resume after a persisted cron was scheduled.", + }); + + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [{ + id: "cron-provider-empty-aliases", + schedule: "*/15 * * * *", + prompt: "Check CI status.", + recurring: true, + }], + }); + await stopHook?.({ + hook_event_name: "Stop", + session_crons: [], + }); + + startCronRun(); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.sessionId === session.id + && event.event.type === "subagent_result" + && event.event.taskId === "cron-run-task-empty-aliases", + ); + + const scheduledEvents = events + .filter((event): event is AgentChatEventEnvelope & { + event: Extract; + } => + event.sessionId === session.id + && event.event.type === "scheduled_work_update" + && event.event.kind === "cron"); + expect(scheduledEvents.map((event) => event.event.id)).toEqual(["cron-provider-empty-aliases"]); + expect(scheduledEvents.map((event) => event.event.id)).not.toContain("cron-run-task-empty-aliases"); + + const snapshots = deriveScheduledWorkSnapshots(events); + expect(snapshots).toHaveLength(1); + expect(snapshots[0]).toMatchObject({ + id: "cron-provider-empty-aliases", + kind: "cron", + status: "scheduled", + }); + }); }); describe("hasRetainableSessions", () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 72da55606..1924b7df4 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -167,6 +167,7 @@ import type { AgentChatRewindFilesArgs, AgentChatRewindFilesResult, AgentChatSession, + AgentChatScheduledWorkKind, AgentChatSessionCapabilities, AgentChatSessionCapabilitiesArgs, AgentChatSessionSummary, @@ -399,7 +400,7 @@ import { } from "../../../shared/orchestrationRuntimePolicy"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; -const CLAUDE_AGENT_SDK_VERSION = "0.3.186"; +const CLAUDE_AGENT_SDK_VERSION = "0.3.202"; const CLAUDE_AGENT_SDK_API = "v1_query"; const CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS = 1_000; const CLAUDE_AGENT_SDK_TELEMETRY_TAGS = { @@ -687,6 +688,8 @@ type ClaudeRuntime = { inputPump: ClaudeInputPump | null; pendingPostResultNext: Promise> | null; pendingPostResultNextSettledAt: number | null; + idleReaderPromise: Promise | null; + idleReaderGeneration: number; warmQuery: WarmQuery | null; /** Resolves when startup() has produced a warm query handle. */ warmupDone: Promise | null; @@ -702,6 +705,7 @@ type ClaudeRuntime = { finalSummary?: string; agentType?: string; agentId?: string; + parentAgentId?: string | null; /** * Cached at spawn time so task_progress / task_notification handlers don't * have to re-derive them from the message. Set for Claude SDK runs only. @@ -735,6 +739,12 @@ type ClaudeRuntime = { * instead of duplicating rows (see claudeWorkflowProgress.ts). */ workflowAgentsByTask: Map>; + scheduledWorkSignatures: Map; + scheduledWorkKindById: Map; + scheduledWorkIdByTaskId: Map; + scheduledWorkIdByToolUseId: Map; + activeProviderCronIds: Set; + activeProviderCronIdsByPrompt: Map>; slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; busy: boolean; activeTurnId: string | null; @@ -9914,10 +9924,347 @@ export function createAgentChatService(args: { if (normalizedEvent.type === "todo_update") { managed.todoItems = normalizedEvent.items; } - commitChatEventWithCanonical(managed, normalizedEvent, options); }; + type ScheduledWorkEvent = Extract; + + const compactString = (value: unknown): string | undefined => { + if (typeof value !== "string") return undefined; + const trimmed = value.trim(); + return trimmed.length ? trimmed : undefined; + }; + + const scheduledWorkInputId = (args: Record): string | undefined => + compactString(args.id) ?? compactString(args.cron_id) ?? compactString(args.cronId); + + const normalizeScheduledWorkLookupText = (value: unknown): string | undefined => { + const text = compactString(value); + if (!text) return undefined; + return text.replace(/\s+/g, " ").toLowerCase(); + }; + + const scheduledWorkPromptLookupKeys = (record: Record): string[] => { + const patch = asRecord(record.patch); + return [ + record.prompt, + record.description, + record.summary, + patch?.description, + patch?.prompt, + ].map(normalizeScheduledWorkLookupText).filter((key): key is string => Boolean(key)); + }; + + const rememberActiveProviderCron = ( + runtime: ClaudeRuntime, + providerCronId: string, + record: Record, + ): void => { + runtime.activeProviderCronIds.add(providerCronId); + for (const key of scheduledWorkPromptLookupKeys(record)) { + let ids = runtime.activeProviderCronIdsByPrompt.get(key); + if (!ids) { + ids = new Set(); + runtime.activeProviderCronIdsByPrompt.set(key, ids); + } + ids.add(providerCronId); + } + }; + + const forgetActiveProviderCron = (runtime: ClaudeRuntime, providerCronId: string): void => { + runtime.activeProviderCronIds.delete(providerCronId); + for (const [key, ids] of runtime.activeProviderCronIdsByPrompt) { + ids.delete(providerCronId); + if (!ids.size) runtime.activeProviderCronIdsByPrompt.delete(key); + } + }; + + const activeProviderCronIdForRecord = ( + runtime: ClaudeRuntime, + record: Record, + ): string | undefined => { + for (const key of scheduledWorkPromptLookupKeys(record)) { + const ids = runtime.activeProviderCronIdsByPrompt.get(key); + if (ids?.size === 1) return Array.from(ids)[0]; + } + return undefined; + }; + + const resolveClaudeScheduledWorkAlias = (runtime: ClaudeRuntime, id: string): string => + runtime.scheduledWorkIdByTaskId.get(id) ?? runtime.scheduledWorkIdByToolUseId.get(id) ?? id; + + const baseClaudeToolName = (toolName: string): string => { + const trimmed = toolName.trim(); + const namespaceSplit = trimmed.split("__").filter(Boolean).pop() ?? trimmed; + return namespaceSplit.split(/[.:/]/).filter(Boolean).pop() ?? namespaceSplit; + }; + + const scheduledWorkSignature = (event: ScheduledWorkEvent): string => { + const { turnId: _turnId, ...stable } = event; + return JSON.stringify(stable); + }; + + const emitClaudeScheduledWorkUpdate = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + event: ScheduledWorkEvent, + ): void => { + const id = event.id.trim(); + if (!id) return; + const normalized: ScheduledWorkEvent = { + ...event, + id, + kind: runtime.scheduledWorkKindById.get(id) ?? event.kind, + }; + runtime.scheduledWorkKindById.set(id, normalized.kind); + if (normalized.sourceToolUseId?.trim()) { + runtime.scheduledWorkIdByToolUseId.set(normalized.sourceToolUseId.trim(), id); + } + if (normalized.sourceTaskId?.trim()) { + runtime.scheduledWorkIdByTaskId.set(normalized.sourceTaskId.trim(), id); + } + const signature = scheduledWorkSignature(normalized); + if (runtime.scheduledWorkSignatures.get(id) === signature) return; + runtime.scheduledWorkSignatures.set(id, signature); + emitChatEvent(managed, normalized); + }; + + const resolveClaudeScheduledWorkId = ( + runtime: ClaudeRuntime, + taskId: string, + parentToolUseId?: string | null, + fallbackScheduledWorkId?: string | null, + ): string => { + const fromTask = runtime.scheduledWorkIdByTaskId.get(taskId); + if (fromTask) return fromTask; + const fromTool = parentToolUseId ? runtime.scheduledWorkIdByToolUseId.get(parentToolUseId) : undefined; + const fallback = fallbackScheduledWorkId ? resolveClaudeScheduledWorkAlias(runtime, fallbackScheduledWorkId) : null; + const resolved = fromTool ?? fallback ?? taskId; + runtime.scheduledWorkIdByTaskId.set(taskId, resolved); + return resolved; + }; + + const resolveClaudeCronScheduledWorkId = ( + runtime: ClaudeRuntime, + taskId: string, + parentToolUseId: string | null | undefined, + record: Record, + ): string | null => { + const fromTask = runtime.scheduledWorkIdByTaskId.get(taskId); + if (fromTask) return fromTask; + const fromTool = parentToolUseId ? runtime.scheduledWorkIdByToolUseId.get(parentToolUseId) : undefined; + if (fromTool) return resolveClaudeScheduledWorkAlias(runtime, fromTool); + const explicitCronId = scheduledWorkInputId(record); + const activeCronId = !parentToolUseId ? activeProviderCronIdForRecord(runtime, record) : undefined; + const singleActiveCronId = !parentToolUseId && runtime.activeProviderCronIds.size === 1 + ? Array.from(runtime.activeProviderCronIds)[0] + : undefined; + const hasKnownCronWork = !parentToolUseId + && Array.from(runtime.scheduledWorkKindById.values()).some((kind) => kind === "cron"); + if ( + !parentToolUseId + && !explicitCronId + && !activeCronId + && !singleActiveCronId + && (hasKnownCronWork || runtime.activeProviderCronIds.size > 1) + ) { + return null; + } + return resolveClaudeScheduledWorkId(runtime, taskId, parentToolUseId, explicitCronId ?? activeCronId ?? singleActiveCronId); + }; + + const normalizeClaudeScheduledStatus = (value: unknown): ScheduledWorkEvent["status"] => { + const normalized = typeof value === "string" ? value.trim().toLowerCase() : ""; + if (normalized === "completed" || normalized === "complete" || normalized === "done") return "completed"; + if (normalized === "failed" || normalized === "error") return "failed"; + if (normalized === "cancelled" || normalized === "canceled") return "cancelled"; + if (normalized === "killed" || normalized === "stopped") return "stopped"; + if (normalized === "missed") return "missed"; + if (normalized === "fired") return "fired"; + if (normalized === "pending" || normalized === "queued" || normalized === "scheduled") return "scheduled"; + return "running"; + }; + + const emitClaudeTranscriptRetraction = ( + managed: ManagedChatSession, + messageIds: unknown, + reason: Extract["reason"], + turnId?: string, + replacementMessageId?: string | null, + ): void => { + const ids = Array.isArray(messageIds) + ? [...new Set(messageIds + .filter((messageId): messageId is string => typeof messageId === "string" && messageId.trim().length > 0) + .map((messageId) => messageId.trim()))] + : []; + if (!ids.length) return; + emitChatEvent(managed, { + type: "transcript_retraction", + messageIds: ids, + reason, + ...(replacementMessageId ? { replacementMessageId } : {}), + ...(turnId ? { turnId } : {}), + }); + }; + + const maybeEmitClaudeScheduledWorkFromToolCall = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + toolName: string, + input: unknown, + itemId: string, + turnId?: string, + ): void => { + const tool = baseClaudeToolName(toolName); + const args = asRecord(input) ?? {}; + if (tool === "ScheduleWakeup") { + const stop = args.stop === true; + const wakeupId = `wakeup:${managed.session.id}`; + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: wakeupId, + kind: "wakeup", + status: stop ? "stopped" : "scheduled", + origin: "schedule_wakeup", + title: stop ? "Wakeup stopped" : "Wakeup scheduled", + reason: compactString(args.reason), + prompt: compactString(args.prompt), + sourceToolUseId: itemId, + ...(turnId ? { turnId } : {}), + }); + return; + } + + if (tool === "CronCreate") { + const cronId = scheduledWorkInputId(args); + if (!cronId) return; + if (args.recurring !== false) { + rememberActiveProviderCron(runtime, cronId, args); + } + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: cronId, + kind: "cron", + status: "scheduled", + origin: "cron", + title: "Cron scheduled", + cron: compactString(args.cron), + prompt: compactString(args.prompt), + recurring: args.recurring !== false, + durable: args.durable === true, + sourceToolUseId: itemId, + ...(turnId ? { turnId } : {}), + }); + return; + } + + if (tool === "CronDelete") { + const inputId = scheduledWorkInputId(args); + if (!inputId) return; + const id = resolveClaudeScheduledWorkAlias(runtime, inputId); + forgetActiveProviderCron(runtime, inputId); + forgetActiveProviderCron(runtime, id); + const kind = runtime.scheduledWorkKindById.get(id) ?? "cron"; + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id, + kind, + status: "cancelled", + origin: kind === "wakeup" ? "schedule_wakeup" : "cron", + title: kind === "wakeup" ? "Wakeup cancelled" : "Cron cancelled", + sourceToolUseId: itemId, + ...(turnId ? { turnId } : {}), + }); + return; + } + + if (tool === "RemoteTrigger") { + const action = compactString(args.action); + if (action !== "run" && action !== "create" && action !== "update") return; + const triggerId = compactString(args.trigger_id) ?? itemId; + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: triggerId, + kind: "remote_trigger", + status: action === "run" ? "running" : "completed", + origin: "remote_trigger", + title: action === "run" ? "Remote trigger running" : `Remote trigger ${action}`, + sourceToolUseId: itemId, + ...(turnId ? { turnId } : {}), + }); + } + }; + + const emitClaudeHookScheduledWorkSnapshots = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + input: HookInput, + ): void => { + const hookRecord = input as unknown as Record; + const turnId = runtime.activeTurnId ?? undefined; + const backgroundTasks = Array.isArray(hookRecord.background_tasks) ? hookRecord.background_tasks : []; + for (const rawTask of backgroundTasks) { + const task = asRecord(rawTask); + if (!task) continue; + const id = compactString(task.id); + if (!id) continue; + const type = compactString(task.type); + const description = compactString(task.description); + const command = compactString(task.command); + const agentType = compactString(task.agent_type); + const workflowName = compactString(task.name); + const title = description ?? command ?? workflowName ?? agentType ?? type ?? "Background work"; + const summaryParts = [ + type, + command ? `command: ${command}` : null, + agentType ? `agent: ${agentType}` : null, + workflowName ? `workflow: ${workflowName}` : null, + ].filter((part): part is string => Boolean(part)); + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: `background:${id}`, + kind: "background_task", + status: normalizeClaudeScheduledStatus(task.status), + origin: "background_task", + title, + summary: summaryParts.length ? summaryParts.join(" | ") : undefined, + sourceTaskId: id, + ...(turnId ? { turnId } : {}), + }); + } + + const hasSessionCrons = Array.isArray(hookRecord.session_crons); + if (hasSessionCrons) { + runtime.activeProviderCronIds.clear(); + runtime.activeProviderCronIdsByPrompt.clear(); + } + const sessionCrons = hasSessionCrons ? hookRecord.session_crons as unknown[] : []; + for (const rawCron of sessionCrons) { + const cron = asRecord(rawCron); + if (!cron) continue; + const id = compactString(cron.id); + if (!id) continue; + const recurring = cron.recurring !== false; + if (recurring) { + rememberActiveProviderCron(runtime, id, cron); + } + const scheduledWorkId = recurring ? id : `wakeup:${managed.session.id}`; + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: recurring ? "cron" : "wakeup", + status: "scheduled", + origin: recurring ? "cron" : "schedule_wakeup", + title: recurring ? "Cron scheduled" : "Wakeup scheduled", + cron: compactString(cron.schedule), + prompt: compactString(cron.prompt), + recurring, + sourceTaskId: id, + ...(turnId ? { turnId } : {}), + }); + } + }; + const publishChatEnvelope = (envelope: AgentChatEventEnvelope): void => { onEvent?.(envelope); for (const subscriber of eventSubscribers) { @@ -12030,81 +12377,794 @@ export function createAgentChatService(args: { }; } - if ( - statusCode === 401 || statusCode === 403 - || lower.includes("unauthorized") || lower.includes("forbidden") - || lower.includes("authentication failed") || lower.includes("invalid api key") - || lower.includes("api key") || lower.includes("invalid_api_key") - ) { - return { - message: `Authentication failed for ${modelDisplayName}. Check your API key in Settings.`, - errorInfo: { category: "auth", provider: providerFamily, model: modelDisplayName }, - }; + if ( + statusCode === 401 || statusCode === 403 + || lower.includes("unauthorized") || lower.includes("forbidden") + || lower.includes("authentication failed") || lower.includes("invalid api key") + || lower.includes("api key") || lower.includes("invalid_api_key") + ) { + return { + message: `Authentication failed for ${modelDisplayName}. Check your API key in Settings.`, + errorInfo: { category: "auth", provider: providerFamily, model: modelDisplayName }, + }; + } + + if (lower.includes("cost limit") || lower.includes("spending limit")) { + return { + message: "Session limit reached. Check Settings or start a new session.", + errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, + }; + } + + if ( + lower.includes("timeout") || lower.includes("timed out") || lower.includes("econnrefused") + || lower.includes("enotfound") || lower.includes("network") || lower.includes("fetch failed") + || lower.includes("econnreset") || lower.includes("socket hang up") + ) { + return { + message: `Connection to ${providerFamily} timed out. Check your network or try again.`, + errorInfo: { category: "network", provider: providerFamily, model: modelDisplayName }, + }; + } + + if (isAbortRelatedError(error)) { + return { + message: "Session was interrupted.", + errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, + }; + } + + return { + message: rawMessage, + errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, + }; + }; + + const openCodeSessionErrorMessage = (error: unknown): string => { + const record = asRecord(error); + const data = asRecord(record?.data); + const nested = asRecord(record?.error); + const nestedData = asRecord(nested?.data); + return ( + (typeof data?.message === "string" && data.message.trim()) + || (typeof nestedData?.message === "string" && nestedData.message.trim()) + || (typeof record?.message === "string" && record.message.trim()) + || "OpenCode session failed." + ); + }; + + const openCodeQuestionsToPendingQuestions = (questions: OpenCodeQuestionInfo[]): PendingInputQuestion[] => + questions.map((question, index) => ({ + id: `q_${index + 1}`, + header: question.header?.trim().length ? question.header.trim() : `Question ${index + 1}`, + question: question.question.trim(), + ...(question.multiple === true ? { multiSelect: true } : {}), + allowsFreeform: question.custom !== false || !question.options?.length, + ...(question.options?.length ? { + options: question.options.map((option) => ({ + label: option.label, + value: option.label, + ...(typeof option.description === "string" && option.description.trim().length + ? { description: option.description.trim() } + : {}), + })), + } : {}), + })); + + // ── Claude SDK streaming turn ── + + type ClaudeIdleTurnState = { + turnId: string | null; + assistantText: string; + usage?: { + inputTokens?: number | null; + outputTokens?: number | null; + cacheReadTokens?: number | null; + cacheCreationTokens?: number | null; + }; + costUsd: number | null; + emittedToolIds: Set; + openToolUses: Map; + toolInputJsonByContentIndex: Map; + toolUseMetaByContentIndex: Map; + currentStreamMessageId: string | null; + }; + + const startClaudeIdleTurn = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + state: ClaudeIdleTurnState, + detail = "Claude resumed background work", + ): string => { + if (state.turnId) return state.turnId; + const turnId = `claude-idle-${randomUUID()}`; + state.turnId = turnId; + captureTurnBeforeSha(managed); + runtime.busy = true; + runtime.activeTurnId = turnId; + setSessionActive(managed); + emitChatEvent(managed, { type: "status", turnStatus: "started", turnId }); + emitChatEvent(managed, { + type: "activity", + activity: "working", + detail, + turnId, + }); + return turnId; + }; + + const finishClaudeIdleTurn = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + state: ClaudeIdleTurnState, + status: "completed" | "interrupted" | "failed" = "completed", + ): Promise => { + const turnId = state.turnId; + if (!turnId) return; + for (const [itemId, toolMeta] of state.openToolUses.entries()) { + emitChatEvent(managed, { + type: "tool_result", + tool: toolMeta.toolName, + result: { + synthetic: true, + source: "claude_idle_turn_finalization", + finalTurnStatus: status, + summary: `Completed ${toolMeta.toolName} when the Claude background turn ended.`, + }, + itemId, + turnId, + status, + }); + } + state.openToolUses.clear(); + runtime.busy = false; + runtime.activeTurnId = null; + markSessionIdleWithFreshCache(managed); + reportProviderRuntimeReady("claude"); + emitChatEvent(managed, { type: "status", turnStatus: status, turnId }); + void emitTurnDiffSummaryIfChanged(managed, turnId); + emitChatEvent(managed, { + type: "done", + turnId, + status, + ...resolveClaudeTurnModelPayload(managed.session, []), + ...(state.usage ? { usage: state.usage } : {}), + ...(state.costUsd != null ? { costUsd: state.costUsd } : {}), + }); + if (state.assistantText.trim().length > 0) { + appendCtoTurnJournal(managed, { assistantText: state.assistantText }); + } + const endSha = await computeHeadShaBestEffort(resolveManagedExecutionLaneId(managed)).catch(() => null); + if (endSha) { + sessionService.setHeadShaEnd(managed.session.id, endSha); + } + persistChatState(managed); + state.turnId = null; + if (runtime.pendingSteers.length && managed.runtime === runtime) { + runtime.idleReaderPromise = null; + await deliverNextQueuedSteer(managed, runtime); + } + }; + + const handleClaudeIdleTaskMessage = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + msg: Record, + state: ClaudeIdleTurnState, + ): boolean => { + const subtype = compactString(msg.subtype); + if (subtype !== "task_started" && subtype !== "task_progress" && subtype !== "task_updated" && subtype !== "task_notification") { + return false; + } + const taskId = compactString(msg.task_id); + if (!taskId) return true; + const existing = runtime.activeSubagents.get(taskId); + if (existing?.skipTranscript) { + if (subtype === "task_notification") { + runtime.activeSubagents.delete(taskId); + } else if (subtype === "task_updated") { + const patch = asRecord(msg.patch) ?? {}; + const status = compactString(patch.status); + if (status === "completed" || status === "failed" || status === "killed") { + runtime.activeSubagents.delete(taskId); + } + } + return true; + } + if (subtype === "task_started" && msg.skip_transcript === true) { + runtime.activeSubagents.set(taskId, { + taskId, + description: compactString(msg.description) ?? "", + parentToolUseId: taskParentToolUseId(msg), + skipTranscript: true, + }); + return true; + } + const taskType = normalizeClaudeTaskType(msg.task_type) ?? existing?.taskType; + const workflowName = normalizeClaudeWorkflowName(msg.workflow_name) ?? existing?.workflowName; + const parentToolUseId = taskParentToolUseId(msg) ?? existing?.parentToolUseId ?? null; + const agentId = compactString(msg.agent_id) ?? existing?.agentId; + const parentAgentId = compactString(msg.parent_agent_id) ?? existing?.parentAgentId ?? null; + const agentType = compactString(msg.subagent_type) ?? existing?.agentType; + const description = compactString(msg.description) ?? existing?.description ?? "Background task"; + const turnId = startClaudeIdleTurn(managed, runtime, state, taskType === "cron" ? "Claude scheduled task is running" : "Claude background task is running"); + if (subtype === "task_started") { + const background = taskType === "background" || taskType === "cron" || taskType === "local_workflow" || isBackgroundTask(msg); + runtime.activeSubagents.set(taskId, { + taskId, + description, + parentToolUseId, + background, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + }); + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: "running", + origin: "cron", + title: description, + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + turnId, + }); + } + } + emitChatEvent(managed, { + type: "subagent_started", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + description, + background, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + return true; + } + if (subtype === "task_notification") { + const summary = String(msg.summary ?? existing?.finalSummary ?? ""); + runtime.activeSubagents.delete(taskId); + if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); + const finalStatus = msg.status === "completed" ? "completed" : msg.status === "stopped" ? "stopped" : "failed"; + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: finalStatus, + origin: "cron", + title: existing?.description ?? description, + summary, + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + turnId, + }); + } + } + emitChatEvent(managed, { + type: "subagent_result", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + status: finalStatus, + summary, + finalSummary: summary, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + return true; + } + const patch = asRecord(msg.patch) ?? {}; + const status = compactString(patch.status); + const summary = compactString(msg.summary) ?? compactString(patch.error) ?? (status ? `Status: ${status.replace(/_/g, " ")}` : "Task updated"); + if (status === "completed" || status === "failed" || status === "killed") { + runtime.activeSubagents.delete(taskId); + if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); + const finalStatus = status === "completed" ? "completed" : status === "killed" ? "stopped" : "failed"; + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, msg); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: finalStatus, + origin: "cron", + title: existing?.description ?? description, + summary, + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + turnId, + }); + } + } + emitChatEvent(managed, { + type: "subagent_result", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + status: finalStatus, + summary, + finalSummary: summary, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + return true; + } + runtime.activeSubagents.set(taskId, { + taskId, + description, + parentToolUseId, + background: patch.is_backgrounded === true || existing?.background === true, + finalSummary: existing?.finalSummary, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + }); + emitChatEvent(managed, { + type: "subagent_progress", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + description, + summary, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + return true; + }; + + const handleClaudeIdleMessage = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + msg: SDKMessage, + state: ClaudeIdleTurnState, + ): Promise => { + const record = msg as unknown as Record; + const messageSessionId = compactString(record.session_id); + if (messageSessionId && runtime.sdkSessionId !== messageSessionId) { + runtime.sdkSessionId = messageSessionId; + mirrorClaudeSessionPointer(managed, runtime.sdkSessionId); + persistChatState(managed); + } + + if (msg.type === "system" && record.subtype === "init") { + const initCommands = Array.isArray(record.slash_commands) ? record.slash_commands : []; + if (initCommands.length) applyClaudeSlashCommands(runtime, initCommands as any[]); + persistChatState(managed); + return; + } + + if (msg.type === "system" && record.subtype === "commands_changed") { + if (Array.isArray(record.commands)) { + applyClaudeSlashCommands(runtime, record.commands as any[]); + persistChatState(managed); + } + return; + } + + if (msg.type === "system" && record.subtype === "model_refusal_fallback") { + emitClaudeTranscriptRetraction(managed, record.retracted_message_uuids, "model_refusal_fallback", state.turnId ?? undefined); + return; + } + + if (msg.type === "system" && record.subtype === "notification") { + const text = compactString(record.text); + if (!text) return; + const priority = compactString(record.priority) ?? "medium"; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: priority === "high" || priority === "immediate" ? "warning" : "info", + severity: priority === "high" || priority === "immediate" ? "warning" : "info", + status: "notification", + message: text, + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + return; + } + + if (msg.type === "system" && handleClaudeIdleTaskMessage(managed, runtime, record, state)) { + return; + } + + if (msg.type === "auth_status") { + const authRecord = record; + if (compactString(authRecord.error)) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "auth", + severity: "warning", + message: CLAUDE_AUTH_STOPPED_NOTICE, + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + } else if (authRecord.isAuthenticating === true) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "auth", + message: "Authenticating...", + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + } + return; + } + + if (msg.type === "rate_limit_event") { + const info = asRecord(record.rate_limit_info) ?? {}; + const rawStatus = compactString(info.status) ?? "updated"; + if (rawStatus === "allowed") return; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "rate_limit", + severity: rawStatus === "allowed_warning" ? "info" : "error", + status: rawStatus, + message: rawStatus === "allowed_warning" ? "Approaching Claude plan limit" : `Claude rate limit ${rawStatus.replace(/_/g, " ")}`, + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + return; + } + + if (msg.type === "assistant") { + const assistantMsg = record; + const betaMessage = asRecord(assistantMsg.message); + const assistantWireUuid = compactString(assistantMsg.uuid); + const assistantMessageId = compactString(betaMessage?.id); + const providerMessageId = assistantWireUuid ?? assistantMessageId ?? null; + const turnId = startClaudeIdleTurn(managed, runtime, state); + emitClaudeTranscriptRetraction(managed, assistantMsg.supersedes, "assistant_supersedes", turnId, providerMessageId); + const content = Array.isArray(betaMessage?.content) ? betaMessage.content : []; + for (const [index, rawBlock] of content.entries()) { + const block = asRecord(rawBlock); + if (!block) continue; + if (block.type === "text") { + const text = typeof block.text === "string" ? block.text : ""; + if (text.length) { + state.assistantText += text; + emitChatEvent(managed, { + type: "text", + text, + ...(providerMessageId ? { messageId: providerMessageId } : {}), + turnId, + }); + } + } else if (block.type === "thinking") { + const text = typeof block.thinking === "string" ? block.thinking : typeof block.text === "string" ? block.text : ""; + if (text.trim().length) { + emitChatEvent(managed, { + type: "reasoning", + text, + itemId: `claude-thinking:${turnId}:${index}`, + turnId, + }); + } + } else if (block.type === "tool_use") { + const toolName = compactString(block.name) ?? "tool"; + const itemId = compactString(block.id) ?? `claude-tool:${turnId}:${index}`; + if (!state.emittedToolIds.has(itemId)) { + state.emittedToolIds.add(itemId); + state.openToolUses.set(itemId, { toolName }); + emitChatEvent(managed, { + type: "tool_call", + tool: toolName, + args: block.input ?? {}, + itemId, + turnId, + }); + maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, toolName, block.input, itemId, turnId); + } + } + } + const usage = asRecord(betaMessage?.usage); + if (usage) { + state.usage = { + inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : null, + outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : null, + }; + } + return; + } + + if (msg.type === "stream_event") { + const streamMsg = record; + const event = asRecord(streamMsg.event); + if (!event) return; + const turnId = startClaudeIdleTurn(managed, runtime, state); + const contentIndex = typeof event.index === "number" ? event.index : null; + if (event.type === "message_start") { + const message = asRecord(event.message); + state.currentStreamMessageId = compactString(message?.id) ?? compactString(streamMsg.uuid) ?? null; + return; + } + if (event.type === "content_block_delta") { + const delta = asRecord(event.delta); + if (delta?.type === "text_delta") { + const text = typeof delta.text === "string" ? delta.text : ""; + if (text.length) { + state.assistantText += text; + const providerMessageId = compactString(streamMsg.uuid) ?? state.currentStreamMessageId; + emitChatEvent(managed, { + type: "text", + text, + ...(providerMessageId ? { messageId: providerMessageId } : {}), + turnId, + }); + } + } else if (delta?.type === "thinking_delta") { + const text = typeof delta.thinking === "string" ? delta.thinking : typeof delta.text === "string" ? delta.text : ""; + if (text.length) { + emitChatEvent(managed, { + type: "reasoning", + text, + ...(contentIndex != null ? { itemId: `claude-thinking:${turnId}:${contentIndex}` } : {}), + turnId, + }); + } + } else if (delta?.type === "input_json_delta" && contentIndex != null) { + const partial = typeof delta.partial_json === "string" ? delta.partial_json : ""; + state.toolInputJsonByContentIndex.set(contentIndex, `${state.toolInputJsonByContentIndex.get(contentIndex) ?? ""}${partial}`); + } + return; + } + if (event.type === "content_block_start") { + const block = asRecord(event.content_block); + if (block?.type !== "tool_use") return; + const toolName = compactString(block.name) ?? "tool"; + const itemId = compactString(block.id) ?? (contentIndex != null ? `claude-tool:${turnId}:${contentIndex}` : randomUUID()); + if (state.emittedToolIds.has(itemId)) return; + state.emittedToolIds.add(itemId); + state.openToolUses.set(itemId, { toolName }); + emitChatEvent(managed, { + type: "tool_call", + tool: toolName, + args: block.input ?? {}, + itemId, + turnId, + }); + if (contentIndex != null) { + const initial = block.input != null && typeof block.input === "object" && Object.keys(block.input as object).length + ? JSON.stringify(block.input) + : ""; + state.toolInputJsonByContentIndex.set(contentIndex, initial); + state.toolUseMetaByContentIndex.set(contentIndex, { + toolName, + itemId, + ...(compactString(block.id) ? { toolUseId: compactString(block.id) } : {}), + argsWereEmpty: initial.length === 0, + }); + } + return; + } + if (event.type === "content_block_stop" && contentIndex != null) { + const meta = state.toolUseMetaByContentIndex.get(contentIndex); + if (!meta) return; + state.toolUseMetaByContentIndex.delete(contentIndex); + const raw = state.toolInputJsonByContentIndex.get(contentIndex) ?? ""; + state.toolInputJsonByContentIndex.delete(contentIndex); + let parsed: unknown = {}; + if (raw.trim().length) { + try { parsed = JSON.parse(raw); } catch { parsed = {}; } + } + if (meta.argsWereEmpty && parsed && typeof parsed === "object" && Object.keys(parsed as object).length > 0) { + emitChatEvent(managed, { + type: "tool_call", + tool: meta.toolName, + args: parsed, + itemId: meta.itemId, + turnId, + }); + } + maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, meta.toolName, parsed, meta.itemId, turnId); + } + return; + } + + if (msg.type === "tool_progress") { + const turnId = startClaudeIdleTurn(managed, runtime, state); + emitChatEvent(managed, { + type: "activity", + activity: "tool_calling", + detail: `Tool '${compactString(record.tool_name) ?? "tool"}' running (${Math.round(typeof record.elapsed_time_seconds === "number" ? record.elapsed_time_seconds : 0)}s)`, + turnId, + }); + return; } - if (lower.includes("cost limit") || lower.includes("spending limit")) { - return { - message: "Session limit reached. Check Settings or start a new session.", - errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, - }; + if (msg.type === "result") { + const resultMsg = record; + const turnId = state.turnId; + const usage = asRecord(resultMsg.usage); + if (usage) { + state.usage = { + inputTokens: typeof usage.input_tokens === "number" ? usage.input_tokens : null, + outputTokens: typeof usage.output_tokens === "number" ? usage.output_tokens : null, + cacheReadTokens: typeof usage.cache_read_input_tokens === "number" ? usage.cache_read_input_tokens : null, + cacheCreationTokens: typeof usage.cache_creation_input_tokens === "number" ? usage.cache_creation_input_tokens : null, + }; + } + if (typeof resultMsg.total_cost_usd === "number") { + state.costUsd = resultMsg.total_cost_usd; + } + if (resultMsg.is_error === true && Array.isArray(resultMsg.errors) && turnId) { + for (const error of resultMsg.errors) { + emitChatEvent(managed, { type: "error", message: String(error), turnId }); + } + } + await finishClaudeIdleTurn(managed, runtime, state, resultMsg.is_error === true ? "failed" : "completed"); + return; } - if ( - lower.includes("timeout") || lower.includes("timed out") || lower.includes("econnrefused") - || lower.includes("enotfound") || lower.includes("network") || lower.includes("fetch failed") - || lower.includes("econnreset") || lower.includes("socket hang up") - ) { - return { - message: `Connection to ${providerFamily} timed out. Check your network or try again.`, - errorInfo: { category: "network", provider: providerFamily, model: modelDisplayName }, - }; + if ((msg as any).type === "tool_use_summary" && state.turnId) { + const summaryMsg = record; + const toolUseIds = Array.isArray(summaryMsg.preceding_tool_use_ids) ? summaryMsg.preceding_tool_use_ids.map(String) : []; + for (const toolUseId of toolUseIds) { + state.openToolUses.delete(toolUseId); + } + emitChatEvent(managed, { + type: "tool_use_summary", + summary: String(summaryMsg.summary ?? ""), + toolUseIds, + turnId: state.turnId, + }); + return; } - if (isAbortRelatedError(error)) { - return { - message: "Session was interrupted.", - errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, - }; + if ((msg as any).type === "prompt_suggestion") { + const suggestion = [record.suggestion, record.prompt, record.text] + .find((value): value is string => typeof value === "string" && value.trim().length > 0)?.trim(); + if (suggestion) { + emitLiveOnlyChatEvent(managed, { + type: "prompt_suggestion", + suggestion, + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + } } + }; - return { - message: rawMessage, - errorInfo: { category: "unknown", provider: providerFamily, model: modelDisplayName }, + const startClaudeIdleReader = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + reason: "turn_completed" | "query_ready" = "turn_completed", + ): void => { + if (managed.closed || managed.deleted) return; + if (!runtime.query || !runtime.inputPump) return; + if (runtime.busy || runtime.activeTurnId) return; + if (runtime.idleReaderPromise) return; + + const sessionQuery = runtime.query; + const generation = ++runtime.idleReaderGeneration; + const state: ClaudeIdleTurnState = { + turnId: null, + assistantText: "", + costUsd: null, + emittedToolIds: new Set(), + openToolUses: new Map(), + toolInputJsonByContentIndex: new Map(), + toolUseMetaByContentIndex: new Map(), + currentStreamMessageId: null, }; - }; - const openCodeSessionErrorMessage = (error: unknown): string => { - const record = asRecord(error); - const data = asRecord(record?.data); - const nested = asRecord(record?.error); - const nestedData = asRecord(nested?.data); - return ( - (typeof data?.message === "string" && data.message.trim()) - || (typeof nestedData?.message === "string" && nestedData.message.trim()) - || (typeof record?.message === "string" && record.message.trim()) - || "OpenCode session failed." - ); - }; + logger.debug("agent_chat.claude_idle_reader_start", { + sessionId: managed.session.id, + reason, + sdkSessionId: runtime.sdkSessionId, + }); - const openCodeQuestionsToPendingQuestions = (questions: OpenCodeQuestionInfo[]): PendingInputQuestion[] => - questions.map((question, index) => ({ - id: `q_${index + 1}`, - header: question.header?.trim().length ? question.header.trim() : `Question ${index + 1}`, - question: question.question.trim(), - ...(question.multiple === true ? { multiSelect: true } : {}), - allowsFreeform: question.custom !== false || !question.options?.length, - ...(question.options?.length ? { - options: question.options.map((option) => ({ - label: option.label, - value: option.label, - ...(typeof option.description === "string" && option.description.trim().length - ? { description: option.description.trim() } - : {}), - })), - } : {}), - })); + const handoffToForegroundIfActive = ( + nextMessage: Promise>, + next: IteratorResult, + ): boolean => { + if (!((runtime.busy || runtime.activeTurnId) && runtime.activeTurnId !== state.turnId)) { + return false; + } + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNext = Promise.resolve(next); + runtime.pendingPostResultNextSettledAt = Date.now(); + } + return true; + }; - // ── Claude SDK streaming turn ── + runtime.idleReaderPromise = (async () => { + try { + while ( + !managed.closed + && !managed.deleted + && managed.runtime === runtime + && runtime.query === sessionQuery + && runtime.idleReaderGeneration === generation + ) { + if ((runtime.busy || runtime.activeTurnId) && runtime.activeTurnId !== state.turnId) { + return; + } + const nextMessage = runtime.pendingPostResultNext ?? sessionQuery.next(); + if (runtime.pendingPostResultNext !== nextMessage) { + runtime.pendingPostResultNext = nextMessage; + runtime.pendingPostResultNextSettledAt = null; + nextMessage.then( + () => { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNextSettledAt = Date.now(); + } + }, + () => { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNextSettledAt = Date.now(); + } + }, + ); + } + let next: IteratorResult; + try { + next = await nextMessage; + } catch (error) { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; + } + logger.debug("agent_chat.claude_idle_reader_next_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + return; + } + if ( + managed.runtime !== runtime + || runtime.query !== sessionQuery + || runtime.idleReaderGeneration !== generation + ) { + return; + } + if (handoffToForegroundIfActive(nextMessage, next)) { + return; + } + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; + } + if (next.done) { + await finishClaudeIdleTurn(managed, runtime, state, "completed"); + return; + } + await handleClaudeIdleMessage(managed, runtime, next.value, state); + } + } catch (error) { + logger.warn("agent_chat.claude_idle_reader_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + if (state.turnId && runtime.activeTurnId === state.turnId) { + await finishClaudeIdleTurn(managed, runtime, state, "failed").catch(() => undefined); + } + } finally { + if (runtime.idleReaderGeneration === generation && runtime.idleReaderPromise) { + runtime.idleReaderPromise = null; + } + } + })(); + }; const runClaudeTurn = async ( managed: ManagedChatSession, @@ -12586,6 +13646,15 @@ export function createAgentChatService(args: { continue; } + if (msg.type === "system" && (msg as any).subtype === "commands_changed") { + const commandsMsg = msg as any; + if (Array.isArray(commandsMsg.commands)) { + applyClaudeSlashCommands(runtime, commandsMsg.commands); + persistChatState(managed); + } + continue; + } + // system:status — permission mode changes and turn-status signals. // // The SDK CLI emits SDKStatusMessage with the new permissionMode when @@ -12829,17 +13898,18 @@ export function createAgentChatService(args: { ? `retracted ${retractedUuids.length} SDK message${retractedUuids.length === 1 ? "" : "s"}: ${retractedUuids.slice(0, 5).join(", ")}${retractedUuids.length > 5 ? ", ..." : ""}` : null, ].filter((entry): entry is string => Boolean(entry)); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "warning", - severity: "warning", - status: "model_refusal_fallback", - message: `Claude retried with ${fallback} after ${original} refused the request.`, - detail: details.length ? details.join(" | ") : undefined, - turnId, - }); - continue; - } + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + status: "model_refusal_fallback", + message: `Claude retried with ${fallback} after ${original} refused the request.`, + detail: details.length ? details.join(" | ") : undefined, + turnId, + }); + emitClaudeTranscriptRetraction(managed, retractedUuids, "model_refusal_fallback", turnId); + continue; + } // system:informational — loop banners such as hook block feedback. if (msg.type === "system" && (msg as any).subtype === "informational") { @@ -12989,6 +14059,7 @@ export function createAgentChatService(args: { ?? (typeof taskMsg.subagent_type === "string" && taskMsg.subagent_type.trim().length ? taskMsg.subagent_type.trim() : undefined); const agentId = existing?.agentId ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); + const parentAgentId = compactString(taskMsg.parent_agent_id) ?? existing?.parentAgentId ?? null; const taskType = existing?.taskType ?? normalizeClaudeTaskType(taskMsg.task_type); const workflowName = existing?.workflowName ?? normalizeClaudeWorkflowName(taskMsg.workflow_name); // Undocumented Workflow-run snapshot (phases + per-agent status). @@ -13004,6 +14075,7 @@ export function createAgentChatService(args: { finalSummary: existing?.finalSummary, ...(agentType ? { agentType } : {}), ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), }); @@ -13011,6 +14083,7 @@ export function createAgentChatService(args: { type: "subagent_progress", taskId, ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), ...(agentType ? { agentType } : {}), parentToolUseId, description, @@ -13046,6 +14119,89 @@ export function createAgentChatService(args: { continue; } + if (msg.type === "system" && (msg as any).subtype === "task_updated") { + const taskMsg = msg as any; + const taskId = String(taskMsg.task_id ?? ""); + if (!taskId) continue; + const existing = runtime.activeSubagents.get(taskId); + if (existing?.skipTranscript) continue; + const patch = asRecord(taskMsg.patch) ?? {}; + const status = compactString(patch.status); + const description = compactString(patch.description) ?? existing?.description ?? "Task update"; + const parentToolUseId = existing?.parentToolUseId ?? null; + const agentId = existing?.agentId; + const parentAgentId = existing?.parentAgentId ?? null; + const agentType = existing?.agentType; + const taskType = existing?.taskType; + const workflowName = existing?.workflowName; + const background = patch.is_backgrounded === true || existing?.background === true; + const summary = compactString(patch.error) + ?? (status ? `Status: ${status.replace(/_/g, " ")}` : "Task updated"); + if (status === "completed" || status === "failed" || status === "killed") { + runtime.activeSubagents.delete(taskId); + if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); + const finalStatus = status === "completed" ? "completed" : status === "killed" ? "stopped" : "failed"; + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, taskMsg as Record); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: finalStatus, + origin: "cron", + title: existing?.description ?? description, + summary, + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + turnId, + }); + } + } + emitChatEvent(managed, { + type: "subagent_result", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + status: finalStatus, + summary, + finalSummary: summary, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + } else { + runtime.activeSubagents.set(taskId, { + taskId, + description, + parentToolUseId, + background, + finalSummary: existing?.finalSummary, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + }); + emitChatEvent(managed, { + type: "subagent_progress", + taskId, + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(agentType ? { agentType } : {}), + parentToolUseId, + description, + summary, + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + turnId, + }); + } + continue; + } + // system:task_started — a task in the SDK started. task_type tells us // what kind: "subagent" (Task tool), "background" (Bash run_in_background // and similar), "local_workflow" (e.g. /spec), "cron" (scheduled), or @@ -13081,6 +14237,7 @@ export function createAgentChatService(args: { const agentId = typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined; + const parentAgentId = compactString(taskMsg.parent_agent_id); const taskType = normalizeClaudeTaskType(taskMsg.task_type); const workflowName = normalizeClaudeWorkflowName(taskMsg.workflow_name); // The SDK sets task_type explicitly. When absent, fall back to the @@ -13091,6 +14248,22 @@ export function createAgentChatService(args: { || taskType === "local_workflow" || isBackgroundTask(taskMsg as Record) || stashed?.isBackground === true; + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, taskMsg as Record); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: "running", + origin: "cron", + title: description || "Scheduled task running", + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + ...(turnId ? { turnId } : {}), + }); + } + } runtime.activeSubagents.set(taskId, { taskId, description, @@ -13098,6 +14271,7 @@ export function createAgentChatService(args: { background, ...(agentType ? { agentType } : {}), ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), ...(taskType ? { taskType } : {}), ...(workflowName ? { workflowName } : {}), }); @@ -13117,6 +14291,7 @@ export function createAgentChatService(args: { type: "subagent_started", taskId, ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), ...(agentType ? { agentType } : {}), parentToolUseId, description, @@ -13148,14 +14323,33 @@ export function createAgentChatService(args: { ?? (typeof taskMsg.subagent_type === "string" && taskMsg.subagent_type.trim().length ? taskMsg.subagent_type.trim() : undefined); const agentId = existing?.agentId ?? (typeof taskMsg.agent_id === "string" && taskMsg.agent_id.trim().length ? taskMsg.agent_id.trim() : undefined); + const parentAgentId = compactString(taskMsg.parent_agent_id) ?? existing?.parentAgentId ?? null; const taskType = existing?.taskType ?? normalizeClaudeTaskType(taskMsg.task_type); const workflowName = existing?.workflowName ?? normalizeClaudeWorkflowName(taskMsg.workflow_name); + if (taskType === "cron") { + const scheduledWorkId = resolveClaudeCronScheduledWorkId(runtime, taskId, parentToolUseId, taskMsg as Record); + if (scheduledWorkId) { + emitClaudeScheduledWorkUpdate(managed, runtime, { + type: "scheduled_work_update", + id: scheduledWorkId, + kind: "cron", + status: taskMsg.status === "completed" ? "completed" : taskMsg.status === "stopped" ? "stopped" : "failed", + origin: "cron", + title: existing?.description || "Scheduled task", + summary, + ...(parentToolUseId ? { sourceToolUseId: parentToolUseId } : {}), + sourceTaskId: taskId, + ...(turnId ? { turnId } : {}), + }); + } + } runtime.activeSubagents.delete(taskId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); emitChatEvent(managed, { type: "subagent_result", taskId, ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), ...(agentType ? { agentType } : {}), parentToolUseId, status: taskMsg.status === "completed" ? "completed" : taskMsg.status === "stopped" ? "stopped" : "failed", @@ -13205,6 +14399,15 @@ export function createAgentChatService(args: { } const betaMessage = assistantMsg.message; const assistantMessageId = typeof betaMessage?.id === "string" ? betaMessage.id : null; + const assistantWireUuid = compactString(assistantMsg.uuid); + const assistantProviderMessageId = assistantWireUuid ?? assistantMessageId ?? null; + emitClaudeTranscriptRetraction( + managed, + assistantMsg.supersedes, + "assistant_supersedes", + turnId, + assistantProviderMessageId, + ); // If the snapshot has no id, advance the id-less boundary once the // prior snapshot is sealed — so two back-to-back id-less assistants // don't alias to the same key. While the stream preamble is actively @@ -13243,6 +14446,7 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "text", text: textToEmit, + ...(assistantProviderMessageId ? { messageId: assistantProviderMessageId } : {}), turnId, }); } @@ -13298,14 +14502,15 @@ export function createAgentChatService(args: { detail: nextActivity.detail, turnId, }); - emitChatEvent(managed, { - type: "tool_call", - tool: toolName, - args: block.input ?? {}, - itemId, - turnId, - }); - maybeEmitTodoUpdate(toolName, block.input, itemId); + emitChatEvent(managed, { + type: "tool_call", + tool: toolName, + args: block.input ?? {}, + itemId, + turnId, + }); + maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, toolName, block.input, itemId, turnId); + maybeEmitTodoUpdate(toolName, block.input, itemId); // Synthesize a tool_result for the proof observer when the // SDK stream does not surface tool results directly. const syntheticResult = maybeSyntheticToolResult(toolName, block.input ?? {}, itemId, turnId); @@ -13341,13 +14546,19 @@ export function createAgentChatService(args: { const delta = event.delta; if (delta?.type === "text_delta") { const text = delta.text ?? ""; - if (text.length) { - const textKey = claudeDedupeKey(currentClaudeStreamMessageId, contentIndex); - if (textKey) streamedClaudeTextContentKeys.add(textKey); - recentClaudeTextDeltaBuffer += text; - assistantText += text; - emitChatEvent(managed, { type: "text", text, turnId }); - } + if (text.length) { + const textKey = claudeDedupeKey(currentClaudeStreamMessageId, contentIndex); + if (textKey) streamedClaudeTextContentKeys.add(textKey); + recentClaudeTextDeltaBuffer += text; + assistantText += text; + const streamProviderMessageId = compactString(streamMsg.uuid) ?? currentClaudeStreamMessageId; + emitChatEvent(managed, { + type: "text", + text, + ...(streamProviderMessageId ? { messageId: streamProviderMessageId } : {}), + turnId, + }); + } } else if (delta?.type === "thinking_delta") { const text = delta.thinking ?? delta.text ?? ""; if (text.length) { @@ -13483,8 +14694,8 @@ export function createAgentChatService(args: { // of a bare tool name. Desktop's appendWorkLogRow and the TUI's // tool registry both collapse this into the original entry via // turn+itemId, so no duplicate row renders. - if ( - meta.argsWereEmpty + if ( + meta.argsWereEmpty && parsed != null && typeof parsed === "object" && Object.keys(parsed as object).length > 0 @@ -13496,8 +14707,9 @@ export function createAgentChatService(args: { itemId: meta.itemId, turnId, }); - } - maybeEmitTodoUpdate(meta.toolName, parsed, meta.itemId); + } + maybeEmitClaudeScheduledWorkFromToolCall(managed, runtime, meta.toolName, parsed, meta.itemId, turnId); + maybeEmitTodoUpdate(meta.toolName, parsed, meta.itemId); const syntheticResult = maybeSyntheticToolResult(meta.toolName, parsed, meta.itemId, turnId); if (syntheticResult && !emittedSyntheticItemIds.has(meta.itemId)) { emittedSyntheticItemIds.add(meta.itemId); @@ -13729,6 +14941,8 @@ export function createAgentChatService(args: { // Process queued steers (skip if session was disposed during execution) if (runtime.pendingSteers.length) { await deliverNextQueuedSteer(managed, runtime); + } else { + startClaudeIdleReader(managed, runtime, "turn_completed"); } } catch (error) { clearClaudeTurnTimers(); @@ -19103,20 +20317,21 @@ export function createAgentChatService(args: { SubagentStop: [ { hooks: [ - async (input: HookInput) => { - if (input.hook_event_name === "SubagentStop") { - const taskId = input.agent_id; - const existing = runtime.activeSubagents.get(taskId); - runtime.activeSubagents.set(taskId, { + async (input: HookInput) => { + if (input.hook_event_name === "SubagentStop") { + const taskId = input.agent_id; + const existing = runtime.activeSubagents.get(taskId); + runtime.activeSubagents.set(taskId, { taskId, description: existing?.description ?? input.agent_type, parentToolUseId: existing?.parentToolUseId ?? null, - background: existing?.background, - finalSummary: input.last_assistant_message ?? "", - }); - } - return { continue: true }; - }, + background: existing?.background, + finalSummary: input.last_assistant_message ?? "", + }); + emitClaudeHookScheduledWorkSnapshots(managed, runtime, input); + } + return { continue: true }; + }, ], }, ], @@ -19186,13 +20401,16 @@ export function createAgentChatService(args: { ], }, ], - Stop: [ - { - hooks: [ - async () => ({ continue: true }), - ], - }, - ], + Stop: [ + { + hooks: [ + async (input: HookInput) => { + emitClaudeHookScheduledWorkSnapshots(managed, runtime, input); + return { continue: true }; + }, + ], + }, + ], TeammateIdle: [ { hooks: [ @@ -19364,8 +20582,8 @@ export function createAgentChatService(args: { append: [ "## Runtime Environment", "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API. The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.", - "**Wake-up semantics:** Work confidently inside the active turn. ADE keeps the SDK query alive for streamed user and steer messages, but this chat does not currently expose Claude Code CLI's scheduled self-resume. Treat `ScheduleWakeup` as unavailable in this ADE chat: it will not start a later turn by itself. `Bash run_in_background: true` notifications may appear in the SDK stream when ADE is reading a turn, but do not rely on them to begin a new turn.", - "**To wait:** For bounded waits, keep the turn active with a foreground command such as `sleep ... && ` or one bounded `until ... ; do sleep N; done` loop. For longer external waits, save state and tell the user exactly what to re-ping when ready.", + "**Wake-up semantics:** ADE keeps the Claude Agent SDK query alive after a visible turn completes and continues reading the session stream while this chat is idle. You may use Claude Code's scheduled/background capabilities (`ScheduleWakeup`, `CronCreate`, `/loop`, background tasks) when they fit the task. ADE will surface scheduled work, wakeups, background-task notifications, and later resumed output in Chat Info.", + "**To wait:** For short bounded waits inside the current turn, a foreground command such as `sleep ... && ` is fine. For longer waits or autonomous follow-up, prefer the SDK's scheduled/background tools and include a concise reason/prompt so ADE can show the pending work clearly.", "", "## ADE Workspace", `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, @@ -19445,12 +20663,20 @@ export function createAgentChatService(args: { options: { clearSdkSessionId?: boolean } = {}, ): void => { cancelClaudeWarmup(managed, runtime, reason); + runtime.idleReaderGeneration += 1; + runtime.idleReaderPromise = null; try { runtime.query?.close(); } catch { /* ignore */ } runtime.inputPump?.close(); runtime.query = null; runtime.inputPump = null; runtime.pendingPostResultNext = null; runtime.pendingPostResultNextSettledAt = null; + runtime.scheduledWorkKindById.clear(); + runtime.scheduledWorkSignatures.clear(); + runtime.scheduledWorkIdByTaskId.clear(); + runtime.scheduledWorkIdByToolUseId.clear(); + runtime.activeProviderCronIds.clear(); + runtime.activeProviderCronIdsByPrompt.clear(); runtime.warmupDone = null; if (options.clearSdkSessionId && runtime.sdkSessionId) { logger.info("agent_chat.claude_sdk_session_cleared", { @@ -19983,16 +21209,24 @@ export function createAgentChatService(args: { sdkSessionId, forkFromSdkSessionId, query: null, - inputPump: null, - pendingPostResultNext: null, - pendingPostResultNextSettledAt: null, - warmQuery: null, + inputPump: null, + pendingPostResultNext: null, + pendingPostResultNextSettledAt: null, + idleReaderPromise: null, + idleReaderGeneration: 0, + warmQuery: null, warmupDone: null, warmupCancel: null, warmupCancelled: false, activeSubagents: new Map(), taskToolInputByToolUseId: new Map(), workflowAgentsByTask: new Map(), + scheduledWorkSignatures: new Map(), + scheduledWorkKindById: new Map(), + scheduledWorkIdByTaskId: new Map(), + scheduledWorkIdByToolUseId: new Map(), + activeProviderCronIds: new Set(), + activeProviderCronIdsByPrompt: new Map(), slashCommands: [], busy: false, activeTurnId: null, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 5182dfd45..f4df60f31 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -123,7 +123,7 @@ import { QuickRunInlineList } from "../run/QuickRunMenu"; import { getLaneAccent } from "../lanes/laneColorPalette"; import { openLaneInLanesTabPath } from "../../lib/laneNavigation"; import { ChatTerminalDrawer, ChatTerminalToggle } from "./ChatTerminalDrawer"; -import { deriveChatSubagentSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; +import { deriveChatSubagentSnapshots, deriveScheduledWorkSnapshots, deriveTodoItems, deriveTurnDiffSummaries } from "./chatExecutionSummary"; import { deriveMissionSnapshot } from "./chatMission"; import { MissionControlPanel } from "./MissionControlPanel"; import { derivePendingInputRequests, type DerivedPendingInput } from "./pendingInput"; @@ -3573,6 +3573,7 @@ export function AgentChatPane({ return sawUsageEvent ? usageFromEvents : (selectedSession?.codexTokenUsage ?? null); }, [selectedEventsForDisplay, selectedSession?.codexTokenUsage]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); + const selectedScheduledWorkSnapshots = useMemo(() => deriveScheduledWorkSnapshots(selectedEvents), [selectedEvents]); // Per-runtime subagent capability — the single source of truth for whether // clicking a subagent takes over the chat (full transcript) or only opens the // inline drawer. Computed from the provider with the same shared resolver the @@ -3631,6 +3632,7 @@ export function AgentChatPane({ // subagents are spawned). const selectedSubagentPaneAvailable = selectedSubagentSnapshots.length > 0 + || selectedScheduledWorkSnapshots.length > 0 || (selectedSession?.provider === "codex" && Boolean(selectedCodexGoal?.objective)); // Latest snapshot for the currently drilled-in subagent — keeps the // breadcrumb status in sync as the agent transitions running → completed. @@ -3895,7 +3897,7 @@ export function AgentChatPane({ if (chatActionsOpen) setChatActionsOpen(false); return; } - const trackedActionCount = selectedSubagentSnapshots.length + selectedTodoItems.length; + const trackedActionCount = selectedSubagentSnapshots.length + selectedTodoItems.length + selectedScheduledWorkSnapshots.length; if (trackedActionCount === 0) { return; } @@ -3939,7 +3941,7 @@ export function AgentChatPane({ setAppControlOpen(false); setCursorCloudPaneOpen(false); setChatActionsOpen(true); - }, [chatActionsOpen, chatActionsTab, selectedSessionId, selectedSubagentSnapshots.length, selectedTodoItems.length]); + }, [chatActionsOpen, chatActionsTab, selectedSessionId, selectedSubagentSnapshots.length, selectedScheduledWorkSnapshots.length, selectedTodoItems.length]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -9126,11 +9128,12 @@ export function AgentChatPane({ const ChatActionsToolbarIcon = chatActionsToolbarIcon; const proofArtifactCount = computerUseSnapshot?.artifacts?.length ?? 0; const proofSessionId = selectedSessionId ?? ""; - const agentsTabContent = selectedSubagentPaneAvailable || selectedTodoItems.length > 0 ? ( + const agentsTabContent = selectedSubagentPaneAvailable || selectedTodoItems.length > 0 || selectedScheduledWorkSnapshots.length > 0 ? ( { setSubagentView({ @@ -9622,6 +9625,9 @@ export function AgentChatPane({ selectedSubagentSnapshots.length > 0 ? `${selectedSubagentSnapshots.length} subagent${selectedSubagentSnapshots.length === 1 ? "" : "s"}` : null, + selectedScheduledWorkSnapshots.length > 0 + ? `${selectedScheduledWorkSnapshots.length} scheduled` + : null, selectedTodoItems.length > 0 ? `${selectedTodoItems.length} task${selectedTodoItems.length === 1 ? "" : "s"}` : null, @@ -9662,6 +9668,10 @@ export function AgentChatPane({ {selectedSubagentSnapshots.length} + ) : selectedScheduledWorkSnapshots.length > 0 ? ( + + {selectedScheduledWorkSnapshots.length} + ) : selectedTodoItems.length > 0 ? ( {selectedTodoItems.length} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index e0fd46f26..78e054b24 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -8,7 +8,7 @@ import { X, } from "@phosphor-icons/react"; import { cn } from "../ui/cn"; -import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; +import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExecutionSummary"; import { derivePlan } from "./chatExecutionSummary"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; @@ -165,12 +165,13 @@ function PlanGlyph({ status }: { status: ChatInfoPlanStep["status"] }) { /* ── Section header — sentence case, paper-section feel ── */ -type SectionTone = "subagent" | "background" | "workflow" | "neutral"; +type SectionTone = "subagent" | "background" | "workflow" | "scheduled" | "neutral"; const SECTION_DOT_CLASS: Record = { subagent: "bg-[color:var(--color-accent,#A78BFA)]/70", background: "bg-cyan-300/65", workflow: "bg-amber-300/65", + scheduled: "bg-sky-300/65", neutral: "bg-fg/30", }; @@ -277,6 +278,104 @@ function kindBadge(snapshot: ChatSubagentSnapshot): string | null { } } +const SCHEDULE_STATUS_LABEL: Record = { + scheduled: "scheduled", + running: "running", + fired: "fired", + missed: "missed", + completed: "done", + cancelled: "cancelled", + failed: "failed", + stopped: "stopped", +}; + +function scheduledKindLabel(kind: ChatScheduledWorkSnapshot["kind"]): string { + switch (kind) { + case "wakeup": + return "wakeup"; + case "cron": + return "cron"; + case "loop": + return "loop"; + case "remote_trigger": + return "trigger"; + case "background_task": + return "background"; + } +} + +function scheduledDetail(snapshot: ChatScheduledWorkSnapshot): string | null { + const parts = [ + snapshot.cron ? snapshot.cron : null, + snapshot.reason ? snapshot.reason : null, + snapshot.summary ? snapshot.summary : null, + ].filter((part): part is string => Boolean(part)); + return parts.length ? parts.join(" · ") : null; +} + +function ScheduledWorkRow({ snapshot }: { snapshot: ChatScheduledWorkSnapshot }) { + const isActive = snapshot.status === "running" || snapshot.status === "fired"; + const isProblem = snapshot.status === "failed" || snapshot.status === "missed"; + const isMuted = snapshot.status === "completed" || snapshot.status === "cancelled" || snapshot.status === "stopped"; + const detail = scheduledDetail(snapshot); + const prompt = snapshot.prompt?.trim(); + + return ( +
+
+
+ + + {snapshot.title} + + + {scheduledKindLabel(snapshot.kind)} + +
+ {detail || prompt ? ( +
+ {detail ?? prompt} +
+ ) : null} +
+ + {SCHEDULE_STATUS_LABEL[snapshot.status]} + +
+ ); +} + function SubagentRow({ snapshot, selected, @@ -454,6 +553,7 @@ export function ChatSubagentsPanel({ onSetGoalStatus, goalPending = false, todoItems = [], + scheduledItems = [], }: { snapshots: ChatSubagentSnapshot[]; events: AgentChatEventEnvelope[]; @@ -477,6 +577,7 @@ export function ChatSubagentsPanel({ onSetGoalStatus?: (status: Extract, "active" | "paused" | "blocked" | "complete">) => void; goalPending?: boolean; todoItems?: TodoItemSnapshot[]; + scheduledItems?: ChatScheduledWorkSnapshot[]; }) { const [expanded, setExpanded] = useState(false); // Which agent's inline details drawer is open (agents with no transcript). @@ -520,11 +621,12 @@ export function ChatSubagentsPanel({ const parts: string[] = []; if (runningCount) parts.push(`${runningCount} running`); if (bgRunningCount) parts.push(`${bgRunningCount} bg`); + if (scheduledItems.length) parts.push(`${scheduledItems.length} scheduled`); if (completedCount) parts.push(`${completedCount} done`); if (!parts.length && snapshots.length) parts.push(`${snapshots.length} tracked`); if (!parts.length) parts.push("idle"); return parts.join(" · "); - }, [runningCount, bgRunningCount, completedCount, snapshots.length]); + }, [runningCount, bgRunningCount, scheduledItems.length, completedCount, snapshots.length]); const takeover = (snap: ChatSubagentSnapshot) => { setExpandedTaskId(null); @@ -616,7 +718,8 @@ export function ChatSubagentsPanel({ const hasGoal = Boolean(goal?.objective?.trim()); const hasTasks = todoItems.length > 0; - const hasAnything = hasGoal || Boolean(plan) || hasTasks || foreground.length > 0 || background.length > 0; + const hasScheduled = scheduledItems.length > 0; + const hasAnything = hasGoal || Boolean(plan) || hasTasks || hasScheduled || foreground.length > 0 || background.length > 0; const body = (
@@ -685,11 +788,33 @@ export function ChatSubagentsPanel({ ) : null} + {/* ── Schedule ─────────────────────────────────────────────── */} + {hasScheduled ? ( +
+ +
+ {scheduledItems.map((item) => ( + + ))} +
+
+ ) : null} + {/* ── Subagents ────────────────────────────────────────────── */}
{ it("keeps running subagents ahead of completed ones and preserves descriptions", () => { @@ -100,6 +104,30 @@ describe("deriveChatSubagentSnapshots", () => { ]); }); + it("uses parentAgentId as a parent key when the SDK omits parentToolUseId", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "child-task-1", + agentId: "child-agent-1", + parentAgentId: "parent-agent-1", + description: "Inspect nested Claude task", + }, + }, + ]; + + expect(deriveChatSubagentSnapshots(events)).toEqual([ + expect.objectContaining({ + taskId: "child-task-1", + agentId: "child-agent-1", + parentToolUseId: "parent-agent-1", + }), + ]); + }); + it("keeps Codex sibling subagents separate when they share a parent tool use id", () => { const events: AgentChatEventEnvelope[] = [ { @@ -433,6 +461,116 @@ describe("deriveChatSubagentSnapshots", () => { }); }); +describe("deriveScheduledWorkSnapshots", () => { + it("returns an empty array when there are no scheduled work events", () => { + expect(deriveScheduledWorkSnapshots([])).toEqual([]); + }); + + it("coalesces scheduled work updates by id while preserving first-seen details", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "scheduled_work_update", + id: "wakeup-1", + kind: "wakeup", + status: "scheduled", + origin: "schedule_wakeup", + title: "Wakeup scheduled", + prompt: "Check CI again", + reason: "CI was still running", + sourceToolUseId: "tool-1", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:03:00.000Z", + event: { + type: "scheduled_work_update", + id: "wakeup-1", + kind: "wakeup", + status: "running", + summary: "Wakeup fired", + lastRunAt: "2026-03-10T12:03:00.000Z", + turnId: "turn-2", + }, + }, + ]; + + expect(deriveScheduledWorkSnapshots(events)).toEqual([ + { + id: "wakeup-1", + kind: "wakeup", + status: "running", + origin: "schedule_wakeup", + title: "Wakeup scheduled", + summary: "Wakeup fired", + prompt: "Check CI again", + reason: "CI was still running", + lastRunAt: "2026-03-10T12:03:00.000Z", + sourceToolUseId: "tool-1", + turnId: "turn-2", + createdAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:03:00.000Z", + }, + ]); + }); + + it("sorts scheduled work by most recent update and supplies default titles", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-03-10T12:00:00.000Z", + event: { + type: "scheduled_work_update", + id: "cron-1", + kind: "cron", + status: "scheduled", + cron: "*/10 * * * *", + recurring: true, + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:01:00.000Z", + event: { + type: "scheduled_work_update", + id: "background-1", + kind: "background_task", + status: "running", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-10T12:02:00.000Z", + event: { + type: "scheduled_work_update", + id: "cron-1", + kind: "cron", + status: "completed", + lastRunAt: "2026-03-10T12:02:00.000Z", + }, + }, + ]; + + const snapshots = deriveScheduledWorkSnapshots(events); + + expect(snapshots.map((snapshot) => snapshot.id)).toEqual(["cron-1", "background-1"]); + expect(snapshots[0]).toEqual(expect.objectContaining({ + title: "Scheduled task", + cron: "*/10 * * * *", + recurring: true, + status: "completed", + })); + expect(snapshots[1]).toEqual(expect.objectContaining({ + title: "Background work", + status: "running", + })); + }); +}); + describe("deriveTodoItems", () => { it("returns an empty array when there are no events", () => { expect(deriveTodoItems([])).toEqual([]); diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index 0a0722add..694ce2d23 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -1,7 +1,14 @@ -import type { AgentChatEventEnvelope, TurnDiffSummary } from "../../../shared/types"; +import type { + AgentChatEventEnvelope, + TurnDiffSummary, +} from "../../../shared/types"; import { latestPlan, type ChatInfoPlan } from "../../../shared/chatSubagents"; export type { ChatInfoPlan, ChatInfoPlanStep } from "../../../shared/chatSubagents"; +export { + deriveScheduledWorkSnapshots, + type ChatScheduledWorkSnapshot, +} from "../../../shared/chatScheduledWork"; /** * Latest plan snapshot derived from the event stream. Returns null when the @@ -53,16 +60,22 @@ function subagentIdentityKey(event: ChatSubagentEvent): string { return event.agentId ?? event.taskId; } +function subagentParentKey(event: ChatSubagentEvent): string | null { + return event.parentToolUseId ?? event.parentAgentId ?? null; +} + function buildResolvedSubagentKeysByParent(events: AgentChatEventEnvelope[]): Map> { const keysByParent = new Map>(); for (const envelope of events) { const event = envelope.event; - if (!isSubagentEvent(event) || !event.parentToolUseId) continue; + if (!isSubagentEvent(event)) continue; + const parentKey = subagentParentKey(event); + if (!parentKey) continue; const key = subagentIdentityKey(event); - if (key === event.parentToolUseId) continue; - const keys = keysByParent.get(event.parentToolUseId) ?? new Set(); + if (key === parentKey) continue; + const keys = keysByParent.get(parentKey) ?? new Set(); keys.add(key); - keysByParent.set(event.parentToolUseId, keys); + keysByParent.set(parentKey, keys); } return keysByParent; } @@ -78,21 +91,22 @@ function resolveSubagentSnapshot( ): { key: string; existing: ChatSubagentSnapshot | undefined; adoptedPlaceholder: boolean } { const key = subagentIdentityKey(event); const direct = snapshots.get(key); - const parentPlaceholder = event.parentToolUseId ? snapshots.get(event.parentToolUseId) : undefined; - const parentResolvedKeys = event.parentToolUseId ? resolvedKeysByParent.get(event.parentToolUseId) : undefined; + const parentKey = subagentParentKey(event); + const parentPlaceholder = parentKey ? snapshots.get(parentKey) : undefined; + const parentResolvedKeys = parentKey ? resolvedKeysByParent.get(parentKey) : undefined; const canAdoptParentPlaceholder = Boolean( - event.parentToolUseId + parentKey && parentPlaceholder - && isParentPlaceholder(parentPlaceholder, event.parentToolUseId) + && isParentPlaceholder(parentPlaceholder, parentKey) && parentResolvedKeys?.size === 1 && parentResolvedKeys.has(key), ); const taskAliasCandidate = key !== event.taskId ? snapshots.get(event.taskId) : undefined; const taskAliasIsParentPlaceholder = Boolean( taskAliasCandidate - && event.parentToolUseId - && event.taskId === event.parentToolUseId - && isParentPlaceholder(taskAliasCandidate, event.parentToolUseId), + && parentKey + && event.taskId === parentKey + && isParentPlaceholder(taskAliasCandidate, parentKey), ); const taskAlias = taskAliasCandidate && (!taskAliasIsParentPlaceholder || canAdoptParentPlaceholder) ? taskAliasCandidate @@ -101,16 +115,16 @@ function resolveSubagentSnapshot( const adoptedPlaceholder = Boolean(adoptParentPlaceholder || (taskAlias && taskAliasIsParentPlaceholder)); if (taskAlias && key !== event.taskId) snapshots.delete(event.taskId); - if (adoptParentPlaceholder && event.parentToolUseId) { - snapshots.delete(event.parentToolUseId); + if (adoptParentPlaceholder && parentKey) { + snapshots.delete(parentKey); } else if ( - event.parentToolUseId + parentKey && parentPlaceholder - && isParentPlaceholder(parentPlaceholder, event.parentToolUseId) + && isParentPlaceholder(parentPlaceholder, parentKey) && parentResolvedKeys && parentResolvedKeys.size > 1 ) { - snapshots.delete(event.parentToolUseId); + snapshots.delete(parentKey); } return { @@ -142,7 +156,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C taskId: event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, - parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, + parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, description: event.description.trim() || existing?.description || "Subagent task", status: "running", turnId: event.turnId ?? existing?.turnId, @@ -165,7 +179,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C taskId: adoptedPlaceholder ? event.taskId : existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, - parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, + parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, description: event.description?.trim() || existing?.description || "Subagent task", status: "running", turnId: event.turnId ?? existing?.turnId, @@ -188,7 +202,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C taskId: adoptedPlaceholder ? event.taskId : existing?.taskId ?? event.taskId, agentId: event.agentId ?? existing?.agentId, agentType: event.agentType ?? existing?.agentType, - parentToolUseId: event.parentToolUseId ?? existing?.parentToolUseId ?? null, + parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, description: existing?.description ?? "Subagent task", status: event.status, turnId: event.turnId ?? existing?.turnId, diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts index d122551a3..b51e326db 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.test.ts @@ -1419,4 +1419,36 @@ describe("chatTranscriptRows edge cases", () => { expect(rows[0]!.event.entry.entryKind).toBe("web_search"); expect(rows[0]!.event.entry.query).toBe("typescript patterns"); }); + + it("removes assistant text rows superseded by transcript retractions", () => { + const rows = collapseChatTranscriptEvents([ + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:00.000Z", + event: { type: "text", text: "Original answer", messageId: "msg-old", turnId: "turn-1" }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:01.000Z", + event: { + type: "transcript_retraction", + messageIds: ["msg-old"], + reason: "assistant_supersedes", + replacementMessageId: "msg-new", + turnId: "turn-1", + }, + }, + { + sessionId: "session-1", + timestamp: "2026-03-17T10:00:02.000Z", + event: { type: "text", text: "Replacement answer", messageId: "msg-new", turnId: "turn-1" }, + }, + ]); + + expect(rows).toHaveLength(1); + expect(rows[0]!.event.type).toBe("text"); + if (rows[0]!.event.type !== "text") throw new Error("Expected text row"); + expect(rows[0]!.event.text).toBe("Replacement answer"); + expect(rows[0]!.event.messageId).toBe("msg-new"); + }); }); diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index 861ce9d06..a011a332a 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -699,6 +699,18 @@ export function appendCollapsedChatTranscriptEvent( return; } + if (event.type === "transcript_retraction") { + const retractedIds = new Set(event.messageIds.map((messageId) => messageId.trim()).filter(Boolean)); + if (!retractedIds.size) return; + for (let index = rows.length - 1; index >= 0; index -= 1) { + const row = rows[index]; + if (row?.event.type === "text" && row.event.messageId && retractedIds.has(row.event.messageId)) { + rows.splice(index, 1); + } + } + return; + } + if (event.type === "text") { if (!event.text.trim().length) return; const previous = rows[rows.length - 1]; diff --git a/apps/desktop/src/shared/chatScheduledWork.ts b/apps/desktop/src/shared/chatScheduledWork.ts new file mode 100644 index 000000000..45ddd8d62 --- /dev/null +++ b/apps/desktop/src/shared/chatScheduledWork.ts @@ -0,0 +1,78 @@ +import type { + AgentChatEventEnvelope, + AgentChatScheduledWorkKind, + AgentChatScheduledWorkOrigin, + AgentChatScheduledWorkStatus, +} from "./types"; + +export type ChatScheduledWorkSnapshot = { + id: string; + kind: AgentChatScheduledWorkKind; + status: AgentChatScheduledWorkStatus; + origin?: AgentChatScheduledWorkOrigin; + title: string; + summary: string | null; + prompt?: string; + reason?: string; + cron?: string; + nextRunAt?: string; + lastRunAt?: string; + recurring?: boolean; + durable?: boolean; + sourceToolUseId?: string; + sourceTaskId?: string; + turnId?: string; + error?: string; + createdAt: string; + updatedAt: string; +}; + +function defaultScheduledWorkTitle(kind: AgentChatScheduledWorkKind): string { + switch (kind) { + case "wakeup": + return "Scheduled wakeup"; + case "cron": + return "Scheduled task"; + case "loop": + return "Loop wakeup"; + case "remote_trigger": + return "Remote trigger"; + case "background_task": + return "Background work"; + } +} + +function compareIsoDesc(left: string, right: string): number { + return Date.parse(right) - Date.parse(left); +} + +export function deriveScheduledWorkSnapshots(events: AgentChatEventEnvelope[]): ChatScheduledWorkSnapshot[] { + const snapshots = new Map(); + for (const envelope of events) { + const event = envelope.event; + if (event.type !== "scheduled_work_update") continue; + const existing = snapshots.get(event.id); + snapshots.set(event.id, { + id: event.id, + kind: event.kind, + status: event.status, + origin: event.origin ?? existing?.origin, + title: event.title?.trim() || existing?.title || defaultScheduledWorkTitle(event.kind), + summary: event.summary?.trim() || existing?.summary || null, + prompt: event.prompt ?? existing?.prompt, + reason: event.reason ?? existing?.reason, + cron: event.cron ?? existing?.cron, + nextRunAt: event.nextRunAt ?? existing?.nextRunAt, + lastRunAt: event.lastRunAt ?? existing?.lastRunAt, + recurring: event.recurring ?? existing?.recurring, + durable: event.durable ?? existing?.durable, + sourceToolUseId: event.sourceToolUseId ?? existing?.sourceToolUseId, + sourceTaskId: event.sourceTaskId ?? existing?.sourceTaskId, + turnId: event.turnId ?? existing?.turnId, + error: event.error ?? existing?.error, + createdAt: existing?.createdAt ?? envelope.timestamp, + updatedAt: envelope.timestamp, + }); + } + return [...snapshots.values()].sort((left, right) => compareIsoDesc(left.updatedAt, right.updatedAt)); +} diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index f2784d22e..f45455db7 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -89,7 +89,8 @@ function eventSubagentIds(event: AgentChatEvent): string[] { } function eventParentToolUseId(event: AgentChatEvent): string | null { - return textField((event as { parentToolUseId?: unknown }).parentToolUseId); + return textField((event as { parentToolUseId?: unknown }).parentToolUseId) + ?? textField((event as { parentAgentId?: unknown }).parentAgentId); } export function workEventItemId(event: AgentChatEvent): string | null { @@ -132,9 +133,7 @@ function buildResolvedSubagentIdsByParent(events: AgentChatEventEnvelope[]): Map const event = envelope.event as Record; const type = typeof event.type === "string" ? event.type : ""; if (!type.startsWith("subagent")) continue; - const parent = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() - ? event.parentToolUseId.trim() - : null; + const parent = textField(event.parentToolUseId) ?? textField(event.parentAgentId); if (!parent) continue; const taskId = typeof event.taskId === "string" && event.taskId.trim() ? event.taskId.trim() : null; const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; @@ -228,9 +227,7 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S const agentId = typeof event.agentId === "string" && event.agentId.trim() ? event.agentId.trim() : null; const id = agentId ?? taskId; if (!id) continue; - const incomingParentToolUseId = typeof event.parentToolUseId === "string" && event.parentToolUseId.trim() - ? event.parentToolUseId.trim() - : null; + const incomingParentToolUseId = textField(event.parentToolUseId) ?? textField(event.parentAgentId); const parentPlaceholder = incomingParentToolUseId ? snapshots.get(incomingParentToolUseId) : undefined; const parentResolvedIds = incomingParentToolUseId ? resolvedIdsByParent.get(incomingParentToolUseId) : undefined; const parentIsPlaceholder = Boolean( diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 629455fab..bf5f5ec0b 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -373,6 +373,31 @@ export type AgentChatCloudRunStatus = export type AgentChatEventMetadata = Record; +export type AgentChatScheduledWorkKind = + | "wakeup" + | "cron" + | "loop" + | "remote_trigger" + | "background_task"; + +export type AgentChatScheduledWorkStatus = + | "scheduled" + | "running" + | "fired" + | "missed" + | "completed" + | "cancelled" + | "failed" + | "stopped"; + +export type AgentChatScheduledWorkOrigin = + | "schedule_wakeup" + | "cron" + | "loop" + | "remote_trigger" + | "background_task" + | "sdk"; + export type AgentChatEvent = | { type: "user_message"; @@ -590,6 +615,7 @@ export type AgentChatEvent = type: "subagent_started"; taskId: string; agentId?: string; + parentAgentId?: string | null; agentType?: string; model?: string | null; reasoningEffort?: string | null; @@ -605,6 +631,7 @@ export type AgentChatEvent = type: "subagent_progress"; taskId: string; agentId?: string; + parentAgentId?: string | null; agentType?: string; model?: string | null; reasoningEffort?: string | null; @@ -628,6 +655,7 @@ export type AgentChatEvent = type: "subagent_result"; taskId: string; agentId?: string; + parentAgentId?: string | null; agentType?: string; model?: string | null; reasoningEffort?: string | null; @@ -713,6 +741,33 @@ export type AgentChatEvent = toolUseIds: string[]; turnId?: string; } + | { + type: "scheduled_work_update"; + id: string; + kind: AgentChatScheduledWorkKind; + status: AgentChatScheduledWorkStatus; + origin?: AgentChatScheduledWorkOrigin; + title?: string; + summary?: string; + prompt?: string; + reason?: string; + cron?: string; + nextRunAt?: string; + lastRunAt?: string; + recurring?: boolean; + durable?: boolean; + sourceToolUseId?: string; + sourceTaskId?: string; + turnId?: string; + error?: string; + } + | { + type: "transcript_retraction"; + messageIds: string[]; + reason?: "model_refusal_fallback" | "assistant_supersedes" | "provider"; + replacementMessageId?: string; + turnId?: string; + } | { type: "context_compact"; trigger: "manual" | "auto"; @@ -886,6 +941,11 @@ export type AgentChatEventEnvelope = { sequence?: number; provenance?: { messageId?: string; + providerMessageId?: string; + providerParentAgentId?: string | null; + providerOrigin?: string | null; + providerSupersedes?: string[]; + providerRetractedMessageIds?: string[]; threadId?: string | null; role?: "user" | "orchestrator" | "worker" | "agent" | null; targetKind?: string | null; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index 834d466fd..5323e2063 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -1847,9 +1847,11 @@ enum AgentChatEvent: Decodable, Equatable { case activity(activity: AgentChatActivityKind, detail: String?, turnId: String?) case stepBoundary(stepNumber: Int, turnId: String?) case todoUpdate(items: [AgentChatTodoItem], turnId: String?) - case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String, background: Bool?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) - case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String?, summary: String, usage: AgentChatSubagentUsage?, lastToolName: String?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) - case subagentResult(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, status: AgentChatSubagentStatus, summary: String, usage: AgentChatSubagentUsage?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) + case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, description: String, background: Bool?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) + case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, description: String?, summary: String, usage: AgentChatSubagentUsage?, lastToolName: String?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) + case subagentResult(taskId: String, agentId: String?, agentType: String?, parentAgentId: String?, parentToolUseId: String?, status: AgentChatSubagentStatus, summary: String, usage: AgentChatSubagentUsage?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) + case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) + case transcriptRetraction(messageIds: [String], reason: String?, replacementMessageId: String?, turnId: String?) case structuredQuestion(question: String, options: [AgentChatStructuredQuestionOption]?, itemId: String, turnId: String?) case toolUseSummary(summary: String, toolUseIds: [String], turnId: String?) case contextCompact(trigger: AgentChatContextCompactTrigger, preTokens: Int?, state: AgentChatContextCompactState?, turnId: String?) @@ -1922,11 +1924,26 @@ extension AgentChatEvent { case taskId case agentId case agentType + case parentAgentId case parentToolUseId case background case lastToolName case question case options + case id + case origin + case title + case prompt + case cron + case nextRunAt + case lastRunAt + case recurring + case durable + case sourceToolUseId + case sourceTaskId + case error + case messageIds + case replacementMessageId case toolUseIds case trigger case preTokens @@ -2104,6 +2121,7 @@ extension AgentChatEvent { taskId: try container.decode(String.self, forKey: .taskId), agentId: try container.decodeIfPresent(String.self, forKey: .agentId), agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentAgentId: try container.decodeIfPresent(String.self, forKey: .parentAgentId), parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), description: try container.decode(String.self, forKey: .description), background: try container.decodeIfPresent(Bool.self, forKey: .background), @@ -2117,6 +2135,7 @@ extension AgentChatEvent { taskId: try container.decode(String.self, forKey: .taskId), agentId: try container.decodeIfPresent(String.self, forKey: .agentId), agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentAgentId: try container.decodeIfPresent(String.self, forKey: .parentAgentId), parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), description: try container.decodeIfPresent(String.self, forKey: .description), summary: try container.decode(String.self, forKey: .summary), @@ -2132,6 +2151,7 @@ extension AgentChatEvent { taskId: try container.decode(String.self, forKey: .taskId), agentId: try container.decodeIfPresent(String.self, forKey: .agentId), agentType: try container.decodeIfPresent(String.self, forKey: .agentType), + parentAgentId: try container.decodeIfPresent(String.self, forKey: .parentAgentId), parentToolUseId: try container.decodeIfPresent(String.self, forKey: .parentToolUseId), status: try container.decode(AgentChatSubagentStatus.self, forKey: .status), summary: try container.decode(String.self, forKey: .summary), @@ -2141,6 +2161,33 @@ extension AgentChatEvent { reasoningEffort: try container.decodeIfPresent(String.self, forKey: .reasoningEffort), turnId: try container.decodeIfPresent(String.self, forKey: .turnId) ) + case "scheduled_work_update": + self = .scheduledWorkUpdate( + id: try container.decode(String.self, forKey: .id), + kind: try container.decode(String.self, forKey: .kind), + status: try container.decode(String.self, forKey: .status), + origin: try container.decodeIfPresent(String.self, forKey: .origin), + title: try container.decodeIfPresent(String.self, forKey: .title), + summary: try container.decodeIfPresent(String.self, forKey: .summary), + prompt: try container.decodeIfPresent(String.self, forKey: .prompt), + reason: try container.decodeIfPresent(String.self, forKey: .reason), + cron: try container.decodeIfPresent(String.self, forKey: .cron), + nextRunAt: try container.decodeIfPresent(String.self, forKey: .nextRunAt), + lastRunAt: try container.decodeIfPresent(String.self, forKey: .lastRunAt), + recurring: try container.decodeIfPresent(Bool.self, forKey: .recurring), + durable: try container.decodeIfPresent(Bool.self, forKey: .durable), + sourceToolUseId: try container.decodeIfPresent(String.self, forKey: .sourceToolUseId), + sourceTaskId: try container.decodeIfPresent(String.self, forKey: .sourceTaskId), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId), + error: try container.decodeIfPresent(String.self, forKey: .error) + ) + case "transcript_retraction": + self = .transcriptRetraction( + messageIds: try container.decodeIfPresent([String].self, forKey: .messageIds) ?? [], + reason: try container.decodeIfPresent(String.self, forKey: .reason), + replacementMessageId: try container.decodeIfPresent(String.self, forKey: .replacementMessageId), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId) + ) case "structured_question": self = .structuredQuestion( question: try container.decode(String.self, forKey: .question), @@ -2273,6 +2320,8 @@ extension AgentChatEvent { case .subagentStarted: return "subagent_started" case .subagentProgress: return "subagent_progress" case .subagentResult: return "subagent_result" + case .scheduledWorkUpdate: return "scheduled_work_update" + case .transcriptRetraction: return "transcript_retraction" case .structuredQuestion: return "structured_question" case .toolUseSummary: return "tool_use_summary" case .contextCompact: return "context_compact" diff --git a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift index db9057d25..b47d6d70d 100644 --- a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift +++ b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift @@ -258,6 +258,7 @@ struct WorkActivityIndicator: View { .systemNotice, .error, .promptSuggestion, .contextCompact, .autoApprovalReview, .pendingInputResolved, .subagentResult, .codexState, .codexTurnStalled, + .scheduledWorkUpdate, .transcriptRetraction, .completionReport, .tokens, .unknown: continue } diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 1d580be0c..8ac8c9149 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -82,6 +82,7 @@ struct WorkSessionHeader: View { /// Menu-relevant values for the chat header overflow menu, split out so the /// menu view can be `Equatable`-gated on exactly this data. struct WorkChatHeaderMenuModel: Equatable { + var chatInfoCount: Int var subagentCount: Int var artifactCount: Int var showsLaneActions: Bool @@ -114,6 +115,7 @@ struct WorkChatHeaderMenuModel: Equatable { /// something the menu actually displays has changed. struct WorkChatHeaderMenu: View, Equatable { var model: WorkChatHeaderMenuModel + var onShowChatInfo: () -> Void var onShowSubagents: () -> Void var onShowProof: () -> Void var onViewPrDetails: () -> Void @@ -134,6 +136,14 @@ struct WorkChatHeaderMenu: View, Equatable { var body: some View { Menu { + Button(action: onShowChatInfo) { + if model.chatInfoCount == 0 { + Label("Chat Info", systemImage: "info.circle") + } else { + Label("Chat Info (\(model.chatInfoCount))", systemImage: "info.circle") + } + } + Button(action: onShowSubagents) { if model.subagentCount == 0 { Label("Subagents", systemImage: "person.2") diff --git a/apps/ios/ADE/Views/Work/WorkChatPrViews.swift b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift index e38e1bf9d..1ab6e163b 100644 --- a/apps/ios/ADE/Views/Work/WorkChatPrViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatPrViews.swift @@ -54,32 +54,22 @@ struct WorkChatPrActivePopup: View { } var body: some View { - Button(action: onOpen) { - HStack(spacing: 7) { - Image(systemName: "arrow.triangle.pull") - .font(.system(size: 12, weight: .semibold)) - Text(badge.label) - .font(.caption.weight(.semibold)) - .lineLimit(1) - if let ciSymbol { - Image(systemName: ciSymbol) - .font(.system(size: 10, weight: .bold)) - } - Image(systemName: "chevron.up") + WorkComposerBadgeCapsule( + tint: tint, + strokeOpacity: 0.24, + accessibilityLabel: accessibilityText, + onOpen: onOpen + ) { + Image(systemName: "arrow.triangle.pull") + .font(.system(size: 12, weight: .semibold)) + Text(badge.label) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if let ciSymbol { + Image(systemName: ciSymbol) .font(.system(size: 10, weight: .bold)) } - .foregroundStyle(tint) - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(ADEColor.cardBackground.opacity(0.76), in: Capsule(style: .continuous)) - .overlay( - Capsule(style: .continuous) - .stroke(tint.opacity(0.24), lineWidth: 1) - ) - .contentShape(Capsule(style: .continuous)) } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityText) } } diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index e13db3550..f70f867cd 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -2331,32 +2331,322 @@ struct WorkSubagentStrip: View { } } -struct WorkSubagentActivePopup: View { - let count: Int +struct WorkComposerBadgeCapsule: View { + let tint: Color + let spacing: CGFloat + let strokeOpacity: Double + let accessibilityLabel: String let onOpen: () -> Void + @ViewBuilder let content: () -> Content + + init( + tint: Color, + spacing: CGFloat = 7, + strokeOpacity: Double = 0.22, + accessibilityLabel: String, + onOpen: @escaping () -> Void, + @ViewBuilder content: @escaping () -> Content + ) { + self.tint = tint + self.spacing = spacing + self.strokeOpacity = strokeOpacity + self.accessibilityLabel = accessibilityLabel + self.onOpen = onOpen + self.content = content + } var body: some View { Button(action: onOpen) { - HStack(spacing: 8) { - Image(systemName: "person.2.fill") - .font(.system(size: 12, weight: .semibold)) - Text("\(count) active") - .font(.caption.weight(.semibold)) + HStack(spacing: spacing) { + content() Image(systemName: "chevron.up") .font(.system(size: 10, weight: .bold)) } - .foregroundStyle(ADEColor.accent) + .foregroundStyle(tint) .padding(.horizontal, 12) .padding(.vertical, 8) .background(ADEColor.cardBackground.opacity(0.76), in: Capsule(style: .continuous)) .overlay( Capsule(style: .continuous) - .stroke(ADEColor.accent.opacity(0.22), lineWidth: 1) + .stroke(tint.opacity(strokeOpacity), lineWidth: 1) ) + .frame(minHeight: 44) .contentShape(Capsule(style: .continuous)) } .buttonStyle(.plain) - .accessibilityLabel("\(count) active subagent\(count == 1 ? "" : "s")") + .accessibilityLabel(accessibilityLabel) + } +} + +struct WorkSubagentActivePopup: View { + let count: Int + let onOpen: () -> Void + + var body: some View { + WorkComposerBadgeCapsule( + tint: ADEColor.accent, + spacing: 8, + accessibilityLabel: "\(count) active subagent\(count == 1 ? "" : "s")", + onOpen: onOpen + ) { + Image(systemName: "person.2.fill") + .font(.system(size: 12, weight: .semibold)) + Text("\(count) active") + .font(.caption.weight(.semibold)) + } + } +} + +struct WorkChatInfoActivePopup: View { + let count: Int + let onOpen: () -> Void + + var body: some View { + WorkComposerBadgeCapsule( + tint: ADEColor.accent, + accessibilityLabel: "Chat Info, \(count) scheduled item\(count == 1 ? "" : "s")", + onOpen: onOpen + ) { + Image(systemName: "info.circle.fill") + .font(.system(size: 12, weight: .semibold)) + Text("Chat Info") + .font(.caption.weight(.semibold)) + .lineLimit(1) + Text("\(count)") + .font(.caption2.weight(.bold)) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(ADEColor.accent.opacity(0.14), in: Capsule(style: .continuous)) + } + } +} + +func workScheduledWorkIsActive(_ item: WorkScheduledWorkSnapshot) -> Bool { + let status = item.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + return status == "scheduled" || status == "running" || status == "fired" +} + +func workScheduledWorkActiveCount(_ snapshots: [WorkScheduledWorkSnapshot]) -> Int { + snapshots.reduce(0) { count, item in + count + (workScheduledWorkIsActive(item) ? 1 : 0) + } +} + +struct WorkChatInfoDetailsSheet: View { + let scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] + + private var activeCount: Int { + workScheduledWorkActiveCount(scheduledWorkSnapshots) + } + + var body: some View { + NavigationStack { + ScrollView { + LazyVStack(alignment: .leading, spacing: 14) { + if scheduledWorkSnapshots.isEmpty { + ADEEmptyStateView( + symbol: "info.circle", + title: "No chat info", + message: "Scheduled wakeups and background work will appear here." + ) + .padding(.top, 24) + } else { + summaryHeader + section(title: "Schedule", count: scheduledWorkSnapshots.count) { + VStack(spacing: 8) { + ForEach(scheduledWorkSnapshots) { item in + WorkScheduledWorkRow(item: item) + } + } + } + } + } + .padding(16) + } + .scrollIndicators(.hidden) + .background(workChatCanvasBackground.ignoresSafeArea()) + .navigationTitle("Chat Info") + .navigationBarTitleDisplayMode(.inline) + } + } + + private var summaryHeader: some View { + HStack(spacing: 10) { + Image(systemName: "clock") + .font(.system(size: 16, weight: .semibold)) + .foregroundStyle(ADEColor.accent) + .frame(width: 30, height: 30) + .background(ADEColor.accent.opacity(0.12), in: Circle()) + VStack(alignment: .leading, spacing: 2) { + Text("\(scheduledWorkSnapshots.count) scheduled item\(scheduledWorkSnapshots.count == 1 ? "" : "s")") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + Text(activeCount == 0 ? "No active scheduled work" : "\(activeCount) active") + .font(.caption) + .foregroundStyle(ADEColor.textMuted) + } + Spacer(minLength: 0) + } + .padding(12) + .background(ADEColor.cardBackground.opacity(0.72), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(ADEColor.border.opacity(0.5), lineWidth: 0.6) + ) + } + + @ViewBuilder + private func section(title: String, count: Int, @ViewBuilder content: () -> Content) -> some View { + VStack(alignment: .leading, spacing: 8) { + HStack { + Text(title) + .font(.caption.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + .textCase(.uppercase) + Spacer(minLength: 0) + Text("\(count)") + .font(.caption2.weight(.semibold)) + .foregroundStyle(ADEColor.textMuted) + } + content() + } + } +} + +private struct WorkScheduledWorkRow: View { + let item: WorkScheduledWorkSnapshot + + private var status: String { + item.status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } + + private var tint: Color { + workScheduledWorkStatusTint(status) + } + + private var metadata: String { + [ + workScheduledWorkKindLabel(item.kind), + item.nextRunAt.map { "next \(relativeTimestamp($0))" }, + item.lastRunAt.map { "last \(relativeTimestamp($0))" }, + item.cron, + ].compactMap(workScheduledWorkText).joined(separator: " · ") + } + + private var detail: String? { + let statusError = status == "failed" || status == "missed" + ? workScheduledWorkText(item.error) + : nil + return statusError + ?? workScheduledWorkText(item.summary) + ?? workScheduledWorkText(item.reason) + ?? workScheduledWorkText(item.prompt) + } + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .top, spacing: 10) { + Image(systemName: workScheduledWorkStatusSymbol(status)) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 24, height: 24) + .background(tint.opacity(0.12), in: Circle()) + VStack(alignment: .leading, spacing: 3) { + Text(item.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(ADEColor.textPrimary) + .lineLimit(2) + if !metadata.isEmpty { + Text(metadata) + .font(.caption2) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(2) + } + } + Spacer(minLength: 0) + Text(workScheduledWorkStatusLabel(status)) + .font(.caption2.weight(.semibold)) + .foregroundStyle(tint) + .padding(.horizontal, 7) + .padding(.vertical, 3) + .background(tint.opacity(0.10), in: Capsule(style: .continuous)) + } + if let detail { + Text(detail) + .font(.caption) + .foregroundStyle(status == "failed" || status == "missed" ? ADEColor.danger : ADEColor.textSecondary) + .lineLimit(3) + } + } + .padding(12) + .background(ADEColor.cardBackground.opacity(0.76), in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .stroke(tint.opacity(0.18), lineWidth: 0.8) + ) + } +} + +private func workScheduledWorkText(_ value: String?) -> String? { + let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed +} + +private func workScheduledWorkKindLabel(_ raw: String) -> String { + switch raw.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "wakeup": + return "Wakeup" + case "cron": + return "Scheduled task" + case "loop": + return "Loop wakeup" + case "remote_trigger": + return "Remote trigger" + case "background_task": + return "Background work" + default: + return raw.replacingOccurrences(of: "_", with: " ").capitalized + } +} + +private func workScheduledWorkStatusLabel(_ raw: String) -> String { + switch raw { + case "fired": + return "Fired" + default: + return raw.replacingOccurrences(of: "_", with: " ").capitalized + } +} + +private func workScheduledWorkStatusTint(_ status: String) -> Color { + switch status { + case "running", "fired": + return ADEColor.accent + case "scheduled": + return ADEColor.warning + case "completed": + return ADEColor.success + case "failed", "missed": + return ADEColor.danger + default: + return ADEColor.textMuted + } +} + +private func workScheduledWorkStatusSymbol(_ status: String) -> String { + switch status { + case "running", "fired": + return "play.circle.fill" + case "scheduled": + return "clock.fill" + case "completed": + return "checkmark.circle.fill" + case "failed", "missed": + return "xmark.circle.fill" + case "cancelled", "stopped": + return "stop.circle" + default: + return "circle" } } diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 74e496c22..5a27ce6f4 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -197,7 +197,10 @@ struct WorkChatSessionView: View { var onLoadOlderTranscript: (@MainActor () async -> Void)? = nil var subagentSnapshots: [WorkSubagentSnapshot] = [] var subagentSnapshotsRenderSignature: Int = 0 + var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] + var scheduledWorkSnapshotsRenderSignature: Int = 0 var selectedSubagentTaskId: String? = nil + var onOpenChatInfo: (() -> Void)? = nil var onOpenSubagents: (() -> Void)? = nil var prBadge: WorkChatPrBadgeModel? = nil var onOpenPrDetails: (() -> Void)? = nil @@ -618,10 +621,15 @@ struct WorkChatSessionView: View { // lifecycle controls live outside the composer; this space is reserved // for pending input and send feedback. let runningSubagentCount = workSubagentRunningCount(subagentSnapshots) + let activeScheduledWorkCount = workScheduledWorkActiveCount(scheduledWorkSnapshots) + let showsChatInfoBadge = inputLockMessage == nil && activeScheduledWorkCount > 0 && onOpenChatInfo != nil let showsSubagentBadge = inputLockMessage == nil && runningSubagentCount > 0 && onOpenSubagents != nil let showsPrBadge = inputLockMessage == nil && prBadge != nil && onOpenPrDetails != nil - if showsSubagentBadge || showsPrBadge { + if showsChatInfoBadge || showsSubagentBadge || showsPrBadge { HStack(spacing: 8) { + if showsChatInfoBadge, let onOpenChatInfo { + WorkChatInfoActivePopup(count: activeScheduledWorkCount, onOpen: onOpenChatInfo) + } if showsSubagentBadge, let onOpenSubagents { WorkSubagentActivePopup(count: runningSubagentCount, onOpen: onOpenSubagents) } @@ -1162,6 +1170,33 @@ func workSubagentSnapshotsRenderSignature(_ snapshots: [WorkSubagentSnapshot]) - return hasher.finalize() } +func workScheduledWorkSnapshotsRenderSignature(_ snapshots: [WorkScheduledWorkSnapshot]) -> Int { + var hasher = Hasher() + hasher.combine(snapshots.count) + for snapshot in snapshots { + hasher.combine(snapshot.id) + hasher.combine(snapshot.kind) + hasher.combine(snapshot.status) + hasher.combine(snapshot.origin) + hasher.combine(snapshot.title) + hasher.combine(snapshot.summary) + hasher.combine(snapshot.prompt) + hasher.combine(snapshot.reason) + hasher.combine(snapshot.cron) + hasher.combine(snapshot.nextRunAt) + hasher.combine(snapshot.lastRunAt) + hasher.combine(snapshot.recurring) + hasher.combine(snapshot.durable) + hasher.combine(snapshot.sourceToolUseId) + hasher.combine(snapshot.sourceTaskId) + hasher.combine(snapshot.turnId) + hasher.combine(snapshot.error) + hasher.combine(snapshot.createdAt) + hasher.combine(snapshot.updatedAt) + } + return hasher.finalize() +} + func workLaneListRenderSignature(_ lanes: [LaneSummary]) -> Int { var hasher = Hasher() hasher.combine(lanes.count) diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index f7ff13006..c13dd1b4f 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -138,6 +138,22 @@ func buildWorkChatMessages(from transcript: [WorkChatEnvelope]) -> [WorkChatMess )) } previousEnvelopeWasAssistantText = true + case .transcriptRetraction(let messageIds, _, _, _): + previousEnvelopeWasAssistantText = false + let retractedIds = Set( + messageIds + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + ) + guard !retractedIds.isEmpty else { continue } + messages.removeAll { message in + guard message.role == "assistant" else { return false } + if retractedIds.contains(message.id) { return true } + guard let itemId = message.itemId?.trimmingCharacters(in: .whitespacesAndNewlines), !itemId.isEmpty else { + return false + } + return retractedIds.contains(itemId) + } default: previousEnvelopeWasAssistantText = false continue @@ -1642,6 +1658,24 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { return ["subagent_progress", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", description ?? "", summary, toolName ?? "", label ?? "", model ?? "", reasoningEffort ?? ""].joined(separator: "|") case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, let label, let model, let reasoningEffort, let turnId): return ["subagent_result", turnId ?? "", taskId, agentId ?? "", agentType ?? "", parentToolUseId ?? "", status, summary, label ?? "", model ?? "", reasoningEffort ?? ""].joined(separator: "|") + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + var parts = ["scheduled_work_update", id, turnId ?? "", kind, status] + parts.append(origin ?? "") + parts.append(title ?? "") + parts.append(summary ?? "") + parts.append(prompt ?? "") + parts.append(reason ?? "") + parts.append(cron ?? "") + parts.append(nextRunAt ?? "") + parts.append(lastRunAt ?? "") + parts.append(recurring.map { $0 ? "1" : "0" } ?? "") + parts.append(durable.map { $0 ? "1" : "0" } ?? "") + parts.append(sourceToolUseId ?? "") + parts.append(sourceTaskId ?? "") + parts.append(error ?? "") + return parts.joined(separator: "|") + case .transcriptRetraction(let messageIds, let reason, let replacementMessageId, let turnId): + return ["transcript_retraction", turnId ?? "", replacementMessageId ?? "", reason ?? "", messageIds.joined(separator: ",")].joined(separator: "|") case .structuredQuestion(let question, let options, let itemId, let turnId): let digest = options.map { "\($0.label)\t\($0.value)\t\($0.description ?? "")\t\($0.recommended ? "1" : "0")" }.joined(separator: "\n") return ["structured_question", turnId ?? "", itemId, question, digest].joined(separator: "|") diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index a405439f1..da4cc8bb0 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -75,12 +75,12 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { case .plan(steps: let steps, turnId: let turnId, explanation: let explanation): let mapped = steps.map { WorkPlanStep(text: $0.text, status: $0.status) } return .plan(steps: mapped, explanation: explanation, turnId: turnId) - case .subagentStarted(let taskId, let agentId, let agentType, let parentToolUseId, let description, let background, let label, let model, let reasoningEffort, let turnId): + case .subagentStarted(let taskId, let agentId, let agentType, let parentAgentId, let parentToolUseId, let description, let background, let label, let model, let reasoningEffort, let turnId): return .subagentStarted( taskId: taskId, agentId: agentId, agentType: agentType, - parentToolUseId: parentToolUseId, + parentToolUseId: parentToolUseId ?? parentAgentId, description: description, background: background ?? false, label: label, @@ -88,12 +88,12 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { reasoningEffort: reasoningEffort, turnId: turnId ) - case .subagentProgress(let taskId, let agentId, let agentType, let parentToolUseId, let description, let summary, _, let lastToolName, let label, let model, let reasoningEffort, let turnId): + case .subagentProgress(let taskId, let agentId, let agentType, let parentAgentId, let parentToolUseId, let description, let summary, _, let lastToolName, let label, let model, let reasoningEffort, let turnId): return .subagentProgress( taskId: taskId, agentId: agentId, agentType: agentType, - parentToolUseId: parentToolUseId, + parentToolUseId: parentToolUseId ?? parentAgentId, description: description, summary: summary, toolName: lastToolName, @@ -102,12 +102,12 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { reasoningEffort: reasoningEffort, turnId: turnId ) - case .subagentResult(let taskId, let agentId, let agentType, let parentToolUseId, let status, let summary, _, let label, let model, let reasoningEffort, let turnId): + case .subagentResult(let taskId, let agentId, let agentType, let parentAgentId, let parentToolUseId, let status, let summary, _, let label, let model, let reasoningEffort, let turnId): return .subagentResult( taskId: taskId, agentId: agentId, agentType: agentType, - parentToolUseId: parentToolUseId, + parentToolUseId: parentToolUseId ?? parentAgentId, status: status.rawValue, summary: summary, label: label, @@ -115,6 +115,33 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { reasoningEffort: reasoningEffort, turnId: turnId ) + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + return .scheduledWorkUpdate( + id: id, + kind: kind, + status: status, + origin: origin, + title: title, + summary: summary, + prompt: prompt, + reason: reason, + cron: cron, + nextRunAt: nextRunAt, + lastRunAt: lastRunAt, + recurring: recurring, + durable: durable, + sourceToolUseId: sourceToolUseId, + sourceTaskId: sourceTaskId, + turnId: turnId, + error: error + ) + case .transcriptRetraction(let messageIds, let reason, let replacementMessageId, let turnId): + return .transcriptRetraction( + messageIds: messageIds, + reason: reason, + replacementMessageId: replacementMessageId, + turnId: turnId + ) case .structuredQuestion(let question, let options, let itemId, let turnId): let mapped = (options ?? []).map { opt in WorkPendingQuestionOption( diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 09d0128af..d300ea79f 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -595,6 +595,28 @@ struct WorkSubagentSelection: Identifiable, Equatable { var id: String { taskId } } +struct WorkScheduledWorkSnapshot: Identifiable, Equatable { + let id: String + let kind: String + let status: String + let origin: String? + let title: String + let summary: String? + let prompt: String? + let reason: String? + let cron: String? + let nextRunAt: String? + let lastRunAt: String? + let recurring: Bool? + let durable: Bool? + let sourceToolUseId: String? + let sourceTaskId: String? + let turnId: String? + let error: String? + let createdAt: String + let updatedAt: String +} + struct WorkChatTimelineSnapshot: Equatable { var signature: Int var pendingInputs: [WorkPendingInputItem] @@ -604,6 +626,7 @@ struct WorkChatTimelineSnapshot: Equatable { var commandCards: [WorkCommandCardModel] var fileChangeCards: [WorkFileChangeCardModel] var subagentSnapshots: [WorkSubagentSnapshot] + var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] var transcriptIndicatesActiveTurn: Bool var transcriptLatestTurnEnded: Bool var transcriptHasInterruptibleActivity: Bool @@ -620,6 +643,7 @@ struct WorkChatTimelineSnapshot: Equatable { commandCards: [], fileChangeCards: [], subagentSnapshots: [], + scheduledWorkSnapshots: [], transcriptIndicatesActiveTurn: false, transcriptLatestTurnEnded: false, transcriptHasInterruptibleActivity: false, @@ -796,6 +820,8 @@ enum WorkChatEvent: Equatable { case subagentStarted(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String, background: Bool, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentProgress(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, description: String?, summary: String, toolName: String?, label: String?, model: String?, reasoningEffort: String?, turnId: String?) case subagentResult(taskId: String, agentId: String?, agentType: String?, parentToolUseId: String?, status: String, summary: String, label: String?, model: String?, reasoningEffort: String?, turnId: String?) + case scheduledWorkUpdate(id: String, kind: String, status: String, origin: String?, title: String?, summary: String?, prompt: String?, reason: String?, cron: String?, nextRunAt: String?, lastRunAt: String?, recurring: Bool?, durable: Bool?, sourceToolUseId: String?, sourceTaskId: String?, turnId: String?, error: String?) + case transcriptRetraction(messageIds: [String], reason: String?, replacementMessageId: String?, turnId: String?) case structuredQuestion(question: String, options: [WorkPendingQuestionOption], itemId: String, turnId: String?) case approvalRequest(description: String, detail: String?, itemId: String, turnId: String?) case pendingInputResolved(itemId: String, resolution: String, turnId: String?) @@ -830,6 +856,8 @@ enum WorkChatEvent: Equatable { case .subagentStarted: return "subagent_started" case .subagentProgress: return "subagent_progress" case .subagentResult: return "subagent_result" + case .scheduledWorkUpdate: return "scheduled_work_update" + case .transcriptRetraction: return "transcript_retraction" case .structuredQuestion: return "structured_question" case .approvalRequest: return "approval_request" case .pendingInputResolved: return "pending_input_resolved" diff --git a/apps/ios/ADE/Views/Work/WorkPreviews.swift b/apps/ios/ADE/Views/Work/WorkPreviews.swift index 847550f93..c77bedece 100644 --- a/apps/ios/ADE/Views/Work/WorkPreviews.swift +++ b/apps/ios/ADE/Views/Work/WorkPreviews.swift @@ -487,7 +487,8 @@ private enum WorkPreviewData { summary: WorkPreviewData.chatSummary ), lanesRenderSignature: workLaneListRenderSignature([]), - subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature([]) + subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature([]), + scheduledWorkSnapshotsRenderSignature: workScheduledWorkSnapshotsRenderSignature([]) ) } .environmentObject(WorkPreviewData.syncService) diff --git a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift index b6464022a..5741f8229 100644 --- a/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift +++ b/apps/ios/ADE/Views/Work/WorkSessionDestinationView.swift @@ -355,12 +355,14 @@ struct WorkSessionDestinationView: View { @State var optimisticPendingSteers: [WorkPendingSteerModel] = [] @State var subagentSnapshots: [WorkSubagentSnapshot] = [] @State var remoteSubagentSnapshots: [WorkSubagentSnapshot] = [] + @State var scheduledWorkSnapshots: [WorkScheduledWorkSnapshot] = [] @State var subagentView: WorkSubagentSelection? @State var subagentTranscript: [WorkChatEnvelope] = [] @State var subagentTranscriptRenderSignature = 0 @State var parentTranscriptBeforeSubagent: [WorkChatEnvelope] = [] @State var parentFallbackEntriesBeforeSubagent: [AgentChatTranscriptEntry] = [] @State var subagentDrawerPresented = false + @State var chatInfoPresented = false @State var expandedSubagentDetailIds: Set = [] @State var probingSubagentTaskId: String? @State var remoteSubagentRefreshInFlight = false @@ -641,6 +643,7 @@ struct WorkSessionDestinationView: View { WorkChatHeaderMenu( model: headerMenuModel(session), + onShowChatInfo: { chatInfoPresented = true }, onShowSubagents: { Task { await prepareSubagentDrawerPresentation() } }, onShowProof: { artifactDrawerPresented = true }, onViewPrDetails: { presentChatPrDetails() }, @@ -664,6 +667,7 @@ struct WorkSessionDestinationView: View { private func headerMenuModel(_ session: TerminalSessionSummary) -> WorkChatHeaderMenuModel { WorkChatHeaderMenuModel( + chatInfoCount: scheduledWorkSnapshots.count, subagentCount: subagentSnapshots.count, artifactCount: artifacts.count, showsLaneActions: showsLaneActions, @@ -774,6 +778,11 @@ struct WorkSessionDestinationView: View { await refreshRemoteSubagentSnapshots() } } + .sheet(isPresented: $chatInfoPresented) { + WorkChatInfoDetailsSheet(scheduledWorkSnapshots: scheduledWorkSnapshots) + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } .sheet(isPresented: $createPrPresented) { chatCreatePrWizardSheet } @@ -845,7 +854,7 @@ struct WorkSessionDestinationView: View { stageInitialOpeningPromptEchoIfNeeded() await load() await sendInitialOpeningPromptIfNeeded() - refreshSubagentSnapshots() + refreshChatInfoSnapshots() // Remote subagent probing hits the host; skip the eager pass for a // cross-project quick look (the drawer still loads it on demand). if !isCrossProject { @@ -915,7 +924,7 @@ struct WorkSessionDestinationView: View { } } .onChange(of: transcript) { _, _ in - refreshSubagentSnapshots() + refreshChatInfoSnapshots() } .onChange(of: subagentDrawerPresented) { _, presented in guard presented else { return } @@ -1079,7 +1088,10 @@ struct WorkSessionDestinationView: View { onLoadOlderTranscript: loadOlderTranscriptAction, subagentSnapshots: subagentSnapshots, subagentSnapshotsRenderSignature: workSubagentSnapshotsRenderSignature(subagentSnapshots), + scheduledWorkSnapshots: viewingSubagent ? [] : scheduledWorkSnapshots, + scheduledWorkSnapshotsRenderSignature: viewingSubagent ? 0 : workScheduledWorkSnapshotsRenderSignature(scheduledWorkSnapshots), selectedSubagentTaskId: subagentView?.taskId, + onOpenChatInfo: viewingSubagent ? nil : { chatInfoPresented = true }, onOpenSubagents: { Task { await prepareSubagentDrawerPresentation() } }, prBadge: chatPrBadge, onOpenPrDetails: openPrDetails, @@ -1905,6 +1917,12 @@ struct WorkSessionDestinationView: View { } } + @MainActor + func refreshChatInfoSnapshots() { + refreshSubagentSnapshots() + refreshScheduledWorkSnapshots() + } + @MainActor func refreshSubagentSnapshots() { let local = buildWorkSubagentSnapshots(from: transcript) @@ -1925,6 +1943,14 @@ struct WorkSessionDestinationView: View { } } + @MainActor + func refreshScheduledWorkSnapshots() { + let next = buildWorkScheduledWorkSnapshots(from: transcript) + if next != scheduledWorkSnapshots { + scheduledWorkSnapshots = next + } + } + @MainActor func refreshRemoteSubagentSnapshots() async { guard subagentCapability.canList, diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 78fc0fa91..916122a0e 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -25,6 +25,7 @@ func buildWorkChatTimelineSnapshot( let commandCards: [WorkCommandCardModel] = [] let fileChangeCards: [WorkFileChangeCardModel] = [] let subagentSnapshots = buildWorkSubagentSnapshots(from: transcript) + let scheduledWorkSnapshots = buildWorkScheduledWorkSnapshots(from: transcript) let transcriptIndicatesActiveTurn = workTranscriptIndicatesActiveTurn(transcript) let transcriptLatestTurnEnded = workTranscriptLatestTurnEnded(transcript) let transcriptHasInterruptibleActivity = WorkActivityIndicator.derivePresentation(from: transcript) != nil @@ -50,6 +51,7 @@ func buildWorkChatTimelineSnapshot( commandCards: commandCards, fileChangeCards: fileChangeCards, subagentSnapshots: subagentSnapshots, + scheduledWorkSnapshots: scheduledWorkSnapshots, transcriptIndicatesActiveTurn: transcriptIndicatesActiveTurn, transcriptLatestTurnEnded: transcriptLatestTurnEnded, transcriptHasInterruptibleActivity: transcriptHasInterruptibleActivity, @@ -191,6 +193,32 @@ private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: combineOptionalText(model, into: &hasher) combineOptionalText(reasoningEffort, into: &hasher) combineOptional(turnId, into: &hasher) + case .scheduledWorkUpdate(let id, let kind, let status, let origin, let title, let summary, let prompt, let reason, let cron, let nextRunAt, let lastRunAt, let recurring, let durable, let sourceToolUseId, let sourceTaskId, let turnId, let error): + hasher.combine(id) + hasher.combine(kind) + hasher.combine(status) + combineOptional(origin, into: &hasher) + combineOptionalText(title, into: &hasher) + combineOptionalText(summary, into: &hasher) + combineOptionalText(prompt, into: &hasher) + combineOptionalText(reason, into: &hasher) + combineOptional(cron, into: &hasher) + combineOptional(nextRunAt, into: &hasher) + combineOptional(lastRunAt, into: &hasher) + combineOptional(recurring, into: &hasher) + combineOptional(durable, into: &hasher) + combineOptional(sourceToolUseId, into: &hasher) + combineOptional(sourceTaskId, into: &hasher) + combineOptional(turnId, into: &hasher) + combineOptionalText(error, into: &hasher) + case .transcriptRetraction(let messageIds, let reason, let replacementMessageId, let turnId): + hasher.combine(messageIds.count) + for messageId in messageIds { + hasher.combine(messageId) + } + combineOptionalText(reason, into: &hasher) + combineOptional(replacementMessageId, into: &hasher) + combineOptional(turnId, into: &hasher) case .structuredQuestion(let question, let options, let itemId, let turnId): combineLongTextSignature(question, into: &hasher) combineQuestionOptions(options, into: &hasher) @@ -380,7 +408,7 @@ private func combineCompletionArtifacts(_ artifacts: [WorkCompletionArtifactMode private func latestWorkTranscriptTimestamp(_ transcript: [WorkChatEnvelope]) -> String? { var latest: String? - for envelope in transcript { + for envelope in sortedWorkChatEnvelopes(transcript) { guard !envelope.timestamp.isEmpty else { continue } if latest.map({ envelope.timestamp > $0 }) ?? true { latest = envelope.timestamp @@ -626,6 +654,101 @@ func buildWorkSubagentSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkSub .map { $0.snapshot } } +func buildWorkScheduledWorkSnapshots(from transcript: [WorkChatEnvelope]) -> [WorkScheduledWorkSnapshot] { + struct Entry { + var snapshot: WorkScheduledWorkSnapshot + var order: Int + } + + var entries: [String: Entry] = [:] + var nextOrder = 0 + + for envelope in transcript { + guard case .scheduledWorkUpdate( + let id, + let kind, + let status, + let origin, + let title, + let summary, + let prompt, + let reason, + let cron, + let nextRunAt, + let lastRunAt, + let recurring, + let durable, + let sourceToolUseId, + let sourceTaskId, + let turnId, + let error + ) = envelope.event else { + continue + } + + let key = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard !key.isEmpty else { continue } + let existing = entries[key] + let displayTitle = nonEmptyWorkTimelineText(title) + ?? existing?.snapshot.title + ?? workScheduledWorkDefaultTitle(kind: kind) + + entries[key] = Entry( + snapshot: WorkScheduledWorkSnapshot( + id: key, + kind: kind, + status: status, + origin: origin, + title: displayTitle, + summary: nonEmptyWorkTimelineText(summary) ?? existing?.snapshot.summary, + prompt: nonEmptyWorkTimelineText(prompt) ?? existing?.snapshot.prompt, + reason: nonEmptyWorkTimelineText(reason) ?? existing?.snapshot.reason, + cron: nonEmptyWorkTimelineText(cron) ?? existing?.snapshot.cron, + nextRunAt: nonEmptyWorkTimelineText(nextRunAt) ?? existing?.snapshot.nextRunAt, + lastRunAt: nonEmptyWorkTimelineText(lastRunAt) ?? existing?.snapshot.lastRunAt, + recurring: recurring ?? existing?.snapshot.recurring, + durable: durable ?? existing?.snapshot.durable, + sourceToolUseId: nonEmptyWorkTimelineText(sourceToolUseId) ?? existing?.snapshot.sourceToolUseId, + sourceTaskId: nonEmptyWorkTimelineText(sourceTaskId) ?? existing?.snapshot.sourceTaskId, + turnId: nonEmptyWorkTimelineText(turnId) ?? existing?.snapshot.turnId, + error: nonEmptyWorkTimelineText(error) ?? existing?.snapshot.error, + createdAt: existing?.snapshot.createdAt ?? envelope.timestamp, + updatedAt: envelope.timestamp + ), + order: existing?.order ?? nextOrder + ) + if existing == nil { + nextOrder += 1 + } + } + + return entries.values + .sorted { lhs, rhs in + if lhs.snapshot.updatedAt == rhs.snapshot.updatedAt { + return lhs.order < rhs.order + } + return lhs.snapshot.updatedAt > rhs.snapshot.updatedAt + } + .map(\.snapshot) +} + +private func workScheduledWorkDefaultTitle(kind: String) -> String { + switch kind.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "wakeup": + return "Scheduled wakeup" + case "cron": + return "Scheduled task" + case "loop": + return "Loop wakeup" + case "remote_trigger": + return "Remote trigger" + case "background_task": + return "Background work" + default: + return "Scheduled work" + } +} + private func buildResolvedWorkSubagentKeysByParent(from transcript: [WorkChatEnvelope]) -> [String: Set] { var keysByParent: [String: Set] = [:] for envelope in transcript { @@ -1996,6 +2119,8 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .subagentStarted(_, _, _, _, _, _, _, _, _, let turnId), .subagentProgress(_, _, _, _, _, _, _, _, _, _, let turnId), .subagentResult(_, _, _, _, _, _, _, _, _, let turnId), + .scheduledWorkUpdate(_, _, _, _, _, _, _, _, _, _, _, _, _, _, _, let turnId, _), + .transcriptRetraction(_, _, _, let turnId), .structuredQuestion(_, _, _, let turnId), .approvalRequest(_, _, _, let turnId), .pendingInputResolved(_, _, let turnId), diff --git a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift index cdbb57fd2..9e27e4d8a 100644 --- a/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift +++ b/apps/ios/ADE/Views/Work/WorkTranscriptParser.swift @@ -114,7 +114,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { taskId: stringValue(eventDict["taskId"]), agentId: optionalString(eventDict["agentId"]), agentType: optionalString(eventDict["agentType"]), - parentToolUseId: optionalString(eventDict["parentToolUseId"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]) ?? optionalString(eventDict["parentAgentId"]), description: stringValue(eventDict["description"]), background: (eventDict["background"] as? Bool) ?? false, label: optionalString(eventDict["label"]), @@ -127,7 +127,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { taskId: stringValue(eventDict["taskId"]), agentId: optionalString(eventDict["agentId"]), agentType: optionalString(eventDict["agentType"]), - parentToolUseId: optionalString(eventDict["parentToolUseId"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]) ?? optionalString(eventDict["parentAgentId"]), description: optionalString(eventDict["description"]), summary: stringValue(eventDict["summary"]), toolName: optionalString(eventDict["lastToolName"]), @@ -141,7 +141,7 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { taskId: stringValue(eventDict["taskId"]), agentId: optionalString(eventDict["agentId"]), agentType: optionalString(eventDict["agentType"]), - parentToolUseId: optionalString(eventDict["parentToolUseId"]), + parentToolUseId: optionalString(eventDict["parentToolUseId"]) ?? optionalString(eventDict["parentAgentId"]), status: stringValue(eventDict["status"]), summary: stringValue(eventDict["summary"]), label: optionalString(eventDict["label"]), @@ -149,6 +149,34 @@ func parseWorkChatTranscript(_ raw: String) -> [WorkChatEnvelope] { reasoningEffort: optionalString(eventDict["reasoningEffort"]), turnId: turnId ) + case "scheduled_work_update": + event = .scheduledWorkUpdate( + id: stringValue(eventDict["id"]), + kind: stringValue(eventDict["kind"]), + status: stringValue(eventDict["status"]), + origin: optionalString(eventDict["origin"]), + title: optionalString(eventDict["title"]), + summary: optionalString(eventDict["summary"]), + prompt: optionalString(eventDict["prompt"]), + reason: optionalString(eventDict["reason"]), + cron: optionalString(eventDict["cron"]), + nextRunAt: optionalString(eventDict["nextRunAt"]), + lastRunAt: optionalString(eventDict["lastRunAt"]), + recurring: workBoolValue(eventDict["recurring"]), + durable: workBoolValue(eventDict["durable"]), + sourceToolUseId: optionalString(eventDict["sourceToolUseId"]), + sourceTaskId: optionalString(eventDict["sourceTaskId"]), + turnId: turnId, + error: optionalString(eventDict["error"]) + ) + case "transcript_retraction": + let messageIds = (eventDict["messageIds"] as? [Any] ?? []).compactMap(optionalString) + event = .transcriptRetraction( + messageIds: messageIds, + reason: optionalString(eventDict["reason"]), + replacementMessageId: optionalString(eventDict["replacementMessageId"]), + turnId: turnId + ) case "status": event = .status(turnStatus: stringValue(eventDict["turnStatus"]), message: optionalString(eventDict["message"]), turnId: turnId) case "reasoning": diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 274aeea15..deca9635a 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -8774,9 +8774,53 @@ final class ADETests: XCTestCase { XCTAssertTrue(toolCards.first?.resultText?.contains("ADE") == true) } + func testParseWorkChatTranscriptBuildsScheduledWorkSnapshots() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:02.000Z","sequence":2,"event":{"type":"scheduled_work_update","id":"wakeup-1","kind":"wakeup","status":"running","origin":"schedule_wakeup","summary":"Wakeup fired","lastRunAt":"2026-07-07T00:05:00.000Z","turnId":"turn-2"}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":1,"event":{"type":"scheduled_work_update","id":"wakeup-1","kind":"wakeup","status":"scheduled","origin":"schedule_wakeup","title":"Wakeup scheduled","prompt":"Check CI","reason":"CI is still running","nextRunAt":"2026-07-07T00:05:00.000Z","recurring":false,"durable":true,"sourceToolUseId":"tool-1","turnId":"turn-1"}} + """ + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: parseWorkChatTranscript(raw), + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + + XCTAssertEqual(snapshot.scheduledWorkSnapshots.count, 1) + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.id, "wakeup-1") + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.status, "running") + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.title, "Wakeup scheduled") + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.summary, "Wakeup fired") + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.prompt, "Check CI") + XCTAssertEqual(snapshot.scheduledWorkSnapshots.first?.durable, true) + } + + func testParseWorkChatTranscriptAppliesTranscriptRetractionsByMessageId() { + let raw = """ + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:01.000Z","sequence":1,"event":{"type":"text","text":"Superseded answer","messageId":"provider-message-1","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:02.000Z","sequence":2,"event":{"type":"transcript_retraction","messageIds":["provider-message-1"],"reason":"assistant_supersedes","replacementMessageId":"provider-message-2","turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-07-07T00:00:03.000Z","sequence":3,"event":{"type":"text","text":"Replacement answer","messageId":"provider-message-2","turnId":"turn-1"}} + """ + + let snapshot = buildWorkChatTimelineSnapshot( + transcript: parseWorkChatTranscript(raw), + fallbackEntries: [], + artifacts: [], + localEchoMessages: [] + ) + let messages = snapshot.timeline.compactMap { entry -> WorkChatMessage? in + if case .message(let message) = entry.payload { return message } + return nil + } + + XCTAssertEqual(messages.map(\.markdown), ["Replacement answer"]) + XCTAssertEqual(messages.first?.itemId, "provider-message-2") + } + func testWorkSubagentSnapshotsPreserveAgentIdAndRunningCount() { let raw = """ - {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","description":"Docs helper","background":true,"turnId":"turn-1"}} + {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:01.000Z","sequence":1,"event":{"type":"subagent_started","taskId":"task-1","agentId":"agent-1","parentAgentId":"parent-agent-1","description":"Docs helper","background":true,"turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:02.000Z","sequence":2,"event":{"type":"subagent_progress","taskId":"task-1","agentId":"agent-1","summary":"Reading README.md","lastToolName":"functions.Read","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:03.000Z","sequence":3,"event":{"type":"subagent_started","taskId":"task-2","agentId":"agent-2","description":"Done helper","turnId":"turn-1"}} {"sessionId":"chat-1","timestamp":"2026-03-25T00:00:04.000Z","sequence":4,"event":{"type":"subagent_result","taskId":"task-2","agentId":"agent-2","status":"completed","summary":"Done","turnId":"turn-1"}} @@ -8787,6 +8831,7 @@ final class ADETests: XCTestCase { XCTAssertEqual(workSubagentRunningCount(snapshots), 1) XCTAssertEqual(snapshots.first?.taskId, "task-1") XCTAssertEqual(snapshots.first?.agentId, "agent-1") + XCTAssertEqual(snapshots.first?.parentToolUseId, "parent-agent-1") XCTAssertEqual(snapshots.first?.background, true) XCTAssertEqual(snapshots.first?.lastToolName, "functions.Read") XCTAssertEqual(snapshots.first?.startedAt, "2026-03-25T00:00:01.000Z") diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 198de26fc..5a700aa85 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -164,7 +164,7 @@ The desktop app is a **client of the runtime**. It owns a trusted main process, | `apps/desktop/src/main/` | Node process with full OS access. Hosts windows, registers IPC handlers, routes runtime-backed APIs through local/remote runtime pools, spawns the local ADE runtime when needed, and owns Electron-only services that cannot run inside the runtime. Entry: `main.ts`. | | `apps/desktop/src/preload/` | Typed bridge. Entry: `preload.ts`. Uses `contextBridge.exposeInMainWorld("ade", { ... })`. Runtime-backed APIs route through `LocalRuntimeConnectionPool` (local) or `RemoteConnectionPool` (SSH-bound window); file APIs are strict once a local/remote runtime is bound, while usage/budget reads only route to runtime for remote-bound windows. During project switches, mutating runtime/sync calls that target the ambiguous active binding are blocked, read-only calls avoid refreshing stale bindings, active remote opens can be awaited before retrying reads, and remote lane preview URLs are localized through desktop-owned TCP forwards. Explicitly targeted work can pass an `OpenProjectBinding` pin through `callPinnedRuntimeAction` to route to the captured project during a switch, used by detached draft launches and rollback. | | `apps/desktop/src/renderer/` | React 18 SPA. No Node access, no filesystem access, no direct process/network. Everything goes through `window.ade`. Entry: `main.tsx`. | -| `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and other DTOs. Imported by both desktop and `apps/ade-cli`. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | +| `apps/desktop/src/shared/` | Types, IPC channel constants (`ipc.ts`), model registry (`modelRegistry.ts`), keybindings, and cross-client chat derivations such as `chatScheduledWork.ts`. Imported by desktop, `apps/ade-cli`, and mobile contract generation paths. New runtime-facing types live in `shared/types/remoteRuntime.ts` and `shared/types/core.ts`. | | `apps/desktop/src/generated/` | Build-time generated code (e.g., bootstrap SQL snapshots). | | `apps/desktop/src/test/` | Shared vitest setup and fixtures. | | `apps/desktop/src/types/` | Ambient type declarations. | @@ -192,7 +192,7 @@ Terminal-native **Work** chat client (Ink 7 + React 19) for agents and power use - **Remote mode**: `ade code remote` reads saved desktop remote targets, picks a target/project/session, starts `ade rpc --stdio` over SSH, bridges that stdio stream to a local one-connection socket (`remoteBridge.ts`), and then runs the normal TUI against the bridged endpoint. Compatibility checks that only make sense for local processes (entrypoint build hash and project-root equality) are skipped. - **Embedded mode**: `--embedded` / `--headless` runs the shared `apps/ade-cli` services in-process without going through a machine brain. Used when no brain endpoint or manual runtime endpoint is reachable. -Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The built TUI bundle is intended to run in isolation: tsup bundles its Ink/xterm/highlight dependencies and injects ESM shims for `__dirname` / `__filename`; both `apps/ade-cli/scripts/verify-built-cli.mjs` and the desktop artifact validators smoke-import it and run `runAdeCodeCli(["--help"])`. Provider/model/interface setup is kept in pure helpers (`modelState.ts`, `providerMetadata.ts`, `modelPickerController.ts`) so Chat-vs-CLI availability, Cursor SDK-vs-CLI model filtering, permission presets, Fast Mode, and setup rows stay testable outside the Ink root. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. +Shared chat DTOs are imported from `apps/desktop/src/shared/types/*` (never the renderer barrel) so `npm run typecheck` in `apps/ade-cli` covers both typed commands and the TUI. Entry: `apps/ade-cli/src/tuiClient/cli.tsx` → `apps/ade-cli/dist/tuiClient/cli.mjs`, loaded by `ade code`. The built TUI bundle is intended to run in isolation: tsup bundles its Ink/xterm/highlight dependencies and injects ESM shims for `__dirname` / `__filename`; both `apps/ade-cli/scripts/verify-built-cli.mjs` and the desktop artifact validators smoke-import it and run `runAdeCodeCli(["--help"])`. Provider/model/interface setup is kept in pure helpers (`modelState.ts`, `providerMetadata.ts`, `modelPickerController.ts`) so Chat-vs-CLI availability, Cursor SDK-vs-CLI model filtering, permission presets, Fast Mode, and setup rows stay testable outside the Ink root. Chat Info uses shared derivations for subagents, tasks, and scheduled work, so Claude wakeups/cron/background activity rendered in desktop also appears in ADE Code. The TUI can hand off to a desktop window via the `app/navigate` JSON-RPC method when a desktop client is attached to the same runtime. ### 2.4 iOS client (`apps/ios/`) @@ -201,7 +201,7 @@ Native SwiftUI app acting as a controller. It pairs with an ADE machine over Web - Stack: native SwiftUI + `SQLite3` C API + iOS system SQLite. - CRDT: pure-SQL CRR emulation layer (trigger-based change tracking) since iOS blocks `sqlite3_load_extension()`/`sqlite3_auto_extension()`. Changesets are wire-compatible with desktop cr-sqlite. - Core services: `Database.swift`, `SyncService.swift`, `KeychainService.swift`, `DpopKeyService.swift` (Secure Enclave P-256 pairing proof), `PushNotificationService.swift`, `LiveActivityService.swift`. -- Shipped tabs: Lanes, Files, Work, PRs, CTO, Settings (including a Push delivery panel). +- Shipped tabs: Lanes, Files, Work, PRs, CTO, Settings (including a Push delivery panel). The Work chat decodes the same chat event union as desktop for live transcripts, including scheduled-work updates and transcript retractions; scheduled work appears in a native Chat Info popup/sheet while the phone remains a controller only. - Shipped widgets: a Lock Screen widget for prioritized agent/PR/sync/offline/idle status, plus an ActivityKit Live Activity + Dynamic Island for active agent runs (`ADEWidgets/ADEAgentActivityWidget.swift`). - Push: APNs alert pushes (deep-linked) and Live Activity updates arrive via the Cloudflare push relay (§2.6); the phone hands tokens/prefs to the brain over the paired sync WebSocket. - Pairing: user-set 6-digit PIN over a v3 smart-URL QR (or discovery / manual entry), hardened with device-bound DPoP proofs. @@ -539,7 +539,7 @@ Most services described here live under `apps/desktop/src/main/services/ | `appControl/` | `appControlService.ts`, `appControlLaunchCommand.ts` | Chrome DevTools Protocol bridge for developer-owned Electron apps. Launches a chat-owned PTY running the user's dev command (or connects to an existing `--remote-debugging-port`), polls `/json` for ready CDP targets, attaches a long-lived `CdpClient` WebSocket, and exposes screenshot / DOM snapshot / hit-test / click / type / scroll / key dispatch / screencast frames. `appControlLaunchCommand.ts` owns the shell-command detection and debug-flag injection helpers for direct Electron and package-script launches. `inspectPoint` and `selectPoint` produce `AppControlContextItem`s for the chat composer (DOM packet + screenshot + source-file candidates resolved by `findSourceMatches` over an indexed tree of project source files). See [features/computer-use/app-control.md](./features/computer-use/app-control.md). | | `builtInBrowser/` | `builtInBrowserService.ts`, `builtInBrowserNavigation.ts`, `builtInBrowserPermissions.ts` | In-app web browser owned by the main process. Allocates `WebContentsView` tabs against persistent project-profile partitions derived from the active project root (fallback `persist:ade-browser`, cap 10 tabs per window/project service), positions them over a renderer-supplied bounds rect, drives navigation / tabs / reload / back / forward, attaches the Chrome DevTools Protocol debugger for inspect-mode hit tests and background input dispatch, captures screenshots, handles Electron download requests with sanitized unique filenames in the user's Downloads folder, and emits `BuiltInBrowserEventPayload`s to subscribers. Tabs carry per-tab owner metadata (`ownerLaneId`, `ownerChatSessionId`, `ownerClaimedAt`, `ownerLeaseExpiresAt`), and observe/click/type/key/scroll/fill/clear/wait/screenshot/select/reload/back/forward/stop can target a hidden or non-active tab by `tabId` or by a lightweight browser session id. Browser sessions bind one agent workflow to one tab, remember owner plus last observation/trace ids, support `ade browser session ` aliases, and end explicitly or when the tab closes. Browser agent observations are scratch PNG/JSON files under `.ade/cache/browser-observations/` with a bounded DOM element list for selector/text/index targeting, optional numbered element-map screenshots, console/network diagnostics, pending request count, and short-lived element handles that preserve same-origin iframe/open-shadow-root context. Click/fill/clear/press/wait can resolve `selector`, `text`, `testId`, `elementIndex`, or `handle`, scroll the target into view, focus as needed, and reject disabled targets before dispatching CDP input. Waits wake from browser/network/page activity events with a timeout fallback; `network-idle` requires complete ready state, zero pending browser requests, and a configurable idle window. Per-tab action traces record action, target metadata, before/after URL, duration, observation id, session id, and errors; typed/fill values are represented by length rather than raw text. Scratch observations prune to the latest 3 observations per tab by default and sweep stale cache files; `ade browser proof` promotes a fresh observation through the computer-use artifact broker. Desktop bridge calls from `ade browser` carry the runtime project root and are routed only to an ADE window/project tab for that root, so a command from one project cannot mutate another project's browser. Navigation normalization and protocol allowlisting live in `builtInBrowserNavigation.ts`; Google-auth permission policy lives in `builtInBrowserPermissions.ts`; WebAuthn account selection is registered per Electron storage session in `builtInBrowserWebAuthn.ts`. Consumed by `ChatBuiltInBrowserPanel` (Work sidebar Browser tab), which hides the native view while ADE overlays intersect the browser rectangle, and by `openUrlInAdeBrowser()` in the renderer so renderer-side link clicks open inside ADE rather than the system browser. | | `automations/` | `automationService.ts`, `automationPlannerService.ts`, `automationIngressService.ts`, `automationSecretService.ts` | Rule lifecycle, NL → rule planner, inbound triggers, per-rule secrets. | -| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, derives prompt-based lane names for parallel model launches, and manages Codex app-server goals with persisted, unlimited-budget session state. | +| `chat/` | `agentChatService.ts`, `runtimeEvents.ts`, `buildClaudeV2Message.ts`, `markdownSlashCommandDiscovery.ts`, `claudeSlashCommandDiscovery.ts`, `codexSlashCommandDiscovery.ts`, `cursorSlashCommandDiscovery.ts`, `projectSlashCommandDiscovery.ts`, `slashCommandPromptExpansion.ts`, `cursorSdk*` (`cursorSdkPool.ts`, `cursorSdkWorker.ts`, `cursorSdkProtocol.ts`, `cursorSdkPolicy.ts`, `cursorSdkSystemPrompt.ts`, `cursorSdkEventMapper.ts`), `sessionRecovery.ts` | Agent chat sessions (lane-scoped + orchestration worker/coordinator). Builds Claude messages, hosts the Cursor SDK in a Node worker pool, formalizes the cross-runtime event vocabulary, discovers and resolves provider-specific slash commands through a shared markdown engine, recovers sessions on restart, derives prompt-based lane names for parallel model launches, keeps Claude Agent SDK streams alive for scheduled wake/cron/background work after visible turns, emits transcript retractions for provider-superseded assistant rows, and manages Codex app-server goals with persisted, unlimited-budget session state. | | `computerUse/` | `computerUseArtifactBrokerService.ts`, `controlPlane.ts`, `localComputerUse.ts`, `agentBrowserArtifactAdapter.ts`, `syntheticToolResult.ts` | Proof-artifact broker (ingests, owner links, review state, routing), control-plane snapshot helpers, macOS capture capability descriptor, agent-browser payload parser, and the synthetic-tool-result helper used by the Claude compaction path. `proofObserver.ts` was removed in the rebuild — there is no passive auto-ingest. | | `config/` | `projectConfigService.ts`, `laneOverlayMatcher.ts` | Load/save `.ade/ade.yaml` + `local.yaml`; trust enforcement; lane overlays. | | `conflicts/` | `conflictService.ts` | Pairwise dry-merge simulation, risk matrix, proposal generation. | diff --git a/docs/features/ade-code/README.md b/docs/features/ade-code/README.md index 2c65c52a6..f03df41fc 100644 --- a/docs/features/ade-code/README.md +++ b/docs/features/ade-code/README.md @@ -54,19 +54,19 @@ Point Cursor’s browser inspector at the served page for layout debugging. The | `apps/ade-cli/src/tuiClient/pendingInput.ts` | Derives pending tool approvals and answer prompts from the chat event stream. Also owns the pure multi-question selection state machine (`PendingQuestionSelectionState` + `create`/`ensure`/`move`/`set` helpers and `optionsForPendingQuestion`) that backs the AskUserQuestion-style approval UI: per-question selected-option index, active-question focus, provisional digit quick-select, accumulated answers, answered-count, and the resolved value for the active question (selected option → question `defaultAssumption`). Only the first question inherits the legacy request-level `options` fallback; later questions must carry their own. | | `apps/ade-cli/src/tuiClient/planMode.ts` | Provider-agnostic plan-mode detector (`isPlanMode(modelState)`) plus `hasFirstUserMessage` event scan. Decides whether the composer should display the plan-mode badge and gate destructive tools. | | `apps/ade-cli/src/tuiClient/spinTick.tsx` | Shared monotonic spinner tick provider (`SpinTickProvider`) so every animated glyph in the TUI ticks in lockstep. | -| `apps/ade-cli/src/tuiClient/chatInfo.ts` | Builds `ChatInfoSnapshot` for the right-pane Chat Info view (provider/model, lane, plan steps, Codex goal, context %, token summary, subagent roster, streaming state). Consumes the same chat-event stream the TUI is already replaying; Codex goal display treats provider budget-limited states as active because ADE keeps goals unlimited. | +| `apps/ade-cli/src/tuiClient/chatInfo.ts` | Builds `ChatInfoSnapshot` for the right-pane Chat Info view (provider/model, lane, plan steps, Codex goal, context %, token summary, subagent roster, scheduled work, streaming state). Consumes the same chat-event stream the TUI is already replaying; Codex goal display treats provider budget-limited states as active because ADE keeps goals unlimited. Scheduled work is derived through the shared `apps/desktop/src/shared/chatScheduledWork.ts` helper so Claude wakeups/cron/background rows match desktop. | | `apps/ade-cli/src/services/sync/syncRemoteCommandService.ts` | Runtime action registry used by desktop, iOS, and ADE Code attached clients. Exposes the typed `chat.getCodexGoal`, `chat.setCodexGoal`, `chat.setCodexGoalStatus`, and `chat.clearCodexGoal` actions so clients can update Codex goals without injecting `/goal` prompt text into CLI-backed chats. | | `apps/ade-cli/src/tuiClient/subagentPane.ts` | Pure builders for the Chat Info pane's subagent roster: `buildSubagentPaneRows`, `subagentIndexForPaneLine`, `selectedSubagentSnapshot`, and `subagentPaneContentFromRightPane` (extracts a `SubagentPaneContent` from the `chat-info` right-pane state). `subagentSnapshotsFromEvents` reconstructs snapshots from `subagent_*` and teammate envelopes with sibling-aware parent-placeholder resolution. | | `apps/ade-cli/src/tuiClient/workEventIds.ts` | Stable Work-tab identity helpers used by the TUI to thread `ade.work-*` event ids through the renderer without re-deriving them per frame. | | `apps/ade-cli/src/tuiClient/state.ts` | Persists terminal-client state under `~/.ade/ade-code-state.json`: the last selected chat per lane (`lastChatByLane`), the most recently active lane (`lastLaneId`), and the last explicitly chosen draft Interface (`draftKind`, `chat` or `cli`). Entries are project-scoped with legacy global fallback, matching desktop's project-scoped Work view memory. Writes are serialized through a short lock file so multiple TUI instances do not corrupt the JSON state. | | `apps/ade-cli/src/tuiClient/theme.ts` | Shared Ink color and status tokens. Mirrors the Claude Design wireframe terminal palette 1:1: surfaces, text levels, brand violets, status (`running`/`attention`/`idle`/`failed`/`primary`), executor brand colors (Claude/Codex/Cursor/OpenCode/Droid + Shell + Copilot), plus helper exports `laneStatusColor`, `agentStatusColor`, `agentStatusGlyph`, and per-provider `glyph` + `wordmark`. | -| `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, `RightPaneContent` (`empty`, `help`, `status`, `details`, `diff`, `chat-info`, `new-chat-setup`, `model-setup`, `form`, `lane-details` with git stats + PR CI fields + lane chat counts, …), `ChatInfoSnapshot`, `ChatInfoPlan`, `ChatInfoPlanStep`, `SubagentSnapshot`, plus navigation DTOs aligned with `apps/desktop/src/shared/types`. | +| `apps/ade-cli/src/tuiClient/types.ts` | `AdeCodeConnection`, `ProjectLaunchContext`, `RightPaneContent` (`empty`, `help`, `status`, `details`, `diff`, `chat-info`, `new-chat-setup`, `model-setup`, `form`, `lane-details` with git stats + PR CI fields + lane chat counts, …), `ChatInfoSnapshot`, `ChatInfoPlan`, `ChatInfoPlanStep`, `SubagentSnapshot`, `ChatScheduledWorkSnapshot`, plus navigation DTOs aligned with `apps/desktop/src/shared/types`. | | `apps/ade-cli/src/tuiClient/components/` | `AdeWordmark`, `Drawer` (`visibleDrawerLaneCount` / `visibleDrawerChatCount`, `DrawerPrSummary` rows, lanes mode chat preview under the selected lane, closed tracked CLI session group), `ChatView` (transcript renderer; exports `renderChatVisibleSelectionRows` / `renderChatSelectableRowTexts` / `selectedTextFromChatRows` for the ADE-owned mouse selection, plus `computeChatScrollMaxOffset` and `renderChatTranscriptPlainText`), `Header`, `RightPane` (`computeLaneChatCounts`, `LANE_DETAIL_PR_ACTION_INDEX`, wireframe `lane-details` STATUS/SETUP/CHANGES/ACTIONS/PR/CHATS sections, Chat Info `chat-info`, `model-setup`, masked `/secrets` list), `SlashPalette`, `MentionPalette`, `ApprovalPrompt`, `ModelStatus`, `FooterControls`, and `TerminalPane` (xterm-headless preview pane that consumes `ChatTerminalPreviewResult` from `ade.terminal.preview` plus live `ade.pty.data` chunks to render a real terminal grid inside Ink; running provider CLI terminals — Claude, Codex, Cursor, Droid, OpenCode — can be put into direct control mode from the TUI). | | `apps/ade-cli/src/tuiClient/keybindings/index.ts` | Verbatim `~/.claude/keybindings.json` reader and TUI action dispatcher (chord support, vim namespace, clipboard-image paste hooks). Resolves `defaultKeybindingsPath()`, parses the Claude keybindings schema, and maps key sequences onto TUI actions. | | `apps/ade-cli/src/tuiClient/statusline/index.ts` | Claude-compatible status line config reader and runner. Reads the `~/.claude/statusline.json` contract, executes the configured status command, and exposes the rendered lines to `ModelStatus`. | | `apps/ade-cli/src/tuiClient/components/ModelPicker/` | Ink ModelPicker pane: `ModelPickerPane.tsx` (provider/category rail + search + model rows), `modelPickerLayout.ts` (pure derivations — imports `modelOrdering` and `modelPickerSearch` from the desktop package so behaviour stays in lockstep with the renderer), `modelPickerGeometry.ts` (shared painted-row / click hit-test geometry), and `types.ts` (`ModelPickerEntry`, `ModelPickerRailEntry`, `ModelPickerState`, plus `AdeCodeProvider` extensions for `ollama` / `lmstudio`). Reads the provider-grouped catalog via `getModelCatalog`, preserves runtime `serviceTiers` and Cursor `cursorAvailability` metadata for Fast Mode / chat-vs-CLI parity, keeps the provider rail stable even when a runtime is signed out or empty, and reads favorites / recents via the cross-surface `modelPicker.*` store. | | `apps/ade-cli/src/services/modelPickerStore.ts` | Cross-surface (desktop + TUI + iOS) favorites and recents stored in the per-project `ade.db` tables `model_picker_favorites` and `model_picker_recents`, with `~/.ade/modelPicker.json` imported once as a legacy migration source. `MAX_RECENTS` caps the recents list in app code because the CRR tables are primary-key-only. Exposed through the top-level `modelPicker.getFavorites` / `setFavorites` / `toggleFavorite` / `getRecents` / `pushRecent` JSON-RPC methods on `adeRpcServer` and through matching iOS sync commands. | -| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds, `AgentChatModelCatalog*`). Imported per-module so ade-cli typecheck stays scoped. | +| `apps/desktop/src/shared/types/chat.ts` | Canonical chat DTOs (`AgentChatEventEnvelope`, sessions, pending input, `AgentChatContextUsage`, `AgentChatClaudeOutputStyle`, `AgentChatClaudePlugin`, subagent kinds, scheduled-work/retraction events, `AgentChatModelCatalog*`). Imported per-module so ade-cli typecheck stays scoped. | | `apps/desktop/src/shared/modelRegistry.ts` | Default model selection for new sessions (`getDefaultModelDescriptor`). | | `apps/desktop/src/shared/adeLayout.ts` | Resolves project-scoped `.ade` paths. | @@ -141,7 +141,8 @@ For the embedded runtime there is no `projects.add` step — the in-process runt - **Header** — project name, active lane, branch, and the terminal client frame. - **Drawer** (toggled with the configured shortcut) — two modes, **lanes** (default) and **chats**, switched with `Tab` while the drawer is focused. Lane cards show name + status (no branch ref — that lives in lane details). Every lane shows its chats: the selected lane expands the full chat block (the same tight single-row chats every lane shows, distinguished only by a violet border plus a trailing `+ new chat` row — there is no `CHATS` header), while every other lane renders a compact always-visible preview (the lane's chats as single rows, plus a `+N more` tail only when the row budget can't fit them all) whose rows are clickable and select lane + chat in one step. Ended tracked CLI sessions are hidden behind a `closed (N)` row in the expanded lane; expanding it shows dim one-line rows with provider glyph, title, and relative end time, and `↵` resumes a resumable closed CLI session through the same terminal resume path as desktop. Row layout and mouse hit-testing share one pure model (`drawerLayout.ts: computeDrawerLayout` / `drawerMouseHitForLayout`) so open chats, closed toggles, closed sessions, and `+ new chat` cannot drift. In **lanes** mode, `↑`/`↓` move lane cards; `↓` on an available lane enters **chats** mode for that lane; `↵` opens lane details or resumes the lane's last chat. In **chats** mode, `↑`/`↓` move within the lane's chat rows, closed group, and `+ new chat`; highlighting a chat previews it in the centre pane via `resolveTuiChatRefreshTarget` before `↵` commits the session. `Esc` returns from the chat list to **lanes**. Lane and chat selection drive the right pane's context. - **ChatView** — the main transcript. Renders user, assistant, tool, and system events from `chat/event` notifications. For the non-Claude runtimes the transcript mirrors the desktop work log: each tool call is one stacked line (`✓ read apps/x.ts`) with the desktop slug + target-arg derivation (pure helpers imported from `apps/desktop/.../chatTranscriptRows` and `toolPresentation`), `web_search` events group with tool calls and include the first provider action query/title/URL when available, reasoning renders as a collapsed `Thinking…`/`Thought` row with a one-line preview, and every row truncates to the pane width (rows never wrap — the scroll math assumes 1 row = 1 line). Codex app-server runtime events (`codex_safety_buffering`, `codex_moderation_metadata`, `codex_sleep`, `codex_thread_deleted`, `codex_turn_stalled`) render as concise transcript notices instead of disappearing into generic activity. A valid ` ```mosaic ` fence (the interactive card the desktop transcript renders — see [chat composer-and-ui.md](../chat/composer-and-ui.md)) collapses to a single dim summary line here via `summarizeMosaicCard` from `apps/desktop/src/shared/chatMosaic.ts`, since the TUI cannot render the interactive form; a fence that fails to parse falls back to the plain code block. The most recent expandable failure id is tracked so `Enter` can drill into it. Mouse selection is ADE-owned so it can follow virtual transcript rows: drag selects, edge-drag scrolls, wheel scrolling preserves the highlighted range, Shift-click extends the current anchor, and `Ctrl+C` / delivered `Cmd+C` copy selected chat text. -- **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`.- **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — a **TASKS** section (latest `todo_update` snapshot, desktop ChatTasksPanel parity) and a **PR** section (the lane's PR state + checks rollup with `/pr` hand-off hints, desktop ChatPrPane parity). PR rows refresh from runtime PR update notifications when available and still keep the 30s poll as a fallback. Codex goal state comes from the shared chat event stream and is normalized so provider token budgets do not show as ADE-side limits. Selecting a subagent row with `↵` first probes for a usable subagent transcript; if one is available the centre transcript swaps to it, otherwise the local reconstruction stays visible with a notice. `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), optional **SETUP** (lane setup progress or retryable failure; press `r` on a failed setup to retry), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. +- **Composer** — multi-line input with mention completion (`@…`) sourced from `MentionPalette` and slash command completion from `SlashPalette`. Both triggers are detected cursor-relatively through the shared `apps/desktop/src/shared/composerTriggers.ts` module (`detectComposerTrigger`), so a `/command` or `@file` token is recognized anywhere in the draft — not just at position 0 (`fix @src/foo.ts then run /test`). Both palettes stay visible with a no-match row while the user is actively typing. Selecting a suggestion splices exactly the trigger span (`replaceComposerTriggerSpan`) rather than replacing the whole prompt; a lone leading `/command` keeps the legacy fill-the-prompt behavior. `Tab` completes the highlighted slash command, and for a **mid-sentence** slash trigger `Enter` completes into the draft (instead of submitting/running), mirroring the desktop command menu — a leading-only command still runs on `Enter`. Confirmed tokens render as colored chips in the prompt rows via `findConfirmedComposerTokens` + `segmentPromptLineText`: inserted `@file` mentions and `/command` names matching the built-in or runtime catalog paint cyan (files) or violet (commands) and bold, while unmatched `@`/`/` text stays plain. Mention completion publishes local lane/chat hits immediately, then debounces remote file/git/PR RPCs; file results are cached per lane+query and git/PR results are cached per lane for the open TUI session. Pending tool approvals surface as `ApprovalPrompt`. AskUserQuestion-style answer requests (one or more questions, each with options) render every question inline with its option list and an `N of M answered` header. While such a request is pending and the composer is empty, keyboard input drives the picker instead of the prompt: `↑`/`↓` move the selected option (or move between questions when the active question has no options), `←`/`→` switch the active question, `1`-`9` within the option count highlights that option without submitting, and `Enter` submits the active question's current selection (advancing to the next unanswered question, or finalizing the whole request once every question is answered). If the next printable input after a digit quick-select is text, that digit becomes the start of a free-text answer and the previous option highlight is restored; digits above the option count type directly into the composer. Clicking an option still submits it immediately. The deny chip still declines the whole request. Selection lives in `pendingInput.ts`'s `PendingQuestionSelectionState`. +- **RightPane** — context-sensitive drawer for slash command output. The "right" placement commands (see below) render their results here as forms, lists, diffs, help text, or rendered objects. `/secrets` opens a masked project-secret list and copies the selected secret value to the local system clipboard with `Enter` or `c`; it never reveals values inline and only uses the read actions behind the existing project-secret RPC path. When a chat is active the default content is the **Chat Info** view (`kind: "chat-info"`): provider/model header, lane label, streaming/idle indicator with context-percent + token summary, plan steps for the current turn (plus the provider's plan explanation / streaming text when present), Codex `/goal` block when present, a roster of subagents (running first, then teammates and background), and — below the roster, like the Droid Missions block — **TASKS** (latest `todo_update` snapshot, desktop ChatTasksPanel parity), **SCHEDULE** (Claude wakeups/cron/background work from `scheduled_work_update`, desktop Chat Info parity), and **PR** (the lane's PR state + checks rollup with `/pr` hand-off hints, desktop ChatPrPane parity). PR rows refresh from runtime PR update notifications when available and still keep the 30s poll as a fallback. Codex goal state comes from the shared chat event stream and is normalized so provider token budgets do not show as ADE-side limits. Selecting a subagent row with `↵` first probes for a usable subagent transcript; if one is available the centre transcript swaps to it, otherwise the local reconstruction stays visible with a notice. `Esc` returns to the main chat. For an active lane with no chat focus, the default switches to the wireframe **`lane-details`** view: **STATUS** (clean/dirty, ahead/behind), optional **SETUP** (lane setup progress or retryable failure; press `r` on a failed setup to retry), **CHANGES** (file list + staged/unstaged counts from `diff.listLaneDiffStats`), **ACTIONS** (lane shortcuts — `new chat`, `open / create PR`, `stage all`, `move unstaged to new lane`, `commit`, `push`, `diff`, `reparent`, `delete lane`; each row carries a semantic glyph color so additive actions are green, navigational actions are violet, the rescue-unstaged action is amber, and `delete lane` is red), optional **PR #N** (state chip, CI activity via `checksPending` / `checksFailed`, `↵` opens the PR URL when the PR row is selected), and **CHATS** (active / closed / killed counts from `computeLaneChatCounts`). A `worktreeAvailable` guard surfaces a recoverable warning when the lane worktree path is missing from disk. `/model` opens a separate **`model-setup`** pane for provider/model/reasoning/permission picks before the first prompt. - **FooterControls** — two-row footer. The top row (mode bar, only present when there's content) shows provider glyph + label, model display, fast-mode badge, reasoning effort, permission summary, pending steer count, a 10-cell token usage bar (`TokenBar`) that recolors at 50 / 80 / 95 %, and the cached context-percent / token summary. The bottom row shows pane toggles (`^o` lanes, `^p` pane, `^a` chat info) and pane-specific hints (drawer mode lanes/chats, details navigation, chat scroll position, `/steer` reminder when steers are queued). The `⊚ chat info` chip shows the live subagent count when greater than zero. `footerControlsForAvailability(agentsAvailable)` decides which toggles are wired. - **Provider CLI terminal control** — when the active session is a running provider CLI terminal (Claude, Codex, Cursor, Droid, or @@ -203,7 +204,7 @@ Right pane (open contextual content): | `/status` | Project, lane, and runtime state summary. | | `/context` | Show chat context usage. | | `/agents` | List Claude agents from user/project config (Claude only). | -| `/info` | Open Chat Info for the active chat: plan, goal, tasks, PR rollup, and subagents. | +| `/info` | Open Chat Info for the active chat: plan, goal, tasks, schedule, PR rollup, and subagents. | | `/skills` | List Agent Skills from project, user, inherited, and bundled ADE roots. | | `/secrets` | List project secret names with masked values; `Enter` or `c` copies the selected value to the local system clipboard without revealing it. | | `/init` | Generate AGENTS.md and Claude pointer files (Claude only). | @@ -316,7 +317,7 @@ After local changes, run `npm run build` inside `apps/ade-cli` so both `dist/cli - **Vim namespace.** When vim mode is active, the model-status row exposes the current `insert`/`normal` mode tag and the keybindings dispatcher routes `vim.*` actions. - **Clipboard image paste.** Cross-platform clipboard-image paste is wired into the composer (Linux via `xclip`/`wl-paste`, macOS via `pngpaste`/AppleScript, Windows via PowerShell), so pasting a screenshot uploads it as a Claude attachment alongside text. The clipboard always lives on the machine running the TUI, so the routing depends on where the runtime is: for a **local** runtime the image is materialized under the project workspace (`.ade/cache/...`) and attached by path. For a **remote** runtime the workspace path lives on another machine, so writing there locally would fail (EACCES) and the agent couldn't read it — instead the image is materialized into a local scratch dir and its bytes are uploaded to the runtime via the `chat/saveTempAttachment` action (`saveRuntimeTempAttachment`, mirroring the desktop composer), which returns a runtime-valid path that is then mentioned. The local scratch temp is cleaned up after upload; a pre-existing user file the clipboard merely *referenced* is uploaded but never deleted. - **Permission modes.** The Claude picker accepts `auto` (mapped onto the SDK `permissionMode: "auto"`) in addition to `default`, `plan`, `acceptEdits`, and `bypassPermissions`. The Droid picker cycles the same ordered modes as desktop, including `agi` after the `auto-*` levels. -- **Chat Info (subagent panel).** The right pane's Chat Info view replaces the legacy Subagents tab strip. It puts the main agent in row 0 and the live subagent / teammate / background roster in rows 1..N, all selectable with `↑`/`↓`; `↵` probes for a usable subagent transcript and only swaps the main transcript when that fetch succeeds, otherwise the local reconstruction remains visible with a notice. Snapshots are still keyed by `agentId + parentToolUseId` and reconstructed from `subagent_*` envelopes (plus `teammate.idle` / `task.completed` for teammates) through `subagentSnapshotsFromEvents()`. Sibling subagents that share a parent tool-use id are tracked separately by counting resolved subagent ids per parent and only adopting the placeholder parent row when exactly one resolves under it. Each snapshot carries `parentToolUseId`, `turnId`, `startedAt`, `endedAt`, and a derived `durationMs` so rows show elapsed time even when the runtime did not report `usage.durationMs`. The `^a` footer toggle opens or closes the Chat Info pane. +- **Chat Info (subagent panel).** The right pane's Chat Info view replaces the legacy Subagents tab strip. It puts the main agent in row 0 and the live subagent / teammate / background roster in rows 1..N, all selectable with `↑`/`↓`; `↵` probes for a usable subagent transcript and only swaps the main transcript when that fetch succeeds, otherwise the local reconstruction remains visible with a notice. Snapshots are still keyed by `agentId + parentToolUseId` and reconstructed from `subagent_*` envelopes (plus `teammate.idle` / `task.completed` for teammates) through `subagentSnapshotsFromEvents()`. Sibling subagents that share a parent tool-use id are tracked separately by counting resolved subagent ids per parent and only adopting the placeholder parent row when exactly one resolves under it. Each snapshot carries `parentToolUseId`, `turnId`, `startedAt`, `endedAt`, and a derived `durationMs` so rows show elapsed time even when the runtime did not report `usage.durationMs`. Below the roster, Chat Info also renders the latest task snapshot and a schedule block from `deriveScheduledWorkSnapshots()`; `todo_update` and `scheduled_work_update` events can auto-open the pane the same way new subagent activity does, without taking composer focus. The `^a` footer toggle opens or closes the Chat Info pane. - **Context, output styles, plugins.** `/context`, `/output-style`, and `/plugin` call `chat.getContextUsage`, `chat.listClaudeOutputStyles` / `chat.setClaudeOutputStyle`, and `chat.listClaudePlugins` / `chat.reloadClaudePlugins` against the same Claude SDK runtime the desktop chat uses. ## Chat setup diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index efaac695d..426cf919b 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -45,10 +45,11 @@ machinery layered on top. | `apps/desktop/src/main/services/opencode/openCodeInventory.ts` | OpenCode provider/model probe. Now classifies model variants into `reasoningTiers` + `serviceTiers` (alias map covering `minimal`/`mini`/`med`/`xhigh`/`extra-high`), reads `capabilities` (tools/vision/reasoning) into descriptor capabilities, and tracks both `modelIds` (connected providers only) and `catalogModelIds` (the full browseable catalog). Anthropic rows normalize retired Sonnet 4.6 / basic Opus 4.7 ids to Sonnet 5 / Opus 4.8 so runtime catalogs cannot reintroduce removed picker rows. `OpenCodeProviderInfo.availableModelCount` exposes the connected count separately from `modelCount`. | | `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/chatSubagents.ts` | Cross-target subagent helpers: `buildSubagentPaneRows`, `selectedSubagentSnapshot`, `subagentIndexForPaneLine`, `subagentPaneSelectableLineOffsets`, `buildSubagentTranscriptEvents`, `isLifecycleEventForSnapshot`, plus the `latestPlan` derivation. Both the desktop `ChatSubagentsPanel` and the `apps/ade-cli/src/tuiClient/subagentPane.ts` / `chatInfo.ts` modules re-export from here so the desktop pane and the terminal TUI render the same roster, transcript filter, and plan summary. | +| `apps/desktop/src/shared/chatScheduledWork.ts` | Cross-target scheduled-work derivation. Folds `scheduled_work_update` envelopes into stable snapshots for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work; consumed by desktop Chat Info, ADE Code Chat Info, and iOS Work chat so scheduled/background activity presents consistently. | | `apps/desktop/src/main/services/chat/claudeWorkflowProgress.ts` | Defensive normalizer for the Claude Agent SDK's undocumented `workflow_progress` snapshot on `system:task_progress` (Workflow orchestration runs). Parses phases + per-agent entries (caps counts, clips previews, drops malformed entries, unknown states degrade to queued/running; unparseable snapshots return undefined so the generic task rendering is untouched), then `planClaudeWorkflowAgentTransitions` diffs each cumulative tick against per-task emit state to fan out `subagent_started/progress/result` events under a stable `::a` identity with the emitted agentId latched at first emission. Consumed by `agentChatService`'s `task_progress`/`task_notification` handlers and the interrupt path (which close still-running agents as `stopped`). | | `apps/desktop/src/shared/chatMosaic.ts` | Mosaic v1 — agent-emitted interactive cards. Strict versioned (`"v":1`) parser for ```` ```mosaic ```` fence bodies (`parseMosaicCard`: unknown version/element types, duplicate ids, or malformed JSON → null → callers render the plain fence), submission serializer (`serializeMosaicSubmission`: readable lines + machine JSON, sent through the normal `agentChat.send` path with `displayText`), and `summarizeMosaicCard` for the TUI's one-line summary. Data only — no expressions, no eval, no host actions. Schema documented for agents in the `ade-mosaic` Agent Skill (`apps/desktop/resources/agent-skills/ade-mosaic/SKILL.md`). | | `apps/desktop/src/renderer/components/chat/MosaicCard.tsx` | Interactive mosaic card renderer (text, select, multiselect, number/slider, input, approve/deny, key-value table). Hooked in at `MarkdownBlock`'s code-fence handler behind a Claude-gated `mosaic` context prop from `AgentChatPane`; answered state persists across virtualized unmounts via a session-lifetime latch that rolls back on send failure. Non-Claude sessions render the plain fence. | -| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal control args, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `user_message` events may carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | +| `apps/desktop/src/shared/types/chat.ts` | All chat types: `AgentChatSession`, `AgentChatEvent` union, `AgentChatEventHistorySnapshot` (with optional `sessionFound` for stale-session detection), Codex goal/token-usage/runtime-state DTOs (`CodexSafetyBufferingState`, moderation metadata, sleep/thread-deleted/stall events), typed Codex goal control args, permission modes, pending input (including app-server `autoResolutionMs`), completion reports, `PARALLEL_CHAT_MAX_ATTACHMENTS`, and parallel launch state DTOs. `scheduled_work_update` captures Claude wake/cron/background lifecycle, while `transcript_retraction` removes provider-superseded assistant text from renderers without rewriting the persisted JSONL stream. `user_message` events may carry metadata such as `hideFullPrompt` for internal handoff briefs, while `displayText` remains the user-facing transcript text. `AgentChatSessionSummary.linearIssueLinks?: SessionLinearIssueLink[]` carries the Linear issues attached to the session (chat or CLI), populated from `session_linear_issues` independent of any lane link. The `session_meta_updated` event additionally carries optional permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`, `codexSandbox`, `codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`) so a mode change made on one client patches every other client's composer state; a title-only emit carries none of them and stays backward-compatible. | | `apps/desktop/src/renderer/components/chat/AgentChatPane.tsx` | Top-level renderer surface: state derivation, IPC wiring, composer mount, message-list mount, End/Delete chat controls in the header, parallel multi-model lane launch orchestration, transient-lane cleanup, and multi-lane deep-link navigation. Renders the inline `InlineQuestionRequestCard` (in `AgentChatMessageList`) when the active pending input is a question/structured-question. Resolves the surface accent colour through `providerChatAccent(provider)` so Claude/Codex/Cursor stay visually consistent regardless of model variant; the question/plan cards inherit that same `--chat-accent`. Visible Work grid tiles flush user/lifecycle/live events immediately and poll-recover active transcripts when IPC misses an event, even when the tile is not focused. Event-history snapshots with `sessionFound: false` clear stale locked-pane state instead of rendering a dead transcript. Draft chats scope their last-launch config by project/lane/surface/draft-kind and mark local model/reasoning/permission edits as touched so late lane-session hydration cannot overwrite the user's draft selection; composer text is also keyed by the real session id or the lane draft key (`draft:`) so switching draft lanes does not leak text through a shared null session key. During project transitions the pane blocks send/model/permission mutations and shows a "Project is switching..." composer placeholder so chat calls do not hit the wrong runtime binding. On macOS, polls `ade.iosSimulator.getStatus` and renders the iOS Simulator drawer toggle in the header when the platform is supported (see [iOS Simulator feature](../ios-simulator/README.md)); selecting elements inside the drawer flows back through the pane as `IosElementContextItem` chips on the composer. Polls `ade.appControl.getStatus` and exposes the App Control drawer toggle when the platform is supported, mounting `ChatAppControlPanel`; selections become `AppControlContextItem` chips + attachments on the composer. See [App Control](../computer-use/app-control.md). When mounted as a Work tile (`SessionSurface` passes `hideLaneToolDrawers={true}`) the iOS, App Control, and chat terminal drawer toggles are suppressed because the Work right-edge sidebar owns those lane-scoped drawers; hidden lane-tool mode also skips App Control status polling and terminal listing. Remote-bound panes further defer local-only App Control / proof snapshot polling until the matching drawer is open, delay unfinished parallel-launch cleanup recovery briefly after mount, cache chat-session lists and slash-command catalogs by active project root, and avoid mount-time session-delta fetches until a remote turn completes. The pane still listens on `ade:agent-chat:add-attachment` / `add-ios-context` / `add-app-control-context` / `add-builtin-browser-context` / `insert-draft` window events so selections from the sidebar flow into the active chat composer; event handlers match on either `sessionId` (for active sessions) or `draftTargetId` (for unsaved draft composers when `draftContextTargetId` is set), enabling the Work sidebar to insert context into a draft composer before a chat session exists. Work-tab CLI launches pass the active lane worktree into the shared launcher so the spawned CLI sees lane-aware Agent Skill roots. Work CLI launches intentionally skip the direct-argv path: the pane drops `command` / `args` from the `onLaunchPtySession` payload and always sends `startupCommand` plus `workCliStartupDelayMs = 180` so the spawned shell can finish drawing its prompt before the CLI invocation is typed in (see [pty-and-processes.md](../terminals-and-sessions/pty-and-processes.md#create-flow-createargs) for how `ptyService.create` consumes the delay). The `onLaunchCliSession` prop is typed as `(args: WorkPtyLaunchArgs) => Promise` and passes `disposition` matching the draft launch mode so background CLI launches do not steal focus. Internal draft launch state is structured through `DraftLaunchMode`, `DraftLaunchKind`, `DraftLaunchLaneTarget`, `StartedDraftLaunch`, and `DraftLaunchJob`. Each draft launch creates a `DraftLaunchJob` that tracks multi-step progress through a state machine (`creating-lane` -> `starting-session` -> `sending-prompt` -> `ready` | `failed`; auto-created lanes are named deterministically up front and the AI rename runs in the background via `startBackgroundLaneNaming` / `startBackgroundParallelLaneNaming`, surfaced through `laneNamingStore`, so there is no blocking `naming-lane` phase) and stores it in the **root** store's `draftLaunchJobsByScope` (read via `useRootAppStore` / `rootAppStoreApi.getState()`) keyed by project root, lane, surface profile, and Work draft kind so loading/error strips survive pane remounts — and a remote project switch that tears down the originating per-project store — without leaking into another lane pane. The detached launch chain captures the originating `OpenProjectBinding`, passes it as a `pin` to branch/lane/chat/orchestration/PTY calls so a mid-launch project switch keeps targeting the originating runtime, pins rollback (`lanes.delete` / `agentChat.delete` with a `pin`) to that binding, and caps each step with `withDraftLaunchTimeout` (90 s). The composer is cleared optimistically when the job starts rather than after it finishes; active jobs remain visible while terminal rows are pruned by scope. The pane renders status strips with Open/Restore for ready/failed jobs, Dismiss for terminal jobs, and a hide-status escape hatch for stale active jobs. Failed jobs offer a Restore button that merges the snapshot back into the composer (merging attachments and context items by identity rather than replacing). `clearDraftLaunchComposer` resets the draft, attachments, and context items after a successful launch. `DraftLaunchJob` carries `draftKind` so the dismissible job strip's "Open" action restores the correct Work draft kind (chat vs. CLI). Proof remains chat-scoped and stays on the chat header. | | `apps/desktop/src/renderer/lib/agentChatSessionListCache.ts` | Short-lived renderer cache for `ade.agentChat.list`, keyed by active project root, lane, automation, and archive flags. Mutations invalidate by project/lane so remote Work panes do not fan out repeated list calls while still refreshing immediately after create/archive/delete. | | `apps/desktop/src/renderer/lib/agentChatSlashCommandsCache.ts` | Short-lived renderer cache for `ade.agentChat.slashCommands`, keyed by project root plus session id or lane/provider. System notices can force-refresh the selected session's commands. | @@ -60,7 +61,7 @@ machinery layered on top. | `apps/desktop/src/renderer/components/chat/ChatPrPane.tsx` | Left floating PR pane for Work chat. Renders cached lane PR details immediately, then performs the same cooldown-bound targeted PR refresh as the toolbar before settling the state. Terminal PRs hide stale running-check labels so merged/closed PRs do not keep showing in-progress CI from an old cache row. | | `apps/desktop/src/renderer/lib/visualContextFormatting.ts` | Serializes iOS, App Control, built-in browser, and attachment context into prompt text. | | `apps/desktop/src/renderer/components/chat/RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Chat file-rewind confirmation surface. Claude uses the SDK `rewindFiles` control call; Codex uses app-server `thread/rollback` plus ADE's git-backed file restore plan. `rewindFilesPreview.ts` maps the selected user message to turn diff summaries and per-file SHA ranges; the dialog lists every restored file, expands rows into `AdeDiffViewer`, and confirms the provider rewind without using browser-native confirm UI. | -| `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `codex/CodexGoalCard.tsx` | Subagent drawer content plus the Codex chat goal card. The goal card sits above the plan/subagent roster, exposes edit/status/clear affordances through typed ADE goal APIs, and shows usage context as tokens/time only; provider token budgets are hidden and cleared so ADE goals stay unlimited. | +| `apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx`, `codex/CodexGoalCard.tsx` | Chat Info drawer content: Codex goal card, latest plan, `todo_update` tasks, scheduled work, and subagent/background rosters. The goal card sits above the plan/subagent roster, exposes edit/status/clear affordances through typed ADE goal APIs, and shows usage context as tokens/time only; provider token budgets are hidden and cleared so ADE goals stay unlimited. | | `apps/desktop/src/renderer/components/chat/ChatBuiltInBrowserPanel.tsx` | Renderer panel for the in-app browser. Renders the address bar, tabs strip, navigation controls, an inspect/select toolbar, and a `BuiltInBrowserStatus`-derived empty/error state, then asks the main process to position the underlying `WebContentsView` over the panel's bounding rect through `ade.builtInBrowser.setBounds`. Because native `WebContentsView` content sits above the renderer, the panel hides it while ADE overlays, dialogs, menus, or popovers overlap the browser surface so ADE chrome remains reachable. Mounted by `WorkSidebar` under the `browser` tab and (indirectly) by any renderer code that calls `openUrlInAdeBrowser()` — the helper opens the sidebar Browser tab and dispatches the URL into a fresh tab. Selections committed through inspect-mode hit-testing fan out via the `onAddContext` callback as `BuiltInBrowserContextItem` payloads. | | `apps/desktop/src/renderer/components/work/WorkSurfaceHeader.tsx`, `ClaudeLoginPromptButton.tsx` | Shared Work surface header chrome for chat and CLI surfaces: title, lane chip, Claude cache badge, git toolbar, caller-provided trailing actions, and the dismissible Claude login CTA that starts `claude auth login` in a tracked PTY. `AgentChatPane` also reuses `ClaudeLoginPromptButton` as a sticky bar above the composer (keyed `composer-auth:`) while a Claude session is logged out, but only when the chat header pill is absent so the two never double up. | | `apps/desktop/src/renderer/components/chat/AgentCliAuthCard.tsx` | Inline install / re-login card for missing or unauthenticated agent CLIs, rendered in the transcript from a decorated `error` event's `errorInfo.agentCli` payload. Copy chips + a tracked-PTY Run button (`window.ade.pty.create`) for the install / auth command. The logged-out (`category: "unauthenticated"`) variant is terracotta-toned for Claude (amber for other agents), retitles to "<Provider> is logged out", and adds an always-on **Retry turn** button that resends the last user message via the `CHAT_RETRY_AUTH_TURN_EVENT` (`ade:chat:retry-auth-turn`) window event; it collapses to a "Reconnected" confirmation when `AgentChatPane` fires `CHAT_AUTH_RECOVERED_EVENT` (`ade:chat:auth-recovered`) after a later turn succeeds. The "missing CLI" variant keeps the red-free amber install card. | @@ -100,7 +101,7 @@ render them, but neither one *runs* them. ## Key concepts - **Claude Agent SDK pipeline.** The Claude adapter is built on the - stable `query()` API (SDK 0.3.186): every chat owns a `ClaudeQuery`, + stable `query()` API (SDK 0.3.202): every chat owns a `ClaudeQuery`, fed by a `ClaudeInputPump` (`claudeInputPump.ts`) async iterable that hands live user turns to `query.streamInput`. Warmup goes through the SDK `startup()` hook, output styles and plugins are discovered by @@ -114,20 +115,22 @@ render them, but neither one *runs* them. path is passed through `pathToClaudeCodeExecutable`. Context usage, rewindFiles, forkSession, and output-style selection all run through the SDK control channel surfaced on the active `Query` handle. -- **Claude SDK 0.3.186 event surfaces.** ADE enables SDK hook events, +- **Claude SDK 0.3.202 event surfaces.** ADE enables SDK hook events, agent progress summaries, prompt suggestions, forwarded subagent text, file checkpointing, and all skills for full Claude chats. The adapter translates SDK retry/refusal/notification/memory/mirror/permission events into `system_notice` rows, drains the SDK-documented post-`result` `prompt_suggestion` message, maps Claude `TaskCreate`/`TaskUpdate` tool calls into `todo_update` snapshots for the actions-pane task board, preserves - refusal-fallback retraction UUIDs on the fallback notice, and gives new built-in - Claude tools readable badges in the transcript. Deliberately not wired yet: - SDK `SessionStore` as ADE's transcript backend, channels/external message - origins, transcript tombstones for SDK `supersedes`/`retracted_message_uuids`, - and true scheduled wake/autonomous post-result turns. Those need the service to - move from per-turn iterator ownership to one long-lived Claude stream owner - that can safely adopt SDK-origin messages after a result. + refusal-fallback retraction UUIDs through `transcript_retraction`, maps SDK + `supersedes` to the same retraction path, and gives new built-in Claude tools + readable badges in the transcript. Claude sessions keep a long-lived idle + reader attached after a visible turn completes, so SDK-origin messages from + `ScheduleWakeup`, `CronCreate`, `/loop`, remote triggers, and background + tasks can start synthetic idle turns later; those emit normal transcript rows + plus `scheduled_work_update` snapshots for desktop, ADE Code, and iOS Chat + Info. Deliberately not wired yet: SDK `SessionStore` as ADE's transcript + backend and channels/external message origins. - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`, `codex`, `opencode`, `cursor`, `droid`, or a free-form string reserved for local providers. The service owns a pluggable adapter per provider (Claude diff --git a/docs/features/chat/composer-and-ui.md b/docs/features/chat/composer-and-ui.md index e61154837..d562b5aec 100644 --- a/docs/features/chat/composer-and-ui.md +++ b/docs/features/chat/composer-and-ui.md @@ -3,9 +3,9 @@ The chat UI lives under `apps/desktop/src/renderer/components/chat/`. It is composed of a pane (`AgentChatPane`), a message list (`AgentChatMessageList`), a composer (`AgentChatComposer`), and a -constellation of side panels (tasks, file changes, subagents, computer -use). The pane derives all visible state from the `AgentChatEventEnvelope` -stream plus session metadata. +constellation of side panels (tasks, scheduled work, file changes, +subagents, computer use). The pane derives all visible state from the +`AgentChatEventEnvelope` stream plus session metadata. ## Source file map @@ -25,9 +25,10 @@ stream plus session metadata. | `ChatCommandMenu.tsx` | Popover for slash commands and `@`-prefixed file search. Consumes a `ComposerTrigger` from `shared/composerTriggers.ts` (so the menu opens for a mid-draft trigger, not just a leading one), debounces file search at 40 ms, and keeps a per-menu-session query cache (`QUERY_CACHE_MAX = 40`) so cached queries render same-frame while a background revalidation still runs; the cache clears when the menu closes. | | `apps/desktop/src/shared/composerTriggers.ts` | Cursor-relative typed-trigger detection shared by the desktop chat composer (rich + textarea), the `WorkViewArea` continue composer, and the ade-code TUI (iOS mirrors the same regexes in Swift). `detectComposerTrigger(text, cursorPos)` finds an in-progress `/command` / `@file` token ending at the cursor at any position; `replaceComposerTriggerSpan` splices exactly that span; `findConfirmedComposerTokens` locates confirmed chip tokens for overlay/prompt styling; `composerTriggerSpansWholeDraft` distinguishes a lone leading command from a mid-sentence one. | | `ChatTasksPanel.tsx` | Todo list rendered from `todo_update` events. | +| `apps/desktop/src/shared/chatScheduledWork.ts` | Pure scheduled-work derivation. Folds `scheduled_work_update` envelopes into Chat Info schedule rows for Claude wakeups, cron tasks, `/loop`, remote triggers, and background work; shared by desktop, ADE Code, and iOS. | | `ChatFileChangesPanel.tsx` | Turn-level file change summary with lazy diff expansion. | | `RewindFilesConfirmDialog.tsx`, `rewindFilesPreview.ts` | Undo confirmation for provider-backed file rewind. Builds a message-scoped file list from provider dry-run output plus turn diff summaries, then renders per-file expandable diffs before applying `rewindFiles`. Claude uses SDK file checkpoints; Codex uses `thread/rollback` for the latest user message and restores files through ADE's git plan. | -| `ChatSubagentsPanel.tsx`, `ChatSubagentStrip.tsx` | Subagent panels. For Codex sessions the panel also hosts the chat goal card above plan/subagent progress so the current objective stays visible without crowding the chat header. | +| `ChatSubagentsPanel.tsx`, `ChatSubagentStrip.tsx` | Chat Info panels. They render the Codex goal card, latest plan, tasks, schedule, and subagent/background rosters; for Codex sessions the goal card stays above plan/subagent progress so the current objective stays visible without crowding the chat header. | | `ChatComputerUsePanel.tsx` | Computer-use backend status. | | `ChatAppControlPanel.tsx` | App Control panel for Electron apps. Two mount points: under the chat composer (chat-scoped, `sessionId` set) and inside the Work right-edge sidebar (lane-scoped, `sessionId={null}`). Two modes: **Control** (live screencast frames + launch/connect form + click/type input + quick `terminal write` / `terminal signal` actions) and **Inspect** (hit-test crosshair on the screenshot; commits selections as `AppControlContextItem`s with screenshot, DOM packet, and source-file candidates). Persists panel state under `sessionStorage["ade.chat.appControlPanel."]`, where the key is `chat:` for the chat mount and `lane::` for the sidebar mount. Connect/launch calls forward `laneId` so the resulting `AppControlSession` records its launching lane. See [App Control](../computer-use/app-control.md). | | `ChatIosSimulatorPanel.tsx` | macOS-only iOS Simulator drawer. Two mount points: under the chat composer and inside the Work right-edge sidebar. Tool-readiness checklist, device + target pickers, three-backend live preview, `interact` vs `inspect` mode, hit-test overlay, and selection emission as `IosElementContextItem`. Accepts an optional `laneId` prop, forwarded into `iosSimulator.launch` so the resulting `IosSimulatorSession` records its launching lane. Simulator controls are not blocked when another chat session owns the simulator — ownership only affects which session receives context insertions, not whether the user can interact with the device. See [iOS Simulator feature](../ios-simulator/README.md). | @@ -58,6 +59,7 @@ stream plus session metadata. - Message rows via `chatTranscriptRows.ts`. - Pending inputs via `pendingInput.ts`. - Todo items via `deriveTodoItems()` in `chatExecutionSummary.ts`. + - Scheduled/background work via `deriveScheduledWorkSnapshots()`. - Subagent snapshots via `deriveChatSubagentSnapshots()`. - Turn diff summaries via `deriveTurnDiffSummaries()`. 3. Resolves a `ChatSurfacePresentation` (standard, resolver, worker @@ -505,13 +507,23 @@ conversation history untouched and only restores files. Codex rolls back the upstream thread by one turn with `thread/rollback` and restores the matching files; for now it is limited to the latest user message. -## Subagents panel +## Chat Info and subagents panel When the Claude Agent SDK spawns background subagents, the service emits `subagent_started`, `subagent_progress`, and `subagent_result` events. `ChatSubagentsPanel` renders running/completed/failed/stopped -subagents with usage metrics. `ChatSubagentStrip` is the compact header -strip showing running subagent count. +subagents with usage metrics. The same panel also renders the current +Codex goal, plan, `todo_update` task list, and the Schedule section +derived from `scheduled_work_update` events. `ChatSubagentStrip` is the +compact header strip showing running subagent count, while the Work tab +actions badge also counts scheduled work when no subagents are present. + +Claude wakeups, cron tasks, `/loop`, remote triggers, and background work +are folded by `deriveScheduledWorkSnapshots()` into rows with kind, +status, cron/prompt/reason details, and source ids. Desktop renders those +rows in the Chat Info drawer, ADE Code renders them in the Chat Info +right pane, and iOS shows a Chat Info popup/sheet for active scheduled +items above the composer. Interrupt transitions all running subagents to `stopped` by emitting a `subagent_result` with `status: "stopped"` for each, matching the @@ -726,7 +738,9 @@ These modules are pure and unit-testable: - `pendingInput.ts` -- event-to-pending-input derivation (including `pending_input_resolved`, `done`-status-based clearing). - `chatExecutionSummary.ts` -- todos, subagent snapshots, turn diff - summaries. + summaries, and re-exports the shared scheduled-work derivation. +- `apps/desktop/src/shared/chatScheduledWork.ts` -- scheduled-work + snapshots from `scheduled_work_update` envelopes. - `chatNavigation.ts` -- keyboard navigation between transcript rows. - `chatToolAppearance.tsx` -- tool-specific visuals (icons, tone, label formatting). diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index 42e1c4bb9..e023c2e7f 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -15,6 +15,11 @@ type AgentChatEventEnvelope = { sequence?: number; provenance?: { messageId?: string; + providerMessageId?: string; + providerParentAgentId?: string | null; + providerOrigin?: string | null; + providerSupersedes?: string[]; + providerRetractedMessageIds?: string[]; threadId?: string | null; role?: "user" | "orchestrator" | "worker" | "agent" | null; targetKind?: string | null; @@ -67,6 +72,7 @@ Two helpers summarise a parsed stream: |---|---| | `user_message` | A user turn; carries text, attachments, `turnId`, optional `steerId` and `deliveryState`. `deliveryState` is `"queued"` while a steer waits for turn-end delivery, `"delivered"` once flushed at turn boundary, `"inline"` when the user inline-dispatched a queued steer into the active Claude turn (SDK `shouldQuery:false` send), and `"failed"` if dispatch errored. | | `text` | Streaming assistant text; identified by `messageId` (preferred) or turn/item identity. Fragments merge when `shouldMergeTextRows()` returns true. | +| `transcript_retraction` | Provider-level retraction signal. Claude emits this for refusal fallback `retracted_message_uuids` and assistant `supersedes`; renderers remove prior assistant text rows whose `messageId` matches `messageIds`, optionally retaining `replacementMessageId` as the new provider message id. The persisted JSONL remains append-only. | | `reasoning` | Chain-of-thought or assistant-internal reasoning; surfaces as a distinct transcript row with a collapsible header. | | `tool_call` / `tool_result` | Paired per tool invocation; rendered inside work-log groups. `tool_result.status` can be `running`, `completed`, `failed`, or `interrupted`. | | `file_change` | Emitted when the agent writes or deletes a file; carries `path`, `diff`, and `kind`. | @@ -80,7 +86,8 @@ Two helpers summarise a parsed stream: | `done` | Final turn marker with model, model id, usage, cost. Also clears non-question pending inputs when status is not `completed`. | | `activity` | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. | | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. | -| `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId` + `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Codex app-server `subAgentActivity` items also flow into these rows and may carry `label`, `model`, and `reasoningEffort` for richer roster labels. Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) are filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: **Claude Workflow runs** — the SDK's undocumented `workflow_progress` snapshot on `system:task_progress` is normalized by `claudeWorkflowProgress.ts` (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable `::a` / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as `stopped`; and **child chat spawns** — a session created with `orchestrationParentSessionId` outside an orchestration run (e.g. `ade chat create` from a tracked agent shell) emits synthetic `subagent_started`/`subagent_result` events keyed `chat:` into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped. | +| `subagent_started` / `subagent_progress` / `subagent_result` | Legacy Claude background subagent lifecycle. Each envelope carries `taskId`, `parentToolUseId`, `description`, and optional `agentId`, `parentAgentId`, and `agentType`: for Claude / ade-code `agentType` is the Task tool's `subagent_type` (stashed at the `tool_use` boundary and joined on `parentToolUseId`); for Codex parallel agents it is a per-turn `Agent #N` label assigned at first announcement and the raw threadId is mirrored as `agentId`; for OpenCode subagents `agentType` is omitted so the row falls back to the `description` (taken from `session.title`). Codex app-server `subAgentActivity` items also flow into these rows and may carry `label`, `model`, and `reasoningEffort` for richer roster labels. Claude SDK runs also stash `taskType` (`subagent` / `background` / `local_workflow` / `cron` / `other`) and `workflowName` at spawn so the renderer can label rows by workflow without re-deriving them per event; ambient/housekeeping tasks (the SDK's `skip_transcript=true` flag — e.g. session-title generation) are filtered out symmetrically across spawn, progress, and completion notifications so the subagent panel never flashes them. The service also emits canonical `subagent.started` / `subagent.progress` / `subagent.completed` rows from `runtimeEvents.ts` so all runtimes can converge on the same envelope. Two additional producers fan into the same three event types: **Claude Workflow runs** — the SDK's undocumented `workflow_progress` snapshot on `system:task_progress` is normalized by `claudeWorkflowProgress.ts` (defensive: malformed entries dropped, previews clipped, counts capped, unknown states degrade to queued/running; an unparseable snapshot leaves the generic task rendering untouched) and diffed per tick into started/progress/result transitions under a stable `::a` / latched-agentId identity, so each workflow agent renders as its own row with phase, tokens, and duration, reconnects upsert instead of duplicating, and agents left running when the workflow ends are closed out as `stopped`; and **child chat spawns** — a session created with `orchestrationParentSessionId` outside an orchestration run (e.g. `ade chat create` from a tracked agent shell) emits synthetic `subagent_started`/`subagent_result` events keyed `chat:` into the parent so the child lists in the parent's subagents panel, its first finished turn reporting completed/failed/stopped. | +| `scheduled_work_update` | Scheduled/background-work lifecycle snapshot. Claude emits it from `ScheduleWakeup`, `CronCreate`, `CronDelete`, `/loop`/hook snapshots, remote triggers, and cron/background task lifecycle messages. It carries `kind` (`wakeup`, `cron`, `loop`, `remote_trigger`, `background_task`), `status` (`scheduled`, `running`, `fired`, `missed`, `completed`, `cancelled`, `failed`, `stopped`), provenance ids, optional cron/prompt/reason/timestamps, and is folded by `shared/chatScheduledWork.ts` into Chat Info schedule rows on desktop, ADE Code, and iOS. | | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). | | `step_boundary` | Workflow step boundary marker. | | `system_notice` | Non-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, and the `status:"subagent_spawned"` chip (emitted into the parent when a child chat session is created with a parent lineage; `detail.spawnedSession` carries the child sessionId/laneId/title and the chip deep-links via `ade:work:select-session`; the TUI shows the message line; iOS renders it through its existing system_notice mapping). | @@ -123,8 +130,11 @@ implements a two-layer transform: - Text, reasoning, plan, status, pending input, and user-message events pass through as visible rows. - `pending_input_resolved`, `activity`, `step_boundary`, raw tool/ - command/file-change events, and standalone reasoning events are - hidden (consumed by other derivations). + command/file-change events, standalone reasoning events, and + `scheduled_work_update` are hidden (consumed by other derivations). + - `transcript_retraction` is also hidden, but mutates the accumulated + rows by removing prior assistant `text` rows whose provider + `messageId` was retracted or superseded. 2. **Grouped envelopes.** Adjacent work-log render events in the same turn merge into `work_log_group` blocks. When a `tool_use_summary` @@ -156,6 +166,9 @@ Adjacent `text` events merge via `shouldMergeTextRows()`: `itemId`. This prevents duplicate rows when the provider streams fragmented text. +For Claude SDK rows, `messageId` is the provider message UUID/id so a +later `transcript_retraction` can remove the exact assistant text that a +model refusal fallback or `supersedes` message invalidated. `plan_text` merging uses `shouldMergePlanTextRows()` with the same heuristic. When a final `plan` event arrives for a turn, any preceding diff --git a/docs/features/sync-and-multi-device/ios-companion.md b/docs/features/sync-and-multi-device/ios-companion.md index fecbf3aa8..12972da1f 100644 --- a/docs/features/sync-and-multi-device/ios-companion.md +++ b/docs/features/sync-and-multi-device/ios-companion.md @@ -161,7 +161,9 @@ apps/ios/ │ │ │ # WorkLanePickerDropdown, │ │ │ # WorkChatRichCardViews (de-glassed │ │ │ # tool-call / work-log / command / -│ │ │ # file-change transcript cards), +│ │ │ # file-change transcript cards plus +│ │ │ # Chat Info schedule popup/sheet for +│ │ │ # scheduled_work_update snapshots), │ │ │ # WorkPlanComposerViews (plan-approval │ │ │ # strip + review sheet), │ │ │ # WorkComposerTypedTriggers (UITextView