From d8f3cb30732da1ca8b3d66b4af6b08a64d95a6ee Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:00:17 -0400 Subject: [PATCH 01/12] Upgrade Claude Agent SDK chat integration --- .agents/skills/ship/SKILL.md | 12 +- apps/ade-cli/package-lock.json | 142 +++---- apps/ade-cli/package.json | 2 +- apps/desktop/package-lock.json | 72 ++-- apps/desktop/package.json | 2 +- .../services/ai/tools/systemPrompt.test.ts | 6 +- .../main/services/ai/tools/systemPrompt.ts | 4 +- .../services/chat/agentChatService.test.ts | 347 ++++++++++++++++-- .../main/services/chat/agentChatService.ts | 281 +++++++++++++- .../chat/AgentChatComposer.test.tsx | 5 +- .../components/chat/AgentChatComposer.tsx | 18 +- .../components/chat/AgentChatPane.test.tsx | 12 +- .../components/chat/AgentChatPane.tsx | 71 ++-- .../components/chat/ChatTasksPanel.tsx | 69 ++-- .../components/chat/chatToolAppearance.tsx | 18 +- docs/features/chat/README.md | 23 +- 16 files changed, 848 insertions(+), 236 deletions(-) diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 4492dfb56..420e49007 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -133,16 +133,14 @@ scheduler re-invokes `/ship $ARGUMENTS` later. Use it at the end of each iteration with the playbook cadence (270s just-pushed / 720s CI or bots running / 1800s waiting on human review). -**ADE Work chat (Claude Agent SDK):** `ScheduleWakeup` is **NOT honored** — the -host only advances on a fresh user message, and `run_in_background` task -notifications flush on the next user turn, not autonomously. So do NOT pretend to -self-resume. Either: +**ADE Work chat (Claude Agent SDK):** Work confidently inside the current turn, +but treat `ScheduleWakeup` as unavailable in this harness. It does not start a +later turn by itself, and `run_in_background` notifications are not a reliable +self-resume signal. Either: - Poll synchronously inside the current turn (one bounded foreground `until ... ; do sleep N; done`), then fix/merge/exit; or - Stop the turn cleanly, write the state file with `status: running`, and tell - the user to re-ping `/ship` when they want the next iteration. - -Do not start a background poller and claim it will wake you — it won't. + the user exactly when to re-ping `/ship` for the next iteration. --- diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 81e71c474..53396a622 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.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.186", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", @@ -92,22 +92,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.170.tgz", - "integrity": "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.186.tgz", + "integrity": "sha512-TbxhqYPDNluWL5C50pyHbUy2wWtwJs4iR8qQOxeVkRRTUWn3rdchzpSA8fLWiU+iyiyLDgxfPs6E79OWqrJ8Pw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.186" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -116,9 +116,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.170.tgz", - "integrity": "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.186.tgz", + "integrity": "sha512-xHlzB+61OJkLhrc5QJXVlpldwM9IXJAiQ7cCxWj9o0qu165eYtsGaAaWg9X9NAc9IWhtAdXXNpNSuiZNc+OzWw==", "cpu": [ "arm64" ], @@ -129,9 +129,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.170.tgz", - "integrity": "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.186.tgz", + "integrity": "sha512-+TJSWfoifLLW+7EEbvE4TIHbCj39PL8zEhL1gUudWQjLAgKxWeti+3h4FRDhPI1B/Uwz1eh/eY430od0iMXjfw==", "cpu": [ "x64" ], @@ -142,9 +142,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.170.tgz", - "integrity": "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.186.tgz", + "integrity": "sha512-bkWmXR3PWcBTrAWAhmJn7+7/ONq/5sIEDe30D6a76qx6Xn8sZICmy9GrbUJec0Mb+XVifcS8LnLjP/Z5GzMc/g==", "cpu": [ "arm64" ], @@ -158,9 +158,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.170.tgz", - "integrity": "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.186.tgz", + "integrity": "sha512-pLEaVXulWqHHEgfTwK/5EILSlxXMN5tRA54Ff0tRmEP/FGye8WhLMYrUqg8TSsM2e1bJSszRbyyJSK10xr9Qtg==", "cpu": [ "arm64" ], @@ -174,9 +174,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.170.tgz", - "integrity": "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.186.tgz", + "integrity": "sha512-ARPQwIliHwypU5FQcq4Epi5ahmbSJt89Use5BgzxyeDyrIM3NgK/0c1IKp8DAiKPNntVXNT5R01TwoHdNI3oBA==", "cpu": [ "x64" ], @@ -190,9 +190,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.170.tgz", - "integrity": "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.186.tgz", + "integrity": "sha512-Zg7htykMkMdQC/00UOPn/gnRJRyDaoY0AeJnyXLUByKEUd0ARshpQEadoJoAS6D//6CscffWaSTsX2ePSvl+aw==", "cpu": [ "x64" ], @@ -206,9 +206,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.170.tgz", - "integrity": "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.186.tgz", + "integrity": "sha512-P/OMuYtKYlgGYs0wMTGCSo8gQfCETfA+0+lGGMAcPMH1xxOn24gjtQ/fLQKdaVh+0DLaSxjYm9W3Ln5jN6ALlQ==", "cpu": [ "arm64" ], @@ -219,9 +219,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.170.tgz", - "integrity": "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.186.tgz", + "integrity": "sha512-aiBJu0rhlU/gvUsNtwxjIoj377Wj+g3HqoUe6eihcLGbvsR0SE5KpQaYT/B7wspRILegwIM+iUV9K7145SW6sA==", "cpu": [ "x64" ], @@ -6715,66 +6715,66 @@ } }, "@anthropic-ai/claude-agent-sdk": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.170.tgz", - "integrity": "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.186.tgz", + "integrity": "sha512-TbxhqYPDNluWL5C50pyHbUy2wWtwJs4iR8qQOxeVkRRTUWn3rdchzpSA8fLWiU+iyiyLDgxfPs6E79OWqrJ8Pw==", "requires": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.186" } }, "@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.170.tgz", - "integrity": "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.186.tgz", + "integrity": "sha512-xHlzB+61OJkLhrc5QJXVlpldwM9IXJAiQ7cCxWj9o0qu165eYtsGaAaWg9X9NAc9IWhtAdXXNpNSuiZNc+OzWw==", "optional": true }, "@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.170.tgz", - "integrity": "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.186.tgz", + "integrity": "sha512-+TJSWfoifLLW+7EEbvE4TIHbCj39PL8zEhL1gUudWQjLAgKxWeti+3h4FRDhPI1B/Uwz1eh/eY430od0iMXjfw==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.170.tgz", - "integrity": "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.186.tgz", + "integrity": "sha512-bkWmXR3PWcBTrAWAhmJn7+7/ONq/5sIEDe30D6a76qx6Xn8sZICmy9GrbUJec0Mb+XVifcS8LnLjP/Z5GzMc/g==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.170.tgz", - "integrity": "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.186.tgz", + "integrity": "sha512-pLEaVXulWqHHEgfTwK/5EILSlxXMN5tRA54Ff0tRmEP/FGye8WhLMYrUqg8TSsM2e1bJSszRbyyJSK10xr9Qtg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.170.tgz", - "integrity": "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.186.tgz", + "integrity": "sha512-ARPQwIliHwypU5FQcq4Epi5ahmbSJt89Use5BgzxyeDyrIM3NgK/0c1IKp8DAiKPNntVXNT5R01TwoHdNI3oBA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.170.tgz", - "integrity": "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.186.tgz", + "integrity": "sha512-Zg7htykMkMdQC/00UOPn/gnRJRyDaoY0AeJnyXLUByKEUd0ARshpQEadoJoAS6D//6CscffWaSTsX2ePSvl+aw==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.170.tgz", - "integrity": "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.186.tgz", + "integrity": "sha512-P/OMuYtKYlgGYs0wMTGCSo8gQfCETfA+0+lGGMAcPMH1xxOn24gjtQ/fLQKdaVh+0DLaSxjYm9W3Ln5jN6ALlQ==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.170.tgz", - "integrity": "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.186.tgz", + "integrity": "sha512-aiBJu0rhlU/gvUsNtwxjIoj377Wj+g3HqoUe6eihcLGbvsR0SE5KpQaYT/B7wspRILegwIM+iUV9K7145SW6sA==", "optional": true }, "@anthropic-ai/sdk": { diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index a2b41a72d..f2073bc29 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.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.186", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index 460ec609d..6c014ca29 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.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.186", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", @@ -246,22 +246,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.170.tgz", - "integrity": "sha512-pAvhfk+iTodXZ6RF18Kz7BEUWFjL7EcR3tKuhUNdPpE1NAYCR3mSHGbafi72JsrNwKEDIs7FU31z3fqhwy8QzA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.186.tgz", + "integrity": "sha512-TbxhqYPDNluWL5C50pyHbUy2wWtwJs4iR8qQOxeVkRRTUWn3rdchzpSA8fLWiU+iyiyLDgxfPs6E79OWqrJ8Pw==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.170", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.170" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.186", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.186" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -270,9 +270,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.170.tgz", - "integrity": "sha512-rwfgArIa5WI0QPNqFsRBgvtSI0mrtpynUm0oK6+l6/KX4hcgnYGEzciZR1bOeD9/7sSZlTdIgt+T9alKeZmXcg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.186.tgz", + "integrity": "sha512-xHlzB+61OJkLhrc5QJXVlpldwM9IXJAiQ7cCxWj9o0qu165eYtsGaAaWg9X9NAc9IWhtAdXXNpNSuiZNc+OzWw==", "cpu": [ "arm64" ], @@ -283,9 +283,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.170.tgz", - "integrity": "sha512-0e58h8UQMtsQxLGIv9r4foxfBFWKZ7NeDtoplLhuD7EwQonehomw1sBXCch77t/IfUS+q5vQ5zv+fOGmap5nLQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.186.tgz", + "integrity": "sha512-+TJSWfoifLLW+7EEbvE4TIHbCj39PL8zEhL1gUudWQjLAgKxWeti+3h4FRDhPI1B/Uwz1eh/eY430od0iMXjfw==", "cpu": [ "x64" ], @@ -296,9 +296,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.170.tgz", - "integrity": "sha512-gLbaFqcGppFJQd4DLNV4IXoeahejT/p2/M8bSSvRDbla9GOsBr1AxV5XLRyBn1e7xFGozZIAIQr3+1chp7NJgQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.186.tgz", + "integrity": "sha512-bkWmXR3PWcBTrAWAhmJn7+7/ONq/5sIEDe30D6a76qx6Xn8sZICmy9GrbUJec0Mb+XVifcS8LnLjP/Z5GzMc/g==", "cpu": [ "arm64" ], @@ -312,9 +312,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.170.tgz", - "integrity": "sha512-SRYfQcsXlOq+CD/FqkQBTSHbaD++w73GnnO+NUV9adLYrca3kfetRwWT1iguY1cNS0l34dCR3rlzCPq78vg1Jg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.186.tgz", + "integrity": "sha512-pLEaVXulWqHHEgfTwK/5EILSlxXMN5tRA54Ff0tRmEP/FGye8WhLMYrUqg8TSsM2e1bJSszRbyyJSK10xr9Qtg==", "cpu": [ "arm64" ], @@ -328,9 +328,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.170.tgz", - "integrity": "sha512-Xl/m7TaSC3T5IDBdHrZQ9fCQYyDmPELN34CL+MoyPIf7uSmuZnjE9fUOqDh2Rv26JxWssi1M6X+BBvVuKd6Cpg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.186.tgz", + "integrity": "sha512-ARPQwIliHwypU5FQcq4Epi5ahmbSJt89Use5BgzxyeDyrIM3NgK/0c1IKp8DAiKPNntVXNT5R01TwoHdNI3oBA==", "cpu": [ "x64" ], @@ -344,9 +344,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.170.tgz", - "integrity": "sha512-m4+I0qBEk7cxRKS+pL+eoWXbXTFOAo83fQ0tQvap4z/mDMm06IWJtEPoYTaMBwsp32GJWLkHWKbZSBCHZnp2DQ==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.186.tgz", + "integrity": "sha512-Zg7htykMkMdQC/00UOPn/gnRJRyDaoY0AeJnyXLUByKEUd0ARshpQEadoJoAS6D//6CscffWaSTsX2ePSvl+aw==", "cpu": [ "x64" ], @@ -360,9 +360,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.170.tgz", - "integrity": "sha512-IG+8isJNNJKbnnhO7m+PGhfVCg+XoQ/MDxGde5eigFI0WsEfitjuWSWwx82bT9ghxI1aa6qNvI+UPgPcZuo5Fg==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.186.tgz", + "integrity": "sha512-P/OMuYtKYlgGYs0wMTGCSo8gQfCETfA+0+lGGMAcPMH1xxOn24gjtQ/fLQKdaVh+0DLaSxjYm9W3Ln5jN6ALlQ==", "cpu": [ "arm64" ], @@ -373,9 +373,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.170", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.170.tgz", - "integrity": "sha512-7cuqSKbHVItPGVwRbd3A0BEJwcNtc7Fhoh6qHN4C6yrmjSrvdYYx3MLvq/VI768/RoG7mAMDxb+j7WfEfoP9BA==", + "version": "0.3.186", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.186.tgz", + "integrity": "sha512-aiBJu0rhlU/gvUsNtwxjIoj377Wj+g3HqoUe6eihcLGbvsR0SE5KpQaYT/B7wspRILegwIM+iUV9K7145SW6sA==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 8583a26bf..2ff37d6a7 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -56,7 +56,7 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.170", + "@anthropic-ai/claude-agent-sdk": "^0.3.186", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.13", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts index b42d19de7..151a85bab 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.test.ts @@ -123,9 +123,11 @@ describe("buildCodingAgentSystemPrompt", () => { const result = buildCodingAgentSystemPrompt({ cwd: "/x", runtime: "claude-agent-sdk-query" }); expect(result).toContain("## Runtime Environment"); expect(result).toContain("Claude Agent SDK stable `query()`"); + expect(result).toContain("Work confidently inside the active turn"); expect(result).toContain("ScheduleWakeup"); - expect(result).toContain("not honored"); - expect(result).toContain("never re-invokes"); + expect(result).toContain("unavailable in this ADE chat"); + expect(result).toContain("will not start a later turn by itself"); + expect(result).not.toContain("never re-invokes"); }); it("describes the Cursor SDK runtime", () => { diff --git a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts index 901faae79..0ee77d58e 100644 --- a/apps/desktop/src/main/services/ai/tools/systemPrompt.ts +++ b/apps/desktop/src/main/services/ai/tools/systemPrompt.ts @@ -23,8 +23,8 @@ function describeRuntime(runtime: AdeRuntimeKind): string[] { case "claude-agent-sdk-query": return [ "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API.", - "**Wake-up semantics:** The session only advances when ADE streams a fresh user message into the SDK query. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", - "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + "**Wake-up semantics:** Work confidently inside the active turn. ADE keeps the SDK query alive for streamed user and steer messages, but this chat does not currently expose Claude Code CLI's scheduled self-resume. Treat `ScheduleWakeup` as unavailable in this ADE chat: it will not start a later turn by itself. `Bash run_in_background: true` notifications may appear in the SDK stream when ADE is reading a turn, but do not rely on them to begin a new turn.", + "**To wait:** For bounded waits, keep the turn active with a foreground command such as `sleep ... && ` or one bounded `until ... ; do sleep N; done` loop. For longer external waits, save state and tell the user exactly what to re-ping when ready.", ]; case "codex-cli": return [ diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index ee4519205..2f00a1f09 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -2072,6 +2072,7 @@ describe("createAgentChatService", () => { }); const opts = vi.mocked(claudeSdkCreateSessionCompat).mock.calls[0]?.[0] as { + includeHookEvents?: boolean; promptSuggestions?: boolean; settingSources?: string[]; settings?: { @@ -2083,7 +2084,8 @@ describe("createAgentChatService", () => { } | undefined; expect(opts?.settingSources).toEqual(expect.arrayContaining(["user", "project"])); expect(opts?.skills).toBe("all"); - expect(opts?.promptSuggestions).toBe(false); + expect(opts?.includeHookEvents).toBe(true); + expect(opts?.promptSuggestions).toBe(true); expect(opts?.settings).toEqual(expect.objectContaining({ outputStyle: "Default", fastMode: false, @@ -2920,32 +2922,48 @@ describe("createAgentChatService", () => { }); }); - it("returns the session even when the kickoff turn never settles", async () => { - // A turn that hangs forever would block the launch if it were awaited. - const send = vi.fn(() => new Promise(() => {})); - vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ - send, - stream: vi.fn(() => (async function* () { - await new Promise(() => {}); - })()), - close: vi.fn(), - sessionId: "sdk-headless-pending", - query: { - setPermissionMode: vi.fn(async () => undefined), - supportedCommands: vi.fn(async () => []), - }, - } as any); + it("returns the session and lets a pending kickoff turn outlive the default runSessionTurn timeout", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + // A turn that hangs forever would block the launch if it were awaited. + const send = vi.fn(() => new Promise(() => {})); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream: vi.fn(() => (async function* () { + await new Promise(() => {}); + })()), + close: vi.fn(), + sessionId: "sdk-headless-pending", + query: { + setPermissionMode: vi.fn(async () => undefined), + supportedCommands: vi.fn(async () => []), + }, + } as any); - const { service } = createService(); - // Resolves promptly despite the hanging turn -> fire-and-forget. - const session = await service.launchHeadless({ - laneId: "lane-1", - provider: "claude", - model: "sonnet", - kickoffText: "Start the work.", - }); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + // Resolves promptly despite the hanging turn -> fire-and-forget. + const session = await service.launchHeadless({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + kickoffText: "Start the work.", + }); - expect(session).toBeDefined(); + expect(session).toBeDefined(); + + await vi.advanceTimersByTimeAsync(300_001); + expect(events.find((event) => + event.event.type === "status" && event.event.turnStatus === "interrupted", + )).toBeUndefined(); + expect(events.find((event) => + event.event.type === "error" && event.event.message.includes("Timed out waiting for session"), + )).toBeUndefined(); + } finally { + vi.useRealTimers(); + } }); it("defaults the session to autonomous full-auto when no permission controls are supplied", async () => { @@ -4726,6 +4744,285 @@ describe("createAgentChatService", () => { } }); + it("surfaces Claude SDK retry, refusal fallback, informational, memory, notification, mirror, and denial events", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + const close = vi.fn(); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + if (streamCall === 2) { + yield { + type: "system", + subtype: "api_retry", + session_id: "sdk-session-events", + attempt: 1, + max_retries: 3, + retry_delay_ms: 2_000, + error_status: 529, + error: "overloaded", + }; + yield { + type: "system", + subtype: "informational", + session_id: "sdk-session-events", + content: "Prompt blocked by hook", + level: "warning", + prevent_continuation: true, + }; + yield { + type: "system", + subtype: "permission_denied", + session_id: "sdk-session-events", + tool_name: "Bash", + tool_use_id: "tool-denied-direct", + decision_reason_type: "classifier", + decision_reason: "blocked by safety policy", + message: "Denied", + }; + yield { + type: "system", + subtype: "model_refusal_fallback", + session_id: "sdk-session-events", + original_model: "claude-opus-4-8", + fallback_model: "claude-sonnet-4-6", + api_refusal_category: "cyber", + api_refusal_explanation: "The original model refused.", + content: "Retrying on fallback model.", + retracted_message_uuids: ["refused-message-1", "refused-tool-result-1"], + }; + yield { + type: "system", + subtype: "notification", + session_id: "sdk-session-events", + key: "heads-up", + text: "Background monitor finished", + priority: "high", + }; + yield { + type: "system", + subtype: "memory_recall", + session_id: "sdk-session-events", + mode: "select", + memories: [{ + path: "/tmp/memory.md", + scope: "personal", + content: "Prefer small focused patches.", + }], + }; + yield { + type: "system", + subtype: "mirror_error", + session_id: "sdk-session-events", + error: "store unavailable", + }; + // This replayed/historical shutdown should be ignored because a + // result follows it in the same stream. + yield { + type: "system", + subtype: "worker_shutting_down", + session_id: "sdk-session-events", + reason: "host_exit", + }; + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + permission_denials: [ + { tool_name: "Bash", tool_use_id: "tool-denied-direct" }, + ], + }; + return; + } + + return; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close, + sessionId: "sdk-session-events", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close, + sessionId: "sdk-session-events", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "show new sdk event handling", + timeoutMs: 15_000, + }); + + const notices = events + .map((envelope) => envelope.event) + .filter((event): event is Extract => + event.type === "system_notice", + ); + expect(notices.some((event) => { + const detail = typeof event.detail === "string" ? event.detail : ""; + return event.noticeKind === "rate_limit" + && event.message === "Claude API retry 1/3: overloaded" + && detail.includes("HTTP 529") + && detail.includes("retrying in 2s"); + })).toBe(true); + expect(notices.some((event) => + event.noticeKind === "warning" + && event.message === "Prompt blocked by hook", + )).toBe(true); + expect(notices.some((event) => + event.message === "Claude denied Bash: blocked by safety policy" + && event.detail === "classifier", + )).toBe(true); + expect(notices.some((event) => + event.status === "model_refusal_fallback" + && event.message === "Claude retried with claude-sonnet-4-6 after claude-opus-4-8 refused the request.", + )).toBe(true); + const refusalFallbackNotice = notices.find((event) => event.status === "model_refusal_fallback"); + expect(refusalFallbackNotice?.detail).toContain("retracted 2 SDK messages: refused-message-1, refused-tool-result-1"); + expect(notices.some((event) => + event.status === "notification" + && event.noticeKind === "warning" + && event.message === "Background monitor finished", + )).toBe(true); + expect(notices.some((event) => + event.status === "memory_recall" + && event.message === "Claude recalled 1 memory.", + )).toBe(true); + expect(notices.some((event) => + event.status === "mirror_error" + && event.noticeKind === "error" + && event.detail === "store unavailable", + )).toBe(true); + expect(notices.filter((event) => event.message.includes("denied this turn"))).toHaveLength(0); + expect(notices.filter((event) => event.status === "worker_shutting_down")).toHaveLength(0); + }); + + it("surfaces Claude prompt suggestions emitted after the result message", async () => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + yield { + type: "prompt_suggestion", + session_id: "sdk-session-prompt-suggestion", + uuid: "suggestion-1", + suggestion: "Audit the Work tab", + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-prompt-suggestion", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-prompt-suggestion", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + await service.runSessionTurn({ + sessionId: session.id, + text: "suggest the next prompt", + timeoutMs: 15_000, + }); + + const eventTypes = events.map((envelope) => envelope.event.type); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "prompt_suggestion", + suggestion: "Audit the Work tab", + }), + }), + ])); + expect(eventTypes.indexOf("prompt_suggestion")).toBeLessThan(eventTypes.indexOf("done")); + }); + + it("surfaces Claude worker shutdown when it is the live stream tail", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { + type: "system", + subtype: "worker_shutting_down", + session_id: "sdk-session-worker-shutdown", + reason: "remote_control_disabled", + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-session-worker-shutdown", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + await service.runSessionTurn({ + sessionId: session.id, + text: "show live-tail shutdown", + timeoutMs: 15_000, + }); + + const notices = events + .map((envelope) => envelope.event) + .filter((event): event is Extract => + event.type === "system_notice", + ); + expect(notices.some((event) => + event.status === "worker_shutting_down" + && event.message === "Claude worker is shutting down: remote control disabled", + )).toBe(true); + }); + it("trims oversized PostToolUse outputs before they return to Claude", async () => { const events: AgentChatEventEnvelope[] = []; vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 3213c39be..f687965fe 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -30,6 +30,7 @@ import type { Query as ClaudeQuery, RewindFilesResult as ClaudeRewindFilesResult, SDKControlGetContextUsageResponse, + SDKMessage, SDKUserMessage, WarmQuery, } from "@anthropic-ai/claude-agent-sdk"; @@ -378,8 +379,9 @@ import { } from "../../../shared/orchestrationRuntimePolicy"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; -const CLAUDE_AGENT_SDK_VERSION = "0.3.170"; +const CLAUDE_AGENT_SDK_VERSION = "0.3.186"; const CLAUDE_AGENT_SDK_API = "v1_query"; +const CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS = 1_000; const CLAUDE_AGENT_SDK_TELEMETRY_TAGS = { "claude_sdk.version": CLAUDE_AGENT_SDK_VERSION, "claude_sdk.api": CLAUDE_AGENT_SDK_API, @@ -11386,9 +11388,11 @@ export function createAgentChatService(args: { let firstStreamEventLogged = false; const emittedClaudeToolIds = new Set(); const emittedSyntheticItemIds = new Set(); + const emittedPermissionDeniedToolUseIds = new Set(); const streamedClaudeTextContentKeys = new Set(); const streamedClaudeThinkingContentKeys = new Set(); let currentClaudeStreamMessageId: string | null = null; + let pendingWorkerShutdownReason: string | null = null; let recentClaudeTextDeltaBuffer = ""; // Track a running boundary for assistant messages whose snapshot has no id // (and whose stream preamble didn't carry a `message_start` id either — real @@ -11585,10 +11589,42 @@ export function createAgentChatService(args: { // Don't emit a pre-emptive "thinking" activity — wait for actual content from the stream. // The renderer will show the turn as "started" (from the status event above) which is sufficient. + let resultSeen = false; + const readNextClaudeTurnMessage = async (): Promise | null> => { + if (!resultSeen) return await sessionQuery.next(); + + let timeoutHandle: ReturnType | null = null; + const nextMessage = sessionQuery.next(); + void nextMessage.catch(() => undefined); + const timeout = new Promise((resolve) => { + timeoutHandle = setTimeout(() => resolve(null), CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS); + }); + try { + return await Promise.race([nextMessage, timeout]); + } finally { + if (timeoutHandle) clearTimeout(timeoutHandle); + } + }; + while (true) { - const nextMessage = await sessionQuery.next(); + const nextMessage = await readNextClaudeTurnMessage(); + if (!nextMessage) { + logger.debug("agent_chat.claude_post_result_drain_timeout", { + sessionId: managed.session.id, + turnId, + timeoutMs: CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS, + }); + if (runtime.query === sessionQuery) { + resetClaudeQuerySession(managed, runtime, "timeout"); + } + break; + } if (nextMessage.done) break; const msg = nextMessage.value; + const msgSubtype = typeof (msg as any).subtype === "string" ? (msg as any).subtype : null; + if (pendingWorkerShutdownReason && !(msg.type === "system" && msgSubtype === "worker_shutting_down")) { + pendingWorkerShutdownReason = null; + } if (runtime.interrupted) break; if (timeoutError) { throw timeoutError; @@ -11803,6 +11839,15 @@ export function createAgentChatService(args: { const resetDate = new Date(resetMs); if (!Number.isNaN(resetDate.getTime())) details.push(`resets ${resetDate.toISOString()}`); } + if (typeof info.errorCode === "string" && info.errorCode.trim().length > 0) { + details.push(`error: ${info.errorCode.replace(/_/g, " ")}`); + } + if (typeof info.canUserPurchaseCredits === "boolean") { + details.push(info.canUserPurchaseCredits ? "credits can be purchased" : "credits cannot be purchased here"); + } + if (typeof info.hasChargeableSavedPaymentMethod === "boolean") { + details.push(info.hasChargeableSavedPaymentMethod ? "payment method available" : "no chargeable payment method"); + } const message = isError ? `Claude rate limit ${rawStatus.replace(/_/g, " ")}` : "Approaching Claude plan limit"; @@ -11818,6 +11863,193 @@ export function createAgentChatService(args: { continue; } + // system:api_retry — transient provider/API failure with a scheduled retry. + if (msg.type === "system" && (msg as any).subtype === "api_retry") { + const retryMsg = msg as any; + const error = typeof retryMsg.error === "string" ? retryMsg.error : "transient_error"; + const retryDelayMs = typeof retryMsg.retry_delay_ms === "number" ? retryMsg.retry_delay_ms : null; + const retryDelay = retryDelayMs != null ? Math.max(0, Math.round(retryDelayMs / 1000)) : null; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: error === "rate_limit" || error === "overloaded" ? "rate_limit" : "warning", + severity: error === "rate_limit" || error === "overloaded" ? "warning" : "info", + status: error, + message: `Claude API retry ${retryMsg.attempt ?? "?"}/${retryMsg.max_retries ?? "?"}: ${error.replace(/_/g, " ")}`, + detail: [ + typeof retryMsg.error_status === "number" ? `HTTP ${retryMsg.error_status}` : null, + retryDelay != null ? `retrying in ${retryDelay}s` : null, + ].filter((entry): entry is string => Boolean(entry)).join(" | ") || undefined, + turnId, + }); + continue; + } + + // system:model_refusal_fallback — Claude retried the turn on a fallback model. + if (msg.type === "system" && (msg as any).subtype === "model_refusal_fallback") { + const fallbackMsg = msg as any; + const original = typeof fallbackMsg.original_model === "string" ? fallbackMsg.original_model : "the selected model"; + const fallback = typeof fallbackMsg.fallback_model === "string" ? fallbackMsg.fallback_model : "a fallback model"; + const retractedUuids = Array.isArray(fallbackMsg.retracted_message_uuids) + ? fallbackMsg.retracted_message_uuids + .filter((uuid: unknown): uuid is string => typeof uuid === "string" && uuid.trim().length > 0) + .map((uuid: string) => uuid.trim()) + : []; + const details = [ + typeof fallbackMsg.api_refusal_category === "string" && fallbackMsg.api_refusal_category.trim().length + ? `category: ${fallbackMsg.api_refusal_category.trim()}` + : null, + typeof fallbackMsg.api_refusal_explanation === "string" && fallbackMsg.api_refusal_explanation.trim().length + ? fallbackMsg.api_refusal_explanation.trim() + : null, + typeof fallbackMsg.content === "string" && fallbackMsg.content.trim().length + ? fallbackMsg.content.trim() + : null, + retractedUuids.length + ? `retracted ${retractedUuids.length} SDK message${retractedUuids.length === 1 ? "" : "s"}: ${retractedUuids.slice(0, 5).join(", ")}${retractedUuids.length > 5 ? ", ..." : ""}` + : null, + ].filter((entry): entry is string => Boolean(entry)); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + status: "model_refusal_fallback", + message: `Claude retried with ${fallback} after ${original} refused the request.`, + detail: details.length ? details.join(" | ") : undefined, + turnId, + }); + continue; + } + + // system:informational — loop banners such as hook block feedback. + if (msg.type === "system" && (msg as any).subtype === "informational") { + const infoMsg = msg as any; + const content = typeof infoMsg.content === "string" ? infoMsg.content.trim() : ""; + const level = typeof infoMsg.level === "string" ? infoMsg.level : "info"; + if (content.length > 0 && (level !== "info" || infoMsg.prevent_continuation === true)) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: level === "warning" ? "warning" : "info", + severity: level === "warning" ? "warning" : "info", + status: level, + message: content, + turnId, + }); + } + continue; + } + + // system:notification — Claude Code loop-side notification queue. + if (msg.type === "system" && (msg as any).subtype === "notification") { + const noticeMsg = msg as any; + const text = typeof noticeMsg.text === "string" ? noticeMsg.text.trim() : ""; + if (text.length > 0) { + const priority = typeof noticeMsg.priority === "string" ? noticeMsg.priority : "medium"; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: priority === "high" || priority === "immediate" ? "warning" : "info", + severity: priority === "high" || priority === "immediate" ? "warning" : "info", + status: "notification", + message: text, + detail: [ + typeof noticeMsg.key === "string" && noticeMsg.key.trim().length ? `key: ${noticeMsg.key.trim()}` : null, + `priority: ${priority}`, + ].filter((entry): entry is string => Boolean(entry)).join(" | "), + turnId, + }); + } + continue; + } + + // system:memory_recall — memories surfaced into this turn. + if (msg.type === "system" && (msg as any).subtype === "memory_recall") { + const memoryMsg = msg as any; + const memories = Array.isArray(memoryMsg.memories) ? memoryMsg.memories : []; + const items = memories.flatMap((entry: unknown) => { + if (!entry || typeof entry !== "object") return []; + const record = entry as Record; + const path = typeof record.path === "string" ? record.path.trim() : ""; + const scope = typeof record.scope === "string" ? record.scope.trim() : ""; + const content = typeof record.content === "string" ? record.content.trim() : ""; + const label = [scope, path].filter(Boolean).join(" · "); + const preview = content.length > 160 ? `${content.slice(0, 157)}...` : content; + return [preview ? `${label || "memory"} — ${preview}` : (label || "memory")]; + }); + const memoryCount = items.length || memories.length || 1; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + severity: "info", + status: "memory_recall", + message: `Claude recalled ${memoryCount} memor${memoryCount === 1 ? "y" : "ies"}.`, + detail: items.length ? { + title: "Recalled memory", + sections: [{ title: "Sources", items }], + } : undefined, + turnId, + }); + continue; + } + + // system:mirror_error — SessionStore transcript mirror failed after retries. + if (msg.type === "system" && (msg as any).subtype === "mirror_error") { + const mirrorMsg = msg as any; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "error", + severity: "error", + status: "mirror_error", + message: "Claude transcript mirror failed.", + detail: typeof mirrorMsg.error === "string" && mirrorMsg.error.trim().length + ? mirrorMsg.error.trim() + : undefined, + turnId, + }); + continue; + } + + // system:permission_denied — immediate denial signal with typed reason metadata. + if (msg.type === "system" && (msg as any).subtype === "permission_denied") { + const deniedMsg = msg as any; + const toolUseId = typeof deniedMsg.tool_use_id === "string" ? deniedMsg.tool_use_id : ""; + const toolName = typeof deniedMsg.tool_name === "string" && deniedMsg.tool_name.trim().length + ? deniedMsg.tool_name.trim() + : "tool"; + if (toolUseId) emittedPermissionDeniedToolUseIds.add(toolUseId); + if (!toolUseId || !runtime.resolvedToolUseIds.has(toolUseId)) { + const reason = typeof deniedMsg.decision_reason === "string" && deniedMsg.decision_reason.trim().length + ? deniedMsg.decision_reason.trim() + : typeof deniedMsg.message === "string" && deniedMsg.message.trim().length + ? deniedMsg.message.trim() + : ""; + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + message: `Claude denied ${toolName}${reason ? `: ${reason}` : "."}`, + detail: typeof deniedMsg.decision_reason_type === "string" ? deniedMsg.decision_reason_type : undefined, + turnId, + }); + } + if (toolUseId && openClaudeToolUses.has(toolUseId)) { + emitClaudeToolCompletion(toolUseId, { + synthetic: true, + source: "permission_denied", + tool: toolName, + }, "failed"); + } + continue; + } + + // system:worker_shutting_down — graceful worker/session-host shutdown. + // This event is durable and can replay on resume, so only surface it + // at turn completion if it stayed at the live tail of the stream. + if (msg.type === "system" && (msg as any).subtype === "worker_shutting_down") { + const shutdownMsg = msg as any; + pendingWorkerShutdownReason = typeof shutdownMsg.reason === "string" && shutdownMsg.reason.trim().length + ? shutdownMsg.reason.trim().replace(/_/g, " ") + : "worker shutdown"; + continue; + } + // system:task_progress — running task summary/usage if (msg.type === "system" && (msg as any).subtype === "task_progress") { const taskMsg = msg as any; @@ -12361,9 +12593,13 @@ export function createAgentChatService(args: { // Those tools have a synthetic tool_result emitted by the approval flow that // already conveys the outcome — surfacing a "denied this turn" notice on top // of that makes the chat look like the approval was rejected. - const surfacedDenials = denials.filter((d) => - !d.tool_use_id || !runtime.resolvedToolUseIds.has(String(d.tool_use_id)) - ); + const surfacedDenials = denials.filter((d) => { + const toolUseId = typeof d.tool_use_id === "string" ? d.tool_use_id : ""; + return !toolUseId || ( + !runtime.resolvedToolUseIds.has(toolUseId) + && !emittedPermissionDeniedToolUseIds.has(toolUseId) + ); + }); if (surfacedDenials.length > 0) { const denialSummary = surfacedDenials.map((d) => d.tool_name).join(", "); emitChatEvent(managed, { @@ -12383,7 +12619,8 @@ export function createAgentChatService(args: { } } } - break; + resultSeen = true; + continue; } // tool_use_summary — summarizes groups of tool calls @@ -12426,14 +12663,35 @@ export function createAgentChatService(args: { turnId, }); } + if (resultSeen) break; continue; } + + if (resultSeen) { + logger.debug("agent_chat.claude_unexpected_post_result_message", { + sessionId: managed.session.id, + turnId, + type: (msg as any).type, + subtype: msgSubtype, + }); + break; + } } if (timeoutError) { throw timeoutError; } // ── Turn completion ── + if (pendingWorkerShutdownReason && !runtime.interrupted) { + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "warning", + severity: "warning", + status: "worker_shutting_down", + message: `Claude worker is shutting down: ${pendingWorkerShutdownReason}`, + turnId, + }); + } clearClaudeTurnTimers(); runtime.pauseIdleWatchdog = null; runtime.resumeIdleWatchdog = null; @@ -17235,8 +17493,9 @@ export function createAgentChatService(args: { permissionMode: claudePermissionMode as any, ...(claudePermissionMode === "bypassPermissions" ? { allowDangerouslySkipPermissions: true } as any : {}), includePartialMessages: true, + includeHookEvents: true, agentProgressSummaries: true, - promptSuggestions: false, + promptSuggestions: true, forwardSubagentText: true, enableFileCheckpointing: true, skills: "all", @@ -17329,8 +17588,8 @@ export function createAgentChatService(args: { append: [ "## Runtime Environment", "**Runtime:** ADE Work chat hosted on the Claude Agent SDK stable `query()` streaming-input API. The `claude_code` preset above is the same system prompt the Claude Code CLI uses, so you may think you're in the CLI — you are NOT. You are inside an ADE-hosted SDK session.", - "**Wake-up semantics:** The session advances when ADE streams a fresh user message into the SDK query. There is no autonomous wake. `ScheduleWakeup` is **not honored** in this harness — the host accepts the call but never re-invokes you. `Bash run_in_background: true` task notifications are queued in the SDK message stream and only flushed on the next user turn; they do not start an autonomous turn either.", - "**To wait:** Either poll synchronously inside the active turn (foreground bash with one bounded `until ... ; do sleep N; done`) or stop the turn cleanly and ask the user to re-ping when ready. Do not run a background poller and claim it will wake you — it will not.", + "**Wake-up semantics:** Work confidently inside the active turn. ADE keeps the SDK query alive for streamed user and steer messages, but this chat does not currently expose Claude Code CLI's scheduled self-resume. Treat `ScheduleWakeup` as unavailable in this ADE chat: it will not start a later turn by itself. `Bash run_in_background: true` notifications may appear in the SDK stream when ADE is reading a turn, but do not rely on them to begin a new turn.", + "**To wait:** For bounded waits, keep the turn active with a foreground command such as `sleep ... && ` or one bounded `until ... ; do sleep N; done` loop. For longer external waits, save state and tell the user exactly what to re-ping when ready.", "", "## ADE Workspace", `ADE launched this session in lane worktree: ${managed.laneWorktreePath}.`, @@ -26762,6 +27021,10 @@ export function createAgentChatService(args: { displayText: kickoffDisplayText ?? kickoffText, contextAttachments: contextAttachments ?? [], reasoningEffort: createArgs.reasoningEffort, + // Background chat launches are fire-and-forget. Let the provider turn + // run until it completes or is explicitly interrupted instead of applying + // runSessionTurn's bounded RPC timeout. + timeoutMs: null, }).catch((err) => { logger.warn("agentChat.launchHeadless turn failed", { sessionId: session.id, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx index ed8381e70..81b585116 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.test.tsx @@ -202,7 +202,10 @@ describe("AgentChatComposer", () => { onDraftChange, }); - fireEvent.keyDown(screen.getByRole("textbox"), { key: "Tab" }); + const textbox = screen.getByRole("textbox"); + expect(textbox.getAttribute("placeholder")).toBe("Audit the Work tab"); + + fireEvent.keyDown(textbox, { key: "Tab" }); expect(onDraftChange).toHaveBeenCalledWith("Audit the Work tab"); }); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx index 7adda7c0e..61eed3a38 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatComposer.tsx @@ -4074,20 +4074,6 @@ export function AgentChatComposer({ registerAsDictationTarget(); }} > - {/* Ghost suggestion overlay */} - {promptSuggestion && !draft.length && !turnActive ? ( - - ) : null} {!draft.trim().length && !iosElementContextItems.length && !appControlContextItems.length && !builtInBrowserContextItems.length ? (
- {composerInputLockMessage ?? (turnActive ? "Steer the active turn..." : (messagePlaceholder ?? "Type to vibecode..."))} + {composerInputLockMessage ?? (turnActive ? "Steer the active turn..." : (promptSuggestion || messagePlaceholder || "Type to vibecode..."))}
) : null}
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 5f30efa0a..4cd9cab8a 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -29,10 +29,10 @@ import { import { AgentChatPane, buildParallelLaunchPrompt, - cleanupSubagentAutoOpenStorage, + cleanupChatActionsAutoOpenStorage, cleanupTransientParallelLaunchLanes, formatParallelLaunchFailureMessage, - getSubagentAutoOpenStorageKey, + getChatActionsAutoOpenStorageKey, advanceOlderHistoryCursor, isMatchingOptimisticUserMessage, mergeChatHistorySnapshot, @@ -6320,9 +6320,9 @@ describe("subagent auto-open storage", () => { it("expires timestamped auto-open markers and migrates legacy markers", () => { const now = Date.parse("2026-05-14T12:00:00.000Z"); - const freshKey = getSubagentAutoOpenStorageKey("fresh-session"); - const staleKey = getSubagentAutoOpenStorageKey("stale-session"); - const legacyKey = getSubagentAutoOpenStorageKey("legacy-session"); + const freshKey = getChatActionsAutoOpenStorageKey("fresh-session"); + const staleKey = getChatActionsAutoOpenStorageKey("stale-session"); + const legacyKey = getChatActionsAutoOpenStorageKey("legacy-session"); const storage = createStorageShim(); storage.setItem(freshKey, JSON.stringify({ firedAt: now - 60_000 })); @@ -6330,7 +6330,7 @@ describe("subagent auto-open storage", () => { storage.setItem(legacyKey, "1"); storage.setItem("ade.chat.other", "keep"); - cleanupSubagentAutoOpenStorage(storage, now); + cleanupChatActionsAutoOpenStorage(storage, now); expect(storage.getItem(freshKey)).toBe(JSON.stringify({ firedAt: now - 60_000 })); expect(storage.getItem(staleKey)).toBeNull(); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index c863b3737..dbff2af49 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -197,8 +197,8 @@ const COMPOSER_DRAFT_STORAGE_KEY_PREFIX = "ade.chat.composerDraft.v1"; const WORK_START_DRAFT_COMPANION_STATE_KEY = "draft:work-start"; const WORK_START_DRAFT_LAUNCH_SCOPE_ID = "work-start"; const COMPOSER_DRAFT_WRITE_DEBOUNCE_MS = 350; -const SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; -const SUBAGENT_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; +const CHAT_ACTIONS_AUTOOPEN_FIRED_KEY_PREFIX = "ade.chat.subagentAutoOpenFired"; +const CHAT_ACTIONS_AUTOOPEN_FIRED_TTL_MS = 7 * 24 * 60 * 60 * 1000; const workCliStartupDelayMs = 180; const REMOTE_PARALLEL_LAUNCH_RECOVERY_DELAY_MS = 15_000; export const DEFAULT_PARALLEL_ATTACHMENT_REQUEST = "Please review the attached files."; @@ -220,17 +220,17 @@ const LEGACY_MODEL_KEY_PREFIX = "ade.chat.lastModel"; const COMPUTER_USE_SNAPSHOT_COOLDOWN_MS = 750; -type SubagentAutoOpenStorage = Pick; +type ChatActionsAutoOpenStorage = Pick; -export function getSubagentAutoOpenStorageKey(sessionId: string): string { - return `${SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX}:${sessionId}`; +export function getChatActionsAutoOpenStorageKey(sessionId: string): string { + return `${CHAT_ACTIONS_AUTOOPEN_FIRED_KEY_PREFIX}:${sessionId}`; } -function encodeSubagentAutoOpenRecord(nowMs: number): string { +function encodeChatActionsAutoOpenRecord(nowMs: number): string { return JSON.stringify({ firedAt: nowMs }); } -function parseSubagentAutoOpenFiredAt(raw: string | null): number | "legacy" | null { +function parseChatActionsAutoOpenFiredAt(raw: string | null): number | "legacy" | null { if (!raw) return null; if (raw === "1") return "legacy"; try { @@ -243,34 +243,34 @@ function parseSubagentAutoOpenFiredAt(raw: string | null): number | "legacy" | n } } -export function cleanupSubagentAutoOpenStorage(storage: SubagentAutoOpenStorage, nowMs = Date.now()): void { +export function cleanupChatActionsAutoOpenStorage(storage: ChatActionsAutoOpenStorage, nowMs = Date.now()): void { const keys: string[] = []; for (let index = 0; index < storage.length; index += 1) { const key = storage.key(index); - if (key?.startsWith(`${SUBAGENT_AUTOOPEN_FIRED_KEY_PREFIX}:`)) keys.push(key); + if (key?.startsWith(`${CHAT_ACTIONS_AUTOOPEN_FIRED_KEY_PREFIX}:`)) keys.push(key); } for (const key of keys) { - const firedAt = parseSubagentAutoOpenFiredAt(storage.getItem(key)); + const firedAt = parseChatActionsAutoOpenFiredAt(storage.getItem(key)); if (firedAt === "legacy") { - storage.setItem(key, encodeSubagentAutoOpenRecord(nowMs)); - } else if (firedAt === null || nowMs - firedAt > SUBAGENT_AUTOOPEN_FIRED_TTL_MS) { + storage.setItem(key, encodeChatActionsAutoOpenRecord(nowMs)); + } else if (firedAt === null || nowMs - firedAt > CHAT_ACTIONS_AUTOOPEN_FIRED_TTL_MS) { storage.removeItem(key); } } } -function hasSubagentAutoOpenFired(storage: SubagentAutoOpenStorage, sessionId: string, nowMs = Date.now()): boolean { - const key = getSubagentAutoOpenStorageKey(sessionId); - const firedAt = parseSubagentAutoOpenFiredAt(storage.getItem(key)); +function hasChatActionsAutoOpenFired(storage: ChatActionsAutoOpenStorage, sessionId: string, nowMs = Date.now()): boolean { + const key = getChatActionsAutoOpenStorageKey(sessionId); + const firedAt = parseChatActionsAutoOpenFiredAt(storage.getItem(key)); if (firedAt === "legacy") { - storage.setItem(key, encodeSubagentAutoOpenRecord(nowMs)); + storage.setItem(key, encodeChatActionsAutoOpenRecord(nowMs)); return true; } if (firedAt === null) { storage.removeItem(key); return false; } - if (nowMs - firedAt > SUBAGENT_AUTOOPEN_FIRED_TTL_MS) { + if (nowMs - firedAt > CHAT_ACTIONS_AUTOOPEN_FIRED_TTL_MS) { storage.removeItem(key); return false; } @@ -3813,15 +3813,16 @@ export function AgentChatPane({ } }, []); // Per-session memo of which sessions have already triggered the auto-open - // affordance, so the panel doesn't keep re-opening every time a new subagent - // appears or the user navigates back to the chat. We only slide it in on the - // *first* spawn within a session — after that, opening is up to the user. + // affordance, so the panel doesn't keep re-opening every time a new task or + // subagent appears or the user navigates back to the chat. We only slide it in + // on the first tracked action within a session — after that, opening is up to + // the user. // Persisted to localStorage so the suppression survives remounts. - const subagentAutoOpenedSessionsRef = useRef>(new Set()); + const chatActionsAutoOpenedSessionsRef = useRef>(new Set()); useEffect(() => { try { - cleanupSubagentAutoOpenStorage(window.localStorage); + cleanupChatActionsAutoOpenStorage(window.localStorage); } catch { /* localStorage unavailable; fall back to in-memory ref */ } @@ -3832,22 +3833,23 @@ export function AgentChatPane({ if (chatActionsOpen) setChatActionsOpen(false); return; } - if (selectedSubagentSnapshots.length === 0) { + const trackedActionCount = selectedSubagentSnapshots.length + selectedTodoItems.length; + if (trackedActionCount === 0) { return; } - if (subagentAutoOpenedSessionsRef.current.has(selectedSessionId)) { + if (chatActionsAutoOpenedSessionsRef.current.has(selectedSessionId)) { return; } try { - if (hasSubagentAutoOpenFired(window.localStorage, selectedSessionId)) { - subagentAutoOpenedSessionsRef.current.add(selectedSessionId); + if (hasChatActionsAutoOpenFired(window.localStorage, selectedSessionId)) { + chatActionsAutoOpenedSessionsRef.current.add(selectedSessionId); return; } } catch { /* localStorage unavailable; fall back to in-memory ref */ } // Don't consume the once-per-session auto-open until we can actually surface - // the agents panel. If the chat-actions pane is already open (possibly on a + // the actions panel. If the chat-actions pane is already open (possibly on a // different tab), leave the user where they are and retry when it next closes // — otherwise the flag gets burned without the agents panel ever opening, // which is exactly why subagents sometimes didn't auto-open (it was runtime- @@ -3855,11 +3857,11 @@ export function AgentChatPane({ if (chatActionsOpen) { return; } - subagentAutoOpenedSessionsRef.current.add(selectedSessionId); + chatActionsAutoOpenedSessionsRef.current.add(selectedSessionId); try { window.localStorage.setItem( - getSubagentAutoOpenStorageKey(selectedSessionId), - encodeSubagentAutoOpenRecord(Date.now()), + getChatActionsAutoOpenStorageKey(selectedSessionId), + encodeChatActionsAutoOpenRecord(Date.now()), ); } catch { /* best-effort persistence */ @@ -3869,7 +3871,7 @@ export function AgentChatPane({ setAppControlOpen(false); setCursorCloudPaneOpen(false); setChatActionsOpen(true); - }, [chatActionsOpen, selectedSessionId, selectedSubagentSnapshots.length]); + }, [chatActionsOpen, selectedSessionId, selectedSubagentSnapshots.length, selectedTodoItems.length]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -9101,6 +9103,9 @@ export function AgentChatPane({ selectedSubagentSnapshots.length > 0 ? `${selectedSubagentSnapshots.length} subagent${selectedSubagentSnapshots.length === 1 ? "" : "s"}` : null, + selectedTodoItems.length > 0 + ? `${selectedTodoItems.length} task${selectedTodoItems.length === 1 ? "" : "s"}` + : null, ].filter(Boolean).join(" · ") || undefined, }} > @@ -9138,6 +9143,10 @@ export function AgentChatPane({ {selectedSubagentSnapshots.length} + ) : selectedTodoItems.length > 0 ? ( + + {selectedTodoItems.length} + ) : null} {!chatActionsOpen && hasRunningBackgroundSubagent ? ( = { - in_progress: 0, - pending: 1, - completed: 2, +const STATUS_ORDER = ["in_progress", "pending", "completed"] as const; +const STATUS_SORT_ORDER = new Map( + STATUS_ORDER.map((status, index) => [status, index]), +); + +const STATUS_GROUP_LABEL: Record = { + in_progress: "In progress", + pending: "Pending", + completed: "Done", }; /* ── Component ── */ @@ -62,7 +66,7 @@ export const ChatTasksPanel = React.memo(function ChatTasksPanel({ }, [items]); const sortedItems = useMemo( - () => [...items].sort((a, b) => STATUS_SORT_ORDER[a.status] - STATUS_SORT_ORDER[b.status]), + () => [...items].sort((a, b) => (STATUS_SORT_ORDER.get(a.status) ?? 0) - (STATUS_SORT_ORDER.get(b.status) ?? 0)), [items], ); @@ -96,30 +100,47 @@ export const ChatTaskList = React.memo(function ChatTaskList({ items: TodoItemSnapshot[]; className?: string; }) { - const sortedItems = useMemo( - () => [...items].sort((a, b) => STATUS_SORT_ORDER[a.status] - STATUS_SORT_ORDER[b.status]), + const groupedItems = useMemo( + () => STATUS_ORDER + .map((status) => ({ + status, + items: items + .filter((item) => item.status === status) + .sort((a, b) => a.description.localeCompare(b.description)), + })) + .filter((group) => group.items.length > 0), [items], ); if (!items.length) return null; return ( -
- {sortedItems.map((item) => ( -
- - {statusIcon(item.status)} - - - {item.description} - -
+
+ {groupedItems.map((group) => ( +
+
+ {STATUS_GROUP_LABEL[group.status]} + {group.items.length} +
+
+ {group.items.map((item) => ( +
+ + {statusIcon(item.status)} + + + {item.description} + +
+ ))} +
+
))}
); diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx index 7c9422898..8fe2f9096 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx @@ -18,10 +18,10 @@ import { Robot, Scissors, StopCircle, + Strategy, Terminal, User, Warning, - Wrench, XCircle, } from "@phosphor-icons/react"; import type { Icon } from "@phosphor-icons/react"; @@ -54,6 +54,22 @@ const TOOL_META: Record = { TodoWrite: { label: "Plan", icon: ClipboardText, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, TodoRead: { label: "Plan", icon: ClipboardText, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, Task: { label: "Task", icon: Cpu, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent" }, + TaskCreate: { label: "Create Task", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent", getTarget: a => String(a.subject ?? a.description ?? "") || null }, + TaskGet: { label: "Get Task", icon: ListChecks, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "read", sourceTone: "info", getTarget: a => String(a.taskId ?? "") || null }, + TaskUpdate: { label: "Update Task", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent", getTarget: a => String(a.subject ?? a.description ?? a.taskId ?? "") || null }, + TaskList: { label: "List Tasks", icon: ListChecks, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "read", sourceTone: "info" }, + REPL: { label: "REPL", icon: Terminal, badgeCls: "border-amber-400/25 bg-amber-400/12 text-amber-200", category: "exec", sourceTone: "warning", getTarget: a => String(a.description ?? "") || null }, + Workflow: { label: "Workflow", icon: Strategy, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.name ?? a.description ?? "") || null }, + CronCreate: { label: "Schedule", icon: ListChecks, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.cron ?? "") || null }, + CronDelete: { label: "Delete Schedule", icon: StopCircle, badgeCls: "border-red-400/25 bg-red-500/12 text-red-200", category: "meta", sourceTone: "warning", getTarget: a => String(a.id ?? "") || null }, + CronList: { label: "List Schedules", icon: ListBullets, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "read", sourceTone: "info" }, + ScheduleWakeup: { label: "Wake Later", icon: ListChecks, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.reason ?? a.prompt ?? "") || null }, + Monitor: { label: "Monitor", icon: Cpu, badgeCls: "border-amber-400/25 bg-amber-400/12 text-amber-200", category: "exec", sourceTone: "warning", getTarget: a => String(a.description ?? a.command ?? "") || null }, + Artifact: { label: "Artifact", icon: Note, badgeCls: "border-emerald-400/25 bg-emerald-400/12 text-emerald-200", category: "meta", sourceTone: "success", getTarget: a => String(a.file_path ?? "") || null }, + PushNotification: { label: "Notify", icon: ChatCircle, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.message ?? "") || null }, + RemoteTrigger: { label: "Remote Trigger", icon: Globe, badgeCls: "border-indigo-400/25 bg-indigo-400/12 text-indigo-200", category: "web", sourceTone: "accent", getTarget: a => String(a.action ?? a.trigger_id ?? "") || null }, + EnterWorktree: { label: "Enter Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.branch ?? a.path ?? "") || null }, + ExitWorktree: { label: "Exit Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent" }, ExitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, exitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, exec_command: { label: "Shell", icon: Terminal, badgeCls: "border-amber-400/25 bg-amber-400/12 text-amber-200", category: "codex", sourceTone: "warning", getTarget: a => String(a.command ?? a.cmd ?? "") || null }, diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index df5b5cabc..7e2885fc3 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -92,7 +92,7 @@ render them, but neither one *runs* them. ## Key concepts - **Claude Agent SDK pipeline.** The Claude adapter is built on the - stable `query()` API (SDK 0.2.139): every chat owns a `ClaudeQuery`, + stable `query()` API (SDK 0.3.186): every chat owns a `ClaudeQuery`, fed by a `ClaudeInputPump` (`claudeInputPump.ts`) async iterable that hands live user turns to `query.streamInput`. Warmup goes through the SDK `startup()` hook, output styles and plugins are discovered by @@ -106,6 +106,20 @@ render them, but neither one *runs* them. path is passed through `pathToClaudeCodeExecutable`. Context usage, rewindFiles, forkSession, and output-style selection all run through the SDK control channel surfaced on the active `Query` handle. +- **Claude SDK 0.3.186 event surfaces.** ADE enables SDK hook events, + agent progress summaries, prompt suggestions, forwarded subagent text, + file checkpointing, and all skills for full Claude chats. The adapter + translates SDK retry/refusal/notification/memory/mirror/permission events + into `system_notice` rows, drains the SDK-documented post-`result` + `prompt_suggestion` message, maps Claude `TaskCreate`/`TaskUpdate` tool calls + into `todo_update` snapshots for the actions-pane task board, preserves + refusal-fallback retraction UUIDs on the fallback notice, and gives new built-in + Claude tools readable badges in the transcript. Deliberately not wired yet: + SDK `SessionStore` as ADE's transcript backend, channels/external message + origins, transcript tombstones for SDK `supersedes`/`retracted_message_uuids`, + and true scheduled wake/autonomous post-result turns. Those need the service to + move from per-turn iterator ownership to one long-lived Claude stream owner + that can safely adopt SDK-origin messages after a result. - **Provider-agnostic sessions.** `AgentChatProvider` is one of `claude`, `codex`, `opencode`, `cursor`, `droid`, or a free-form string reserved for local providers. The service owns a pluggable adapter per provider (Claude @@ -312,8 +326,11 @@ See the detail docs for the specifics: does not complete within that window the stale runtime is discarded and recreated on the next turn. 3. `sendMessage({ sessionId, text, attachments? })` via - `ade.agentChat.send` dispatches a turn. Each turn has a 5 min - turn-level timeout enforced by the abort machinery. + `ade.agentChat.send` dispatches a turn. Interactive chat sends are not + wall-clock bounded by the service; the turn runs until the provider + completes or the user/app interrupts it. The blocking `runSessionTurn` + helper used by automation has a 5 min default RPC timeout unless the + caller passes `timeoutMs: null`; background/headless chat launches opt out. 4. The runtime streams events through the main-process event emitter and into the renderer via `ade.agentChat.event` (a push channel owned by `registerIpc.ts`). From 3c3a7ef6bb2fc6cca43ddedda95d38a97bb4861e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:15:23 -0400 Subject: [PATCH 02/12] Fix Claude worktree tool badge target --- .../renderer/components/chat/chatToolAppearance.test.ts | 7 +++++++ .../src/renderer/components/chat/chatToolAppearance.tsx | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts index 8e1d75ca3..b320df6e3 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts @@ -87,6 +87,13 @@ describe("getToolMeta", () => { expect(meta.getTarget!({})).toBe("0 task(s)"); }); + it("extracts worktree names for Claude worktree tools", () => { + const meta = getToolMeta("EnterWorktree"); + expect(meta.getTarget).toBeDefined(); + expect(meta.getTarget!({ name: "feature/sdk-upgrade" })).toBe("feature/sdk-upgrade"); + expect(meta.getTarget!({ branch: "unused", path: "/tmp/unused" })).toBeNull(); + }); + it("returns null or empty for getTarget when args are missing", () => { const readMeta = getToolMeta("Read"); const result = readMeta.getTarget!({}); diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx index 8fe2f9096..e68b833f6 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx @@ -68,7 +68,7 @@ const TOOL_META: Record = { Artifact: { label: "Artifact", icon: Note, badgeCls: "border-emerald-400/25 bg-emerald-400/12 text-emerald-200", category: "meta", sourceTone: "success", getTarget: a => String(a.file_path ?? "") || null }, PushNotification: { label: "Notify", icon: ChatCircle, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.message ?? "") || null }, RemoteTrigger: { label: "Remote Trigger", icon: Globe, badgeCls: "border-indigo-400/25 bg-indigo-400/12 text-indigo-200", category: "web", sourceTone: "accent", getTarget: a => String(a.action ?? a.trigger_id ?? "") || null }, - EnterWorktree: { label: "Enter Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.branch ?? a.path ?? "") || null }, + EnterWorktree: { label: "Enter Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.name ?? "") || null }, ExitWorktree: { label: "Exit Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent" }, ExitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, exitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, From f0115d940a2d038401cc47201e4aa8eddc107461 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:29:13 -0400 Subject: [PATCH 03/12] Include existing worktree path in Claude tool badge --- .../src/renderer/components/chat/chatToolAppearance.test.ts | 3 ++- .../src/renderer/components/chat/chatToolAppearance.tsx | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts index b320df6e3..38acff07b 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts @@ -91,7 +91,8 @@ describe("getToolMeta", () => { const meta = getToolMeta("EnterWorktree"); expect(meta.getTarget).toBeDefined(); expect(meta.getTarget!({ name: "feature/sdk-upgrade" })).toBe("feature/sdk-upgrade"); - expect(meta.getTarget!({ branch: "unused", path: "/tmp/unused" })).toBeNull(); + expect(meta.getTarget!({ path: "/tmp/existing-worktree" })).toBe("/tmp/existing-worktree"); + expect(meta.getTarget!({ branch: "unused" })).toBeNull(); }); it("returns null or empty for getTarget when args are missing", () => { diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx index e68b833f6..a19f2e0bb 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.tsx @@ -68,7 +68,7 @@ const TOOL_META: Record = { Artifact: { label: "Artifact", icon: Note, badgeCls: "border-emerald-400/25 bg-emerald-400/12 text-emerald-200", category: "meta", sourceTone: "success", getTarget: a => String(a.file_path ?? "") || null }, PushNotification: { label: "Notify", icon: ChatCircle, badgeCls: "border-cyan-400/25 bg-cyan-400/12 text-cyan-200", category: "meta", sourceTone: "info", getTarget: a => String(a.message ?? "") || null }, RemoteTrigger: { label: "Remote Trigger", icon: Globe, badgeCls: "border-indigo-400/25 bg-indigo-400/12 text-indigo-200", category: "web", sourceTone: "accent", getTarget: a => String(a.action ?? a.trigger_id ?? "") || null }, - EnterWorktree: { label: "Enter Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.name ?? "") || null }, + EnterWorktree: { label: "Enter Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent", getTarget: a => String(a.name ?? a.path ?? "") || null }, ExitWorktree: { label: "Exit Worktree", icon: FolderOpen, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "meta", sourceTone: "accent" }, ExitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, exitPlanMode: { label: "Plan Approval", icon: ListChecks, badgeCls: "border-violet-400/25 bg-violet-400/12 text-violet-200", category: "plan", sourceTone: "accent" }, From 8b3d55d8427a03218fc32fb0fab2bca13e9434eb Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 19:46:01 -0400 Subject: [PATCH 04/12] Scope Claude prompt suggestions per chat --- .../components/chat/AgentChatPane.test.tsx | 54 +++++++++++++++++++ .../components/chat/AgentChatPane.tsx | 54 +++++++++++++------ 2 files changed, 91 insertions(+), 17 deletions(-) diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 4cd9cab8a..1dc002796 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -5341,6 +5341,60 @@ describe("AgentChatPane submit recovery", () => { expect(await screen.findByText("Background output kept streaming")).toBeTruthy(); }); + it("keeps Claude prompt suggestions scoped to the selected chat", async () => { + const primarySession = buildSession("session-1", { + title: "Primary chat", + lastActivityAt: "2026-03-24T05:57:45.700Z", + }); + const backgroundSession = buildSession("session-2", { + title: "Background chat", + lastActivityAt: "2026-03-24T05:57:45.600Z", + }); + const { emitChatEvent } = installAdeMocks({ + sessions: [primarySession, backgroundSession], + }); + + renderTabbedPane(primarySession); + + const primaryTab = await screen.findByRole("button", { name: /Primary chat/i }); + const backgroundTab = await screen.findByRole("button", { name: /Background chat/i }); + + act(() => { + emitChatEvent({ + sessionId: "session-1", + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "prompt_suggestion", + suggestion: "Continue primary work", + }, + } as AgentChatEventEnvelope); + emitChatEvent({ + sessionId: "session-2", + timestamp: "2026-03-24T06:00:01.000Z", + sequence: 1, + event: { + type: "prompt_suggestion", + suggestion: "Continue background work", + }, + } as AgentChatEventEnvelope); + }); + + await waitFor(() => { + expect((screen.getByRole("textbox") as HTMLTextAreaElement).placeholder).toBe("Continue primary work"); + }); + + fireEvent.click(backgroundTab); + await waitFor(() => { + expect((screen.getByRole("textbox") as HTMLTextAreaElement).placeholder).toBe("Continue background work"); + }); + + fireEvent.click(primaryTab); + await waitFor(() => { + expect((screen.getByRole("textbox") as HTMLTextAreaElement).placeholder).toBe("Continue primary work"); + }); + }); + it("validates empty legacy event-history snapshots before treating them as loaded", async () => { const session = buildSession("session-1", { title: "Possibly foreign chat" }); installAdeMocks({ diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index dbff2af49..f3b5a38ec 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -3221,7 +3221,7 @@ export function AgentChatPane({ const composerDraftHydratingTextRef = useRef(null); const [sessionDelta, setSessionDelta] = useState<{ insertions: number; deletions: number } | null>(null); const [sessionMutationKind, setSessionMutationKind] = useState<"model" | "permission" | "computer-use" | null>(null); - const [promptSuggestion, setPromptSuggestion] = useState(null); + const [promptSuggestionsBySession, setPromptSuggestionsBySession] = useState>({}); const [optimisticOutgoingMessage, setOptimisticOutgoingMessage] = useState<{ sessionId: string; envelope: AgentChatEventEnvelope; @@ -3321,6 +3321,16 @@ export function AgentChatPane({ () => (selectedSessionId ? sessions.find((session) => session.sessionId === selectedSessionId) ?? null : null), [sessions, selectedSessionId] ); + const promptSuggestion = selectedSessionId ? promptSuggestionsBySession[selectedSessionId] ?? null : null; + const clearPromptSuggestionForSession = useCallback((sessionId: string | null) => { + if (!sessionId) return; + setPromptSuggestionsBySession((prev) => { + if (!(sessionId in prev)) return prev; + const next = { ...prev }; + delete next[sessionId]; + return next; + }); + }, []); const effectiveIosSimulatorOpen = !hideLaneToolDrawers && iosSimulatorOpen; const effectiveAppControlOpen = !hideLaneToolDrawers && appControlOpen; const laneToolsVisible = Boolean(showWorkspaceChrome && !hideLaneToolDrawers && laneId); @@ -3444,16 +3454,16 @@ export function AgentChatPane({ const updateComposerDraft = useCallback((value: string) => { setDraft(value); draftsPerSessionRef.current.set(companionStateKey, value); - if (value.length > 0) setPromptSuggestion(null); - }, [companionStateKey]); + if (value.length > 0) clearPromptSuggestionForSession(selectedSessionId); + }, [clearPromptSuggestionForSession, companionStateKey, selectedSessionId]); const insertComposerDraft = useCallback((value: string) => { setDraft((current) => { const next = current.trim().length ? `${current.trimEnd()}\n\n${value}` : value; draftsPerSessionRef.current.set(companionStateKey, next); return next; }); - setPromptSuggestion(null); - }, [companionStateKey]); + clearPromptSuggestionForSession(selectedSessionId); + }, [clearPromptSuggestionForSession, companionStateKey, selectedSessionId]); const iosSimulatorProjectRoot = useMemo(() => { const scopedLaneId = selectedSession?.laneId ?? laneId; @@ -5639,7 +5649,6 @@ export function AgentChatPane({ ]); useEffect(() => { - setPromptSuggestion(null); setChatActionsOpen(false); setHandoffBusy(false); optimisticOutgoingMessageRef.current = null; @@ -5891,18 +5900,27 @@ export function AgentChatPane({ setSelectedSessionId(lockSessionId); } - // Wire prompt_suggestion events to state + // Keep prompt suggestions keyed by session so suggestions never leak + // across chat tabs before the composer renders the new selection. if (envelope.event.type === "prompt_suggestion" && "suggestion" in envelope.event) { - if (envelope.sessionId === selectedSessionIdRef.current) { - setPromptSuggestion((envelope.event as any).suggestion); - } + const suggestion = typeof (envelope.event as any).suggestion === "string" + ? (envelope.event as any).suggestion + : ""; + setPromptSuggestionsBySession((prev) => ( + suggestion.length > 0 + ? { ...prev, [envelope.sessionId]: suggestion } + : (() => { + if (!(envelope.sessionId in prev)) return prev; + const next = { ...prev }; + delete next[envelope.sessionId]; + return next; + })() + )); } // Clear prompt suggestion when a new turn starts if (envelope.event.type === "status" && envelope.event.turnStatus === "started") { - if (envelope.sessionId === selectedSessionIdRef.current) { - setPromptSuggestion(null); - } + clearPromptSuggestionForSession(envelope.sessionId); } if (shouldRefreshSessionListForChatEvent(envelope)) { @@ -5967,7 +5985,7 @@ export function AgentChatPane({ } }); return unsubscribe; - }, [isRemoteProject, isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); + }, [clearPromptSuggestionForSession, isRemoteProject, isTileVisible, layoutVariant, lockSessionId, flushQueuedEvents, patchSessionSummary, scheduleQueuedEventFlush, scheduleSessionsRefresh, touchSession]); useEffect(() => { if (!isTileActive) return undefined; @@ -7265,7 +7283,7 @@ export function AgentChatPane({ createdAtMs: Date.now(), snapshot, }; - setPromptSuggestion(null); + clearPromptSuggestionForSession(selectedSessionId); setError(null); setDraftLaunchJobs((current) => pruneDraftLaunchJobs([ job, @@ -7367,6 +7385,7 @@ export function AgentChatPane({ } }, [ buildDraftLaunchSnapshotForCurrentState, + clearPromptSuggestionForSession, clearDraftLaunchComposer, draftLaunchJobExists, draftLaunchTargetIsAutoCreate, @@ -7596,7 +7615,7 @@ export function AgentChatPane({ setError("Plan revisions from the ready gate are text-only. Remove attachments or click Keep planning first."); return; } - setPromptSuggestion(null); + clearPromptSuggestionForSession(selectedSessionId); void copyPromptForLaunch(draftText); const resolved = await handleApproval(planReadyGate.itemId, "decline", draftText); if (resolved) setDraft(""); @@ -7608,7 +7627,7 @@ export function AgentChatPane({ return; } } - setPromptSuggestion(null); + clearPromptSuggestionForSession(selectedSessionId); const isParallelLaunch = !lockSessionId @@ -8191,6 +8210,7 @@ export function AgentChatPane({ attachments, buildNativeControlPayload, busy, + clearPromptSuggestionForSession, fastMode, constrainedModelSelectionError, copyPromptForLaunch, From 6ccd4fda1667383102429799e931a6bb1c57d96e Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:12:51 -0400 Subject: [PATCH 05/12] Address Claude chat review feedback --- .../services/chat/agentChatService.test.ts | 94 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 24 +++-- .../components/chat/AgentChatPane.test.tsx | 38 ++++++++ .../components/chat/AgentChatPane.tsx | 64 +++++++------ 4 files changed, 186 insertions(+), 34 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2f00a1f09..49f414a9c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4981,6 +4981,100 @@ describe("createAgentChatService", () => { expect(eventTypes.indexOf("prompt_suggestion")).toBeLessThan(eventTypes.indexOf("done")); }); + it("keeps the live Claude query when post-result prompt suggestions are suppressed", async () => { + vi.useFakeTimers(); + try { + const close = vi.fn(); + let streamCall = 0; + let releaseFollowUpStream!: () => void; + const followUpStreamReady = new Promise((resolve) => { + releaseFollowUpStream = resolve; + }); + const send = vi.fn(async (message: unknown) => { + const text = String(legacyClaudeSendPayload(message)); + if (text.includes("follow up after the suppressed suggestion")) { + releaseFollowUpStream(); + } + }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "result", + session_id: "sdk-session-no-suggestion", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + + await followUpStreamReady; + yield { + type: "assistant", + session_id: "sdk-session-no-suggestion", + message: { + content: [{ type: "text", text: "Still on the same Claude query." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + session_id: "sdk-session-no-suggestion", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close, + sessionId: "sdk-session-no-suggestion", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close, + sessionId: "sdk-session-no-suggestion", + } as any); + + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "wait for a suppressed prompt suggestion", + timeoutMs: 15_000, + }); + await vi.advanceTimersByTimeAsync(1_000); + await firstTurn; + + expect(close).not.toHaveBeenCalled(); + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); + + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "follow up after the suppressed suggestion", + timeoutMs: 15_000, + }); + + expect(followUp.outputText).toContain("same Claude query"); + expect(close).not.toHaveBeenCalled(); + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); + expect(send).toHaveBeenCalledTimes(3); + } finally { + vi.useRealTimers(); + } + }); + it("surfaces Claude worker shutdown when it is the live stream tail", async () => { const events: AgentChatEventEnvelope[] = []; const stream = vi.fn(() => (async function* () { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f687965fe..e74cccc88 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -656,6 +656,7 @@ type ClaudeRuntime = { forkFromSdkSessionId: string | null; query: ClaudeQuery | null; inputPump: ClaudeInputPump | null; + pendingPostResultNext: Promise> | null; warmQuery: WarmQuery | null; /** Resolves when startup() has produced a warm query handle. */ warmupDone: Promise | null; @@ -11591,16 +11592,28 @@ export function createAgentChatService(args: { let resultSeen = false; const readNextClaudeTurnMessage = async (): Promise | null> => { - if (!resultSeen) return await sessionQuery.next(); + if (!resultSeen) { + if (runtime.pendingPostResultNext && runtime.query === sessionQuery) { + const pending = runtime.pendingPostResultNext; + runtime.pendingPostResultNext = null; + return await pending; + } + return await sessionQuery.next(); + } let timeoutHandle: ReturnType | null = null; - const nextMessage = sessionQuery.next(); + const nextMessage = runtime.pendingPostResultNext ?? sessionQuery.next(); + runtime.pendingPostResultNext = nextMessage; void nextMessage.catch(() => undefined); const timeout = new Promise((resolve) => { timeoutHandle = setTimeout(() => resolve(null), CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS); }); try { - return await Promise.race([nextMessage, timeout]); + const drained = await Promise.race([nextMessage, timeout]); + if (drained && runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNext = null; + } + return drained; } finally { if (timeoutHandle) clearTimeout(timeoutHandle); } @@ -11614,9 +11627,6 @@ export function createAgentChatService(args: { turnId, timeoutMs: CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS, }); - if (runtime.query === sessionQuery) { - resetClaudeQuerySession(managed, runtime, "timeout"); - } break; } if (nextMessage.done) break; @@ -17673,6 +17683,7 @@ export function createAgentChatService(args: { runtime.inputPump?.close(); runtime.query = null; runtime.inputPump = null; + runtime.pendingPostResultNext = null; runtime.warmupDone = null; if (options.clearSdkSessionId && runtime.sdkSessionId) { logger.info("agent_chat.claude_sdk_session_cleared", { @@ -18206,6 +18217,7 @@ export function createAgentChatService(args: { forkFromSdkSessionId, query: null, inputPump: null, + pendingPostResultNext: null, warmQuery: null, warmupDone: null, warmupCancel: null, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index 1dc002796..c9cac755d 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -1261,6 +1261,41 @@ describe("AgentChatPane companion drawers", () => { }); }); + it("does not reopen chat actions after tasks arrive while the Agents tab is already open", async () => { + const session = buildSession("session-1"); + const { emitChatEvent } = installAdeMocks({ sessions: [session] }); + renderPane(session); + + fireEvent.click(await screen.findByRole("button", { name: "Open chat actions drawer" })); + fireEvent.click(await screen.findByRole("button", { name: "Agents" })); + + act(() => { + emitChatEvent({ + sessionId: session.sessionId, + timestamp: "2026-03-24T06:00:00.000Z", + sequence: 1, + event: { + type: "todo_update", + items: [ + { id: "task-1", description: "Inspect Claude task events", status: "in_progress" }, + ], + }, + } as AgentChatEventEnvelope); + }); + + expect((await screen.findAllByText("Inspect Claude task events")).length).toBeGreaterThan(0); + await waitFor(() => { + expect(window.localStorage.getItem(getChatActionsAutoOpenStorageKey(session.sessionId))).toContain("firedAt"); + }); + + fireEvent.click(screen.getByRole("button", { name: "Close chat actions drawer" })); + + await waitFor(() => { + expect(screen.getByRole("button", { name: "Open chat actions drawer" })).toBeTruthy(); + }); + expect(screen.queryByRole("button", { name: "Close chat actions drawer" })).toBeNull(); + }); + it("persists split resize from the real divider on a working panel", async () => { renderDrawerPane(); @@ -5358,6 +5393,7 @@ describe("AgentChatPane submit recovery", () => { const primaryTab = await screen.findByRole("button", { name: /Primary chat/i }); const backgroundTab = await screen.findByRole("button", { name: /Background chat/i }); + const setTimeoutSpy = vi.spyOn(window, "setTimeout"); act(() => { emitChatEvent({ @@ -5379,6 +5415,8 @@ describe("AgentChatPane submit recovery", () => { }, } as AgentChatEventEnvelope); }); + expect(setTimeoutSpy.mock.calls.some(([, delay]) => delay === 16)).toBe(false); + setTimeoutSpy.mockRestore(); await waitFor(() => { expect((screen.getByRole("textbox") as HTMLTextAreaElement).placeholder).toBe("Continue primary work"); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f3b5a38ec..d9dcb7ac4 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -3858,6 +3858,17 @@ export function AgentChatPane({ } catch { /* localStorage unavailable; fall back to in-memory ref */ } + const markChatActionsAutoOpened = () => { + chatActionsAutoOpenedSessionsRef.current.add(selectedSessionId); + try { + window.localStorage.setItem( + getChatActionsAutoOpenStorageKey(selectedSessionId), + encodeChatActionsAutoOpenRecord(Date.now()), + ); + } catch { + /* best-effort persistence */ + } + }; // Don't consume the once-per-session auto-open until we can actually surface // the actions panel. If the chat-actions pane is already open (possibly on a // different tab), leave the user where they are and retry when it next closes @@ -3865,23 +3876,18 @@ export function AgentChatPane({ // which is exactly why subagents sometimes didn't auto-open (it was runtime- // independent; it depended on whether the pane happened to be open already). if (chatActionsOpen) { + if (chatActionsTab === "agents") { + markChatActionsAutoOpened(); + } return; } - chatActionsAutoOpenedSessionsRef.current.add(selectedSessionId); - try { - window.localStorage.setItem( - getChatActionsAutoOpenStorageKey(selectedSessionId), - encodeChatActionsAutoOpenRecord(Date.now()), - ); - } catch { - /* best-effort persistence */ - } + markChatActionsAutoOpened(); setChatActionsTab("agents"); setIosSimulatorOpen(false); setAppControlOpen(false); setCursorCloudPaneOpen(false); setChatActionsOpen(true); - }, [chatActionsOpen, selectedSessionId, selectedSubagentSnapshots.length, selectedTodoItems.length]); + }, [chatActionsOpen, chatActionsTab, selectedSessionId, selectedSubagentSnapshots.length, selectedTodoItems.length]); const persistParallelLaunchState = useCallback(async (state: AgentChatParallelLaunchState | null) => { if (!projectRoot || !laneId) return; @@ -5857,6 +5863,26 @@ export function AgentChatPane({ return; } + // Keep prompt suggestions keyed by session so suggestions never leak + // across chat tabs before the composer renders the new selection. These + // are composer UI hints, not transcript content. + if (envelope.event.type === "prompt_suggestion" && "suggestion" in envelope.event) { + const suggestion = typeof (envelope.event as any).suggestion === "string" + ? (envelope.event as any).suggestion.trim() + : ""; + setPromptSuggestionsBySession((prev) => ( + suggestion.length > 0 + ? { ...prev, [envelope.sessionId]: suggestion } + : (() => { + if (!(envelope.sessionId in prev)) return prev; + const next = { ...prev }; + delete next[envelope.sessionId]; + return next; + })() + )); + return; + } + pendingEventQueueRef.current.push(envelope); const touchTimestamp = getChatSessionLocalTouchTimestampForEvent(envelope); if (touchTimestamp) { @@ -5900,24 +5926,6 @@ export function AgentChatPane({ setSelectedSessionId(lockSessionId); } - // Keep prompt suggestions keyed by session so suggestions never leak - // across chat tabs before the composer renders the new selection. - if (envelope.event.type === "prompt_suggestion" && "suggestion" in envelope.event) { - const suggestion = typeof (envelope.event as any).suggestion === "string" - ? (envelope.event as any).suggestion - : ""; - setPromptSuggestionsBySession((prev) => ( - suggestion.length > 0 - ? { ...prev, [envelope.sessionId]: suggestion } - : (() => { - if (!(envelope.sessionId in prev)) return prev; - const next = { ...prev }; - delete next[envelope.sessionId]; - return next; - })() - )); - } - // Clear prompt suggestion when a new turn starts if (envelope.event.type === "status" && envelope.event.turnStatus === "started") { clearPromptSuggestionForSession(envelope.sessionId); From 255be31602c875a84bd9b276262c83b6571fe801 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:29:01 -0400 Subject: [PATCH 06/12] Discard stale Claude prompt suggestions --- .../main/services/chat/agentChatService.test.ts | 14 ++++++++++++-- .../src/main/services/chat/agentChatService.ts | 12 ++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 49f414a9c..e10ac737e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4981,9 +4981,10 @@ describe("createAgentChatService", () => { expect(eventTypes.indexOf("prompt_suggestion")).toBeLessThan(eventTypes.indexOf("done")); }); - it("keeps the live Claude query when post-result prompt suggestions are suppressed", async () => { + it("keeps the live Claude query without replaying late prompt suggestions into the next turn", async () => { vi.useFakeTimers(); try { + const events: AgentChatEventEnvelope[] = []; const close = vi.fn(); let streamCall = 0; let releaseFollowUpStream!: () => void; @@ -5013,6 +5014,12 @@ describe("createAgentChatService", () => { }; await followUpStreamReady; + yield { + type: "prompt_suggestion", + session_id: "sdk-session-no-suggestion", + uuid: "late-suggestion-1", + suggestion: "This suggestion belongs to the previous turn", + }; yield { type: "assistant", session_id: "sdk-session-no-suggestion", @@ -5040,7 +5047,9 @@ describe("createAgentChatService", () => { sessionId: "sdk-session-no-suggestion", } as any); - const { service } = createService(); + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); const session = await service.createSession({ laneId: "lane-1", provider: "claude", @@ -5066,6 +5075,7 @@ describe("createAgentChatService", () => { }); expect(followUp.outputText).toContain("same Claude query"); + expect(events.filter((event) => event.event.type === "prompt_suggestion")).toHaveLength(0); expect(close).not.toHaveBeenCalled(); expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index e74cccc88..c03530b02 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -11593,10 +11593,18 @@ export function createAgentChatService(args: { let resultSeen = false; const readNextClaudeTurnMessage = async (): Promise | null> => { if (!resultSeen) { - if (runtime.pendingPostResultNext && runtime.query === sessionQuery) { + while (runtime.pendingPostResultNext && runtime.query === sessionQuery) { const pending = runtime.pendingPostResultNext; runtime.pendingPostResultNext = null; - return await pending; + const replayed = await pending; + if (!replayed.done && (replayed.value as any).type === "prompt_suggestion") { + logger.debug("agent_chat.claude_late_prompt_suggestion_discarded", { + sessionId: managed.session.id, + turnId, + }); + continue; + } + return replayed; } return await sessionQuery.next(); } From 18a16645f4c282c81cba72caf22f11a5317cdc35 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:45:56 -0400 Subject: [PATCH 07/12] Handle Claude prompt suggestion review gaps --- .../services/chat/agentChatService.test.ts | 28 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 4 +-- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e10ac737e..ebee67118 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4979,6 +4979,8 @@ describe("createAgentChatService", () => { }), ])); expect(eventTypes.indexOf("prompt_suggestion")).toBeLessThan(eventTypes.indexOf("done")); + expect(service.getChatEventHistory(session.id, { maxEvents: 50 }).events + .some((event) => event.event.type === "prompt_suggestion")).toBe(false); }); it("keeps the live Claude query without replaying late prompt suggestions into the next turn", async () => { @@ -18473,6 +18475,27 @@ describe("createAgentChatService", () => { }); await exitPromise; + yield { + type: "stream_event", + event: { + type: "content_block_start", + index: 0, + content_block: { + type: "tool_use", + id: "tool-exit-plan-suppress", + name: "ExitPlanMode", + input: { planDescription: "Ship the approved plan." }, + }, + }, + }; + yield { + type: "system", + subtype: "permission_denied", + session_id: "sdk-session-denial-suppression", + tool_name: "ExitPlanMode", + tool_use_id: "tool-exit-plan-suppress", + decision_reason: "echoed denial from the SDK", + }; yield { type: "result", usage: { input_tokens: 1, output_tokens: 1 }, @@ -18520,6 +18543,11 @@ describe("createAgentChatService", () => { expect(denialNotices[0]!.message).toContain("Bash"); expect(denialNotices[0]!.message).not.toContain("ExitPlanMode"); expect(denialNotices[0]!.message).toMatch(/^1 tool call was denied this turn/); + expect(events.filter((envelope) => + envelope.event.type === "tool_result" + && envelope.event.itemId === "tool-exit-plan-suppress" + && envelope.event.status === "failed" + )).toHaveLength(0); }); it("bridges Claude AskUserQuestion through ADE's question UI", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index c03530b02..72523359c 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -12047,7 +12047,7 @@ export function createAgentChatService(args: { turnId, }); } - if (toolUseId && openClaudeToolUses.has(toolUseId)) { + if (toolUseId && !runtime.resolvedToolUseIds.has(toolUseId) && openClaudeToolUses.has(toolUseId)) { emitClaudeToolCompletion(toolUseId, { synthetic: true, source: "permission_denied", @@ -12675,7 +12675,7 @@ export function createAgentChatService(args: { [suggestionMsg.suggestion, suggestionMsg.prompt, suggestionMsg.text] .find((v): v is string => typeof v === "string" && v.trim().length > 0)?.trim() ?? null; if (suggestionText) { - emitChatEvent(managed, { + emitLiveOnlyChatEvent(managed, { type: "prompt_suggestion", suggestion: suggestionText, turnId, From 543f59c2166c95c88cde12b586742fa9c1d7161d Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 20:56:17 -0400 Subject: [PATCH 08/12] Discard stale Claude post-result tail events --- .../services/chat/agentChatService.test.ts | 20 ++++++++- .../main/services/chat/agentChatService.ts | 41 ++++++++++++++++--- .../chat/chatToolAppearance.test.ts | 24 +++++++++++ 3 files changed, 79 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index ebee67118..61adaf915 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4983,7 +4983,7 @@ describe("createAgentChatService", () => { .some((event) => event.event.type === "prompt_suggestion")).toBe(false); }); - it("keeps the live Claude query without replaying late prompt suggestions into the next turn", async () => { + it("keeps the live Claude query without replaying late post-result tail messages into the next turn", async () => { vi.useFakeTimers(); try { const events: AgentChatEventEnvelope[] = []; @@ -5022,6 +5022,18 @@ describe("createAgentChatService", () => { uuid: "late-suggestion-1", suggestion: "This suggestion belongs to the previous turn", }; + yield { + type: "tool_use_summary", + session_id: "sdk-session-no-suggestion", + summary: "This summary belongs to the previous turn", + preceding_tool_use_ids: ["late-tool-use-1"], + }; + yield { + type: "system", + subtype: "mirror_error", + session_id: "sdk-session-no-suggestion", + error: "late mirror error from the previous turn", + }; yield { type: "assistant", session_id: "sdk-session-no-suggestion", @@ -5078,6 +5090,12 @@ describe("createAgentChatService", () => { expect(followUp.outputText).toContain("same Claude query"); expect(events.filter((event) => event.event.type === "prompt_suggestion")).toHaveLength(0); + expect(events.filter((event) => event.event.type === "tool_use_summary")).toHaveLength(0); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "mirror_error" + && event.event.detail === "late mirror error from the previous turn", + )).toBe(false); expect(close).not.toHaveBeenCalled(); expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 72523359c..7cfe6f2fb 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -11591,21 +11591,52 @@ export function createAgentChatService(args: { // The renderer will show the turn as "started" (from the status event above) which is sufficient. let resultSeen = false; + const isStalePostResultTailMessage = (message: SDKMessage): boolean => { + const messageType = (message as any).type; + if (messageType === "prompt_suggestion" || messageType === "tool_use_summary") { + return true; + } + if (messageType !== "system") { + return false; + } + const subtype = (message as any).subtype; + return subtype === "memory_recall" + || subtype === "mirror_error" + || subtype === "model_refusal_fallback" + || subtype === "notification" + || subtype === "permission_denied" + || subtype === "worker_shutting_down"; + }; + const logStalePostResultTailDiscard = (message: SDKMessage): void => { + logger.debug("agent_chat.claude_stale_post_result_tail_discarded", { + sessionId: managed.session.id, + turnId, + type: (message as any).type, + subtype: typeof (message as any).subtype === "string" ? (message as any).subtype : undefined, + }); + }; const readNextClaudeTurnMessage = async (): Promise | null> => { if (!resultSeen) { + let discardedPostResultTail = false; while (runtime.pendingPostResultNext && runtime.query === sessionQuery) { const pending = runtime.pendingPostResultNext; runtime.pendingPostResultNext = null; const replayed = await pending; - if (!replayed.done && (replayed.value as any).type === "prompt_suggestion") { - logger.debug("agent_chat.claude_late_prompt_suggestion_discarded", { - sessionId: managed.session.id, - turnId, - }); + if (!replayed.done && isStalePostResultTailMessage(replayed.value)) { + discardedPostResultTail = true; + logStalePostResultTailDiscard(replayed.value); continue; } return replayed; } + while (discardedPostResultTail) { + const next = await sessionQuery.next(); + if (!next.done && isStalePostResultTailMessage(next.value)) { + logStalePostResultTailDiscard(next.value); + continue; + } + return next; + } return await sessionQuery.next(); } diff --git a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts index 38acff07b..fbb8e1cf4 100644 --- a/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatToolAppearance.test.ts @@ -95,6 +95,30 @@ describe("getToolMeta", () => { expect(meta.getTarget!({ branch: "unused" })).toBeNull(); }); + it("extracts targets for Claude SDK tool metadata", () => { + const cases: Array<[string, Record, string]> = [ + ["TaskCreate", { subject: "ship SDK" }, "ship SDK"], + ["TaskGet", { taskId: "task-123" }, "task-123"], + ["TaskUpdate", { description: "update docs" }, "update docs"], + ["REPL", { description: "run snippet" }, "run snippet"], + ["Workflow", { name: "nightly-sync" }, "nightly-sync"], + ["CronCreate", { cron: "0 * * * *" }, "0 * * * *"], + ["CronDelete", { id: "cron-1" }, "cron-1"], + ["ScheduleWakeup", { reason: "retry later" }, "retry later"], + ["Monitor", { command: "tail -f logs" }, "tail -f logs"], + ["Artifact", { file_path: "/tmp/out.txt" }, "/tmp/out.txt"], + ["PushNotification", { message: "done" }, "done"], + ["RemoteTrigger", { action: "deploy" }, "deploy"], + ["EnterWorktree", { path: "/tmp/wt" }, "/tmp/wt"], + ]; + + for (const [tool, args, expected] of cases) { + const meta = getToolMeta(tool); + expect(meta.getTarget).toBeDefined(); + expect(meta.getTarget!(args)).toBe(expected); + } + }); + it("returns null or empty for getTarget when args are missing", () => { const readMeta = getToolMeta("Read"); const result = readMeta.getTarget!({}); From b0acc5f0d739a5de66f9809e034cd885f5562ebe Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:08:28 -0400 Subject: [PATCH 09/12] Continue draining Claude post-result tail --- .../services/chat/agentChatService.test.ts | 115 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 7 +- .../chat/ChatSubagentsPanel.test.tsx | 23 ++++ .../components/chat/ChatTasksPanel.tsx | 3 +- 4 files changed, 145 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 61adaf915..7934ea755 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -4983,6 +4983,121 @@ describe("createAgentChatService", () => { .some((event) => event.event.type === "prompt_suggestion")).toBe(false); }); + it("continues draining stale post-result tail messages after a prompt suggestion", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let releaseFollowUpStream!: () => void; + const followUpStreamReady = new Promise((resolve) => { + releaseFollowUpStream = resolve; + }); + const send = vi.fn(async (message: unknown) => { + const text = String(legacyClaudeSendPayload(message)); + if (text.includes("follow up after the drained suggestion")) { + releaseFollowUpStream(); + } + }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "result", + session_id: "sdk-session-drain-after-suggestion", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + yield { + type: "prompt_suggestion", + session_id: "sdk-session-drain-after-suggestion", + uuid: "suggestion-tail-1", + suggestion: "Audit the next action", + }; + yield { + type: "tool_use_summary", + session_id: "sdk-session-drain-after-suggestion", + summary: "This stale summary should stay out of the next turn", + preceding_tool_use_ids: ["stale-tool-use-1"], + }; + yield { + type: "system", + subtype: "mirror_error", + session_id: "sdk-session-drain-after-suggestion", + error: "stale mirror error after suggestion", + }; + await followUpStreamReady; + yield { + type: "assistant", + session_id: "sdk-session-drain-after-suggestion", + message: { + content: [{ type: "text", text: "Still on the same Claude query after draining." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + session_id: "sdk-session-drain-after-suggestion", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-drain-after-suggestion", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-drain-after-suggestion", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "suggest the next prompt and drain stale tail", + timeoutMs: 15_000, + }); + await vi.advanceTimersByTimeAsync(1_000); + await firstTurn; + + expect(events.filter((event) => event.event.type === "prompt_suggestion")).toHaveLength(1); + + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "follow up after the drained suggestion", + timeoutMs: 15_000, + }); + + expect(followUp.outputText).toContain("same Claude query after draining"); + expect(events.filter((event) => event.event.type === "tool_use_summary")).toHaveLength(0); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "mirror_error" + && event.event.detail === "stale mirror error after suggestion", + )).toBe(false); + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the live Claude query without replaying late post-result tail messages into the next turn", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 7cfe6f2fb..d7f49bc89 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -11692,6 +11692,11 @@ export function createAgentChatService(args: { persistChatState(managed); } + if (resultSeen && (msg as any).type !== "prompt_suggestion" && isStalePostResultTailMessage(msg)) { + logStalePostResultTailDiscard(msg); + continue; + } + // system:init — capture data silently (no UI emission) if (msg.type === "system" && (msg as any).subtype === "init") { const initMsg = msg as any; @@ -12712,7 +12717,7 @@ export function createAgentChatService(args: { turnId, }); } - if (resultSeen) break; + if (resultSeen) continue; continue; } diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx index 323368064..395ee4521 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.test.tsx @@ -7,6 +7,7 @@ import type { AgentChatEventEnvelope } from "../../../shared/types"; import { SUBAGENT_CAPABILITIES } from "../../../shared/subagentCapabilities"; import type { ChatSubagentSnapshot } from "./chatExecutionSummary"; import { ChatSubagentsPanel, type SubagentSelection } from "./ChatSubagentsPanel"; +import { ChatTaskList } from "./ChatTasksPanel"; // Real per-runtime descriptors so the tests exercise the actual capability // matrix: codex = takeover + immediate-for-running; claude = takeover via probe; @@ -290,6 +291,28 @@ describe("ChatSubagentsPanel (pane variant)", () => { expect(screen.queryByText(/No agent activity/i)).toBeNull(); }); + it("preserves task order within each status group", () => { + const { container } = render( + , + ); + + const rows = Array.from(container.querySelectorAll(".ade-chat-task-row")) + .map((row) => row.textContent?.trim()); + expect(rows).toEqual([ + "Implement fix", + "Write docs", + "Audit API", + "Ship old item", + ]); + }); + it("toggles the inline drawer closed on a second click of the same row", async () => { const probeSubagentTranscript = vi.fn().mockResolvedValue(false); render( diff --git a/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx index 15cea639c..b539c997b 100644 --- a/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatTasksPanel.tsx @@ -105,8 +105,7 @@ export const ChatTaskList = React.memo(function ChatTaskList({ .map((status) => ({ status, items: items - .filter((item) => item.status === status) - .sort((a, b) => a.description.localeCompare(b.description)), + .filter((item) => item.status === status), })) .filter((group) => group.items.length > 0), [items], From 43558326e561471b2da5e8695e16dff7d95e1ee7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:25:09 -0400 Subject: [PATCH 10/12] Handle Claude post-result drain rejection --- .../services/chat/agentChatService.test.ts | 61 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 21 ++++++- 2 files changed, 81 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7934ea755..e3c2c59ab 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5098,6 +5098,67 @@ describe("createAgentChatService", () => { } }); + it("keeps completed Claude turns successful when the post-result drain rejects", async () => { + const events: AgentChatEventEnvelope[] = []; + const stream = vi.fn(() => (async function* () { + yield { + type: "assistant", + session_id: "sdk-session-drain-rejects", + message: { + content: [{ type: "text", text: "Finished before the drain failed." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + session_id: "sdk-session-drain-rejects", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + throw new Error("SDK worker closed after result"); + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-session-drain-rejects", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream, + close: vi.fn(), + sessionId: "sdk-session-drain-rejects", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const result = await service.runSessionTurn({ + sessionId: session.id, + text: "finish even if the post-result drain rejects", + timeoutMs: 15_000, + }); + + expect(result.outputText).toContain("Finished before the drain failed"); + expect(events.some((event) => + event.event.type === "status" + && event.event.turnStatus === "failed", + )).toBe(false); + expect(events).toEqual(expect.arrayContaining([ + expect.objectContaining({ + event: expect.objectContaining({ + type: "done", + status: "completed", + }), + }), + ])); + }); + it("keeps the live Claude query without replaying late post-result tail messages into the next turn", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d7f49bc89..1894b2a6e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -11615,13 +11615,26 @@ export function createAgentChatService(args: { subtype: typeof (message as any).subtype === "string" ? (message as any).subtype : undefined, }); }; + const logPostResultDrainRejected = (error: unknown): void => { + logger.debug("agent_chat.claude_post_result_drain_rejected", { + sessionId: managed.session.id, + turnId, + error: error instanceof Error ? error.message : String(error), + }); + }; const readNextClaudeTurnMessage = async (): Promise | null> => { if (!resultSeen) { let discardedPostResultTail = false; while (runtime.pendingPostResultNext && runtime.query === sessionQuery) { const pending = runtime.pendingPostResultNext; runtime.pendingPostResultNext = null; - const replayed = await pending; + let replayed: IteratorResult; + try { + replayed = await pending; + } catch (error) { + logPostResultDrainRejected(error); + continue; + } if (!replayed.done && isStalePostResultTailMessage(replayed.value)) { discardedPostResultTail = true; logStalePostResultTailDiscard(replayed.value); @@ -11653,6 +11666,12 @@ export function createAgentChatService(args: { runtime.pendingPostResultNext = null; } return drained; + } catch (error) { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNext = null; + } + logPostResultDrainRejected(error); + return null; } finally { if (timeoutHandle) clearTimeout(timeoutHandle); } From fb796a309bcfe267d8d4c178e6944838eca74796 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:41:39 -0400 Subject: [PATCH 11/12] Preserve next-turn Claude system events --- .../services/chat/agentChatService.test.ts | 105 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 10 +- 2 files changed, 111 insertions(+), 4 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e3c2c59ab..b49744aa6 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5159,6 +5159,111 @@ describe("createAgentChatService", () => { ])); }); + it("does not discard first next-turn system events from a carried post-result read", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let releaseFollowUpStream!: () => void; + const followUpStreamReady = new Promise((resolve) => { + releaseFollowUpStream = resolve; + }); + const send = vi.fn(async (message: unknown) => { + const text = String(legacyClaudeSendPayload(message)); + if (text.includes("follow up after an empty post-result drain")) { + releaseFollowUpStream(); + } + }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "result", + session_id: "sdk-session-next-turn-system", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + await followUpStreamReady; + yield { + type: "system", + subtype: "memory_recall", + session_id: "sdk-session-next-turn-system", + mode: "select", + memories: [{ + path: "/tmp/preference.md", + scope: "project", + content: "Prefer preserving next-turn system events.", + }], + }; + yield { + type: "assistant", + session_id: "sdk-session-next-turn-system", + message: { + content: [{ type: "text", text: "Memory recall reached the next turn." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + session_id: "sdk-session-next-turn-system", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-next-turn-system", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-next-turn-system", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "complete without a post-result tail", + timeoutMs: 15_000, + }); + await vi.advanceTimersByTimeAsync(1_000); + await firstTurn; + + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "follow up after an empty post-result drain", + timeoutMs: 15_000, + }); + + expect(followUp.outputText).toContain("Memory recall reached the next turn"); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "memory_recall" + && event.event.message === "Claude recalled 1 memory.", + )).toBe(true); + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the live Claude query without replaying late post-result tail messages into the next turn", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 1894b2a6e..d7e4ee097 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -11593,9 +11593,7 @@ export function createAgentChatService(args: { let resultSeen = false; const isStalePostResultTailMessage = (message: SDKMessage): boolean => { const messageType = (message as any).type; - if (messageType === "prompt_suggestion" || messageType === "tool_use_summary") { - return true; - } + if (isPostResultOnlyTailMessage(message)) return true; if (messageType !== "system") { return false; } @@ -11607,6 +11605,10 @@ export function createAgentChatService(args: { || subtype === "permission_denied" || subtype === "worker_shutting_down"; }; + const isPostResultOnlyTailMessage = (message: SDKMessage): boolean => { + const messageType = (message as any).type; + return messageType === "prompt_suggestion" || messageType === "tool_use_summary"; + }; const logStalePostResultTailDiscard = (message: SDKMessage): void => { logger.debug("agent_chat.claude_stale_post_result_tail_discarded", { sessionId: managed.session.id, @@ -11635,7 +11637,7 @@ export function createAgentChatService(args: { logPostResultDrainRejected(error); continue; } - if (!replayed.done && isStalePostResultTailMessage(replayed.value)) { + if (!replayed.done && isPostResultOnlyTailMessage(replayed.value)) { discardedPostResultTail = true; logStalePostResultTailDiscard(replayed.value); continue; From 9255b8338c1b6b218000caf4e78dc0f7830a55b7 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Mon, 22 Jun 2026 21:59:10 -0400 Subject: [PATCH 12/12] Track settled Claude post-result drains --- .../services/chat/agentChatService.test.ts | 108 ++++++++++++++++++ .../main/services/chat/agentChatService.ts | 34 +++++- 2 files changed, 140 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index b49744aa6..756d14334 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -5264,6 +5264,114 @@ describe("createAgentChatService", () => { } }); + it("drops stale system tails that settle before the next Claude turn starts", async () => { + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + let streamCall = 0; + let releaseStaleTail!: () => void; + let releaseFollowUpStream!: () => void; + const staleTailReady = new Promise((resolve) => { + releaseStaleTail = resolve; + }); + const followUpStreamReady = new Promise((resolve) => { + releaseFollowUpStream = resolve; + }); + const send = vi.fn(async (message: unknown) => { + const text = String(legacyClaudeSendPayload(message)); + if (text.includes("follow up after a stale settled tail")) { + releaseFollowUpStream(); + } + }); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "result", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + return; + } + + yield { + type: "result", + session_id: "sdk-session-settled-stale-tail", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + await staleTailReady; + yield { + type: "system", + subtype: "mirror_error", + session_id: "sdk-session-settled-stale-tail", + error: "stale mirror error before the follow-up", + }; + await followUpStreamReady; + yield { + type: "assistant", + session_id: "sdk-session-settled-stale-tail", + message: { + content: [{ type: "text", text: "Follow-up started after dropping the stale tail." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + yield { + type: "result", + session_id: "sdk-session-settled-stale-tail", + usage: { input_tokens: 1, output_tokens: 1 }, + }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-settled-stale-tail", + } as any); + vi.mocked(claudeSdkResumeSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-session-settled-stale-tail", + } as any); + + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "sonnet", + }); + + const firstTurn = service.runSessionTurn({ + sessionId: session.id, + text: "complete before a stale system tail", + timeoutMs: 15_000, + }); + await vi.advanceTimersByTimeAsync(1_000); + await firstTurn; + + releaseStaleTail(); + await vi.advanceTimersByTimeAsync(0); + + const followUp = await service.runSessionTurn({ + sessionId: session.id, + text: "follow up after a stale settled tail", + timeoutMs: 15_000, + }); + + expect(followUp.outputText).toContain("Follow-up started after dropping the stale tail"); + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.status === "mirror_error" + && event.event.detail === "stale mirror error before the follow-up", + )).toBe(false); + expect(claudeSdkCreateSessionCompat).toHaveBeenCalledTimes(1); + expect(claudeSdkResumeSessionCompat).not.toHaveBeenCalled(); + } finally { + vi.useRealTimers(); + } + }); + it("keeps the live Claude query without replaying late post-result tail messages into the next turn", async () => { vi.useFakeTimers(); try { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index d7e4ee097..d8c576695 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -657,6 +657,7 @@ type ClaudeRuntime = { query: ClaudeQuery | null; inputPump: ClaudeInputPump | null; pendingPostResultNext: Promise> | null; + pendingPostResultNextSettledAt: number | null; warmQuery: WarmQuery | null; /** Resolves when startup() has produced a warm query handle. */ warmupDone: Promise | null; @@ -11583,6 +11584,7 @@ export function createAgentChatService(args: { messageToSend.uuid = userMessageId; messageToSend.timestamp = new Date().toISOString(); + const pendingPostResultSettledBeforeInput = runtime.pendingPostResultNextSettledAt != null; bumpClaudeIdleDeadline(); runtime.inputPump?.push(messageToSend); persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); @@ -11630,6 +11632,8 @@ export function createAgentChatService(args: { while (runtime.pendingPostResultNext && runtime.query === sessionQuery) { const pending = runtime.pendingPostResultNext; runtime.pendingPostResultNext = null; + const pendingSettledBeforeInput = pendingPostResultSettledBeforeInput; + runtime.pendingPostResultNextSettledAt = null; let replayed: IteratorResult; try { replayed = await pending; @@ -11637,7 +11641,14 @@ export function createAgentChatService(args: { logPostResultDrainRejected(error); continue; } - if (!replayed.done && isPostResultOnlyTailMessage(replayed.value)) { + if ( + !replayed.done + && ( + pendingSettledBeforeInput + ? isStalePostResultTailMessage(replayed.value) + : isPostResultOnlyTailMessage(replayed.value) + ) + ) { discardedPostResultTail = true; logStalePostResultTailDiscard(replayed.value); continue; @@ -11657,7 +11668,22 @@ export function createAgentChatService(args: { let timeoutHandle: ReturnType | null = null; const nextMessage = runtime.pendingPostResultNext ?? sessionQuery.next(); - runtime.pendingPostResultNext = nextMessage; + if (runtime.pendingPostResultNext !== nextMessage) { + runtime.pendingPostResultNext = nextMessage; + runtime.pendingPostResultNextSettledAt = null; + nextMessage.then( + () => { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNextSettledAt = Date.now(); + } + }, + () => { + if (runtime.pendingPostResultNext === nextMessage) { + runtime.pendingPostResultNextSettledAt = Date.now(); + } + }, + ); + } void nextMessage.catch(() => undefined); const timeout = new Promise((resolve) => { timeoutHandle = setTimeout(() => resolve(null), CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS); @@ -11666,11 +11692,13 @@ export function createAgentChatService(args: { const drained = await Promise.race([nextMessage, timeout]); if (drained && runtime.pendingPostResultNext === nextMessage) { runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; } return drained; } catch (error) { if (runtime.pendingPostResultNext === nextMessage) { runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; } logPostResultDrainRejected(error); return null; @@ -17749,6 +17777,7 @@ export function createAgentChatService(args: { runtime.query = null; runtime.inputPump = null; runtime.pendingPostResultNext = null; + runtime.pendingPostResultNextSettledAt = null; runtime.warmupDone = null; if (options.clearSdkSessionId && runtime.sdkSessionId) { logger.info("agent_chat.claude_sdk_session_cleared", { @@ -18283,6 +18312,7 @@ export function createAgentChatService(args: { query: null, inputPump: null, pendingPostResultNext: null, + pendingPostResultNextSettledAt: null, warmQuery: null, warmupDone: null, warmupCancel: null,