From 258904083cbe7cedec7cc4c1853c56142ba584eb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sat, 11 Jul 2026 20:32:20 -0400 Subject: [PATCH 1/6] fix(chat): align Claude SDK turn lifecycle --- apps/ade-cli/package-lock.json | 142 +-- apps/ade-cli/package.json | 2 +- .../sync/syncRemoteCommandService.test.ts | 39 + .../services/sync/syncRemoteCommandService.ts | 9 + apps/desktop/package-lock.json | 72 +- apps/desktop/package.json | 2 +- .../services/chat/agentChatService.test.ts | 530 ++++++++-- .../main/services/chat/agentChatService.ts | 990 ++++++++++++++---- .../chat/claudeQueryLifecycle.test.ts | 27 +- .../src/main/services/ipc/registerIpc.ts | 9 +- .../chat/AgentChatComposer.test.tsx | 48 +- .../components/chat/AgentChatComposer.tsx | 310 +++--- .../components/chat/AgentChatPane.test.tsx | 114 +- .../components/chat/AgentChatPane.tsx | 145 ++- .../chat/ChatSubagentsPanel.test.tsx | 32 +- .../components/chat/ChatSubagentsPanel.tsx | 27 +- .../src/shared/chatScheduledWork.test.ts | 34 +- apps/desktop/src/shared/chatScheduledWork.ts | 44 +- apps/desktop/src/shared/types/chat.ts | 14 +- .../ADE/Views/Work/WorkTimelineHelpers.swift | 84 +- apps/ios/ADETests/ADETests.swift | 21 +- docs/features/chat/README.md | 89 +- docs/features/chat/composer-and-ui.md | 59 +- docs/features/chat/transcript-and-turns.md | 4 +- .../sync-and-multi-device/remote-commands.md | 24 +- 25 files changed, 2063 insertions(+), 808 deletions(-) diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 8baa52809..f9040e809 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.202", + "@anthropic-ai/claude-agent-sdk": "^0.3.207", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", @@ -96,22 +96,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", + "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@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": "0.3.207", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -120,9 +120,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", + "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", "cpu": [ "arm64" ], @@ -133,9 +133,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", + "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", "cpu": [ "x64" ], @@ -146,9 +146,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", + "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", "cpu": [ "arm64" ], @@ -162,9 +162,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", + "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", "cpu": [ "arm64" ], @@ -178,9 +178,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", + "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", "cpu": [ "x64" ], @@ -194,9 +194,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", + "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", "cpu": [ "x64" ], @@ -210,9 +210,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", + "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", "cpu": [ "arm64" ], @@ -223,9 +223,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", + "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", "cpu": [ "x64" ], @@ -5395,66 +5395,66 @@ } }, "@anthropic-ai/claude-agent-sdk": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", + "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", "requires": { - "@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": "0.3.207", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" } }, "@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", + "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", "optional": true }, "@anthropic-ai/claude-agent-sdk-darwin-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", + "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", + "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", + "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", + "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", + "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", + "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", + "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", "optional": true }, "@anthropic-ai/sdk": { diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 9fb69ddcd..de9183d48 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.202", + "@anthropic-ai/claude-agent-sdk": "^0.3.207", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index a069d3afa..ddc8ea9d8 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -406,6 +406,45 @@ describe("createSyncRemoteCommandService", () => { expect(recoverCodexTurn).not.toHaveBeenCalled(); }); + it("preserves Claude priority steering and guarded queue cancellation", async () => { + const steer = vi.fn().mockResolvedValue({ steerId: "steer-1", queued: false }); + const cancelSteer = vi.fn().mockResolvedValue(undefined); + const { service } = createService({ agentChatService: { steer, cancelSteer } }); + + await expect(service.execute(makePayload("chat.steer", { + sessionId: "chat-1", + text: "Redirect the active turn.", + dispatchMode: "interrupt", + }))).resolves.toEqual({ ok: true, steerId: "steer-1", queued: false }); + expect(steer).toHaveBeenCalledWith({ + sessionId: "chat-1", + text: "Redirect the active turn.", + dispatchMode: "interrupt", + }); + + await expect(service.execute(makePayload("chat.cancelSteer", { + sessionId: "chat-1", + steerId: "steer-1", + requireQueued: true, + }))).resolves.toEqual({ ok: true }); + expect(cancelSteer).toHaveBeenCalledWith({ + sessionId: "chat-1", + steerId: "steer-1", + requireQueued: true, + }); + + await expect(service.execute(makePayload("chat.steer", { + sessionId: "chat-1", + text: "Invalid mode.", + dispatchMode: "later", + }))).rejects.toThrow("chat.steer dispatchMode must be 'inline' or 'interrupt'."); + await expect(service.execute(makePayload("chat.cancelSteer", { + sessionId: "chat-1", + steerId: "steer-1", + requireQueued: "yes", + }))).rejects.toThrow("chat.cancelSteer requireQueued must be a boolean."); + }); + it("omits non-finite work.resumeCliSession dimensions", async () => { const { service, ptyService } = createService(); diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 953c67c67..8f2cfb13d 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -2060,17 +2060,26 @@ function parseAgentChatSendArgs(value: Record): AgentChatSendAr function parseAgentChatSteerArgs(value: Record): AgentChatSteerArgs { const attachments = parseAgentChatFileRefs(value.attachments); + const dispatchMode = value.dispatchMode; + if (dispatchMode !== undefined && dispatchMode !== "inline" && dispatchMode !== "interrupt") { + throw new Error("chat.steer dispatchMode must be 'inline' or 'interrupt'."); + } return { sessionId: requireString(value.sessionId, "chat.steer requires sessionId."), text: requireString(value.text, "chat.steer requires text."), ...(attachments?.length ? { attachments } : {}), + ...(dispatchMode ? { dispatchMode } : {}), }; } function parseAgentChatCancelSteerArgs(value: Record): AgentChatCancelSteerArgs { + if (value.requireQueued !== undefined && typeof value.requireQueued !== "boolean") { + throw new Error("chat.cancelSteer requireQueued must be a boolean."); + } return { sessionId: requireString(value.sessionId, "chat.cancelSteer requires sessionId."), steerId: requireString(value.steerId, "chat.cancelSteer requires steerId."), + ...(value.requireQueued === true ? { requireQueued: true } : {}), }; } diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index db5f94e72..0704906db 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.202", + "@anthropic-ai/claude-agent-sdk": "^0.3.207", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", @@ -247,22 +247,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", + "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@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": "0.3.207", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -271,9 +271,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", + "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", "cpu": [ "arm64" ], @@ -284,9 +284,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", + "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", "cpu": [ "x64" ], @@ -297,9 +297,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", + "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", "cpu": [ "arm64" ], @@ -313,9 +313,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", + "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", "cpu": [ "arm64" ], @@ -329,9 +329,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", + "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", "cpu": [ "x64" ], @@ -345,9 +345,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", + "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", "cpu": [ "x64" ], @@ -361,9 +361,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", + "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", "cpu": [ "arm64" ], @@ -374,9 +374,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "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==", + "version": "0.3.207", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", + "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index d6b90e25c..d3afe3921 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -59,7 +59,7 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.202", + "@anthropic-ai/claude-agent-sdk": "^0.3.207", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d351dafd6..b908a66a1 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -3,7 +3,7 @@ import { createHash } from "node:crypto"; import { EventEmitter } from "node:events"; import os from "node:os"; import path from "node:path"; -import { getSessionInfo, getSessionMessages, query, startup, tagSession } from "@anthropic-ai/claude-agent-sdk"; +import { getSessionInfo, getSessionMessages, getSubagentMessages, query, startup, tagSession } from "@anthropic-ai/claude-agent-sdk"; import { resolveClaudeCodeExecutable } from "../ai/claudeCodeExecutable"; import { codexComputerUseClientCandidates } from "../../utils/codexComputerUse"; import { buildOpenCodePromptParts, startOpenCodeSession } from "../opencode/openCodeRuntime"; @@ -238,6 +238,7 @@ vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ })), getSessionInfo: vi.fn(), getSessionMessages: vi.fn(), + getSubagentMessages: vi.fn(), listSessions: vi.fn(), query: vi.fn(), renameSession: vi.fn(async () => undefined), @@ -866,12 +867,13 @@ function legacyClaudeSendPayload(message: unknown): unknown { const record = message as { type?: unknown; shouldQuery?: unknown; + priority?: unknown; message?: { content?: Array> }; }; if (record.type !== "user") { return message; } - if (record.shouldQuery === false) { + if (record.shouldQuery === false || record.priority != null) { return message; } const content = record.message?.content; @@ -1383,6 +1385,7 @@ function installClaudeWakeupFixture(args: { sdkSessionId: string; delaySeconds: number; prompt?: string; + lingerAfterTurn?: Promise; }) { let streamCall = 0; const send = vi.fn().mockResolvedValue(undefined); @@ -1423,6 +1426,9 @@ function installClaudeWakeupFixture(args: { session_id: args.sdkSessionId, usage: { input_tokens: 1, output_tokens: 1 }, }; + if (args.lingerAfterTurn) { + await args.lingerAfterTurn; + } })()), close, sessionId: args.sdkSessionId, @@ -1728,6 +1734,8 @@ beforeEach(() => { vi.mocked(startup).mockReset(); vi.mocked(getSessionMessages).mockReset(); vi.mocked(getSessionMessages).mockResolvedValue([]); + vi.mocked(getSubagentMessages).mockReset(); + vi.mocked(getSubagentMessages).mockResolvedValue([]); vi.mocked(tagSession).mockClear(); installClaudeSdkCompatMocks(); vi.mocked(resolveClaudeCodeExecutable).mockClear(); @@ -2541,6 +2549,44 @@ describe("createAgentChatService", () => { expect((doneEvent!.event as any).modelId).toBe("anthropic/claude-opus-4-7-1m"); }); + it("suppresses Claude EDE diagnostics without hiding real result errors", async () => { + const diagnostic = "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"; + const diagnosticOnlyEvents = await runClaudeStreamFixture({ + sdkSessionId: "sdk-ede-diagnostic-only", + messages: [{ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: [diagnostic], + session_id: "sdk-ede-diagnostic-only", + }], + }); + + expect(diagnosticOnlyEvents.filter((event) => event.event.type === "error")).toEqual([]); + expect(diagnosticOnlyEvents.findLast((event) => event.event.type === "done")?.event).toMatchObject({ + type: "done", + status: "completed", + }); + + const mixedEvents = await runClaudeStreamFixture({ + sdkSessionId: "sdk-ede-diagnostic-mixed", + messages: [{ + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: [diagnostic, "Real Claude failure"], + session_id: "sdk-ede-diagnostic-mixed", + }], + }); + const mixedErrors = mixedEvents + .map((event) => event.event) + .filter((event): event is Extract => event.type === "error") + .map((event) => event.message); + + expect(mixedErrors).toEqual(["Real Claude failure"]); + expect(mixedErrors.join("\n")).not.toContain("[ede_diagnostic]"); + }); + it("fast-fails a logged-out Claude turn into the inline re-login card", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; @@ -7465,7 +7511,7 @@ describe("createAgentChatService", () => { )).toBe(true); }); - it("does not mark SubagentStop hooks completed before task notification status arrives", async () => { + it("caches SubagentStop hooks without publishing lifecycle edges before task notifications", async () => { const events: AgentChatEventEnvelope[] = []; vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ send: vi.fn(), @@ -7508,8 +7554,12 @@ describe("createAgentChatService", () => { { signal: new AbortController().signal } as any, ); - expect(events.some((event) => event.event.type === "subagent_started")).toBe(true); + // Hooks are cache/enrichment signals only. The SDK task_started and + // task_notification messages own visible lifecycle edges so the hook and + // stream cannot produce duplicate rows. + expect(events.some((event) => event.event.type === "subagent_started")).toBe(false); expect(events.some((event) => event.event.type === "subagent_result")).toBe(false); + expect(service.hasActiveWorkloads()).toBe(true); }); }); @@ -8272,7 +8322,7 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); }); - it("keeps emitting subagent_* events for a real subagent (Task tool with agentType)", async () => { + it("keeps a native background Agent in Subagents without a duplicate Background row", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; @@ -8297,7 +8347,7 @@ describe("createAgentChatService", () => { type: "tool_use", id: "toolu_real_1", name: "Task", - input: { subagent_type: "Explore", description: "Explore the repo", prompt: "Look around." }, + input: { subagent_type: "Explore", description: "Explore the repo", prompt: "Look around.", run_in_background: true }, }, ], usage: { input_tokens: 1, output_tokens: 1 }, @@ -8308,8 +8358,22 @@ describe("createAgentChatService", () => { subtype: "task_started", task_id: "task-real-1", parent_tool_use_id: "toolu_real_1", + subagent_type: "Explore", + task_type: "local_agent", description: "Explore the repo", }; + // The SDK does not guarantee ordering between the lifecycle edge and + // the authoritative background-membership level. The live smoke sent + // task_started first, so exercise the late correction path here. + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ + task_id: "task-real-1", + task_type: "local_agent", + description: "Explore the repo", + }], + }; yield { type: "system", subtype: "task_notification", @@ -8351,9 +8415,13 @@ describe("createAgentChatService", () => { const startEnvelope = await waitForEvent( events, (e): e is AgentChatEventEnvelope => - e.event.type === "subagent_started" && (e.event as any).taskId === "task-real-1", + e.event.type === "subagent_started" + && (e.event as any).taskId === "task-real-1" + && (e.event as any).background === true, ); expect((startEnvelope.event as any).agentType).toBe("Explore"); + expect((startEnvelope.event as any).background).toBe(true); + expect((startEnvelope.event as any).providerSessionId).toBe(readPersistedChatState(session.id).sdkSessionId); // A real subagent must NOT produce a background_task scheduled row. expect(events.some((e) => e.event.type === "scheduled_work_update" @@ -8513,6 +8581,24 @@ describe("createAgentChatService", () => { expect(terminalCount).toBe(1); }); + it("does not duplicate hook-native subagents into Background", async () => { + const { events, fireSnapshot } = await bootClaudeHooks("sdk-bg-agent-filter"); + + await fireSnapshot([ + { id: "agent-1", type: "subagent", status: "running", description: "Review code", agent_type: "reviewer" }, + { id: "shell-1", type: "shell", status: "running", description: "Watch build", command: "npm run watch" }, + ]); + + expect(events.some((e) => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:agent-1", + )).toBe(false); + expect(events.some((e) => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:shell-1", + )).toBe(true); + }); + it("converges hook diff-close with a task_notification terminal (no duplicate distinct terminal events)", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; @@ -8574,7 +8660,7 @@ describe("createAgentChatService", () => { expect(terminalStatuses.length).toBeGreaterThanOrEqual(1); }); - it("keeps a still-open background task running across a normal turn boundary (no turn-end stop)", async () => { + it("keeps background work across a turn boundary, then settles it when the idle query dies", async () => { // A run_in_background shell keeps running across turns: the SDK query // stays alive and delivers the real completion on a later turn. Turn end // must NOT falsely settle it as stopped. @@ -8583,8 +8669,11 @@ describe("createAgentChatService", () => { let warmupComplete = false; let turnDone: (() => void) | null = null; const turnDonePromise = new Promise((resolve) => { turnDone = resolve; }); + let endQuery: (() => void) | null = null; + const queryEndPromise = new Promise((resolve) => { endQuery = resolve; }); const send = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); const stream = vi.fn(() => (async function* () { streamCall += 1; if (streamCall === 1) { @@ -8594,6 +8683,11 @@ describe("createAgentChatService", () => { return; } // Background shell starts but never reports a notification this turn. + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ task_id: "bg-orphan" }], + }; yield { type: "system", subtype: "task_started", @@ -8604,9 +8698,11 @@ describe("createAgentChatService", () => { }; await turnDonePromise; yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + await queryEndPromise; + throw new Error("idle query transport closed"); })()); vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ - send, stream, close: vi.fn(), sessionId: "sdk-bg-sweep", setPermissionMode, + send, stream, close, sessionId: "sdk-bg-sweep", setPermissionMode, } as any); const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); @@ -8622,8 +8718,9 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); // Wait for the turn to actually settle so any (erroneous) turn-end sweep // would have fired by now. - await waitForEvent(events, (e): e is AgentChatEventEnvelope => - e.event.type === "done" && (e.event as any).status === "completed"); + await vi.waitFor(() => { + expect(events.some((e) => e.event.type === "done" && (e.event as any).status === "completed")).toBe(true); + }, { timeout: 3_000 }); // The background row must NOT have been settled at the turn boundary. const terminalBgRows = events.filter((e) => @@ -8634,6 +8731,13 @@ describe("createAgentChatService", () => { // And no subagent_result leaked for the background shell. expect(events.some((e) => e.event.type === "subagent_result" && (e.event as any).taskId === "bg-orphan")).toBe(false); + endQuery!(); + await waitForEvent(events, (e): e is AgentChatEventEnvelope => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:bg-orphan" + && (e.event as any).status === "stopped"); + await vi.waitFor(() => expect(service.hasActiveWorkloads()).toBe(false)); + expect(close).toHaveBeenCalled(); }); it("settles a still-open background task as stopped on interrupt (genuine teardown)", async () => { @@ -8691,10 +8795,10 @@ describe("createAgentChatService", () => { await expect(sendPromise).resolves.toBeUndefined(); }); - it("routes a local_bash run_in_background shell to background_task rows, not subagent events, with background flag", async () => { - // The Claude Agent SDK tags Bash run_in_background with task_type - // "local_bash". It must land in the background pane (never the roster) and - // its scheduled_work row must be a background_task. + it("uses the SDK background level to distinguish background and foreground local_bash tasks", async () => { + // `local_bash` is the implementation kind for both foreground and + // background Bash. Only the SDK's authoritative membership level makes + // the latter a Background row. const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; @@ -8710,6 +8814,30 @@ describe("createAgentChatService", () => { yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; return; } + yield { + type: "system", + subtype: "task_started", + task_id: "foreground-bash", + description: "Foreground build", + command: "sleep 1; echo done", + task_type: "local_bash", + }; + yield { + type: "system", + subtype: "task_notification", + task_id: "foreground-bash", + status: "completed", + summary: "Process exited", + }; + yield { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ + task_id: "bgo5i8f6y", + task_type: "local_bash", + description: "Run codex gpt-5.6-sol backend implementation (background)", + }], + }; yield { type: "system", subtype: "task_started", @@ -8743,6 +8871,10 @@ describe("createAgentChatService", () => { && (e.event as any).status === "running"); expect((runningRow.event as any).kind).toBe("background_task"); expect((runningRow.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation (background)"); + expect(events.some((e) => + e.event.type === "scheduled_work_update" + && (e.event as any).id === "background:foreground-bash", + )).toBe(false); // No subagent_* events for a background shell (this is the background:false // spawn-flag pollution the classifier now prevents). @@ -8847,7 +8979,7 @@ describe("createAgentChatService", () => { expect((terminal!.event as any).title).toBe("Run codex gpt-5.6-sol backend implementation"); }); - it("does not cross-wire finalSummary between two concurrent subagents on an empty task_notification", async () => { + it("keeps each concurrent subagent's real final text over a generic task notification", async () => { const events: AgentChatEventEnvelope[] = []; let streamCall = 0; let warmupComplete = false; @@ -8872,10 +9004,11 @@ describe("createAgentChatService", () => { ], usage: { input_tokens: 1, output_tokens: 1 } } }; yield { type: "system", subtype: "task_started", task_id: "task-A", agent_id: "agent-A", parent_tool_use_id: "toolu_A", description: "review A" }; yield { type: "system", subtype: "task_started", task_id: "task-B", agent_id: "agent-B", parent_tool_use_id: "toolu_B", description: "review B" }; + yield { type: "system", subtype: "task_updated", task_id: "task-A", patch: { status: "completed" } }; // The SubagentStop hooks fire out-of-band (below). Wait for them, then - // deliver an EMPTY-summary notification for A. + // deliver the generic completion summary for A. await stopHooksFiredPromise; - yield { type: "system", subtype: "task_notification", task_id: "task-A", agent_id: "agent-A", parent_tool_use_id: "toolu_A", status: "completed", summary: "" }; + yield { type: "system", subtype: "task_notification", task_id: "task-A", agent_id: "agent-A", parent_tool_use_id: "toolu_A", status: "completed", summary: "Agent finished" }; await turnDonePromise; yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; })()); @@ -8896,16 +9029,16 @@ describe("createAgentChatService", () => { e.event.type === "subagent_started" && (e.event as any).taskId === "task-B"); const sig = { signal: new AbortController().signal } as any; - // B finishes with a distinctive final message; A finishes with none. + // Both finish with distinctive messages; A's generic notification must + // keep A's hook result and never steal B's. await stopHook!({ hook_event_name: "SubagentStop", agent_id: "agent-B", agent_type: "reviewer", last_assistant_message: "B-SECRET-RESULT" } as any, undefined as any, sig); - await stopHook!({ hook_event_name: "SubagentStop", agent_id: "agent-A", agent_type: "reviewer", last_assistant_message: "" } as any, undefined as any, sig); + await stopHook!({ hook_event_name: "SubagentStop", agent_id: "agent-A", agent_type: "reviewer", last_assistant_message: "A-REAL-RESULT" } as any, undefined as any, sig); stopHooksFired!(); - // A's empty-summary notification must NOT surface B's finalSummary. const aResult = await waitForEvent(events, (e): e is AgentChatEventEnvelope => e.event.type === "subagent_result" && (e.event as any).taskId === "task-A"); expect(JSON.stringify(aResult.event)).not.toContain("B-SECRET-RESULT"); - expect((aResult.event as any).summary ?? "").not.toContain("B-SECRET-RESULT"); + expect((aResult.event as any).summary).toBe("A-REAL-RESULT"); turnDone!(); await expect(sendPromise).resolves.toBeUndefined(); @@ -11055,6 +11188,90 @@ describe("createAgentChatService", () => { expect(service.hasActiveWorkloads()).toBe(false); }); + it("treats an idle-reader EDE diagnostic as internal lifecycle noise", async () => { + const events: AgentChatEventEnvelope[] = []; + const diagnostic = "[ede_diagnostic] result_type=user last_content_type=n/a stop_reason=null"; + let streamCall = 0; + let releaseIdle!: () => void; + const releaseIdlePromise = new Promise((resolve) => { releaseIdle = resolve; }); + + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: "sdk-idle-ede-diagnostic", + slash_commands: [], + }; + return; + } + + yield { + type: "result", + subtype: "success", + is_error: false, + session_id: "sdk-idle-ede-diagnostic", + }; + await releaseIdlePromise; + yield { + type: "assistant", + uuid: "assistant-idle-ede-diagnostic", + message: { + id: "message-idle-ede-diagnostic", + content: [{ type: "text", text: "Background work finished." }], + usage: { input_tokens: 1, output_tokens: 3 }, + }, + }; + yield { + type: "result", + subtype: "error_during_execution", + is_error: true, + errors: [diagnostic], + session_id: "sdk-idle-ede-diagnostic", + }; + })()), + close: vi.fn(), + sessionId: "sdk-idle-ede-diagnostic", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service, logger } = 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: "Finish the foreground turn, then report background work.", + }); + releaseIdle(); + + const idleDone = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => event.sessionId === session.id + && event.event.type === "done" + && event.event.turnId.startsWith("claude-idle-"), + ); + expect(idleDone.event.status).toBe("completed"); + expect(events.some((event) => event.event.type === "error" && event.event.message.includes("[ede_diagnostic]"))).toBe(false); + expect(logger.debug).toHaveBeenCalledWith( + "agent_chat.claude_internal_diagnostic", + expect.objectContaining({ + source: "idle_reader", + diagnostics: [diagnostic], + }), + ); + }); + it("groups idle-reader Claude deltas by the stable message id and suppresses the repeated snapshot", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -11323,6 +11540,15 @@ describe("createAgentChatService", () => { task_type: "cron", description: "Check queued steer", }; + yield { + type: "assistant", + uuid: "assistant-background-progress", + message: { + id: "msg-background-progress", + content: [{ type: "text", text: "The background check is still running." }], + usage: { input_tokens: 1, output_tokens: 2 }, + }, + }; await finishBackgroundPromise; yield { @@ -12435,6 +12661,10 @@ describe("createAgentChatService", () => { sdkSessionId: "sdk-pending-native-wake", delaySeconds: 60, prompt: "Stale native wake must not survive dispose.", + // Keep the stable SDK query alive after the foreground result so the + // scheduler exercises the native pending-wake handoff instead of the + // cold-query fallback path. + lingerAfterTurn: new Promise(() => undefined), }); const { service } = createService({ db: scheduledWork.db, @@ -12445,11 +12675,13 @@ describe("createAgentChatService", () => { provider: "claude", model: "sonnet", }); - await service.runSessionTurn({ + const foregroundTurn = service.runSessionTurn({ sessionId: session.id, text: "Queue a native wake.", }); - await vi.advanceTimersByTimeAsync(60_000); + await vi.advanceTimersByTimeAsync(1_000); + await foregroundTurn; + await vi.advanceTimersByTimeAsync(59_000); expect(scheduledWork.readState()?.schedules[0]?.status).toBe("fired"); expect(service.pendingNativeScheduledWakeCountForTesting(session.id)).toBe(1); @@ -22868,6 +23100,55 @@ describe("createAgentChatService", () => { })); }); + it("uses the parent SDK session captured when an older Claude subagent started", async () => { + installClaudeResponseFixture({ sdkSessionId: "sdk-current", responseText: "unused" }); + const { service } = createService(); + const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); + writePersistedChatState(session.id, { + ...readPersistedChatState(session.id), + sdkSessionId: "sdk-current", + }); + const transcriptPath = path.join(tmpRoot, ".ade", "transcripts", "chat", `${session.id}.jsonl`); + fs.writeFileSync(transcriptPath, `${JSON.stringify({ + sessionId: session.id, + timestamp: "2026-07-10T12:00:00.000Z", + sequence: 1, + event: { + type: "subagent_started", + taskId: "task-old", + agentId: "agent-old", + providerSessionId: "sdk-old", + description: "Older child", + }, + })}\n`, "utf8"); + vi.mocked(parseAgentChatTranscript).mockImplementation((raw) => String(raw) + .split(/\r?\n/) + .filter(Boolean) + .map((line) => JSON.parse(line))); + vi.mocked(getSubagentMessages).mockResolvedValue([{ + type: "assistant", + uuid: "child-old-answer", + session_id: "sdk-old", + parent_tool_use_id: "tool-old", + message: { role: "assistant", content: [{ type: "text", text: "Historical child answer" }] }, + }] as any); + + const result = await service.getSubagentTranscript({ + sessionId: session.id, + agentId: "agent-old", + taskId: "task-old", + }); + + expect(getSubagentMessages).toHaveBeenCalledWith( + "sdk-old", + "agent-old", + expect.objectContaining({ dir: fs.realpathSync(tmpRoot) }), + ); + expect(result).toEqual([ + expect.objectContaining({ uuid: "child-old-answer", text: "Historical child answer" }), + ]); + }); + it("gates main transcripts to Claude and byte-bounds the response", async () => { const { service } = createService(); const codex = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5.4" }); @@ -23986,10 +24267,12 @@ describe("createAgentChatService", () => { interactionMode: "plan", }, { routeActiveToSteer: true }); expect(result).toMatchObject({ queued: true, steerId: expect.any(String) }); - // The queued chip shows the display text, not the raw prompt text. + // Transcript events retain the raw prompt for delivery while exposing + // the shorter display label separately for the queued chip. expect(events.some((event) => event.event.type === "user_message" - && event.event.text === "Follow up soon" + && event.event.text === "Follow up when the active turn finishes" + && event.event.displayText === "Follow up soon" && event.event.deliveryState === "queued" )).toBe(true); @@ -24436,31 +24719,24 @@ describe("createAgentChatService", () => { expect(deliveredWithUpdatedText).toBeUndefined(); }); - it("dispatchSteer mode:'inline' sends with shouldQuery:false and clears the queue", async () => { + it("sends atomic and staged inline steers as querying priority-next messages", async () => { const events: AgentChatEventEnvelope[] = []; const send = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); - let streamCall = 0; let interruptedTurnClosed = false; + let releaseWarmup!: () => void; + const warmupGate = new Promise((resolve) => { + releaseWarmup = resolve; + }); const stream = vi.fn(() => (async function* () { - streamCall += 1; - if (streamCall === 1) { - yield { type: "system", subtype: "init", session_id: "sdk-session-1", slash_commands: [] }; - yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; - return; - } - if (streamCall === 2) { - yield { - type: "assistant", - message: { content: [{ type: "text", text: "Working..." }], usage: { input_tokens: 1, output_tokens: 1 } }, - }; - while (!interruptedTurnClosed) { - await new Promise((resolve) => setTimeout(resolve, 0)); - } - return; + yield { + type: "assistant", + message: { content: [{ type: "text", text: "Working..." }], usage: { input_tokens: 1, output_tokens: 1 } }, + }; + while (!interruptedTurnClosed) { + await new Promise((resolve) => setTimeout(resolve, 0)); } - yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; })()); const mockSession = { @@ -24472,12 +24748,71 @@ describe("createAgentChatService", () => { }; vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(mockSession as any); vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(mockSession as any); + vi.mocked(startup).mockImplementationOnce(async () => { + await warmupGate; + return { + query: (prompt: unknown) => bridgeClaudeSessionToQuery(mockSession, prompt), + close: () => mockSession.close(), + } as any; + }); const { service } = createService({ onEvent: (e: AgentChatEventEnvelope) => events.push(e) }); const session = await service.createSession({ laneId: "lane-1", provider: "claude", model: "sonnet" }); const activeTurn = service.runSessionTurn({ sessionId: session.id, text: "Do work", timeoutMs: 15_000 }); - await new Promise((resolve) => setTimeout(resolve, 25)); + await waitForEvent(events, (event): event is AgentChatEventEnvelope => + event.event.type === "status" && event.event.turnStatus === "started"); + + const guarded = await service.steer({ sessionId: session.id, text: "dispatch exactly once" }); + expect(guarded.queued).toBe(true); + const guardedDispatchPromise = service.dispatchSteer({ + sessionId: session.id, + steerId: guarded.steerId, + mode: "inline", + }); + await expect(service.cancelSteer({ + sessionId: session.id, + steerId: guarded.steerId, + requireQueued: true, + })).rejects.toThrow("already being dispatched"); + + const directPromise = service.steer({ + sessionId: session.id, + text: "send this atomically", + dispatchMode: "inline", + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + expect(send.mock.calls.some((call: any[]) => call[0]?.priority === "next")).toBe(false); + releaseWarmup(); + const guardedDispatch = await guardedDispatchPromise; + const direct = await directPromise; + expect(guardedDispatch.dispatchedAt).not.toBeNull(); + await expect(service.cancelSteer({ + sessionId: session.id, + steerId: guarded.steerId, + requireQueued: true, + })).rejects.toThrow("no longer queued"); + expect(events.filter((e) => + e.event.type === "user_message" + && (e.event as any).steerId === guarded.steerId + && (e.event as any).deliveryState === "inline", + )).toHaveLength(1); + expect(direct).toMatchObject({ queued: false, steerId: expect.any(String) }); + expect(events.some((e) => + e.event.type === "user_message" + && (e.event as any).text === "send this atomically" + && (e.event as any).deliveryState === "queued", + )).toBe(false); + await vi.waitFor(() => { + expect(send.mock.calls.map((call: any[]) => call[0]).find((arg: any) => + arg?.priority === "next" + && arg?.shouldQuery === true + && JSON.stringify(arg).includes("send this atomically"), + )).toBeDefined(); + }); + const sentPayloads = send.mock.calls.map((call: any[]) => call[0]); + expect(sentPayloads.findIndex((payload: any) => typeof payload === "string" && payload.includes("Do work"))) + .toBeLessThan(sentPayloads.findIndex((payload: any) => payload?.priority === "next")); await service.steer({ sessionId: session.id, text: "fold this in" }); const queued = events.find((e) => @@ -24488,18 +24823,23 @@ describe("createAgentChatService", () => { expect(queued).toBeDefined(); const steerId = (queued!.event as any).steerId as string; - // Dispatch inline — should call session.send with shouldQuery:false + // Dispatch inline — Claude consumes priority-next between tool steps. const result = await service.dispatchSteer({ sessionId: session.id, steerId, mode: "inline" }); expect(result.dispatchedAt).not.toBeNull(); // The 2nd send call (after the initial turn's send) is the inline dispatch const inlineSendCall = send.mock.calls.find((c: any[]) => { const arg = c[0]; - return typeof arg === "object" && arg && (arg as any).shouldQuery === false; + return typeof arg === "object" + && arg + && (arg as any).shouldQuery === true + && (arg as any).priority === "next" + && JSON.stringify(arg).includes("fold this in"); }); expect(inlineSendCall).toBeDefined(); const inlinePayload = inlineSendCall![0] as any; - expect(inlinePayload.shouldQuery).toBe(false); + expect(inlinePayload.shouldQuery).toBe(true); + expect(inlinePayload.priority).toBe("next"); expect(inlinePayload.message?.content).toEqual( expect.arrayContaining([expect.objectContaining({ type: "text", text: expect.stringContaining("fold this in") })]), ); @@ -24517,7 +24857,7 @@ describe("createAgentChatService", () => { await activeTurn; }); - it("dispatchSteer mode:'interrupt' moves the steer to head, sets interrupted, and calls query.interrupt", async () => { + it("dispatchSteer mode:'interrupt' uses Claude priority-now without tearing down the query", async () => { const events: AgentChatEventEnvelope[] = []; const send = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -24577,14 +24917,14 @@ describe("createAgentChatService", () => { const result = await service.dispatchSteer({ sessionId: session.id, steerId, mode: "interrupt" }); expect(result.dispatchedAt).not.toBeNull(); - // The SDK's query.interrupt should have been invoked - expect(queryInterrupt).toHaveBeenCalledTimes(1); + const interruptPayload = send.mock.calls + .map((call: any[]) => call[0]) + .find((arg: any) => arg?.priority === "now" && arg?.shouldQuery === true); + expect(interruptPayload).toBeDefined(); + expect(queryInterrupt).not.toHaveBeenCalled(); - // Simulate the SDK responding to the interrupt by letting the mock stream exit. - // (service.interrupt() would short-circuit here because dispatchSteer already - // set runtime.interrupted = true.) - interruptedTurnClosed = true; - await activeTurn.catch(() => {}); + await service.interrupt({ sessionId: session.id }); + await activeTurn; }); it("dispatchSteer no-ops when the steerId is not in the queue", async () => { @@ -24611,6 +24951,70 @@ describe("createAgentChatService", () => { expect(result.dispatchedAt).toBeNull(); }); + it("delivers a restored staged Claude message as a fresh turn and clears persistence", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const handle = { + send, + stream: vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-restored", slash_commands: [] }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + return; + } + yield { + type: "assistant", + session_id: "sdk-restored", + message: { content: [{ type: "text", text: "Handled restored message." }], usage: { input_tokens: 1, output_tokens: 1 } }, + }; + yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 } }; + })()), + close: vi.fn(), + sessionId: "sdk-restored", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + }; + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue(handle as any); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue(handle as any); + + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const sessionId = "restored-staged-claude"; + const steerId = "restored-steer"; + sessionService.create({ + sessionId, + laneId: "lane-1", + toolType: "claude-chat", + title: "Restored staged chat", + startedAt: "2026-07-10T12:00:00.000Z", + }); + writePersistedChatState(sessionId, { + version: 2, + sessionId, + laneId: "lane-1", + provider: "claude", + model: "sonnet", + sdkSessionId: "sdk-restored", + pendingSteers: [{ steerId, text: "Handle this restored message" }], + updatedAt: "2026-07-10T12:00:00.000Z", + }); + await service.resumeSession({ sessionId }); + + const result = await service.dispatchSteer({ sessionId, steerId, mode: "inline" }); + + expect(result.dispatchedAt).not.toBeNull(); + expect(send).toHaveBeenCalledWith(expect.stringContaining("Handle this restored message")); + expect(send.mock.calls.some((call: any[]) => call[0]?.priority != null)).toBe(false); + expect(events.some((event) => + event.event.type === "system_notice" + && (event.event as any).steerId === steerId + && /delivering/i.test((event.event as any).message) + )).toBe(true); + expect(readPersistedChatState(sessionId).pendingSteers).toBeUndefined(); + }); + it("dispatchSteer rejects on Codex sessions", async () => { const { service } = createService({ onEvent: () => {} }); const session = await service.createSession({ laneId: "lane-1", provider: "codex", model: "gpt-5-codex" }); @@ -24678,7 +25082,10 @@ describe("createAgentChatService", () => { // Capture the UUID we sent on the SDK message const inlineSendCall = send.mock.calls.find((c: any[]) => { const arg = c[0]; - return typeof arg === "object" && arg && (arg as any).shouldQuery === false; + return typeof arg === "object" + && arg + && (arg as any).shouldQuery === true + && (arg as any).priority === "next"; }); const sentUuid = (inlineSendCall![0] as any).uuid as string; expect(typeof sentUuid).toBe("string"); @@ -24688,13 +25095,6 @@ describe("createAgentChatService", () => { expect(cancelResult.cancelled).toBe(false); expect(cancelAsyncMessage).not.toHaveBeenCalled(); - const notice = events.find((e) => - e.event.type === "system_notice" - && (e.event as any).steerId === steerId - && /does not support cancelling/i.test((e.event as any).message), - ); - expect(notice).toBeDefined(); - // Cleanup await service.interrupt({ sessionId: session.id }); await activeTurn; @@ -25579,7 +25979,7 @@ describe("createAgentChatService", () => { expect(sessionOpts).toEqual(expect.objectContaining({ includePartialMessages: true, agentProgressSummaries: true, - forwardSubagentText: true, + forwardSubagentText: false, })); }); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 812e84919..3c3a0253d 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -166,6 +166,7 @@ import type { AgentChatContextUsageArgs, AgentChatDeleteArgs, AgentChatDispatchSteerArgs, + AgentChatDispatchSteerMode, AgentChatDispatchSteerResult, AgentChatDroidPermissionMode, AgentChatCancelDispatchedSteerArgs, @@ -464,14 +465,33 @@ import { } from "../../../shared/orchestrationRuntimePolicy"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; -const CLAUDE_AGENT_SDK_VERSION = "0.3.202"; +const CLAUDE_AGENT_SDK_VERSION = "0.3.207"; const CLAUDE_AGENT_SDK_API = "v1_query"; const CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS = 1_000; +const CLAUDE_INTERNAL_EDE_DIAGNOSTIC_PREFIX = "[ede_diagnostic]"; const CLAUDE_AGENT_SDK_TELEMETRY_TAGS = { "claude_sdk.version": CLAUDE_AGENT_SDK_VERSION, "claude_sdk.api": CLAUDE_AGENT_SDK_API, } as const; +function partitionClaudeResultErrors(value: unknown): { + all: string[]; + internalDiagnostics: string[]; + userFacing: string[]; +} { + const all = Array.isArray(value) ? value.map(String) : []; + const internalDiagnostics: string[] = []; + const userFacing: string[] = []; + for (const error of all) { + if (error.trim().startsWith(CLAUDE_INTERNAL_EDE_DIAGNOSTIC_PREFIX)) { + internalDiagnostics.push(error); + } else { + userFacing.push(error); + } + } + return { all, internalDiagnostics, userFacing }; +} + type JsonRpcEnvelope = { jsonrpc?: string; id?: string | number; @@ -850,6 +870,15 @@ type ClaudeRuntime = { * this durable record is the guard. Bounded to the most recent messages. */ emittedTextByAssistantMessage: Map>; + /** + * Full level-set reported by SDK `background_tasks_changed`. Unlike paired + * task edge events, this cannot leave ADE believing a finished task is live + * (or miss a still-live task after an edge race). It exists primarily to + * protect the long-lived query from idle cleanup and runtime eviction. + */ + liveBackgroundTaskIds: Set; + /** True after this CLI process has emitted its first authoritative level. */ + backgroundTasksLevelObserved: boolean; seenBackgroundTaskIds: Set; /** * Sticky per-background-task title, keyed by taskId. The first meaningful @@ -867,9 +896,18 @@ type ClaudeRuntime = { slashCommands: Array<{ name: string; description: string; argumentHint?: string }>; busy: boolean; activeTurnId: string | null; + /** Orders active-turn steers after the parent turn's first SDK input push. */ + initialInputDispatchGate: { + turnId: string; + promise: Promise; + resolve: () => void; + reject: (error: Error) => void; + settled: boolean; + } | null; + /** Prevents turn-finalization from auto-delivering a staged row while the + * user is explicitly dispatching it with Claude's priority queue. */ + dispatchingSteerIds: Set; pendingSteers: QueuedSteer[]; - /** UUIDs of inline-dispatched steer messages, keyed by steerId. */ - dispatchedInlineSteers: Map; approvals: Map; interrupted: boolean; /** Set when early interrupt events have been emitted to avoid duplicate emission later. */ @@ -890,6 +928,25 @@ type ClaudeRuntime = { rateLimitWarningEmitted: boolean; }; +function resetClaudeProcessBackgroundLevel(runtime: ClaudeRuntime): void { + runtime.liveBackgroundTaskIds.clear(); + runtime.backgroundTasksLevelObserved = false; +} + +function settleClaudeInitialInputDispatch( + runtime: ClaudeRuntime, + error?: Error, +): void { + const gate = runtime.initialInputDispatchGate; + if (!gate || gate.settled) return; + gate.settled = true; + if (runtime.initialInputDispatchGate === gate) { + runtime.initialInputDispatchGate = null; + } + if (error) gate.reject(error); + else gate.resolve(); +} + const CODEX_BUILT_IN_SLASH_COMMANDS: AgentChatSlashCommand[] = [ { name: "/permissions", description: "Set what Codex can do without asking first.", source: "sdk" }, { name: "/sandbox-add-read-dir", description: "Grant sandbox read access to an extra directory.", source: "sdk" }, @@ -1426,14 +1483,23 @@ function hasRuntimeActiveWorkload(runtime: ChatRuntime | null): boolean { || runtime.activeSubagents.size > 0 || runtime.pendingPlanFollowups.length > 0 ); - case "claude": + case "claude": { + // Before the first level signal, task edge state is the only evidence we + // have. Once observed, background_tasks_changed is authoritative for + // background agents/shells; only foreground subagent edges remain an + // independent workload signal. + const hasUnlevelledSubagent = [...runtime.activeSubagents.values()].some( + (subagent) => !subagent.background || !runtime.backgroundTasksLevelObserved, + ); return Boolean( runtime.busy || runtime.activeTurnId || runtime.pendingSteers.length > 0 || runtime.approvals.size > 0 - || runtime.activeSubagents.size > 0 + || hasUnlevelledSubagent + || runtime.liveBackgroundTaskIds.size > 0 ); + } case "opencode": return Boolean( runtime.busy @@ -3370,10 +3436,11 @@ const CLAUDE_TASK_TYPE_SET = new Set(CLAUDE_TASK_TYPES); function normalizeClaudeTaskType(value: unknown): ClaudeTaskType | undefined { if (typeof value !== "string") return undefined; const trimmed = value.trim(); - // The SDK tags a Bash run_in_background shell as "local_bash"; it is a - // background task (background-work pane, never the subagent roster), so fold - // it onto "background" — the id space every downstream branch already keys on. - if (trimmed === "local_bash") return "background"; + // `local_bash` describes the task implementation, not whether it is actually + // backgrounded. Claude emits it for ordinary foreground Bash tools too. Keep + // it in the non-agent bucket until run_in_background, task_updated, or the + // authoritative background_tasks_changed level proves otherwise. + if (trimmed === "local_bash") return "other"; return CLAUDE_TASK_TYPE_SET.has(trimmed as ClaudeTaskType) ? (trimmed as ClaudeTaskType) : undefined; } @@ -7282,7 +7349,7 @@ export function createAgentChatService(args: { supportedCommands?: () => Promise>; getContextUsage?: () => Promise; rewindFiles?: (userMessageId: string, options?: { dryRun?: boolean }) => Promise; - interrupt?: () => Promise; + interrupt?: ClaudeQuery["interrupt"]; stopTask?: ClaudeQuery["stopTask"]; } => { return { @@ -9579,20 +9646,22 @@ export function createAgentChatService(args: { ...(managed.runtime?.kind === "claude" && managed.runtime.approvalOverrides.size > 0 ? { approvalOverrides: [...managed.runtime.approvalOverrides] } : prevPersisted?.approvalOverrides?.length ? { approvalOverrides: prevPersisted.approvalOverrides } : {}), - ...(managed.runtime?.kind === "claude" && managed.runtime.pendingSteers.length > 0 - ? { - pendingSteers: managed.runtime.pendingSteers.map((s): PersistedPendingSteer => ({ - steerId: s.steerId, - text: s.text, - ...(s.displayText ? { displayText: s.displayText } : {}), - ...(s.attachments.length ? { attachments: s.attachments } : {}), - ...(s.contextAttachments.length ? { contextAttachments: s.contextAttachments } : {}), - ...(s.metadata ? { metadata: s.metadata } : {}), - ...(s.reasoningEffort != null ? { reasoningEffort: s.reasoningEffort } : {}), - ...(s.executionMode ? { executionMode: s.executionMode } : {}), - ...(s.interactionMode ? { interactionMode: s.interactionMode } : {}), - })), - } + ...(managed.runtime?.kind === "claude" + ? managed.runtime.pendingSteers.length > 0 + ? { + pendingSteers: managed.runtime.pendingSteers.map((s): PersistedPendingSteer => ({ + steerId: s.steerId, + text: s.text, + ...(s.displayText ? { displayText: s.displayText } : {}), + ...(s.attachments.length ? { attachments: s.attachments } : {}), + ...(s.contextAttachments.length ? { contextAttachments: s.contextAttachments } : {}), + ...(s.metadata ? { metadata: s.metadata } : {}), + ...(s.reasoningEffort != null ? { reasoningEffort: s.reasoningEffort } : {}), + ...(s.executionMode ? { executionMode: s.executionMode } : {}), + ...(s.interactionMode ? { interactionMode: s.interactionMode } : {}), + })), + } + : {} : prevPersisted?.pendingSteers?.length ? { pendingSteers: prevPersisted.pendingSteers } : {}), ...(managed.runtime?.kind === "opencode" ? { providerSessionId: managed.runtime.handle.sessionId } @@ -10773,7 +10842,19 @@ export function createAgentChatService(args: { const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; - const taskType = normalizeClaudeTaskType(taskMsg.task_type) ?? existing?.taskType; + const rawTaskType = compactString(taskMsg.task_type); + const taskId = compactString(taskMsg.task_id) ?? existing?.taskId; + const patch = asRecord(taskMsg.patch); + const localBash = rawTaskType === "local_bash"; + const localBashIsBackground = localBash && Boolean( + existing?.background === true + || (taskId && runtime.liveBackgroundTaskIds.has(taskId)) + || isBackgroundTask(taskMsg) + || patch?.is_backgrounded === true, + ); + const taskType = localBashIsBackground + ? "background" + : normalizeClaudeTaskType(taskMsg.task_type) ?? existing?.taskType; const messageAgentType = compactString(taskMsg.subagent_type); const classificationAgentType = stashed?.subagentType ?? messageAgentType ?? existing?.agentType; const agentType = classificationAgentType ?? stashed?.name; @@ -10793,6 +10874,7 @@ export function createAgentChatService(args: { agentType, command, description, + localBash, backgroundShell: isBackgroundShellCommand(input), realSubagent: isRealSubagent(input), }; @@ -10897,6 +10979,81 @@ export function createAgentChatService(args: { } }; + const isClaudeAgentBackgroundTaskType = (value: unknown): boolean => { + const taskType = compactString(value)?.toLowerCase(); + return taskType === "subagent" + || taskType === "agent" + || taskType === "local_agent" + || taskType === "workflow" + || taskType === "local_workflow"; + }; + + const applyClaudeBackgroundTasksLevel = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + tasks: unknown, + ): void => { + const nextIds = new Set(); + for (const rawTask of Array.isArray(tasks) ? tasks : []) { + const task = asRecord(rawTask); + const taskId = compactString(task?.task_id); + if (!task || !taskId) continue; + nextIds.add(taskId); + + const description = compactString(task.description) ?? "Background work"; + if (isClaudeAgentBackgroundTaskType(task.task_type)) { + const existing = resolveClaudeActiveSubagent(runtime, taskId, taskId); + if (!existing || existing.background === true) continue; + runtime.activeSubagents.set(taskId, { ...existing, background: true }); + + // The level signal usually precedes task_started, but ordering is + // intentionally unspecified. If it arrives second, append an enriched + // start snapshot so every consumer corrects the existing row in place. + if ( + runtime.emittedSubagentStartIds.has(existing.taskId) + || Boolean(existing.agentId && runtime.emittedSubagentStartIds.has(existing.agentId)) + ) { + emitChatEvent(managed, { + type: "subagent_started", + taskId: existing.taskId, + ...(existing.agentId ? { agentId: existing.agentId } : {}), + ...(existing.parentAgentId ? { parentAgentId: existing.parentAgentId } : {}), + ...(existing.agentType ? { agentType: existing.agentType } : {}), + parentToolUseId: existing.parentToolUseId ?? null, + description: existing.description || description, + background: true, + ...(existing.taskType ? { taskType: existing.taskType } : {}), + ...(existing.workflowName ? { workflowName: existing.workflowName } : {}), + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + ...(runtime.sdkSessionId ? { providerSessionId: runtime.sdkSessionId } : {}), + }); + } + continue; + } + + const existing = runtime.activeSubagents.get(taskId); + if (existing?.nonAgentTaskRun) { + runtime.activeSubagents.set(taskId, { + ...existing, + background: true, + taskType: "background", + nonAgentTaskRun: false, + }); + } + emitClaudeBackgroundTaskUpdate(managed, runtime, { + taskId, + status: "running", + title: description, + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + }); + } + + runtime.liveBackgroundTaskIds.clear(); + for (const taskId of nextIds) runtime.liveBackgroundTaskIds.add(taskId); + runtime.backgroundTasksLevelObserved = true; + managed.lastActivityTimestamp = Date.now(); + }; + const closeOpenClaudeBackgroundTasks = ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -11132,8 +11289,17 @@ export function createAgentChatService(args: { if (!task) continue; const id = compactString(task.id); if (!id) continue; - snapshotBackgroundTaskIds.add(id); const type = compactString(task.type); + // Claude's hook snapshot is session-wide and includes native subagents + // and workflows alongside shell/Monitor work. Agents already have a + // richer subagent_* lifecycle; projecting them again as background rows + // creates two peer entries for one task. Keep only the real background + // work here. Child shell/Monitor rows intentionally remain session-wide, + // matching Claude Code. + if (isClaudeAgentBackgroundTaskType(type) || compactString(task.agent_type)) { + continue; + } + snapshotBackgroundTaskIds.add(id); const description = compactString(task.description); const command = compactString(task.command); const agentType = compactString(task.agent_type); @@ -12206,26 +12372,30 @@ export function createAgentChatService(args: { if (preserveProviderResumeState) persistChatState(managed); cancelClaudeWarmup(managed, managed.runtime, "teardown"); try { managed.runtime.query?.close(); } catch { /* ignore */ } - if ( - openCodeReason === "ended_session" - || openCodeReason === "handle_close" - || openCodeReason === "model_switch" - ) { - claudeSubprocessReaper.reapForSession(managed.session.id, openCodeReason); - } + // Every teardown abandons this query's control channel. Enforce process + // ownership even for idle eviction so an ended iterator cannot leave a + // detached Claude worker (or its children) behind. + claudeSubprocessReaper.reapForSession(managed.session.id, openCodeReason); managed.runtime.inputPump?.close(); try { managed.runtime.warmQuery?.close(); } catch { /* ignore */ } + settleClaudeInitialInputDispatch(managed.runtime, new Error("Claude runtime was closed before the turn input was dispatched.")); + resetClaudeProcessBackgroundLevel(managed.runtime); managed.runtime.query = null; managed.runtime.inputPump = null; managed.runtime.warmQuery = null; managed.runtime.warmupDone = null; - // Settle any still-open background shell rows as stopped before the maps - // are cleared, so teardown never leaves a persisted "running" row. - closeOpenClaudeBackgroundTasks(managed, managed.runtime, "stopped", managed.runtime.activeTurnId ?? undefined); - managed.runtime.activeSubagents.clear(); + // Query is already null, so settle every visible background/native task + // without trying provider stopTask on the dead control channel. + void stopActiveClaudeSubagents( + managed, + managed.runtime, + managed.runtime.activeTurnId ?? undefined, + "The Claude session ended before this task reported completion.", + ); managed.runtime.emittedSubagentStartIds.clear(); managed.runtime.taskToolInputByToolUseId.clear(); managed.runtime.workflowAgentsByTask.clear(); + managed.runtime.dispatchingSteerIds.clear(); for (const pending of managed.runtime.approvals.values()) { pending.resolve({ decision: "cancel" }); } @@ -13479,9 +13649,17 @@ export function createAgentChatService(args: { toolUseMetaByContentIndex: Map; currentStreamMessageId: string | null; streamedTextByContentIndex: Map; + recentTextDeltaBuffer: string; scheduledWakeAttached: boolean; }; + const isClaudeForwardedSubagentMessage = (msg: SDKMessage): boolean => { + if (msg.type !== "assistant" && msg.type !== "stream_event" && msg.type !== "tool_progress") { + return false; + } + return compactString((msg as unknown as Record).parent_tool_use_id) != null; + }; + const startClaudeIdleTurn = ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -13604,9 +13782,11 @@ export function createAgentChatService(args: { state.turnId = null; state.currentStreamMessageId = null; state.streamedTextByContentIndex.clear(); + state.recentTextDeltaBuffer = ""; if (runtime.pendingSteers.length && managed.runtime === runtime) { runtime.idleReaderPromise = null; - await deliverNextQueuedSteer(managed, runtime); + const delivered = await deliverNextQueuedSteer(managed, runtime); + if (!delivered) startClaudeIdleReader(managed, runtime, "turn_completed"); } }; @@ -13650,7 +13830,8 @@ export function createAgentChatService(args: { }); return true; } - const taskType = normalizeClaudeTaskType(msg.task_type) ?? existing?.taskType; + const classification = classifyClaudeTaskMessage(runtime, msg, existing); + const taskType = classification.taskType; const workflowName = normalizeClaudeWorkflowName(msg.workflow_name) ?? existing?.workflowName; const parentToolUseId = taskParentToolUseId(msg) ?? existing?.parentToolUseId ?? null; const agentId = notificationAgentId ?? existing?.agentId; @@ -13658,8 +13839,11 @@ export function createAgentChatService(args: { const agentType = compactString(msg.subagent_type) ?? existing?.agentType; const command = compactString(msg.command) ?? existing?.command; const description = compactString(msg.description) ?? existing?.description ?? "Background task"; - const backgroundShell = classifyClaudeTaskMessage(runtime, msg, existing).backgroundShell; - const turnId = startClaudeIdleTurn(managed, runtime, state, taskType === "cron" ? "Claude scheduled task is running" : "Claude background task is running"); + const backgroundShell = classification.backgroundShell; + // Progress from a task that outlives its parent turn must not manufacture a + // new main-thread turn. A genuine parent assistant frame (normally the + // completion wake-up) will start the next idle turn when it arrives. + const turnId = state.turnId ?? undefined; // Background shell commands are tracked as background scheduled_work rows, // not subagents — mirror the main-loop gating in every idle subtype. if (backgroundShell) { @@ -13719,7 +13903,12 @@ export function createAgentChatService(args: { return true; } if (subtype === "task_started") { - const background = taskType === "background" || taskType === "cron" || taskType === "local_workflow" || isBackgroundTask(msg); + const background = taskType === "background" + || taskType === "cron" + || taskType === "local_workflow" + || runtime.liveBackgroundTaskIds.has(taskId) + || isBackgroundTask(msg) + || (parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId)?.isBackground === true : false); // An explicit task_type "other" with no agent metadata is a plain Claude // Code task run, not a subagent — track it for cleanup but never emit // subagent rows. Uses the same shared predicate as the foreground path @@ -13788,11 +13977,15 @@ export function createAgentChatService(args: { agentId: notificationAgentId, parentToolUseId, }); - const summary = String(msg.summary ?? gatedFinalSummary ?? ""); + const finalStatus = msg.status === "completed" ? "completed" : msg.status === "stopped" ? "stopped" : "failed"; + const summary = String( + finalStatus === "completed" + ? gatedFinalSummary ?? msg.summary ?? "" + : msg.summary ?? gatedFinalSummary ?? "", + ); runtime.activeSubagents.delete(taskId); if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); 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) { @@ -13828,7 +14021,31 @@ export function createAgentChatService(args: { } 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"); + const summary = status === "completed" && existing?.finalSummary + ? existing.finalSummary + : compactString(msg.summary) + ?? compactString(patch.error) + ?? existing?.finalSummary + ?? (status ? `Status: ${status.replace(/_/g, " ")}` : "Task updated"); + if (status === "completed" && classification.realSubagent) { + // task_updated is a state patch. The SDK's task_notification is the + // terminal edge and follows SubagentStop, which carries the child's real + // final text. Publishing here would lock in "Status: completed" and gate + // the richer notification as a duplicate. + 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 } : {}), + }); + return true; + } if (status === "completed" || status === "failed" || status === "killed") { runtime.activeSubagents.delete(taskId); if (notificationAgentId) runtime.activeSubagents.delete(notificationAgentId); @@ -13909,6 +14126,11 @@ export function createAgentChatService(args: { persistChatState(managed); } + // Native background-subagent assistant/tool frames are forwarded over the + // parent Query for observation. They belong to the subagent transcript, + // which ADE reads through getSubagentTranscript, not the main chat. + if (isClaudeForwardedSubagentMessage(msg)) return; + 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[]); @@ -13924,6 +14146,11 @@ export function createAgentChatService(args: { return; } + if (msg.type === "system" && record.subtype === "background_tasks_changed") { + applyClaudeBackgroundTasksLevel(managed, runtime, record.tasks); + return; + } + if (msg.type === "system" && record.subtype === "model_refusal_fallback") { emitClaudeTranscriptRetraction(managed, record.retracted_message_uuids, "model_refusal_fallback", state.turnId ?? undefined); return; @@ -14011,8 +14238,16 @@ export function createAgentChatService(args: { const emittedRecord = providerMessageId ? claudeEmittedTextRecord(runtime, providerMessageId) : null; const streamedPrefix = emittedRecord?.get(index) ?? (snapshotMatchesCurrentStream ? state.streamedTextByContentIndex.get(index) ?? "" : ""); + const replayedStreamPrefix = state.recentTextDeltaBuffer.length > 0 + && text.startsWith(state.recentTextDeltaBuffer); + const replayedSnapshotPrefix = state.recentTextDeltaBuffer.length > 0 + && state.recentTextDeltaBuffer.startsWith(text); let textToEmit: string; - if (!streamedPrefix.length) { + if (replayedSnapshotPrefix) { + textToEmit = ""; + } else if (!streamedPrefix.length && replayedStreamPrefix) { + textToEmit = text.slice(state.recentTextDeltaBuffer.length); + } else if (!streamedPrefix.length) { textToEmit = text; } else if (text.startsWith(streamedPrefix)) { textToEmit = text.slice(streamedPrefix.length); @@ -14032,10 +14267,13 @@ export function createAgentChatService(args: { turnId, }); } - emittedRecord?.set(index, textToEmit.length ? text : streamedPrefix); + emittedRecord?.set(index, text.length >= streamedPrefix.length ? text : streamedPrefix); if (snapshotMatchesCurrentStream) { state.streamedTextByContentIndex.set(index, text); } + state.recentTextDeltaBuffer = replayedSnapshotPrefix + ? state.recentTextDeltaBuffer.slice(text.length) + : ""; } else if (block.type === "thinking") { const text = typeof block.thinking === "string" ? block.thinking : typeof block.text === "string" ? block.text : ""; if (text.trim().length) { @@ -14088,6 +14326,7 @@ export function createAgentChatService(args: { if (event.type === "message_start") { const message = asRecord(event.message); state.streamedTextByContentIndex.clear(); + state.recentTextDeltaBuffer = ""; state.currentStreamMessageId = compactString(message?.id) ?? compactString(streamMsg.uuid) ?? null; return; } @@ -14096,6 +14335,7 @@ export function createAgentChatService(args: { if (delta?.type === "text_delta") { const text = typeof delta.text === "string" ? delta.text : ""; if (text.length) { + state.recentTextDeltaBuffer += text; state.assistantText += text; if (contentIndex != null) { state.streamedTextByContentIndex.set( @@ -14208,6 +14448,20 @@ export function createAgentChatService(args: { if (msg.type === "result") { const resultMsg = record; const turnId = state.turnId; + const resultErrors = partitionClaudeResultErrors(resultMsg.errors); + const diagnosticOnlyError = resultMsg.is_error === true + && resultErrors.all.length > 0 + && resultErrors.userFacing.length === 0; + const resultIsError = resultMsg.is_error === true && !diagnosticOnlyError; + if (resultErrors.internalDiagnostics.length > 0) { + logger.debug("agent_chat.claude_internal_diagnostic", { + ...CLAUDE_AGENT_SDK_TELEMETRY_TAGS, + sessionId: managed.session.id, + turnId, + source: "idle_reader", + diagnostics: resultErrors.internalDiagnostics, + }); + } const usage = asRecord(resultMsg.usage); if (usage) { state.usage = { @@ -14220,12 +14474,12 @@ export function createAgentChatService(args: { 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 }); + if (resultIsError && turnId) { + for (const error of resultErrors.userFacing) { + emitChatEvent(managed, { type: "error", message: error, turnId }); } } - await finishClaudeIdleTurn(managed, runtime, state, resultMsg.is_error === true ? "failed" : "completed"); + await finishClaudeIdleTurn(managed, runtime, state, resultIsError ? "failed" : "completed"); return; } @@ -14280,6 +14534,7 @@ export function createAgentChatService(args: { toolUseMetaByContentIndex: new Map(), currentStreamMessageId: null, streamedTextByContentIndex: new Map(), + recentTextDeltaBuffer: "", scheduledWakeAttached: false, }; @@ -14303,6 +14558,51 @@ export function createAgentChatService(args: { return true; }; + const terminateEndedQuery = async ( + status: "completed" | "failed", + summary: string, + reaperReason: string, + ): Promise => { + if ( + managed.runtime !== runtime + || runtime.query !== sessionQuery + || runtime.idleReaderGeneration !== generation + ) { + return; + } + + const turnId = state.turnId ?? runtime.activeTurnId ?? undefined; + // Invalidate the dead iterator before finalizing the idle turn. That + // finalization may deliver a queued message, which must start a fresh + // query instead of pushing into this ended input stream. + runtime.idleReaderGeneration += 1; + runtime.idleReaderPromise = null; + runtime.queryGeneration += 1; + runtime.queryStartPromise = null; + runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; + try { sessionQuery.close(); } catch { /* ignore */ } + runtime.inputPump?.close(); + runtime.query = null; + runtime.inputPump = null; + runtime.warmupDone = null; + resetClaudeProcessBackgroundLevel(runtime); + claudeSubprocessReaper.reapForSession(managed.session.id, reaperReason); + + // The process is gone, so every still-open task edge is now terminal. + // Query is already null, which deliberately prevents provider stopTask + // calls against a dead control channel while still settling all UI rows. + await stopActiveClaudeSubagents(managed, runtime, turnId, summary); + + if (state.turnId && runtime.activeTurnId === state.turnId) { + await finishClaudeIdleTurn(managed, runtime, state, status); + } else if (runtime.pendingSteers.length && managed.runtime === runtime) { + await deliverNextQueuedSteer(managed, runtime); + } else { + persistChatState(managed); + } + }; + runtime.idleReaderPromise = (async () => { try { while ( @@ -14344,6 +14644,11 @@ export function createAgentChatService(args: { sessionId: managed.session.id, error: error instanceof Error ? error.message : String(error), }); + await terminateEndedQuery( + "failed", + "Claude's background query ended before this task reported completion.", + "claude_idle_reader_next_failed", + ); return; } if ( @@ -14367,7 +14672,11 @@ export function createAgentChatService(args: { runtime.pendingPostResultNextSettledAt = null; } if (next.done) { - await finishClaudeIdleTurn(managed, runtime, state, "completed"); + await terminateEndedQuery( + "completed", + "Claude's background query ended before this task reported completion.", + "claude_idle_reader_done", + ); return; } await handleClaudeIdleMessage(managed, runtime, next.value, state); @@ -14377,9 +14686,11 @@ export function createAgentChatService(args: { 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); - } + await terminateEndedQuery( + "failed", + "Claude's background query ended before this task reported completion.", + "claude_idle_reader_failed", + ).catch(() => undefined); } finally { if (runtime.idleReaderGeneration === generation && runtime.idleReaderPromise) { runtime.idleReaderPromise = null; @@ -14419,6 +14730,23 @@ export function createAgentChatService(args: { const userMessageId = randomUUID(); runtime.busy = true; runtime.activeTurnId = turnId; + settleClaudeInitialInputDispatch(runtime, new Error("A newer Claude turn replaced the pending input dispatch.")); + let resolveInitialInput!: () => void; + let rejectInitialInput!: (error: Error) => void; + const initialInputPromise = new Promise((resolve, reject) => { + resolveInitialInput = resolve; + rejectInitialInput = reject; + }); + // A host steer may be the only waiter; keep a rejection from becoming an + // unhandled promise while preserving rejection for callers that do await it. + void initialInputPromise.catch(() => undefined); + runtime.initialInputDispatchGate = { + turnId, + promise: initialInputPromise, + resolve: resolveInitialInput, + reject: rejectInitialInput, + settled: false, + }; runtime.interrupted = false; runtime.interruptEventsEmitted = false; runtime.resolvedToolUseIds.clear(); @@ -14649,7 +14977,10 @@ export function createAgentChatService(args: { turnPermissionMode, error: String(permErr), }); - resetClaudeQuerySession(managed, runtime, "session_reset", { clearSdkSessionId: true }); + resetClaudeQuerySession(managed, runtime, "session_reset", { + clearSdkSessionId: true, + preserveInitialInputDispatchGate: true, + }); sessionQuery = await ensureClaudeQuery(managed, runtime); sessionControl = getClaudeQueryControl(sessionQuery); if (typeof sessionControl.setPermissionMode === "function") { @@ -14675,7 +15006,11 @@ export function createAgentChatService(args: { const pendingPostResultSettledBeforeInput = runtime.pendingPostResultNextSettledAt != null; bumpClaudeIdleDeadline(); - runtime.inputPump?.push(messageToSend); + if (!runtime.inputPump) { + throw new Error("Claude's live input stream closed before the turn could be dispatched."); + } + runtime.inputPump.push(messageToSend); + settleClaudeInitialInputDispatch(runtime); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); // Don't emit a pre-emptive "thinking" activity — wait for actual content from the stream. @@ -14852,6 +15187,10 @@ export function createAgentChatService(args: { persistChatState(managed); } + if (isClaudeForwardedSubagentMessage(msg)) { + continue; + } + if (resultSeen && (msg as any).type !== "prompt_suggestion" && isStalePostResultTailMessage(msg)) { logStalePostResultTailDiscard(msg); continue; @@ -14895,6 +15234,11 @@ export function createAgentChatService(args: { continue; } + if (msg.type === "system" && (msg as any).subtype === "background_tasks_changed") { + applyClaudeBackgroundTasksLevel(managed, runtime, (msg as any).tasks); + continue; + } + // system:status — permission mode changes and turn-status signals. // // The SDK CLI emits SDKStatusMessage with the new permissionMode when @@ -15384,12 +15728,20 @@ export function createAgentChatService(args: { 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"); + const classification = classifyClaudeTaskMessage( + runtime, + taskMsg as Record, + existing, + ); + const summary = status === "completed" && existing?.finalSummary + ? existing.finalSummary + : compactString(patch.error) + ?? existing?.finalSummary + ?? (status ? `Status: ${status.replace(/_/g, " ")}` : "Task updated"); // Background shell commands are tracked as background scheduled_work // rows, not subagents. Emit a terminal background row on completion, // and nothing for interim updates. - if (classifyClaudeTaskMessage(runtime, taskMsg as Record, existing).backgroundShell) { + if (classification.backgroundShell) { if (status === "completed" || status === "failed" || status === "killed") { runtime.activeSubagents.delete(taskId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); @@ -15404,6 +15756,24 @@ export function createAgentChatService(args: { } continue; } + if (status === "completed" && classification.realSubagent) { + // Wait for task_notification: it follows SubagentStop and therefore + // has access to the child's real final text. task_updated alone is + // not the terminal rendering edge for native Agents/workflows. + runtime.activeSubagents.set(taskId, { + taskId, + description, + parentToolUseId, + background, + finalSummary: existing?.finalSummary, + ...(agentType ? { agentType } : {}), + ...(agentId ? { agentId } : {}), + ...(parentAgentId ? { parentAgentId } : {}), + ...(taskType ? { taskType } : {}), + ...(workflowName ? { workflowName } : {}), + }); + continue; + } if (status === "completed" || status === "failed" || status === "killed") { runtime.activeSubagents.delete(taskId); if (parentToolUseId) runtime.taskToolInputByToolUseId.delete(parentToolUseId); @@ -15505,7 +15875,11 @@ export function createAgentChatService(args: { ? taskMsg.agent_id.trim() : undefined; const parentAgentId = compactString(taskMsg.parent_agent_id); - const taskType = normalizeClaudeTaskType(taskMsg.task_type); + const classification = classifyClaudeTaskMessage( + runtime, + taskMsg as Record, + ); + const taskType = classification.taskType; const workflowName = normalizeClaudeWorkflowName(taskMsg.workflow_name); // The SDK sets task_type explicitly. When absent, fall back to the // legacy hints (run_in_background field, Task-tool stash) so older @@ -15513,13 +15887,14 @@ export function createAgentChatService(args: { const background = taskType === "background" || taskType === "cron" || taskType === "local_workflow" + || runtime.liveBackgroundTaskIds.has(taskId) || isBackgroundTask(taskMsg as Record) || stashed?.isBackground === true; const command = compactString(taskMsg.command); // A background *shell* command (Bash run_in_background) has task_type // "local_bash"/"background" and no real subagent agentType. It must NOT // surface as a subagent row; it belongs in the background-work pane. - if (isBackgroundShellCommand({ taskType, agentType, command, description })) { + if (classification.backgroundShell) { runtime.activeSubagents.set(taskId, { taskId, description, @@ -15639,7 +16014,16 @@ export function createAgentChatService(args: { agentId: notificationAgentId, parentToolUseId, }); - const summary = String(taskMsg.summary ?? gatedFinalSummary ?? ""); + const finalStatus = taskMsg.status === "completed" + ? "completed" + : taskMsg.status === "stopped" + ? "stopped" + : "failed"; + const summary = String( + finalStatus === "completed" + ? gatedFinalSummary ?? taskMsg.summary ?? "" + : taskMsg.summary ?? gatedFinalSummary ?? "", + ); const stashed = parentToolUseId ? runtime.taskToolInputByToolUseId.get(parentToolUseId) : undefined; const agentType = existing?.agentType ?? stashed?.subagentType @@ -15693,7 +16077,7 @@ export function createAgentChatService(args: { ...(parentAgentId ? { parentAgentId } : {}), ...(agentType ? { agentType } : {}), parentToolUseId, - status: taskMsg.status === "completed" ? "completed" : taskMsg.status === "stopped" ? "stopped" : "failed", + status: finalStatus, summary, finalSummary: summary, usage: taskMsg.usage ? { @@ -16122,6 +16506,20 @@ export function createAgentChatService(args: { // result — turn complete if (msg.type === "result") { const resultMsg = msg as any; + const resultErrors = partitionClaudeResultErrors(resultMsg.errors); + const diagnosticOnlyError = resultMsg.is_error === true + && resultErrors.all.length > 0 + && resultErrors.userFacing.length === 0; + const resultIsError = resultMsg.is_error === true && !diagnosticOnlyError; + if (resultErrors.internalDiagnostics.length > 0) { + logger.debug("agent_chat.claude_internal_diagnostic", { + ...CLAUDE_AGENT_SDK_TELEMETRY_TAGS, + sessionId: managed.session.id, + turnId, + source: "active_turn", + diagnostics: resultErrors.internalDiagnostics, + }); + } for (const modelName of extractReportedModelUsageNames(resultMsg.modelUsage)) { reportedUsageModels.add(modelName); } @@ -16136,28 +16534,28 @@ export function createAgentChatService(args: { if (typeof resultMsg.total_cost_usd === "number") { costUsd = resultMsg.total_cost_usd; } - if (resultMsg.is_error && resultMsg.errors?.length) { + if (resultIsError && resultErrors.userFacing.length > 0) { // A logged-out result surfaces here as 401 / "invalid authentication // credentials" errors — route them into the re-login card rather than // dumping raw API error text. - if (isClaudeRuntimeAuthError(resultMsg.errors.map(String).join(" "))) { + if (isClaudeRuntimeAuthError(resultErrors.userFacing.join(" "))) { failClaudeTurnUnauthenticated(); } if (onBackendDispatched) { - throw new Error(resultMsg.errors.map(String).join("\n")); + throw new Error(resultErrors.userFacing.join("\n")); } - for (const err of resultMsg.errors) { + for (const err of resultErrors.userFacing) { emitChatEvent(managed, { type: "error", - message: String(err), + message: err, turnId, }); } } - if (resultMsg.is_error && onBackendDispatched) { + if (resultIsError && onBackendDispatched) { throw new Error("Claude rejected the prompt before starting the turn."); } - if (!resultMsg.is_error) { + if (!resultIsError) { markBackendDispatched(); } if (Array.isArray(resultMsg.permission_denials) && resultMsg.permission_denials.length > 0) { @@ -16331,7 +16729,8 @@ export function createAgentChatService(args: { // Process queued steers (skip if session was disposed during execution) if (runtime.pendingSteers.length) { - await deliverNextQueuedSteer(managed, runtime); + const delivered = await deliverNextQueuedSteer(managed, runtime); + if (!delivered) startClaudeIdleReader(managed, runtime, "turn_completed"); } else { startClaudeIdleReader(managed, runtime, "turn_completed"); } @@ -16344,13 +16743,14 @@ export function createAgentChatService(args: { runtime.busy = false; runtime.activeTurnId = null; const effectiveError = timeoutError ?? error; + settleClaudeInitialInputDispatch( + runtime, + effectiveError instanceof Error ? effectiveError : new Error(String(effectiveError)), + ); const finalToolStatus: "completed" | "failed" | "interrupted" = runtime.interrupted || isAbortRelatedError(effectiveError) ? "interrupted" : "failed"; - if (finalToolStatus === "interrupted") { - await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); - } flushOpenClaudeToolUses(finalToolStatus); flushClaudeStructuredActivities(finalToolStatus); @@ -16360,10 +16760,24 @@ export function createAgentChatService(args: { if (!runtime.interrupted) { try { runtime.query?.close(); } catch { /* ignore */ } runtime.inputPump?.close(); + runtime.queryGeneration += 1; + runtime.queryStartPromise = null; + runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; + resetClaudeProcessBackgroundLevel(runtime); runtime.query = null; runtime.inputPump = null; runtime.warmupDone = null; + claudeSubprocessReaper.reapForSession(managed.session.id, "claude_turn_failed"); } + await stopActiveClaudeSubagents( + managed, + runtime, + turnId, + finalToolStatus === "interrupted" + ? "Interrupted" + : "Claude's query ended before this task reported completion.", + ); const doneModel = buildDoneModelPayload(); void emitTurnDiffSummaryIfChanged(managed, turnId); @@ -18663,9 +19077,16 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, event: Extract, ): void => { + const alreadyStarted = runtime.emittedSubagentStartIds.has(event.taskId) + || Boolean(event.agentId && runtime.emittedSubagentStartIds.has(event.agentId)); runtime.emittedSubagentStartIds.add(event.taskId); if (event.agentId) runtime.emittedSubagentStartIds.add(event.agentId); - emitChatEvent(managed, event); + if (alreadyStarted) return; + const providerSessionId = runtime.sdkSessionId?.trim(); + emitChatEvent(managed, { + ...event, + ...(providerSessionId ? { providerSessionId } : {}), + }); }; const emitClaudeSubagentResult = ( @@ -21943,15 +22364,8 @@ export function createAgentChatService(args: { taskId, description: input.agent_type, parentToolUseId: null, - }); - emitClaudeSubagentStarted(managed, runtime, { - type: "subagent_started", - taskId, agentId: input.agent_id, agentType: input.agent_type, - parentToolUseId: null, - description: input.agent_type, - turnId: runtime.activeTurnId ?? undefined, }); } return { continue: true }; @@ -22153,7 +22567,10 @@ export function createAgentChatService(args: { includeHookEvents: true, agentProgressSummaries: true, promptSuggestions: true, - forwardSubagentText: true, + // ADE renders native subagents from getSubagentMessages() in their own + // drill-in transcript. Do not ask the parent query to replay full child + // text; default tool heartbeat frames are still filtered defensively. + forwardSubagentText: false, enableFileCheckpointing: true, skills: "all", maxBudgetUsd: chatConfig.sessionBudgetUsd ?? undefined, @@ -22325,9 +22742,12 @@ export function createAgentChatService(args: { managed: ManagedChatSession, runtime: ClaudeRuntime, reason: "interrupt" | "teardown" | "session_reset" | "timeout", - options: { clearSdkSessionId?: boolean } = {}, + options: { clearSdkSessionId?: boolean; preserveInitialInputDispatchGate?: boolean } = {}, ): void => { cancelClaudeWarmup(managed, runtime, reason); + if (!options.preserveInitialInputDispatchGate) { + settleClaudeInitialInputDispatch(runtime, new Error(`Claude query reset before turn input dispatch (${reason}).`)); + } runtime.idleReaderGeneration += 1; runtime.idleReaderPromise = null; try { runtime.query?.close(); } catch { /* ignore */ } @@ -22342,12 +22762,18 @@ export function createAgentChatService(args: { // the reset. A leaked twin keeps streaming the same resumed session into a // dead reader, and its background children die silently later. claudeSubprocessReaper.reapForSession(managed.session.id, `claude_${reason}`); - const hadOpenBackgroundTasks = runtime.seenBackgroundTaskIds.size > 0; - // Tearing down the query orphans any still-open background shell: its - // completion notification can only ever arrive on THIS query, so settle the - // rows as stopped before the tracking maps are cleared. (Turn boundaries do - // NOT do this — the query survives them and delivers the real completion.) - closeOpenClaudeBackgroundTasks(managed, runtime, "stopped"); + const hadOpenBackgroundTasks = runtime.seenBackgroundTaskIds.size > 0 + || runtime.liveBackgroundTaskIds.size > 0; + // Tearing down the query orphans every still-open task: completion can only + // arrive on THIS query, so settle background shells, native subagents, and + // workflow agents before clearing their tracking maps. Query is already + // null, so this performs no provider stopTask calls. + void stopActiveClaudeSubagents( + managed, + runtime, + runtime.activeTurnId ?? undefined, + "The Claude session restarted before this task reported completion.", + ); if (hadOpenBackgroundTasks) { emitChatEvent(managed, { type: "system_notice", @@ -22358,12 +22784,16 @@ export function createAgentChatService(args: { runtime.scheduledWorkKindById.clear(); runtime.scheduledWorkSignatures.clear(); runtime.emittedSubagentStartIds.clear(); + resetClaudeProcessBackgroundLevel(runtime); runtime.seenBackgroundTaskIds.clear(); runtime.backgroundTaskTitleById.clear(); runtime.scheduledWorkIdByTaskId.clear(); runtime.scheduledWorkIdByToolUseId.clear(); runtime.activeProviderCronIds.clear(); runtime.activeProviderCronIdsByPrompt.clear(); + if (!options.preserveInitialInputDispatchGate) { + runtime.dispatchingSteerIds.clear(); + } runtime.warmupDone = null; if (options.clearSdkSessionId && runtime.sdkSessionId) { logger.info("agent_chat.claude_sdk_session_cleared", { @@ -22542,6 +22972,9 @@ export function createAgentChatService(args: { const startClaudeQuery = async (managed: ManagedChatSession, runtime: ClaudeRuntime): Promise => { const startGeneration = runtime.queryGeneration; + // background_tasks_changed is per CLI process and emits no startup value. + // A new process must never inherit the prior process's level set. + resetClaudeProcessBackgroundLevel(runtime); const pump = new ClaudeInputPump(); const assertCurrentStart = (spawnedQuery?: ClaudeQuery): void => { if (runtime.queryGeneration === startGeneration) return; @@ -22656,6 +23089,11 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime | OpenCodeRuntime | CursorRuntime | DroidRuntime, ): Promise => { if (managed.closed) return false; + // A user-selected priority dispatch owns the staged queue while its SDK + // message is being built. Do not auto-deliver another row at the parent + // turn boundary; the idle reader will consume the explicit dispatch, then + // remaining staged rows can proceed in order. + if (runtime.kind === "claude" && runtime.dispatchingSteerIds.size > 0) return false; const nextSteer = runtime.pendingSteers.shift(); if (!nextSteer) return false; @@ -22813,7 +23251,8 @@ export function createAgentChatService(args: { : metadata; emitChatEvent(managed, { type: "user_message", - text: displayText, + text, + ...(displayText !== text ? { displayText } : {}), ...(attachments.length ? { attachments } : {}), ...(contextAttachments.length ? { contextAttachments } : {}), ...(queuedMetadata && Object.keys(queuedMetadata).length ? { metadata: queuedMetadata } : {}), @@ -22821,13 +23260,6 @@ export function createAgentChatService(args: { turnId: runtime.activeTurnId ?? undefined, deliveryState: "queued", }); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - steerId, - message: `Message queued (#${runtime.pendingSteers.length}) — will be sent after the current turn.`, - turnId: runtime.activeTurnId ?? undefined, - }); persistChatState(managed); return true; }; @@ -23143,6 +23575,8 @@ export function createAgentChatService(args: { scheduledWorkSignatures: new Map(), taskTodos: { seeded: false, byId: new Map() }, emittedTextByAssistantMessage: new Map(), + liveBackgroundTaskIds: new Set(), + backgroundTasksLevelObserved: false, seenBackgroundTaskIds: new Set(), backgroundTaskTitleById: new Map(), scheduledWorkKindById: new Map(), @@ -23153,8 +23587,9 @@ export function createAgentChatService(args: { slashCommands: [], busy: false, activeTurnId: null, + initialInputDispatchGate: null, + dispatchingSteerIds: new Set(), pendingSteers: hydratePersistedPendingSteers(persisted, managed), - dispatchedInlineSteers: new Map(), approvals: new Map(), interrupted: false, interruptEventsEmitted: false, @@ -28941,7 +29376,66 @@ export function createAgentChatService(args: { } } - const steer = async ({ sessionId, text, displayText, attachments = [], contextAttachments = [], metadata, reasoningEffort, executionMode, interactionMode }: AgentChatSteerArgs): Promise => { + const dispatchClaudeSteerMessage = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + steer: QueuedSteer, + mode: AgentChatDispatchSteerMode, + onAccepted?: () => void, + ): Promise => { + const initialInputGate = runtime.initialInputDispatchGate; + if (runtime.busy && initialInputGate) { + await initialInputGate.promise; + } + if (!runtime.inputPump) { + await ensureClaudeQuery(managed, runtime); + } + if (!runtime.inputPump) { + throw new Error("Claude's live input stream is not ready."); + } + + const dispatchUuid = randomUUID(); + const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); + const promptText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; + const sdkMsg = await buildClaudeV2MessageAsync(promptText, steer.resolvedAttachments, { + baseDir: managed.laneWorktreePath, + sessionId: runtime.sdkSessionId ?? null, + forceUserMessage: true, + getDirtyFileTextForPath, + }) as unknown as SDKUserMessage; + + // Match Claude Code's own command queue. "next" is consumed between tool + // steps; "now" aborts only the live model request and redirects without + // tearing down the SDK query or killing unrelated background work. + sdkMsg.priority = mode === "interrupt" ? "now" : "next"; + sdkMsg.shouldQuery = true; + sdkMsg.uuid = dispatchUuid; + + runtime.inputPump.push(sdkMsg); + onAccepted?.(); + + emitChatEvent(managed, { + type: "user_message", + text: steer.text, + ...(steer.displayText && steer.displayText !== steer.text ? { displayText: steer.displayText } : {}), + ...(steer.attachments.length ? { attachments: steer.attachments } : {}), + ...(steer.contextAttachments.length ? { contextAttachments: steer.contextAttachments } : {}), + ...(steer.metadata ? { metadata: steer.metadata } : {}), + steerId: steer.steerId, + deliveryState: "inline", + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + if (!runtime.busy && !runtime.activeTurnId && !runtime.idleReaderPromise) { + startClaudeIdleReader(managed, runtime, "query_ready"); + } + return Date.now(); + }; + + const steer = async ({ sessionId, text, displayText, attachments = [], contextAttachments = [], metadata, reasoningEffort, executionMode, interactionMode, dispatchMode }: AgentChatSteerArgs): Promise => { + if (dispatchMode !== undefined && dispatchMode !== "inline" && dispatchMode !== "interrupt") { + throw new Error(`Unsupported Claude steer dispatch mode: ${String(dispatchMode)}`); + } const trimmed = text.trim(); const steerId = randomUUID(); // Allow context-only steers: if text is empty but issue context attachments @@ -28951,6 +29445,9 @@ export function createAgentChatService(args: { } const managed = ensureManagedSession(sessionId); + if (dispatchMode && managed.session.provider !== "claude") { + throw new Error("Atomic steer dispatch modes are only supported on Claude sessions."); + } if (hasLivePendingInput(managed) && !metadata?.scheduledWake) { throw new Error(PENDING_INPUT_SEND_BLOCKED_MESSAGE); } @@ -29250,6 +29747,22 @@ export function createAgentChatService(args: { if (managed.session.provider === "claude") { const runtime = ensureClaudeSessionRuntime(managed); if (runtime.busy || managed.session.status === "active") { + if (dispatchMode) { + const immediateSteer: QueuedSteer = { + steerId, + text: preparedSteer.submittedText, + ...(preparedSteer.visibleText !== preparedSteer.submittedText ? { displayText: preparedSteer.visibleText } : {}), + attachments: preparedSteer.attachments, + contextAttachments: preparedSteer.contextAttachments, + resolvedAttachments: preparedSteer.resolvedAttachments, + ...(preparedSteer.metadata ? { metadata: preparedSteer.metadata } : {}), + ...(reasoningEffort != null ? { reasoningEffort } : {}), + ...(executionMode ? { executionMode } : {}), + ...(interactionMode ? { interactionMode } : {}), + }; + await dispatchClaudeSteerMessage(managed, runtime, immediateSteer, dispatchMode); + return { steerId, queued: false }; + } const queued = enqueueSteerOrDrop( managed, runtime, @@ -29337,27 +29850,26 @@ export function createAgentChatService(args: { }, { awaitDispatch: false }, ); + } else if (statusBefore === "active") { + await steer({ + sessionId, + text, + attachments, + contextAttachments, + metadata, + dispatchMode: "interrupt", + }); } else { - try { - await interrupt({ sessionId }, { requireClaudeProviderInterrupt: true }); - await sendMessage( - { - sessionId, - text, - attachments, - contextAttachments, - metadata, - }, - { awaitBackendDispatch: true }, - ); - } catch (error) { - logger.warn("agent_chat.interrupt_replace_failed", { + await sendMessage( + { sessionId, - provider: managed.session.provider, - error: error instanceof Error ? error.message : String(error), - }); - throw error; - } + text, + attachments, + contextAttachments, + metadata, + }, + { awaitBackendDispatch: true }, + ); } return { sessionId, @@ -29389,15 +29901,23 @@ export function createAgentChatService(args: { }; }; - const cancelSteer = async ({ sessionId, steerId }: AgentChatCancelSteerArgs): Promise => { + const cancelSteer = async ({ sessionId, steerId, requireQueued = false }: AgentChatCancelSteerArgs): Promise => { const managed = ensureManagedSession(sessionId); const runtime = managed.runtime; - if (!runtime || runtime.kind === "codex") return; + if (!runtime || runtime.kind === "codex") { + if (requireQueued) throw new Error("This message is no longer queued."); + return; + } const queue = runtime.pendingSteers; + if (requireQueued && runtime.kind === "claude" && runtime.dispatchingSteerIds.has(steerId)) { + throw new Error("This message is already being dispatched."); + } const idx = queue.findIndex((s) => s.steerId === steerId); if (idx !== -1) { queue.splice(idx, 1); + } else if (requireQueued) { + throw new Error("This message is no longer queued."); } // Always emit the cancelled notice — even when the steer already left the // server-side queue (e.g. dispatched inline before this call landed) — so @@ -29452,6 +29972,9 @@ export function createAgentChatService(args: { steerId, mode, }: AgentChatDispatchSteerArgs): Promise => { + if (mode !== "inline" && mode !== "interrupt") { + throw new Error(`Unsupported Claude steer dispatch mode: ${String(mode)}`); + } const managed = ensureManagedSession(sessionId); if (managed.session.provider === "codex") { throw new Error("dispatchSteer is not supported on Codex sessions."); @@ -29473,79 +29996,17 @@ export function createAgentChatService(args: { const steer = queue[idx]; - if (mode === "inline") { - if (!runtime.inputPump) { - // No active query — can't fold mid-turn; fall back to a fresh - // send. Only splice from the queue once `prepareSendMessage` accepts - // the steer; otherwise leave it queued so the user can retry rather - // than losing the message silently. - const prepared = prepareSendMessage({ - sessionId, - text: steer.text, - displayText: steer.text, - attachments: steer.attachments, - contextAttachments: steer.contextAttachments, - metadata: steer.metadata, - }); - if (!prepared) { - logger.warn("agent_chat.dispatch_steer_inline_drop_skipped", { - sessionId, - steerId, - }); - return { dispatchedAt: null }; - } - queue.splice(idx, 1); - await executePreparedSendMessage(prepared); - persistChatState(managed); - return { dispatchedAt: Date.now() }; - } - queue.splice(idx, 1); - - // Build an SDK user message with shouldQuery:false. The SDK appends it - // to the in-flight transcript and the model picks it up at the next - // thinking step without triggering a separate assistant turn. - const dispatchUuid = randomUUID(); - const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); - const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; - const sdkMsg = await buildClaudeV2MessageAsync(inlineSteerText, steer.resolvedAttachments, { - baseDir: managed.laneWorktreePath, - sessionId: runtime.sdkSessionId ?? null, - forceUserMessage: true, - getDirtyFileTextForPath, - }) as unknown as SDKUserMessage; - sdkMsg.shouldQuery = false; - sdkMsg.uuid = dispatchUuid; - - try { - runtime.inputPump.push(sdkMsg); - } catch (err) { - // Re-queue at the original position so the user can retry / it flushes naturally. - queue.splice(idx, 0, steer); - logger.warn("agent_chat.dispatch_steer_inline_failed", { - sessionId, - steerId, - err: err instanceof Error ? err.message : String(err), - }); - throw err; - } - - runtime.dispatchedInlineSteers.set(steerId, dispatchUuid); - - emitChatEvent(managed, { - type: "user_message", - text: steer.text, - ...(steer.attachments.length ? { attachments: steer.attachments } : {}), - ...(steer.contextAttachments.length ? { contextAttachments: steer.contextAttachments } : {}), - ...(steer.metadata ? { metadata: steer.metadata } : {}), - steerId, - deliveryState: "inline", - turnId: runtime.activeTurnId ?? undefined, - }); - persistChatState(managed); - return { dispatchedAt: Date.now() }; - } - - if (mode === "interrupt") { + // A restored/idle staged row may have no query consumer attached. Pushing + // a priority message into a freshly created input pump here would run with + // no foreground loop or idle reader. Deliver it as a normal turn instead; + // active setup races still use the priority path and wait on the parent + // turn's initial-input gate below. + const hasPriorityMessageConsumer = Boolean( + runtime.busy + || runtime.activeTurnId + || runtime.idleReaderPromise, + ); + if (!hasPriorityMessageConsumer) { const prepared = prepareSendMessage({ sessionId, text: steer.text, @@ -29553,42 +30014,71 @@ export function createAgentChatService(args: { attachments: steer.attachments, contextAttachments: steer.contextAttachments, metadata: steer.metadata, - reasoningEffort: normalizeReasoningEffort(managed.session.reasoningEffort), + reasoningEffort: steer.reasoningEffort, executionMode: steer.executionMode, interactionMode: steer.interactionMode, allowActiveSession: true, }); if (!prepared) return { dispatchedAt: null }; - // Keep the replacement out of interrupt()'s queued-steer cancellation, - // then acknowledge only after Claude has accepted the replacement turn. queue.splice(idx, 1); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Delivering your queued message...", + }); + persistChatState(managed); try { - await interrupt({ sessionId }, { requireClaudeProviderInterrupt: true }); - await sendMessage({ - sessionId, - text: prepared.submittedText, - displayText: prepared.visibleText, - attachments: prepared.attachments, - contextAttachments: prepared.contextAttachments, - metadata: prepared.metadata, - reasoningEffort: normalizeReasoningEffort(managed.session.reasoningEffort), - executionMode: steer.executionMode, - interactionMode: steer.interactionMode, - }, { awaitBackendDispatch: true }); + await executePreparedSendMessage(prepared); } catch (error) { if (!queue.some((entry) => entry.steerId === steerId)) { queue.splice(Math.min(idx, queue.length), 0, steer); + emitChatEvent(managed, { + type: "user_message", + text: steer.text, + ...(steer.displayText && steer.displayText !== steer.text ? { displayText: steer.displayText } : {}), + ...(steer.attachments.length ? { attachments: steer.attachments } : {}), + ...(steer.contextAttachments.length ? { contextAttachments: steer.contextAttachments } : {}), + ...(steer.metadata ? { metadata: steer.metadata } : {}), + steerId, + deliveryState: "queued", + }); persistChatState(managed); } - logger.warn("agent_chat.dispatch_steer_interrupt_replace_failed", { + throw error; + } + return { dispatchedAt: Date.now() }; + } + + if (mode === "inline" || mode === "interrupt") { + if (runtime.dispatchingSteerIds.has(steerId)) { + throw new Error("This queued Claude message is already being dispatched."); + } + runtime.dispatchingSteerIds.add(steerId); + try { + const dispatchedAt = await dispatchClaudeSteerMessage( + managed, + runtime, + steer, + mode, + () => { + const acceptedIndex = queue.findIndex((entry) => entry.steerId === steerId); + if (acceptedIndex >= 0) queue.splice(acceptedIndex, 1); + }, + ); + return { dispatchedAt }; + } catch (error) { + logger.warn("agent_chat.dispatch_steer_failed", { sessionId, steerId, + mode, error: error instanceof Error ? error.message : String(error), }); - return { dispatchedAt: null }; + throw error; + } finally { + runtime.dispatchingSteerIds.delete(steerId); } - return { dispatchedAt: Date.now() }; } return { dispatchedAt: null }; @@ -29606,17 +30096,7 @@ export function createAgentChatService(args: { if (!runtime || runtime.kind !== "claude") { return { cancelled: false }; } - if (!runtime.dispatchedInlineSteers.has(steerId)) return { cancelled: false }; logger.warn("agent_chat.cancel_dispatched_steer_unavailable", { sessionId, steerId }); - runtime.dispatchedInlineSteers.delete(steerId); - emitChatEvent(managed, { - type: "system_notice", - noticeKind: "info", - steerId, - message: "Claude Agent SDK does not support cancelling inline-dispatched steers after they have been streamed.", - turnId: runtime.activeTurnId ?? undefined, - }); - persistChatState(managed); return { cancelled: false }; }; @@ -29831,6 +30311,7 @@ export function createAgentChatService(args: { }); } cancelClaudeWarmup(managed, runtime, "interrupt"); + settleClaudeInitialInputDispatch(runtime, new Error("Claude turn was interrupted before its input was dispatched.")); await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); runtime.queryGeneration += 1; runtime.queryStartPromise = null; @@ -29844,6 +30325,7 @@ export function createAgentChatService(args: { // resetClaudeQuerySession applies on reset/remodel). claudeSubprocessReaper.reapForSession(managed.session.id, "claude_interrupt"); runtime.inputPump?.close(); + resetClaudeProcessBackgroundLevel(runtime); runtime.query = null; runtime.inputPump = null; runtime.warmupDone = null; @@ -31955,6 +32437,7 @@ export function createAgentChatService(args: { && !managed.closed && managed.session.status === "idle" && !hasLivePendingInput(managed) + && !hasRuntimeActiveWorkload(managed.runtime) && now - managed.lastActivityTimestamp > getSessionInactivityTimeoutMs(managed) ) { teardownRuntime(managed, "idle_ttl"); @@ -31973,6 +32456,7 @@ export function createAgentChatService(args: { if (!managed.runtime) continue; if (managed.session.status !== "idle") continue; if (hasLivePendingInput(managed)) continue; + if (hasRuntimeActiveWorkload(managed.runtime)) continue; if (managed.lastActivityTimestamp < oldestTimestamp) { oldestTimestamp = managed.lastActivityTimestamp; oldest = managed; @@ -33207,6 +33691,33 @@ export function createAgentChatService(args: { .filter((entry): entry is AgentChatSubagentTranscriptMessage => entry !== null); } + function readClaudeSubagentProviderSessionId( + sessionId: string, + taskId: string | null, + agentId: string, + ): string | null { + const persisted = readFullTranscriptEnvelopesForSessionId(sessionId); + const buffered = eventHistoryBySession.get(sessionId) ?? []; + const envelopes = mergeEnvelopeStreams(persisted, buffered); + const findNewestMatch = ( + predicate: (event: Extract) => boolean, + ): string | null => { + for (let index = envelopes.length - 1; index >= 0; index -= 1) { + const event = envelopes[index]?.event; + if (event?.type !== "subagent_started" || !predicate(event)) continue; + const providerSessionId = event.providerSessionId?.trim(); + if (providerSessionId) return providerSessionId; + } + return null; + }; + + if (taskId) { + const exactTaskMatch = findNewestMatch((event) => event.taskId === taskId); + if (exactTaskMatch) return exactTaskMatch; + } + return findNewestMatch((event) => event.agentId === agentId || event.taskId === agentId); + } + /** * Fetch the transcript of a subagent run within an existing chat session. * @@ -33233,12 +33744,14 @@ export function createAgentChatService(args: { const collectSubagentTranscript = async ({ sessionId, agentId, + taskId, laneId, limit, offset, }: AgentChatSubagentTranscriptArgs): Promise => { const normalizedSessionId = sessionId.trim(); const normalizedAgentId = agentId.trim(); + const normalizedTaskId = taskId?.trim() || null; if (!normalizedSessionId.length) throw new Error("sessionId is required."); if (!normalizedAgentId.length) throw new Error("agentId is required."); const managed = managedSessions.get(normalizedSessionId); @@ -33386,16 +33899,29 @@ export function createAgentChatService(args: { return null; } - // Resolve the on-disk Claude session id and project dir the same way - // getClaudeSessionMessages does — works whether the chat is live or - // resolved purely from the persisted pointer. - const claudeSessionId = managed?.runtime?.kind === "claude" - ? (managed.runtime.sdkSessionId ?? normalizedSessionId) - : normalizedSessionId; - const pointer = sessionService.getClaudeSessionPointer(claudeSessionId); - const laneFallback = await resolveClaudeSessionLaneFallback( - laneId ?? pointer?.laneId ?? managed?.session.laneId ?? null, + // Resolve the backing Claude session id from every durable source. The ADE + // chat id is not a Claude session id; falling back to it made transcript + // drill-in go empty as soon as the live runtime was reset or reopened. + const persisted = readPersistedState(normalizedSessionId); + const pointer = getClaudeSessionPointerForChat(normalizedSessionId); + const eventBoundClaudeSessionId = readClaudeSubagentProviderSessionId( + normalizedSessionId, + normalizedTaskId, + normalizedAgentId, ); + const claudeSessionId = ( + eventBoundClaudeSessionId + ?? (managed?.runtime?.kind === "claude" ? managed.runtime.sdkSessionId : null) + ?? persisted?.sdkSessionId + ?? pointer?.sessionId + ?? "" + ).trim(); + if (!claudeSessionId) return []; + const laneFallback = managed?.laneWorktreePath + ? { dir: managed.laneWorktreePath } + : await resolveClaudeSessionLaneFallback( + laneId ?? pointer?.laneId ?? managed?.session.laneId ?? null, + ); const messages = await getClaudeSdkSubagentMessages(claudeSessionId, normalizedAgentId, { ...(laneFallback.dir ? { dir: laneFallback.dir } : {}), ...(normalizedLimit !== undefined ? { limit: normalizedLimit } : {}), diff --git a/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts index a41216d3f..f99467928 100644 --- a/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts +++ b/apps/desktop/src/main/services/chat/claudeQueryLifecycle.test.ts @@ -287,14 +287,25 @@ describe("Claude query lifecycle", () => { }); it("emits an orphan notice when reset stops an open background task", async () => { - const { service, events, reaper, session } = await createDrivenSession([{ - type: "system", - subtype: "task_started", - task_id: "background-task-1", - task_type: "local_bash", - description: "Run the verification suite", - command: "npm test", - }]); + const { service, events, reaper, session } = await createDrivenSession([ + { + type: "system", + subtype: "background_tasks_changed", + tasks: [{ + task_id: "background-task-1", + task_type: "local_bash", + description: "Run the verification suite", + }], + }, + { + type: "system", + subtype: "task_started", + task_id: "background-task-1", + task_type: "local_bash", + description: "Run the verification suite", + command: "npm test", + }, + ]); await resetReasoningEffort(service, session.id); diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 832589027..080423a9c 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -5629,7 +5629,14 @@ export function registerIpc({ if (typeof record.steerId !== "string" || !record.steerId.trim()) { throw new Error("Agent chat cancel steer steerId must be a non-empty string"); } - return { sessionId: record.sessionId.trim(), steerId: record.steerId.trim() }; + if (record.requireQueued !== undefined && typeof record.requireQueued !== "boolean") { + throw new Error("Agent chat cancel steer requireQueued must be a boolean"); + } + return { + sessionId: record.sessionId.trim(), + steerId: record.steerId.trim(), + ...(record.requireQueued === true ? { requireQueued: true } : {}), + }; }; const parseAgentChatEditSteerArgs = ( diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index 1e9365f07..58d0419eb 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -225,26 +225,34 @@ describe("AgentChatComposer", () => { expect(props.onInterrupt).not.toHaveBeenCalled(); }); - it("edits a queued steer message", () => { + it("moves a queued steer message back to the composer for editing", () => { const onEditSteer = vi.fn(); + const attachments = [{ path: "docs/queued.md", type: "file" as const }]; renderComposer({ - pendingSteers: [{ steerId: "steer-1", text: "Queued one" }], + pendingSteers: [{ + steerId: "steer-1", + text: "Queued one", + attachments, + contextAttachments: [], + }], onEditSteer, }); fireEvent.click(screen.getByRole("button", { name: "Edit queued message" })); - fireEvent.change(screen.getByDisplayValue("Queued one"), { - target: { value: "Queued one, revised" }, - }); - fireEvent.click(screen.getByRole("button", { name: /save/i })); - expect(onEditSteer).toHaveBeenCalledWith("steer-1", "Queued one, revised"); + expect(onEditSteer).toHaveBeenCalledWith("steer-1", "Queued one", attachments, []); + expect(screen.queryByDisplayValue("Queued one")).toBeNull(); }); it("removes a queued steer message", () => { const onCancelSteer = vi.fn(); renderComposer({ - pendingSteers: [{ steerId: "steer-1", text: "Queued one" }], + pendingSteers: [{ + steerId: "steer-1", + text: "Queued one", + attachments: [], + contextAttachments: [], + }], onCancelSteer, }); @@ -267,12 +275,12 @@ describe("AgentChatComposer", () => { onSendSteerInterrupt: vi.fn(), }); - fireEvent.click(screen.getByRole("button", { name: "Send now" })); + fireEvent.click(screen.getByRole("button", { name: "Send during turn" })); expect(onSendSteerNow).toHaveBeenCalledTimes(1); }); - it("split-button menu queues after the turn or interrupts and replaces it", () => { + it("split-button menu selects what the primary send action will do", () => { const onSubmit = vi.fn(); const onSendSteerInterrupt = vi.fn(); renderComposer({ @@ -283,11 +291,16 @@ describe("AgentChatComposer", () => { }); fireEvent.click(screen.getByRole("button", { name: "More send options" })); - fireEvent.click(screen.getByRole("menuitem", { name: /Queue for after turn/ })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Send after turn/ })); + expect(onSubmit).not.toHaveBeenCalled(); + expect(onSendSteerInterrupt).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Send after turn" })); expect(onSubmit).toHaveBeenCalledTimes(1); fireEvent.click(screen.getByRole("button", { name: "More send options" })); - fireEvent.click(screen.getByRole("menuitem", { name: /Interrupt & replace/ })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Interrupt & send/ })); + expect(onSendSteerInterrupt).not.toHaveBeenCalled(); + fireEvent.click(screen.getByRole("button", { name: "Interrupt & send" })); expect(onSendSteerInterrupt).toHaveBeenCalledTimes(1); }); @@ -300,14 +313,15 @@ describe("AgentChatComposer", () => { onSendSteerInterrupt: vi.fn(), }); - const sendNow = screen.getByRole("button", { name: "Send now" }) as HTMLButtonElement; + const sendNow = screen.getByRole("button", { name: "Send during turn" }) as HTMLButtonElement; expect(sendNow.disabled).toBe(true); + expect((screen.getByRole("button", { name: "More send options" }) as HTMLButtonElement).disabled).toBe(false); fireEvent.click(sendNow); expect(onSendSteerNow).not.toHaveBeenCalled(); }); - it("routes Enter to Send now during an active Claude turn", () => { + it("routes Enter to the selected active-turn send mode", () => { const onSendSteerNow = vi.fn(); const onSubmit = vi.fn(); renderComposer({ @@ -317,10 +331,12 @@ describe("AgentChatComposer", () => { onSubmit, }); + fireEvent.click(screen.getByRole("button", { name: "More send options" })); + fireEvent.click(screen.getByRole("menuitemradio", { name: /Send after turn/ })); fireEvent.keyDown(screen.getByRole("textbox"), { key: "Enter" }); - expect(onSendSteerNow).toHaveBeenCalledTimes(1); - expect(onSubmit).not.toHaveBeenCalled(); + expect(onSubmit).toHaveBeenCalledTimes(1); + expect(onSendSteerNow).not.toHaveBeenCalled(); }); it("accepts the prompt suggestion with Tab", () => { diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index a1ee49e8b..8e595b972 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState } from "react"; import { createPortal } from "react-dom"; -import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, CloudArrowUp, Desktop, DeviceMobile, GearSix, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, ShieldCheck, ShieldWarning, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; +import { ArrowBendDownRight, ArrowUp, At, Bug, CaretDown, Check, Clock, CloudArrowUp, Desktop, DeviceMobile, GearSix, GithubLogo, Globe, Image, Lightning, MicrophoneSlash, Paperclip, PencilSimple, Plus, RocketLaunch, ShieldCheck, ShieldWarning, Square, SquareSplitHorizontal, Strategy, Trash, X } from "@phosphor-icons/react"; import { BorderBeam } from "border-beam"; import { inferAttachmentType, @@ -870,123 +870,57 @@ function PendingSteerItem({ onSendNow, onInterrupt, }: { - steer: { steerId: string; text: string }; + steer: { + steerId: string; + text: string; + attachments: AgentChatFileRef[]; + contextAttachments: AgentChatContextAttachment[]; + }; onCancel: () => void; - onEdit: (text: string) => void; + onEdit: () => void; onSendNow?: () => void; onInterrupt?: () => void; }) { - const [editing, setEditing] = useState(false); - const [editText, setEditText] = useState(steer.text); - const inputRef = useRef(null); - - useEffect(() => { - if (editing) { - inputRef.current?.focus(); - inputRef.current?.select(); - } - }, [editing]); - - useEffect(() => { - if (!editing) { - setEditText(steer.text); - } - }, [editing, steer.text]); - - function cancelEdit(): void { - setEditing(false); - setEditText(steer.text); - } - - function commitEdit(): void { - const trimmed = editText.trim(); - if (!trimmed.length) { - onCancel(); - return; - } - if (trimmed !== steer.text) { - onEdit(trimmed); - } - setEditing(false); - } - return (
- {editing ? ( -
-