diff --git a/apps/ade-cli/package-lock.json b/apps/ade-cli/package-lock.json index 2b1607d06..cc7290bd7 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.207", + "@anthropic-ai/claude-agent-sdk": "^0.3.211", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", @@ -97,22 +97,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", - "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.211.tgz", + "integrity": "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -121,9 +121,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", - "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.211.tgz", + "integrity": "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ==", "cpu": [ "arm64" ], @@ -134,9 +134,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", - "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.211.tgz", + "integrity": "sha512-sMBW1CLe2Hq4PwwvEbz9r8LxF84UErgB45TSf+iEa8M/EjYZCsXSoTRT0vKnN4TvrAN5mWoKgi39dkUxS9ybsA==", "cpu": [ "x64" ], @@ -147,15 +147,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", - "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.211.tgz", + "integrity": "sha512-orZm8p+BzVRZ7I8c5yD43hEZ5TvBZ+UbKTZTlvID00Y8HSn3M3rNX3sW4RUvNGF2e0eNMaXKysPyEHPEj3kqpg==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -163,15 +160,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", - "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.211.tgz", + "integrity": "sha512-X1eg+lCwNH2VXyqLQR722dsDDtWPfUnz7OtnmPVoNMxcMexDlSLMMuvm5fNNi94kYT0pxmBdhIvudew145JuHg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -179,15 +173,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", - "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.211.tgz", + "integrity": "sha512-ohDS5EGKQvKiUUMtDNPjyWUDvaeIa+DlzUVjrZ8Y4hPtoWFpvOBtOFIYChJGpllwZ4YULS4H3gywVnLGB4do6Q==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -195,15 +186,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", - "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.211.tgz", + "integrity": "sha512-cR12YFMVGSj38074OYkkjgwhYeblgVRK3Uw7ZXw3ZTOevjQLASPajVruFuDRW07ixxTa1ZWxqI3kSsIl71RXYA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -211,9 +199,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", - "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.211.tgz", + "integrity": "sha512-AOIQRFO0YMDUCrG8W0NUWitBQQUB6fYdNW3SMcPPq3mXpYC/pCNURFyvRdrFm729xXkxDAP5OMLk9x2/IFrTmA==", "cpu": [ "arm64" ], @@ -224,9 +212,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", - "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.211.tgz", + "integrity": "sha512-pwzNuJg2xRBsv3kSSVVhgLdGAFxd5DqPkQX5ZLrT4uBZDJ+QM4xWKZyN8ZoFLLzNl+u0/4U83Q1ZQ0NdG/9JsQ==", "cpu": [ "x64" ], @@ -5396,66 +5384,66 @@ } }, "@anthropic-ai/claude-agent-sdk": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", - "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.211.tgz", + "integrity": "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA==", "requires": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" } }, "@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", - "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.211.tgz", + "integrity": "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ==", "optional": true }, "@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", - "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.211.tgz", + "integrity": "sha512-sMBW1CLe2Hq4PwwvEbz9r8LxF84UErgB45TSf+iEa8M/EjYZCsXSoTRT0vKnN4TvrAN5mWoKgi39dkUxS9ybsA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", - "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.211.tgz", + "integrity": "sha512-orZm8p+BzVRZ7I8c5yD43hEZ5TvBZ+UbKTZTlvID00Y8HSn3M3rNX3sW4RUvNGF2e0eNMaXKysPyEHPEj3kqpg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", - "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.211.tgz", + "integrity": "sha512-X1eg+lCwNH2VXyqLQR722dsDDtWPfUnz7OtnmPVoNMxcMexDlSLMMuvm5fNNi94kYT0pxmBdhIvudew145JuHg==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", - "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.211.tgz", + "integrity": "sha512-ohDS5EGKQvKiUUMtDNPjyWUDvaeIa+DlzUVjrZ8Y4hPtoWFpvOBtOFIYChJGpllwZ4YULS4H3gywVnLGB4do6Q==", "optional": true }, "@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", - "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.211.tgz", + "integrity": "sha512-cR12YFMVGSj38074OYkkjgwhYeblgVRK3Uw7ZXw3ZTOevjQLASPajVruFuDRW07ixxTa1ZWxqI3kSsIl71RXYA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", - "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.211.tgz", + "integrity": "sha512-AOIQRFO0YMDUCrG8W0NUWitBQQUB6fYdNW3SMcPPq3mXpYC/pCNURFyvRdrFm729xXkxDAP5OMLk9x2/IFrTmA==", "optional": true }, "@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", - "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.211.tgz", + "integrity": "sha512-pwzNuJg2xRBsv3kSSVVhgLdGAFxd5DqPkQX5ZLrT4uBZDJ+QM4xWKZyN8ZoFLLzNl+u0/4U83Q1ZQ0NdG/9JsQ==", "optional": true }, "@anthropic-ai/sdk": { diff --git a/apps/ade-cli/package.json b/apps/ade-cli/package.json index 598e74f5a..5e4f8b74a 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.207", + "@anthropic-ai/claude-agent-sdk": "^0.3.211", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx index d707c50df..ef5fb1aa4 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx +++ b/apps/ade-cli/src/tuiClient/__tests__/ChatView.test.tsx @@ -420,6 +420,42 @@ describe("ChatView", () => { expect(frame).not.toContain("model working"); }); + it("renders conversation reset with the completed-compaction divider style", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "conversation_reset", newConversationId: "conversation-2" }, + }, + ], { width: 80 }); + + expect(frame).toContain("✓ conversation reset"); + expect(frame).not.toContain("Context compacted"); + expect(frame).not.toContain("[conversation_reset]"); + }); + + it("shows a mapped terminal reason on the visible failed status row", () => { + const frame = renderEvents([ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "status", turnStatus: "failed", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "done", status: "failed", turnId: "turn-1", terminalReason: "prompt_too_long" }, + }, + ], { width: 80 }); + + expect(frame).toContain("[status] failed · context window overflow"); + expect(frame).not.toContain("[done]"); + expect(frame).not.toContain("prompt_too_long"); + }); + it("renders queued steer messages as staged instead of normal sent bubbles", () => { const frame = renderEvents([ { diff --git a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts index 362a73a54..a9d2f69ab 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/adeApi.test.ts @@ -3,7 +3,7 @@ import os from "node:os"; import path from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import type { AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; -import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; +import { archiveChatSession, cancelSteerMessage, createChatSession, DEFAULT_CODEX_REASONING_EFFORT, deleteChatSession, deriveClaudeGoalFromEvents, dispatchSteerMessage, discoverProjectSlashCommands, editSteerMessage, getAvailableModels, getChatHistoryPage, getMainTranscript, latestGoal, latestTokenStats, listChatSessions, listLaneDiffStats, listPrsByLane, listTerminalSessions, messageChatSession, recoverCodexTurn, resumeTerminalSession, runDefaultLaneSetup, sendChatMessage, signalTerminal, startCliTerminalSession, steerChatMessage, trackedCliTerminalProvider, unarchiveChatSession } from "../adeApi"; import type { ChatTerminalSession } from "../../../../desktop/src/shared/types/sessions"; import type { AdeCodeConnection } from "../types"; @@ -316,6 +316,23 @@ describe("latestTokenStats", () => { expect(latestTokenStats(events).percent).toBeNull(); }); + it("uses live context usage events for mid-turn occupancy", () => { + const stats = latestTokenStats([ + envelope(1, { type: "status", turnStatus: "started", turnId: "turn-1" }), + envelope(2, { + type: "context_usage", + origin: "live", + turnId: "turn-1", + usage: { categories: [], totalTokens: 48_000, maxTokens: 100_000, percentage: 48 }, + }), + ]); + + expect(stats.streaming).toBe(true); + expect(stats.inputTokens).toBe(48_000); + expect(stats.contextWindow).toBe(100_000); + expect(stats.percent).toBe(48); + }); + it("reads cachedInputTokens / cacheReadTokens from both tokens and codex_token_usage events", () => { const events = [ envelope(1, { @@ -671,6 +688,38 @@ describe("latestGoal", () => { }); }); +describe("deriveClaudeGoalFromEvents", () => { + const snapshotGoal = { + condition: "All checks pass", + iterations: 2, + setAt: 10, + tokensAtStart: 100, + updatedAt: 20, + }; + + it("falls back to the session snapshot when the transcript window has no goal event", () => { + const derived = deriveClaudeGoalFromEvents([], snapshotGoal); + expect(derived).toBe(snapshotGoal); + expect(derived?.condition).toBe("All checks pass"); + expect(deriveClaudeGoalFromEvents([], null)).toBeNull(); + }); + + it("uses the latest update and lets an explicit clear override the snapshot", () => { + const updated = { ...snapshotGoal, condition: "Tests and lint pass", iterations: 4, updatedAt: 30 }; + expect(deriveClaudeGoalFromEvents([ + envelope(1, { type: "claude_goal_updated", goal: updated }), + ], snapshotGoal)).toEqual(updated); + expect(deriveClaudeGoalFromEvents([ + envelope(1, { type: "claude_goal_updated", goal: updated }), + ], snapshotGoal)?.iterations).toBe(4); + + expect(deriveClaudeGoalFromEvents([ + envelope(1, { type: "claude_goal_updated", goal: updated }), + envelope(2, { type: "claude_goal_cleared" }), + ], snapshotGoal)).toBeNull(); + }); +}); + describe("discoverProjectSlashCommands", () => { it("prefers project .claude command metadata over same-named global Codex prompts", () => { const projectRoot = makeTmpRoot("ade-code-project-commands-"); diff --git a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts index 8fd03db41..0be6a2084 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/aggregate.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest"; -import { aggregateChatBlocks, type AggregatedBlock } from "../aggregate"; +import { aggregateChatBlocks, derivePendingSteers, type AggregatedBlock } from "../aggregate"; import type { AgentChatEvent, AgentChatEventEnvelope } from "../../../../desktop/src/shared/types/chat"; let sequenceCounter = 0; @@ -372,6 +372,30 @@ describe("aggregateChatBlocks typed groups", () => { expect(blocks[0]).toMatchObject({ kind: "compaction", live: true, trigger: "manual" }); }); + it("routes conversation resets through the compaction-style divider block", () => { + const blocks = aggregate([ + env("2026-01-01T12:00:00.000Z", { type: "conversation_reset", newConversationId: "conversation-2" }), + ]); + + expect(blocks).toHaveLength(1); + expect(blocks[0]?.kind).toBe("conversation-reset"); + expect(blocks[0]?.id).toContain("conversation_reset"); + }); + + it("clears a staged steer when Claude starts its queued command", () => { + const events = [ + env("2026-01-01T12:00:00.000Z", { type: "user_message", text: "queued", steerId: "steer-1", deliveryState: "queued" }), + env("2026-01-01T12:00:01.000Z", { + type: "command_lifecycle", + commandUuid: "command-1", + status: "started", + steerId: "steer-1", + }), + ]; + + expect(derivePendingSteers(events)).toEqual([]); + }); + it("treats a stateless context_compact as a completed (done) block", () => { const events: AgentChatEventEnvelope[] = [ env("2026-01-01T12:00:00.000Z", { type: "context_compact", trigger: "auto", turnId: "turn-1" }), diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index 935ed4d95..90aae3057 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -20,6 +20,7 @@ import { firstUrlInText, footerControlsForAvailability, inlineRowCellOrder, + formatGoalBannerLine, formatGitConflictReport, formatLaneDeleteRisk, formFieldUsesPromptInput, @@ -1970,6 +1971,21 @@ describe("optimistic chat summaries", () => { expect(optimistic.has("chat-new")).toBe(true); }); + it("forwards the Claude goal snapshot into an optimistic summary", () => { + const claudeGoal = { + condition: "All checks pass", + iterations: 4, + setAt: 10, + tokensAtStart: 100, + updatedAt: 20, + }; + + const summary = chatSessionToOptimisticSummary(createdSession({ provider: "claude", claudeGoal })); + expect(summary.provider).toBe("claude"); + expect(summary.claudeGoal).toEqual(claudeGoal); + expect(summary.claudeGoal?.iterations).toBe(4); + }); + it("drops the optimistic row once the runtime returns the created chat", () => { const optimistic = new Map(); optimistic.set("chat-new", chatSessionToOptimisticSummary(createdSession())); @@ -1981,6 +1997,21 @@ describe("optimistic chat summaries", () => { }); }); +describe("formatGoalBannerLine", () => { + it("renders Claude goals through the shared banner with iterations only after the first check", () => { + const goal = { + condition: "All checks pass", + iterations: 4, + setAt: 10, + tokensAtStart: 100, + updatedAt: 20, + }; + expect(formatGoalBannerLine(goal)).toBe("◎ goal: All checks pass · iter 4"); + expect(formatGoalBannerLine({ ...goal, iterations: 0 })).toBe("◎ goal: All checks pass"); + expect(formatGoalBannerLine({ ...goal, condition: " " })).toBeNull(); + }); +}); + describe("latestAuthFailedPrompt", () => { it("restores the most recent prompt when the latest turn failed with auth", () => { const events: AgentChatEventEnvelope[] = [ diff --git a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts index 952f15c05..08c3a3056 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/format.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/format.test.ts @@ -12,6 +12,27 @@ import { webSearchResultPreviewLines, } from "../format"; import { formatRelativePastTime } from "../relativeTime"; +import { terminalReasonLabel } from "../terminalReason"; + +describe("terminalReasonLabel", () => { + it.each([ + ["budget_exhausted", "budget limit reached"], + ["max_turns", "max turns reached"], + ["prompt_too_long", "context window overflow"], + ["api_error", "API error after retries"], + ["malformed_tool_use_exhausted", "tool-call retries exhausted"], + ["structured_output_retry_exhausted", "output retries exhausted"], + ["model_error", "model error"], + ["turn_setup_failed", "turn setup failed"], + ["tool_deferred_unavailable", "deferred tool unavailable"], + ])("maps %s to its shared short label", (reason, label) => { + expect(terminalReasonLabel(reason)).toBe(label); + }); + + it("keeps unknown future reasons silent", () => { + expect(terminalReasonLabel("future_reason")).toBeNull(); + }); +}); describe("webSearchResultPreviewLines", () => { it("derives domains defensively and never throws on malformed urls", () => { @@ -306,6 +327,74 @@ describe("renderChatLines", () => { expect(renderObject(value)).toBe("[object Object]"); }); + it("passes known terminal-reason labels through failed turn endings", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "status", turnStatus: "failed", turnId: "turn-1" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "done", status: "failed", turnId: "turn-1", terminalReason: "prompt_too_long" }, + }, + ], + }); + + expect(terminalReasonLabel("budget_exhausted")).toBe("budget limit reached"); + expect(terminalReasonLabel("future_reason")).toBeNull(); + expect(lines.map((line) => line.body)).toEqual([ + "[status] failed · context window overflow", + "[done] failed · context window overflow", + ]); + }); + + it("renders stop receipts and conversation resets as dim notice lines", () => { + const lines = renderChatLines({ + activeSession: null, + notices: [], + events: [ + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:00.000Z", + sequence: 1, + event: { type: "interrupt_receipt", stillQueuedUuids: ["a", "b"], known: [] }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:01.000Z", + sequence: 2, + event: { type: "conversation_reset", newConversationId: "conversation-2" }, + }, + { + sessionId: "s1", + timestamp: "2026-01-01T12:00:02.000Z", + sequence: 3, + event: { + type: "command_lifecycle", + commandUuid: "command-1", + status: "discarded", + preview: "Run the old request", + }, + }, + ], + }); + + expect(lines).toEqual([ + expect.objectContaining({ tone: "notice", body: "stopped — 2 queued messages will still run" }), + expect.objectContaining({ tone: "notice", body: "conversation reset" }), + expect.objectContaining({ tone: "notice", body: "queued message discarded · Run the old request" }), + ]); + expect(lines).toHaveLength(3); + expect(lines.every((line) => line.tone === "notice")).toBe(true); + }); + it("renders tool, edit, and compaction events compactly", () => { const lines = renderChatLines({ activeSession: null, diff --git a/apps/ade-cli/src/tuiClient/adeApi.ts b/apps/ade-cli/src/tuiClient/adeApi.ts index 9cf5062a1..9aea161c2 100644 --- a/apps/ade-cli/src/tuiClient/adeApi.ts +++ b/apps/ade-cli/src/tuiClient/adeApi.ts @@ -43,6 +43,7 @@ import type { AgentChatSubagentSnapshot, AgentChatSubagentTranscriptArgs, AgentChatSubagentTranscriptMessage, + ClaudeActiveGoal, CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { @@ -987,3 +988,24 @@ export function latestGoal(events: AgentChatEventEnvelope[]): CodexThreadGoal | } return goal; } + +/** + * Walk the event stream and return the most recently observed Claude goal. + * When this transcript window has no goal event, retain the session snapshot; + * an explicit clear event always wins over that fallback. + */ +export function deriveClaudeGoalFromEvents( + events: AgentChatEventEnvelope[], + sessionGoal: ClaudeActiveGoal | null | undefined = null, +): ClaudeActiveGoal | null { + let goal = sessionGoal ?? null; + for (const envelope of events) { + const event = envelope.event; + if (event.type === "claude_goal_updated") { + goal = event.goal; + } else if (event.type === "claude_goal_cleared") { + goal = null; + } + } + return goal; +} diff --git a/apps/ade-cli/src/tuiClient/aggregate.ts b/apps/ade-cli/src/tuiClient/aggregate.ts index 0a2133aff..1b7d039ed 100644 --- a/apps/ade-cli/src/tuiClient/aggregate.ts +++ b/apps/ade-cli/src/tuiClient/aggregate.ts @@ -87,7 +87,8 @@ export type AggregatedBlock = | { kind: "files-changed-group"; id: string; turnId: string | null; entries: FileChangeEntry[]; live: boolean; durationMs?: number } | { kind: "runtime-activity"; id: string; turnId: string | null; entries: RuntimeActivityEntry[]; live: boolean } | { kind: "activity-bundle"; id: string; turnId: string | null; entries: ActivityBundleEntry[]; live: boolean } - | { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto"; live: boolean; preTokens?: number; postTokens?: number; durationMs?: number; sessionCompactionCount?: number } + | { kind: "compaction"; id: string; turnId: string | null; trigger: "manual" | "auto" | "ade_fallback"; live: boolean; preTokens?: number; postTokens?: number; durationMs?: number; sessionCompactionCount?: number } + | { kind: "conversation-reset"; id: string } | { kind: "queued-steer"; id: string; turnId: string | null; steerId: string; text: string } | { kind: "plan"; id: string; turnId: string | null; steps: PlanStep[]; current: number; total: number; live: boolean } | { kind: "approval"; id: string; line: RenderedChatLine } @@ -762,6 +763,11 @@ export function derivePendingSteers(events: AgentChatEventEnvelope[]): PendingSt if (isSteerLifecycleNotice(event) && event.message.trim().toLowerCase() !== "message queued") { steerMap.delete(event.steerId); resolvedSteerIds.add(event.steerId); + continue; + } + if (event.type === "command_lifecycle" && event.steerId && event.status !== "queued") { + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); } } return Array.from(steerMap.values()); @@ -1072,6 +1078,10 @@ export function aggregateChatBlocks(args: { }); continue; } + if (event.type === "conversation_reset") { + blocks.push({ kind: "conversation-reset", id }); + continue; + } if (event.type === "codex_image_generation" || event.type === "codex_image_view") { const line = linesById.get(id); if (!line) continue; diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 5ab2cce04..146f6c53f 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -25,15 +25,16 @@ import type { AgentChatClaudePlugin, AgentChatCodexRecoveryAction, AgentChatReloadClaudePluginsResult, - AgentChatEventEnvelope, - AgentChatFileRef, - AgentChatModelCatalog, - AgentChatModelCatalogModel, - AgentChatModelCatalogRefreshProvider, - AgentChatModelInfo, + AgentChatEventEnvelope, + AgentChatFileRef, + AgentChatModelCatalog, + AgentChatModelCatalogModel, + AgentChatModelCatalogRefreshProvider, + AgentChatModelInfo, AgentChatSession, AgentChatSessionSummary, AgentChatSlashCommand, + ClaudeActiveGoal, CodexThreadGoal, } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; @@ -52,6 +53,7 @@ import { deleteChatSession, discoverProjectSlashCommands, dispatchSteerMessage, + deriveClaudeGoalFromEvents, editSteerMessage, getAvailableModels, getAiSettingsStatus, @@ -870,6 +872,7 @@ export function chatSessionToOptimisticSummary( ...(session.capabilityMode ? { capabilityMode: session.capabilityMode } : {}), ...(session.completion ? { completion: session.completion } : {}), ...(session.codexGoal ? { codexGoal: session.codexGoal } : {}), + ...(session.claudeGoal ? { claudeGoal: session.claudeGoal } : {}), ...(session.codexTokenUsage ? { codexTokenUsage: session.codexTokenUsage } : {}), status: session.status, ...(session.idleSinceAt !== undefined ? { idleSinceAt: session.idleSinceAt } : {}), @@ -1037,7 +1040,12 @@ function formatTokenSummary(stats: ReturnType): string return parts.length ? parts.join(" ") : null; } -function formatGoalBannerLine(goal: CodexThreadGoal | null): string | null { +export function formatGoalBannerLine(goal: CodexThreadGoal | ClaudeActiveGoal | null): string | null { + if (goal && "condition" in goal) { + const condition = goal.condition.trim(); + if (!condition) return null; + return `◎ goal: ${condition}${goal.iterations > 0 ? ` · iter ${goal.iterations}` : ""}`; + } if (!goal?.objective) return null; const objective = goal.objective.trim(); if (!objective) return null; @@ -4146,7 +4154,14 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, setVimModeEnabled(readClaudeVimMode(project.workspaceRoot)); setVimMode("insert"); }, [project.workspaceRoot]); - const goalBannerText = useMemo(() => formatGoalBannerLine(currentGoal), [currentGoal]); + const currentClaudeGoal = useMemo( + () => deriveClaudeGoalFromEvents(events, activeSession?.claudeGoal), + [activeSession?.claudeGoal, events], + ); + const goalBannerText = useMemo( + () => formatGoalBannerLine(activeSession?.provider === "claude" ? currentClaudeGoal : currentGoal), + [activeSession?.provider, currentClaudeGoal, currentGoal], + ); const statusLineRows = statusLineText ? Math.min(3, statusLineText.split(/\r?\n/).filter(Boolean).length || 1) : 0; const statusRows = statusLineRows; const modelStatusOverlayRows = statusRows diff --git a/apps/ade-cli/src/tuiClient/components/ChatView.tsx b/apps/ade-cli/src/tuiClient/components/ChatView.tsx index 4dd53ea00..ce0324f8c 100644 --- a/apps/ade-cli/src/tuiClient/components/ChatView.tsx +++ b/apps/ade-cli/src/tuiClient/components/ChatView.tsx @@ -1063,6 +1063,16 @@ function compactionRows(block: Extract, }]; } +function conversationResetRows(block: Extract): RenderedChatRow[] { + return [{ + id: block.id, + tone: "work", + text: " ✓ conversation reset", + color: theme.color.violet, + rail: null, + }]; +} + function formatCompactTokenCount(value: number): string { if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`; if (value >= 10_000) return `${Math.round(value / 1_000)}k`; @@ -1252,6 +1262,8 @@ function rowsForBlock( return reasoningRows(block, spinFrame); case "compaction": return compactionRows(block, brailleFrame); + case "conversation-reset": + return conversationResetRows(block); case "queued-steer": return queuedSteerRows(block, width); case "plan": diff --git a/apps/ade-cli/src/tuiClient/format.ts b/apps/ade-cli/src/tuiClient/format.ts index ae7438be5..e682b69b7 100644 --- a/apps/ade-cli/src/tuiClient/format.ts +++ b/apps/ade-cli/src/tuiClient/format.ts @@ -6,6 +6,7 @@ import { highlightCode, type HighlightedToken } from "./highlightCache"; import { glyphFor } from "./theme"; import type { LocalNotice } from "./types"; import { appendStreamingText, isCodexSubagentMessageId, shouldMergeAssistantText } from "./assistantTextIdentity"; +import { terminalReasonLabel } from "./terminalReason"; export type { HighlightedToken } from "./highlightCache"; @@ -526,6 +527,13 @@ export function renderChatLines(args: { maxLines?: number; }): RenderedChatLine[] { const lines: RenderedChatLine[] = []; + const terminalReasonByTurnId = new Map(); + for (const envelope of args.events) { + const event = envelope.event; + if (event.type !== "done" || event.status === "completed") continue; + const label = terminalReasonLabel(event.terminalReason); + if (label) terminalReasonByTurnId.set(event.turnId, label); + } const timeline: TimelineEntry[] = [ ...args.events.map((envelope, index): TimelineEntry => ({ kind: "event", @@ -744,10 +752,9 @@ export function renderChatLines(args: { }); continue; } - // codex_goal_updated / codex_goal_cleared: rendered as amber banner above - // chat by app.tsx — suppress here. codex_token_usage: rendered in - // ContextMeter footer — suppress here too. - if (event.type === "codex_goal_updated" || event.type === "codex_goal_cleared") { + // Provider goal events render as the amber banner above chat in app.tsx. + if (event.type === "codex_goal_updated" || event.type === "codex_goal_cleared" + || event.type === "claude_goal_updated" || event.type === "claude_goal_cleared") { continue; } if (event.type === "codex_token_usage") { @@ -787,6 +794,32 @@ export function renderChatLines(args: { }); continue; } + if (event.type === "conversation_reset") { + lines.push({ id, tone: "notice", body: "conversation reset" }); + continue; + } + if (event.type === "interrupt_receipt") { + const count = event.stillQueuedUuids.length; + if (count > 0) { + lines.push({ + id, + tone: "notice", + body: `stopped — ${count} queued message${count === 1 ? "" : "s"} will still run`, + }); + } + continue; + } + if (event.type === "command_lifecycle") { + if (event.status === "cancelled" || event.status === "discarded") { + const verb = event.status === "discarded" ? "discarded" : "cancelled"; + lines.push({ + id, + tone: "notice", + body: `queued message ${verb}${event.preview ? ` · ${singleLine(event.preview, 120)}` : ""}`, + }); + } + continue; + } if (event.type === "status") { // The interrupted state is conveyed by the dedicated "Interrupted · chat to continue" // suffix row, so suppress the redundant [status] interrupted transcript line. @@ -794,7 +827,14 @@ export function renderChatLines(args: { continue; } const tone: "error" | "notice" = event.turnStatus === "failed" ? "error" : "notice"; - lines.push({ id, tone, body: `[status] ${event.turnStatus}${event.message ? ` · ${singleLine(event.message, 120)}` : ""}` }); + const terminalReason = event.turnStatus !== "completed" && event.turnId + ? terminalReasonByTurnId.get(event.turnId) ?? null + : null; + lines.push({ + id, + tone, + body: `[status] ${event.turnStatus}${terminalReason ? ` · ${terminalReason}` : ""}${event.message ? ` · ${singleLine(event.message, 120)}` : ""}`, + }); continue; } if (event.type === "error") { @@ -806,6 +846,7 @@ export function renderChatLines(args: { const inputTokens = compactTokenCount(usage.inputTokens); const outputTokens = compactTokenCount(usage.outputTokens); const parts = [ + event.status !== "completed" ? terminalReasonLabel(event.terminalReason) : null, inputTokens ? `in ${inputTokens}` : null, outputTokens ? `out ${outputTokens}` : null, typeof event.costUsd === "number" ? `$${event.costUsd.toFixed(2)}` : null, diff --git a/apps/ade-cli/src/tuiClient/terminalReason.ts b/apps/ade-cli/src/tuiClient/terminalReason.ts new file mode 100644 index 000000000..1e84ee5ca --- /dev/null +++ b/apps/ade-cli/src/tuiClient/terminalReason.ts @@ -0,0 +1,19 @@ +// Keep this map aligned with WU2's desktop terminal-reason labels. The shared +// event contract deliberately keeps terminalReason open-ended, so unknown SDK +// reasons stay silent until a concise user-facing label is chosen. +const TERMINAL_REASON_LABELS: Readonly> = { + budget_exhausted: "budget limit reached", + max_turns: "max turns reached", + prompt_too_long: "context window overflow", + api_error: "API error after retries", + malformed_tool_use_exhausted: "tool-call retries exhausted", + structured_output_retry_exhausted: "output retries exhausted", + model_error: "model error", + turn_setup_failed: "turn setup failed", + tool_deferred_unavailable: "deferred tool unavailable", +}; + +export function terminalReasonLabel(reason: string | null | undefined): string | null { + if (!reason) return null; + return TERMINAL_REASON_LABELS[reason] ?? null; +} diff --git a/apps/desktop/package-lock.json b/apps/desktop/package-lock.json index b17ca2922..a68c06caf 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.207", + "@anthropic-ai/claude-agent-sdk": "^0.3.211", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", @@ -247,22 +247,22 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.207.tgz", - "integrity": "sha512-y0PkQRmQBi96MHiN5Xzfq+GaddxCZCqI/cXEQBLYBLXGa4i1nDSlulQqkMBj2RorrrSGQJ6Wdw+uhu6OfHNPzA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk/-/claude-agent-sdk-0.3.211.tgz", + "integrity": "sha512-JhbLu6o1v2g9fjqkO+LDNPWrE0bgd9UeRQQ41JBGouAgows3KyPPYgU2WU0q7M2onuwQxR5plGDpas01F+oaUA==", "license": "SEE LICENSE IN README.md", "engines": { "node": ">=18.0.0" }, "optionalDependencies": { - "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.207", - "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.207" + "@anthropic-ai/claude-agent-sdk-darwin-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-darwin-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-arm64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-linux-x64-musl": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-arm64": "0.3.211", + "@anthropic-ai/claude-agent-sdk-win32-x64": "0.3.211" }, "peerDependencies": { "@anthropic-ai/sdk": ">=0.93.0", @@ -271,9 +271,9 @@ } }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.207.tgz", - "integrity": "sha512-08xSo1FDx8h0aLhL5tvcRxa2SMmcUV3aDWeZiEJVTclyiDAs61BgTjAxCg+SZcu1CndjJO8cfO0yM5dhamxz3g==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-arm64/-/claude-agent-sdk-darwin-arm64-0.3.211.tgz", + "integrity": "sha512-Iwhm4kfcs20LdXffZ2RGRjj+BFdUOrT/JjhGtICjlGlBPlrLkkkAiHtGzqO9K36v5B/kSIHwOw9CM836kYYPHQ==", "cpu": [ "arm64" ], @@ -284,9 +284,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-darwin-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.207.tgz", - "integrity": "sha512-1o7K4EYqyCixZ/oeOZSh7AzSy6TM86xoOuf4VuORjPSS31hBnoqY0NGZd27+2VDs9LGtsdksmsTqcNGx9xd1hA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-darwin-x64/-/claude-agent-sdk-darwin-x64-0.3.211.tgz", + "integrity": "sha512-sMBW1CLe2Hq4PwwvEbz9r8LxF84UErgB45TSf+iEa8M/EjYZCsXSoTRT0vKnN4TvrAN5mWoKgi39dkUxS9ybsA==", "cpu": [ "x64" ], @@ -297,15 +297,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.207.tgz", - "integrity": "sha512-X4uezYOifDiNTTmmugfRCdg3nNamrr1LFRY9hg30vWYTShL+bbN+nfC3KaFfSYCl4GTtsEEUbYdOTC2F3bBpcA==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64/-/claude-agent-sdk-linux-arm64-0.3.211.tgz", + "integrity": "sha512-orZm8p+BzVRZ7I8c5yD43hEZ5TvBZ+UbKTZTlvID00Y8HSn3M3rNX3sW4RUvNGF2e0eNMaXKysPyEHPEj3kqpg==", "cpu": [ "arm64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -313,15 +310,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-arm64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.207.tgz", - "integrity": "sha512-oPj+g2DslhH4Y9nCTs7al4t9wZv78FZwLFQwOCg99BXuz1o0ZOpKmxyvR7J9eBR+GPszeMMS8gYplQTiZC9o2w==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-arm64-musl/-/claude-agent-sdk-linux-arm64-musl-0.3.211.tgz", + "integrity": "sha512-X1eg+lCwNH2VXyqLQR722dsDDtWPfUnz7OtnmPVoNMxcMexDlSLMMuvm5fNNi94kYT0pxmBdhIvudew145JuHg==", "cpu": [ "arm64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -329,15 +323,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.207.tgz", - "integrity": "sha512-Kg6BPH8Ee0ny/oEUWJmvT1jCRBne4jVpRSOMsJcYp1Fav1rMEgpU219oJJs+LWwx4ifuuLtNWedqJNnVw7mnKg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64/-/claude-agent-sdk-linux-x64-0.3.211.tgz", + "integrity": "sha512-ohDS5EGKQvKiUUMtDNPjyWUDvaeIa+DlzUVjrZ8Y4hPtoWFpvOBtOFIYChJGpllwZ4YULS4H3gywVnLGB4do6Q==", "cpu": [ "x64" ], - "libc": [ - "glibc" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -345,15 +336,12 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-linux-x64-musl": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.207.tgz", - "integrity": "sha512-uRv+D5oG/7EYr41FAJ9IPo2pZYBe2ZMaA6nSHCeizsgPxCSMtl5bNppmU21+jZJvo4hivObEgkGFERAhdGqygg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-linux-x64-musl/-/claude-agent-sdk-linux-x64-musl-0.3.211.tgz", + "integrity": "sha512-cR12YFMVGSj38074OYkkjgwhYeblgVRK3Uw7ZXw3ZTOevjQLASPajVruFuDRW07ixxTa1ZWxqI3kSsIl71RXYA==", "cpu": [ "x64" ], - "libc": [ - "musl" - ], "license": "SEE LICENSE IN LICENSE.md", "optional": true, "os": [ @@ -361,9 +349,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-arm64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.207.tgz", - "integrity": "sha512-9fWpUzfkXlPAg2tf8JpQe7w9avFaomAUbfAwyAmykQgSIf66LwaJjvI5hNqhNqczRKyfsXPn3ei2S5HKlmFP+Q==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-arm64/-/claude-agent-sdk-win32-arm64-0.3.211.tgz", + "integrity": "sha512-AOIQRFO0YMDUCrG8W0NUWitBQQUB6fYdNW3SMcPPq3mXpYC/pCNURFyvRdrFm729xXkxDAP5OMLk9x2/IFrTmA==", "cpu": [ "arm64" ], @@ -374,9 +362,9 @@ ] }, "node_modules/@anthropic-ai/claude-agent-sdk-win32-x64": { - "version": "0.3.207", - "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.207.tgz", - "integrity": "sha512-YPjVT0q6aXEM2MgN4CI6/9fqiTXwETji+4NoPOzCYuqAkhXZqp30Jsk7/NHqYGNNSfURKrsuAoliKB0rsbpbjg==", + "version": "0.3.211", + "resolved": "https://registry.npmjs.org/@anthropic-ai/claude-agent-sdk-win32-x64/-/claude-agent-sdk-win32-x64-0.3.211.tgz", + "integrity": "sha512-pwzNuJg2xRBsv3kSSVVhgLdGAFxd5DqPkQX5ZLrT4uBZDJ+QM4xWKZyN8ZoFLLzNl+u0/4U83Q1ZQ0NdG/9JsQ==", "cpu": [ "x64" ], diff --git a/apps/desktop/package.json b/apps/desktop/package.json index ec5dc1cc9..70ec3af45 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -59,7 +59,7 @@ "version:release": "node ./scripts/set-release-version.mjs" }, "dependencies": { - "@anthropic-ai/claude-agent-sdk": "^0.3.207", + "@anthropic-ai/claude-agent-sdk": "^0.3.211", "@anthropic-ai/sdk": "^0.103.0", "@cursor/sdk": "^1.0.23", "@factory/droid-sdk": "^0.2.0", diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 7e85a6cb2..776b1019b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -1020,6 +1020,15 @@ function bridgeClaudeSessionToQuery(sessionHandle: any, prompt: unknown) { } return undefined; }), + cancelAsyncMessage: vi.fn(async (uuid: string) => { + if (typeof session.cancelAsyncMessage === "function") { + return session.cancelAsyncMessage(uuid); + } + if (typeof session.query?.cancelAsyncMessage === "function") { + return session.query.cancelAsyncMessage(uuid); + } + return false; + }), setPermissionMode: vi.fn(async (mode: string) => { if (typeof session.setPermissionMode === "function") { return session.setPermissionMode(mode); @@ -1716,10 +1725,10 @@ async function waitForEvent( throw new Error("Timed out waiting for agent chat event."); } -async function runClaudeStreamFixture(args: { +async function createClaudeStreamFixture(args: { sdkSessionId: string; messages: Array>; -}): Promise { +}) { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); const send = vi.fn().mockResolvedValue(undefined); @@ -1750,9 +1759,10 @@ async function runClaudeStreamFixture(args: { setPermissionMode, } as any); - const { service } = createService({ + const harness = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event), }); + const { service } = harness; const session = await service.createSession({ laneId: "lane-1", provider: "claude", @@ -1765,7 +1775,28 @@ async function runClaudeStreamFixture(args: { text: "Exercise Claude streaming text.", }); - return events; + return { ...harness, events, session, send }; +} + +async function runClaudeStreamFixture(args: { + sdkSessionId: string; + messages: Array>; +}): Promise { + return (await createClaudeStreamFixture(args)).events; +} + +function claudeInputText(message: unknown): string { + if (typeof message === "string") return message; + const content = (message as { message?: { content?: unknown } } | null)?.message?.content; + if (typeof content === "string") return content; + if (!Array.isArray(content)) return ""; + return content + .map((block) => typeof block === "string" + ? block + : typeof block === "object" && block !== null && typeof (block as { text?: unknown }).text === "string" + ? String((block as { text: string }).text) + : "") + .join(""); } async function waitForSessionTitle(sessionService: ReturnType, sessionId: string, title: string): Promise { @@ -9024,6 +9055,7 @@ describe("createAgentChatService", () => { uuid: "wire-1", session_id: "sdk-session-1", parent_tool_use_id: null, + parent_agent_id: "parent-agent-1", message: { id: "msg-1", role: "assistant", @@ -9040,6 +9072,7 @@ describe("createAgentChatService", () => { })).resolves.toEqual([expect.objectContaining({ uuid: "wire-1", sessionId: "sdk-session-1", + parentAgentId: "parent-agent-1", text: "SDK transcript text", })]); expect(getSessionMessages).toHaveBeenCalledWith("sdk-session-1", { @@ -26700,6 +26733,99 @@ describe("createAgentChatService", () => { // -------------------------------------------------------------------------- describe("interrupt", () => { + it.each([ + { providerFirst: true, expectedOrder: ["interrupt_receipt", "done"] }, + { providerFirst: false, expectedOrder: ["done", "interrupt_receipt"] }, + ])("matches known and unknown Claude interrupt receipts when providerFirst=$providerFirst", async ({ + providerFirst, + expectedOrder, + }) => { + const events: AgentChatEventEnvelope[] = []; + const send = vi.fn().mockResolvedValue(undefined); + let streamCall = 0; + let releaseTurn!: () => void; + const turnGate = new Promise((resolve) => { releaseTurn = resolve; }); + const queryInterrupt = vi.fn(async () => { + const sent = send.mock.calls.find(([message]) => typeof message?.uuid === "string")?.[0]; + setTimeout(releaseTurn, 0); + return { still_queued: [sent.uuid, "unknown-internal-uuid"] }; + }); + const close = vi.fn(() => releaseTurn()); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { + type: "system", + subtype: "init", + session_id: `sdk-interrupt-receipt-${providerFirst}`, + slash_commands: [], + capabilities: ["interrupt_receipt_v1"], + }; + return; + } + yield { + type: "assistant", + message: { + id: `receipt-assistant-${providerFirst}`, + content: [{ type: "text", text: "Still working." }], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }; + await turnGate; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close, + interrupt: queryInterrupt, + sessionId: `sdk-interrupt-receipt-${providerFirst}`, + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + const turnPromise = service.runSessionTurn({ + sessionId: session.id, + text: "Keep the turn active while a queued message is dispatched.", + }); + await waitForEvent(events, (entry): entry is AgentChatEventEnvelope => entry.event.type === "text"); + const queued = await service.steer({ + sessionId: session.id, + text: "Match this visible queued-message preview.", + }); + expect(queued.queued).toBe(true); + await service.dispatchSteer({ + sessionId: session.id, + steerId: queued.steerId, + mode: "inline", + }); + await vi.waitFor(() => { + expect(send.mock.calls.some(([message]) => typeof message?.uuid === "string")).toBe(true); + }); + + await (service.interrupt as any)( + { sessionId: session.id }, + providerFirst ? { requireClaudeProviderInterrupt: true } : undefined, + ); + await turnPromise; + + const receipt = events.find((entry) => entry.event.type === "interrupt_receipt"); + const sentUuid = send.mock.calls.find(([message]) => typeof message?.uuid === "string")?.[0].uuid; + expect(receipt?.event).toMatchObject({ + stillQueuedUuids: [sentUuid, "unknown-internal-uuid"], + known: [{ uuid: sentUuid, preview: "Match this visible queued-message preview." }], + }); + const relevantOrder = events + .map((entry) => entry.event.type) + .filter((type) => type === "interrupt_receipt" || type === "done"); + expect(relevantOrder.slice(0, 2)).toEqual(expectedOrder); + }); + it("throws when interrupting an unknown session", async () => { const { service } = createService(); await expect( @@ -28403,11 +28529,11 @@ describe("createAgentChatService", () => { ).rejects.toThrow(/not supported on Codex/i); }); - it("cancelDispatchedSteer reports inline-dispatched Claude steers as non-cancellable", async () => { + it("cancelDispatchedSteer cancels an SDK-queued Claude steer by its command UUID", async () => { const events: AgentChatEventEnvelope[] = []; const send = vi.fn().mockResolvedValue(undefined); const setPermissionMode = vi.fn().mockResolvedValue(undefined); - const cancelAsyncMessage = vi.fn().mockResolvedValue({ cancelled: true }); + const cancelAsyncMessage = vi.fn().mockResolvedValue(true); let streamCall = 0; let interruptedTurnClosed = false; @@ -28471,8 +28597,17 @@ describe("createAgentChatService", () => { expect(sentUuid.length).toBeGreaterThan(0); const cancelResult = await service.cancelDispatchedSteer({ sessionId: session.id, steerId }); - expect(cancelResult.cancelled).toBe(false); - expect(cancelAsyncMessage).not.toHaveBeenCalled(); + expect(cancelResult.cancelled).toBe(true); + expect(cancelAsyncMessage).toHaveBeenCalledWith(sentUuid); + expect(events.find((entry) => + entry.event.type === "system_notice" + && entry.event.steerId === steerId + && /cancelled/i.test(entry.event.message) + )?.event).toMatchObject({ + type: "system_notice", + steerId, + message: "Queued message cancelled.", + }); // Cleanup await service.interrupt({ sessionId: session.id }); @@ -29444,6 +29579,517 @@ describe("createAgentChatService", () => { await sendPromise; }); + it.each([ + { subtype: "error_during_execution", terminalReason: undefined, expectedStatus: "failed" }, + { subtype: "error_max_turns", terminalReason: undefined, expectedStatus: "failed" }, + { subtype: "error_max_budget_usd", terminalReason: undefined, expectedStatus: "failed" }, + { subtype: "error_max_structured_output_retries", terminalReason: undefined, expectedStatus: "failed" }, + { subtype: "success", terminalReason: "budget_exhausted", expectedStatus: "failed" }, + { subtype: "success", terminalReason: "structured_output_retry_exhausted", expectedStatus: "failed" }, + { subtype: "success", terminalReason: "max_turns", expectedStatus: "failed" }, + { subtype: "success", terminalReason: "api_error", expectedStatus: "failed" }, + { subtype: "success", terminalReason: "malformed_tool_use_exhausted", expectedStatus: "failed" }, + { subtype: "success", terminalReason: "prompt_too_long", expectedStatus: "failed" }, + { subtype: "error_during_execution", terminalReason: "aborted_streaming", expectedStatus: "interrupted" }, + { subtype: "error_during_execution", terminalReason: "aborted_tools", expectedStatus: "interrupted" }, + ])("maps Claude $subtype/$terminalReason results to $expectedStatus", async ({ + subtype, + terminalReason, + expectedStatus, + }) => { + const events = await runClaudeStreamFixture({ + sdkSessionId: `sdk-terminal-${subtype}-${terminalReason ?? "none"}`, + messages: [{ + type: "result", + subtype, + is_error: subtype !== "success", + ...(terminalReason ? { terminal_reason: terminalReason } : {}), + usage: { input_tokens: 1, output_tokens: 1 }, + }], + }); + + const done = events.find((entry) => + entry.event.type === "done" + && entry.event.terminalReason === terminalReason + ); + expect(done?.event).toMatchObject({ + type: "done", + status: expectedStatus, + ...(terminalReason + ? { terminalReason, terminalReasonSource: "sdk" } + : {}), + }); + }); + + it("surfaces Claude protocol frames and adopts a reset conversation as the SDK resume pointer", async () => { + const goal = { + condition: "Finish the SDK wiring", + iterations: 1, + set_at: 100, + tokens_at_start: 200, + last_reason: "initial", + }; + const { events, service, session } = await createClaudeStreamFixture({ + sdkSessionId: "sdk-protocol-surfaces", + messages: [ + { + type: "system", + subtype: "init", + session_id: "sdk-protocol-surfaces", + slash_commands: [], + capabilities: ["interrupt_receipt_v1", "future_capability", "interrupt_receipt_v1"], + }, + { + type: "conversation_reset", + new_conversation_id: "conversation-after-clear", + session_id: "sdk-protocol-surfaces", + uuid: "conversation-reset-1", + }, + { type: "active_goal", value: goal, session_id: "conversation-after-clear", uuid: "goal-1" }, + { type: "active_goal", value: goal, session_id: "conversation-after-clear", uuid: "goal-duplicate" }, + { + type: "active_goal", + value: { ...goal, iterations: 2, last_reason: "continued" }, + session_id: "conversation-after-clear", + uuid: "goal-2", + }, + { type: "active_goal", value: null, session_id: "conversation-after-clear", uuid: "goal-clear" }, + { + type: "system", + subtype: "api_retry", + attempt: 2, + max_retries: 5, + retry_delay_ms: 750, + error_status: 529, + error: "overloaded", + }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + expect(events.find((entry) => entry.event.type === "conversation_reset")?.event).toEqual(expect.objectContaining({ + type: "conversation_reset", + newConversationId: "conversation-after-clear", + })); + expect(events.filter((entry) => entry.event.type === "claude_goal_updated")).toHaveLength(2); + expect(events.filter((entry) => entry.event.type === "claude_goal_cleared")).toHaveLength(1); + expect(events.find((entry) => entry.event.type === "api_retry")?.event).toMatchObject({ + attempt: 2, + maxRetries: 5, + retryDelayMs: 750, + errorStatus: 529, + }); + expect(readPersistedChatState(session.id)).toMatchObject({ + sdkSessionId: "conversation-after-clear", + protocolCapabilities: ["interrupt_receipt_v1", "future_capability"], + }); + await expect(service.getSessionSummary(session.id)).resolves.toMatchObject({ + protocolCapabilities: ["interrupt_receipt_v1", "future_capability"], + }); + }); + + it("emits deduplicated command lifecycle events only for ADE-owned Claude messages", 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: "system", subtype: "init", session_id: "sdk-command-lifecycle", slash_commands: [] }; + return; + } + let sentUuid: string | undefined; + await vi.waitFor(() => { + const userMessage = events.find((entry) => entry.event.type === "user_message")?.event; + sentUuid = userMessage?.type === "user_message" ? userMessage.messageId : undefined; + expect(sentUuid).toEqual(expect.any(String)); + }); + yield { type: "command_lifecycle", command_uuid: "internal-command", status: "queued" }; + yield { type: "command_lifecycle", command_uuid: sentUuid, status: "queued" }; + yield { type: "command_lifecycle", command_uuid: sentUuid, status: "queued" }; + yield { type: "command_lifecycle", command_uuid: sentUuid, status: "started" }; + yield { type: "command_lifecycle", command_uuid: sentUuid, status: "discarded" }; + yield { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-command-lifecycle", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await service.runSessionTurn({ sessionId: session.id, text: "Track this command." }); + + const lifecycle = events + .map((entry) => entry.event) + .filter((event): event is Extract => + event.type === "command_lifecycle"); + expect(lifecycle.map((event) => event.status)).toEqual(["queued", "started", "discarded"]); + expect(lifecycle.every((event) => event.preview === "Track this command.")).toBe(true); + }); + + it("persists and rehydrates the current Claude active goal", async () => { + const goal = { + condition: "Prove goal persistence", + iterations: 3, + set_at: 1_234, + tokens_at_start: 5_678, + last_reason: "verification", + }; + const harness = await createClaudeStreamFixture({ + sdkSessionId: "sdk-goal-persistence", + messages: [ + { type: "active_goal", value: goal, session_id: "sdk-goal-persistence", uuid: "goal-persist" }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + expect(readPersistedChatState(harness.session.id).claudeGoal).toMatchObject({ + condition: goal.condition, + iterations: 3, + setAt: 1_234, + tokensAtStart: 5_678, + lastReason: "verification", + updatedAt: expect.any(Number), + }); + + const rehydrated = createService({ sessionService: harness.sessionService }); + await expect(rehydrated.service.getSessionSummary(harness.session.id)).resolves.toMatchObject({ + claudeGoal: { + condition: goal.condition, + iterations: 3, + setAt: 1_234, + tokensAtStart: 5_678, + lastReason: "verification", + updatedAt: expect.any(Number), + }, + }); + }); + + it("attaches structured Claude tool outputs and enriches Agent and Task results", async () => { + const toolUseResult = (toolUseId: string, structured: unknown) => ({ + type: "user", + message: { + role: "user", + content: [{ type: "tool_result", tool_use_id: toolUseId, content: `result:${toolUseId}` }], + }, + tool_use_result: structured, + }); + const agentOutput = (summary: string, worktreePath: string) => ({ + status: "completed", + content: [{ type: "text", text: summary }], + usage: { + input_tokens: 10, + output_tokens: 20, + cache_creation_input_tokens: 2, + cache_read_input_tokens: 3, + }, + toolStats: { + readCount: 1, + searchCount: 2, + bashCount: 3, + editFileCount: 4, + otherToolCount: 5, + }, + worktreePath, + worktreeBranch: "feature/sdk-wiring", + }); + const { events } = await createClaudeStreamFixture({ + sdkSessionId: "sdk-structured-tool-results", + messages: [ + { + type: "assistant", + message: { + id: "assistant-tools", + content: [ + { type: "tool_use", id: "bash-1", name: "Bash", input: { command: "npm test" } }, + { type: "tool_use", id: "grep-1", name: "Grep", input: { pattern: "needle" } }, + { type: "tool_use", id: "read-1", name: "Read", input: { file_path: "README.md" } }, + { type: "tool_use", id: "agent-1", name: "Agent", input: { prompt: "Inspect Agent output" } }, + { type: "tool_use", id: "task-1", name: "Task", input: { prompt: "Inspect Task output" } }, + ], + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }, + { + type: "system", + subtype: "task_started", + task_id: "agent-task-1", + agent_id: "agent-id-1", + parent_tool_use_id: "agent-1", + description: "Inspect Agent output", + }, + { + type: "system", + subtype: "task_started", + task_id: "agent-task-2", + agent_id: "agent-id-2", + parent_tool_use_id: "task-1", + description: "Inspect Task output", + }, + toolUseResult("bash-1", { timedOutAfterMs: 30_000, backgroundCwdHint: "cwd remains unchanged" }), + toolUseResult("grep-1", { totalFiles: 7, totalLines: 19 }), + toolUseResult("read-1", { futureShape: { preserved: true } }), + toolUseResult("agent-1", agentOutput("Agent completed.", "/tmp/agent-worktree")), + toolUseResult("task-1", agentOutput("Task completed.", "/tmp/task-worktree")), + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + const toolResults = events + .map((entry) => entry.event) + .filter((event): event is Extract => + event.type === "tool_result"); + expect(toolResults.find((event) => event.itemId === "bash-1")).toMatchObject({ + structured: { timedOutAfterMs: 30_000, backgroundCwdHint: "cwd remains unchanged" }, + timedOutAfterMs: 30_000, + backgroundCwdHint: "cwd remains unchanged", + }); + expect(toolResults.find((event) => event.itemId === "grep-1")).toMatchObject({ + grepTotals: { files: 7, lines: 19 }, + }); + expect(toolResults.find((event) => event.itemId === "read-1")?.structured).toEqual({ + futureShape: { preserved: true }, + }); + + const subagentResults = events + .map((entry) => entry.event) + .filter((event): event is Extract => + event.type === "subagent_result" && event.worktreePath != null); + expect(subagentResults).toEqual(expect.arrayContaining([ + expect.objectContaining({ + taskId: "agent-task-1", + worktreePath: "/tmp/agent-worktree", + worktreeBranch: "feature/sdk-wiring", + totalTokens: 35, + toolUseCount: 15, + }), + expect.objectContaining({ + taskId: "agent-task-2", + worktreePath: "/tmp/task-worktree", + worktreeBranch: "feature/sdk-wiring", + totalTokens: 35, + toolUseCount: 15, + }), + ])); + }); + + it("throttles live Claude context usage by both time and percentage movement", async () => { + let now = 0; + const dateNow = vi.spyOn(Date, "now").mockImplementation(() => now); + const messages = [ + { + type: "stream_event", + event: { + type: "message_start", + message: { + id: "usage-message", + usage: { + input_tokens: 100_000, + cache_read_input_tokens: 20_000, + cache_creation_input_tokens: 30_000, + output_tokens: 0, + }, + }, + }, + }, + { + type: "stream_event", + event: { type: "message_delta", usage: { output_tokens: 20_000 } }, + }, + { + type: "stream_event", + event: { type: "message_delta", usage: { output_tokens: 5_000 } }, + }, + { + type: "stream_event", + event: { type: "message_delta", usage: { output_tokens: 20_000 } }, + }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ]; + const timedMessages = (async function* () { + now = 0; + yield messages[0]!; + now = 1_000; + yield messages[1]!; + now = 6_000; + yield messages[2]!; + now = 7_000; + yield messages[3]!; + yield messages[4]!; + })(); + const materialized: Array> = []; + for await (const message of timedMessages) materialized.push(message); + // Re-apply the intended times from inside the SDK stream rather than while + // materializing the fixtures. + let streamCall = 0; + const send = vi.fn().mockResolvedValue(undefined); + const stream = vi.fn(() => (async function* () { + streamCall += 1; + if (streamCall === 1) { + yield { type: "system", subtype: "init", session_id: "sdk-live-usage", slash_commands: [] }; + return; + } + now = 0; + yield materialized[0]!; + now = 1_000; + yield materialized[1]!; + now = 6_000; + yield materialized[2]!; + now = 7_000; + yield materialized[3]!; + yield materialized[4]!; + })()); + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send, + stream, + close: vi.fn(), + sessionId: "sdk-live-usage", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ onEvent: (event: AgentChatEventEnvelope) => events.push(event) }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await service.runSessionTurn({ sessionId: session.id, text: "Measure context." }); + dateNow.mockRestore(); + + const usageEvents = events + .map((entry) => entry.event) + .filter((event): event is Extract => + event.type === "context_usage" && event.origin === "live"); + expect(usageEvents.map((event) => event.usage.percentage)).toEqual([15, 17]); + }); + + it("lets natural compaction suppress the 97% fallback", async () => { + const harness = await createClaudeStreamFixture({ + sdkSessionId: "sdk-natural-compact", + messages: [ + { + type: "stream_event", + event: { + type: "message_start", + message: { id: "natural-usage", usage: { input_tokens: 980_000, output_tokens: 0 } }, + }, + }, + { type: "system", subtype: "status", status: "compacting" }, + { + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 980_000, post_tokens: 500_000 }, + }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + expect(harness.send.mock.calls.map(([message]) => claudeInputText(message))).not.toContain("/compact"); + expect(harness.logger.info).toHaveBeenCalledWith( + "agent_chat.claude_context_compaction_observed", + expect.objectContaining({ trigger: "natural", occupancyPctAtTrigger: 98 }), + ); + }); + + it("starts a fresh guardrail episode after occupancy drops below 80%", async () => { + const harness = await createClaudeStreamFixture({ + sdkSessionId: "sdk-guardrail-episode-reset", + messages: [ + { + type: "stream_event", + event: { + type: "message_start", + message: { id: "first-episode", usage: { input_tokens: 920_000, output_tokens: 0 } }, + }, + }, + { type: "system", subtype: "status", status: "compacting" }, + { + type: "system", + subtype: "compact_boundary", + compact_metadata: { trigger: "auto", pre_tokens: 920_000, post_tokens: 700_000 }, + }, + { + type: "stream_event", + event: { + type: "message_start", + message: { id: "second-episode", usage: { input_tokens: 970_000, output_tokens: 0 } }, + }, + }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + await vi.waitFor(() => { + expect(harness.send.mock.calls.map(([message]) => claudeInputText(message)).filter((text) => text === "/compact")) + .toHaveLength(1); + }); + }); + + it("issues one fallback compact at a 97% turn boundary", async () => { + const harness = await createClaudeStreamFixture({ + sdkSessionId: "sdk-fallback-compact", + messages: [ + { + type: "stream_event", + event: { + type: "message_start", + message: { id: "fallback-usage", usage: { input_tokens: 970_000, output_tokens: 0 } }, + }, + }, + { type: "result", subtype: "success", is_error: false, usage: { input_tokens: 1, output_tokens: 1 } }, + ], + }); + + await vi.waitFor(() => { + expect(harness.send.mock.calls.map(([message]) => claudeInputText(message)).filter((text) => text === "/compact")) + .toHaveLength(1); + }); + expect(harness.events.find((entry) => + entry.event.type === "context_compact" + && entry.event.trigger === "ade_fallback" + && entry.event.state === "started" + )?.event).toMatchObject({ compactionId: expect.any(String) }); + }); + + it("recovers once from prompt_too_long without replaying the failed user message", async () => { + const harness = await createClaudeStreamFixture({ + sdkSessionId: "sdk-overflow-recovery", + messages: [{ + type: "result", + subtype: "error_during_execution", + is_error: true, + terminal_reason: "prompt_too_long", + errors: ["prompt is too long for this context window"], + usage: { input_tokens: 1, output_tokens: 0 }, + }], + }); + + await vi.waitFor(() => { + expect(harness.send.mock.calls.map(([message]) => claudeInputText(message)).filter((text) => text === "/compact")) + .toHaveLength(1); + }); + expect(harness.events.find((entry) => + entry.event.type === "system_notice" + && entry.event.message === "context overflowed — compacted; please re-send your last message" + )?.event).toMatchObject({ + type: "system_notice", + noticeKind: "info", + message: "context overflowed — compacted; please re-send your last message", + }); + expect(harness.send.mock.calls.map(([message]) => claudeInputText(message)).filter((text) => + text.includes("Exercise Claude streaming text."))).toHaveLength(1); + }); + it("does not duplicate Claude thinking when the final assistant message repeats streamed content", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); @@ -30290,6 +30936,42 @@ describe("createAgentChatService", () => { }); }); + it("allows generic Claude tools without manufacturing updatedInput", async () => { + vi.mocked(claudeSdkCreateSessionCompat).mockReturnValue({ + send: vi.fn().mockResolvedValue(undefined), + stream: vi.fn(() => (async function* () { + yield { type: "system", subtype: "init", session_id: "sdk-can-use-tool", slash_commands: [] }; + })()), + close: vi.fn(), + sessionId: "sdk-can-use-tool", + setPermissionMode: vi.fn().mockResolvedValue(undefined), + } as any); + const { service } = createService(); + await service.createSession({ + laneId: "lane-1", + provider: "claude", + model: "claude-sonnet-5", + modelId: "anthropic/claude-sonnet-5", + }); + await vi.waitFor(() => expect(claudeSdkCreateSessionCompat).toHaveBeenCalled()); + const options = vi.mocked(claudeSdkCreateSessionCompat).mock.calls.at(-1)?.[0] as { + canUseTool: ( + tool: string, + input: Record, + options: Record, + ) => Promise>; + }; + + const result = await options.canUseTool( + "Read", + { file_path: "README.md" }, + { signal: new AbortController().signal, toolUseID: "read-tool-1" }, + ); + + expect(result).toEqual({ behavior: "allow" }); + expect(result).not.toHaveProperty("updatedInput"); + }); + it("suppresses the 'tool calls were denied' notice for tool_use_ids resolved inline via canUseTool", async () => { const events: AgentChatEventEnvelope[] = []; const setPermissionMode = vi.fn().mockResolvedValue(undefined); diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 36130cf32..19a4cb08b 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -9,10 +9,12 @@ import { } from "../externalSessions/claudeSessionTransplant"; import { findCodexRolloutPathBySessionId } from "../externalSessions/discoverCodex"; import fs from "node:fs"; +import { createRequire } from "node:module"; import net from "node:net"; import os from "node:os"; import path from "node:path"; import readline from "node:readline"; +import { fileURLToPath } from "node:url"; import { getSessionInfo as getClaudeSdkSessionInfo, getSessionMessages as getClaudeSdkSessionMessages, @@ -38,6 +40,7 @@ import type { Query as ClaudeQuery, RewindFilesResult as ClaudeRewindFilesResult, SDKControlGetContextUsageResponse, + SDKControlInterruptResponse, SDKMessage, SDKUserMessage, WarmQuery, @@ -295,6 +298,7 @@ import type { CodexTokenUsageBreakdown, CodexWebSearchAction, CodexWebSearchResult, + ClaudeActiveGoal, PendingInputQuestion, PendingInputKind, PendingInputRequest, @@ -526,7 +530,28 @@ import { } from "../../../shared/orchestrationRuntimePolicy"; import type { ProcessRegistryService } from "../runtime/processRegistryService"; -const CLAUDE_AGENT_SDK_VERSION = "0.3.207"; +const requireFromRuntime = createRequire( + typeof __filename === "string" ? __filename : fileURLToPath(import.meta.url), +); + +function resolveClaudeAgentSdkVersion(): string { + try { + const sdkEntryPath = requireFromRuntime.resolve("@anthropic-ai/claude-agent-sdk"); + const packageJson = JSON.parse( + fs.readFileSync(path.join(path.dirname(sdkEntryPath), "package.json"), "utf8"), + ) as { + version?: unknown; + }; + if (typeof packageJson.version === "string" && packageJson.version.trim()) { + return packageJson.version; + } + } catch { + // The package metadata can be unavailable in partial development installs. + } + return "0.3.211"; +} + +const CLAUDE_AGENT_SDK_VERSION = resolveClaudeAgentSdkVersion(); const CLAUDE_AGENT_SDK_API = "v1_query"; const CLAUDE_POST_RESULT_DRAIN_TIMEOUT_MS = 1_000; const CLAUDE_INTERNAL_EDE_DIAGNOSTIC_PREFIX = "[ede_diagnostic]"; @@ -562,6 +587,184 @@ function partitionClaudeResultErrors(value: unknown): { return { all, internalDiagnostics, userFacing }; } +type ClaudeTerminalStatus = "completed" | "interrupted" | "failed"; + +const CLAUDE_INTERRUPTED_TERMINAL_REASONS = new Set([ + "aborted_streaming", + "aborted_tools", +]); + +const CLAUDE_FAILED_TERMINAL_REASONS = new Set([ + "budget_exhausted", + "structured_output_retry_exhausted", + "max_turns", + "api_error", + "malformed_tool_use_exhausted", + "prompt_too_long", +]); + +function classifyClaudeResultStatus(result: Record): ClaudeTerminalStatus { + const terminalReason = stringOrNull(result.terminal_reason); + if (terminalReason && CLAUDE_INTERRUPTED_TERMINAL_REASONS.has(terminalReason)) { + return "interrupted"; + } + if (terminalReason && CLAUDE_FAILED_TERMINAL_REASONS.has(terminalReason)) { + return "failed"; + } + const errors = partitionClaudeResultErrors(result.errors); + if (result.is_error === true && errors.all.length > 0 && errors.userFacing.length === 0) { + return "completed"; + } + const subtype = stringOrNull(result.subtype); + if (subtype?.startsWith("error_")) return "failed"; + return result.is_error === true ? "failed" : "completed"; +} + +function normalizeClaudeActiveGoalPayload(value: unknown): Omit | null { + const record = asRecord(value); + const condition = stringOrNull(record?.condition); + const iterations = numberOrNull(record?.iterations); + const setAt = numberOrNull(record?.set_at ?? record?.setAt); + const tokensAtStart = numberOrNull(record?.tokens_at_start ?? record?.tokensAtStart); + if (!condition || iterations == null || setAt == null || tokensAtStart == null) return null; + const lastReason = stringOrNull(record?.last_reason ?? record?.lastReason); + return { + condition, + iterations, + setAt, + tokensAtStart, + ...(lastReason ? { lastReason } : {}), + }; +} + +function claudeActiveGoalsEqual( + left: ClaudeActiveGoal | null | undefined, + right: Omit, +): boolean { + return Boolean( + left + && left.condition === right.condition + && left.iterations === right.iterations + && left.setAt === right.setAt + && left.tokensAtStart === right.tokensAtStart + && left.lastReason === right.lastReason, + ); +} + +function normalizeProtocolCapabilities(value: unknown): string[] { + return [...new Set( + (Array.isArray(value) ? value : []) + .filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0) + .map((entry) => entry.trim()), + )]; +} + +function claudeQueuedMessagePreview(value: string): string { + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length <= 80 ? compact : `${compact.slice(0, 77)}...`; +} + +function rememberClaudeKnownQueuedMessage( + known: Map, + uuid: string, + value: { preview: string; steerId?: string }, +): void { + known.set(uuid, value); + while (known.size > 256) { + const oldest = known.keys().next().value; + if (typeof oldest !== "string") break; + known.delete(oldest); + } +} + +function claudeToolResultPayload(message: unknown): { toolUseId: string; result: unknown } | null { + const content = asRecord(message)?.content; + if (!Array.isArray(content)) return null; + for (const rawBlock of content) { + const block = asRecord(rawBlock); + if (block?.type !== "tool_result") continue; + const toolUseId = stringOrNull(block.tool_use_id); + if (!toolUseId) continue; + return { toolUseId, result: block.content }; + } + return null; +} + +function claudeStructuredToolResultFields(toolName: string, structured: unknown): { + timedOutAfterMs?: number; + backgroundCwdHint?: string; + grepTotals?: { files?: number; lines?: number }; +} { + const record = asRecord(structured); + if (!record) return {}; + if (toolName === "Bash") { + const timedOutAfterMs = numberOrNull(record.timedOutAfterMs); + const backgroundCwdHint = stringOrNull(record.backgroundCwdHint); + return { + ...(timedOutAfterMs != null ? { timedOutAfterMs } : {}), + ...(backgroundCwdHint ? { backgroundCwdHint } : {}), + }; + } + if (toolName === "Grep") { + const files = numberOrNull(record.totalFiles); + const lines = numberOrNull(record.totalLines); + return files != null || lines != null + ? { grepTotals: { ...(files != null ? { files } : {}), ...(lines != null ? { lines } : {}) } } + : {}; + } + return {}; +} + +type ClaudeStructuredSubagentResult = { + summary: string; + worktreePath?: string; + worktreeBranch?: string; + totalTokens?: number; + toolUseCount?: number; +}; + +function claudeStructuredSubagentResult(value: unknown): ClaudeStructuredSubagentResult | null { + const record = asRecord(value); + if (!record || record.status !== "completed") return null; + const usage = asRecord(record.usage); + const tokenFields = [ + usage?.input_tokens, + usage?.output_tokens, + usage?.cache_creation_input_tokens, + usage?.cache_read_input_tokens, + ]; + const numericTokenFields = tokenFields.filter((entry): entry is number => typeof entry === "number" && Number.isFinite(entry)); + const totalTokens = numericTokenFields.length + ? numericTokenFields.reduce((sum, entry) => sum + entry, 0) + : numberOrNull(record.totalTokens); + const toolStats = asRecord(record.toolStats); + const toolUseCount = toolStats + ? ["readCount", "searchCount", "bashCount", "editFileCount", "otherToolCount"] + .map((key) => numberOrNull(toolStats[key]) ?? 0) + .reduce((sum, entry) => sum + entry, 0) + : null; + const content = Array.isArray(record.content) + ? record.content.flatMap((entry) => { + const block = asRecord(entry); + return block?.type === "text" && typeof block.text === "string" ? [block.text] : []; + }).join("\n").trim() + : ""; + const worktreePath = stringOrNull(record.worktreePath); + const worktreeBranch = stringOrNull(record.worktreeBranch); + return { + summary: content || "Subagent completed.", + ...(worktreePath ? { worktreePath } : {}), + ...(worktreeBranch ? { worktreeBranch } : {}), + ...(totalTokens != null ? { totalTokens } : {}), + ...(toolUseCount != null ? { toolUseCount } : {}), + }; +} + +function isClaudeContextOverflowResult(result: Record, errors: string[]): boolean { + if (result.terminal_reason === "prompt_too_long") return true; + return /prompt.{0,20}too long|context.{0,30}(?:overflow|window|length)|maximum context|too many tokens/i.test(errors.join(" ")); +} + type JsonRpcEnvelope = { jsonrpc?: string; id?: string | number; @@ -636,7 +839,9 @@ type PersistedChatState = { capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; codexGoal?: CodexThreadGoal | null; + claudeGoal?: ClaudeActiveGoal | null; codexTokenUsage?: CodexThreadTokenUsage | null; + protocolCapabilities?: string[]; threadId?: string; continuityRecovery?: AgentChatContinuityRecovery; recoveredFromSessionId?: string; @@ -765,6 +970,7 @@ function pointerFingerprint(state: { provider: ThreadPointerLedgerEntry["provide type PersistedPendingSteer = { steerId: string; + uuid?: string; text: string; displayText?: string; attachments?: AgentChatFileRef[]; @@ -929,6 +1135,7 @@ type CodexRuntime = { type QueuedSteer = { steerId: string; + uuid: string; text: string; displayText?: string; attachments: AgentChatFileRef[]; @@ -972,6 +1179,23 @@ type ClaudeActiveSubagent = { nonAgentTaskRun?: boolean; }; +type ClaudeContextGuardrailState = { + inputTokens: number; + cacheReadTokens: number; + cacheCreationTokens: number; + outputTokens: number; + totalTokens: number; + maxTokens: number; + occupancyPct: number | null; + highWaterEpisode: boolean; + naturalCompactSeen: boolean; + fallbackIssuedThisEpisode: boolean; + pendingInternalCompactionTrigger: "ade_fallback" | "recovery" | null; + pendingInternalCompactionId: string | null; + lastUsageEmitAt: number | null; + lastUsageEmitPct: number | null; +}; + type ClaudeRuntime = { kind: "claude"; sdkSessionId: string | null; @@ -1073,6 +1297,9 @@ type ClaudeRuntime = { * user is explicitly dispatching it with Claude's priority queue. */ dispatchingSteerIds: Set; pendingSteers: QueuedSteer[]; + knownQueuedMessages: Map; + commandLifecycleByUuid: Map; + contextGuardrail: ClaudeContextGuardrailState; approvals: Map; interrupted: boolean; /** Set when early interrupt events have been emitted to avoid duplicate emission later. */ @@ -7446,7 +7673,6 @@ export function createAgentChatService(args: { if (approved) { return { behavior: "allow", - updatedInput: input, ...(response.decision === "accept_for_session" && sdkOptions?.suggestions?.length ? { updatedPermissions: sdkOptions.suggestions } : {}), @@ -7461,7 +7687,10 @@ export function createAgentChatService(args: { }; } - return { behavior: "allow", updatedInput: input }; + // SDK >=0.3.207 deliberately runs the original tool input when an allow + // result omits updatedInput. Keep the generic pass-through on that path so + // a future accidental `{ updatedInput: undefined }` regression is visible. + return { behavior: "allow" }; }; const clearSubagentSnapshots = (sessionId: string): void => { @@ -7510,6 +7739,7 @@ export function createAgentChatService(args: { map.set(key, { taskId: event.taskId, agentId: event.agentId ?? previous?.agentId, + parentAgentId: event.parentAgentId ?? previous?.parentAgentId ?? null, agentType: event.agentType ?? previous?.agentType, label: event.label?.trim() || previous?.label, parentToolUseId: event.parentToolUseId ?? previous?.parentToolUseId ?? null, @@ -7531,6 +7761,7 @@ export function createAgentChatService(args: { map.set(key, { taskId: adoptEventTaskId ? event.taskId : previous?.taskId ?? event.taskId, agentId: event.agentId ?? previous?.agentId, + parentAgentId: event.parentAgentId ?? previous?.parentAgentId ?? null, agentType: event.agentType ?? previous?.agentType, label: event.label?.trim() || previous?.label, parentToolUseId: event.parentToolUseId ?? previous?.parentToolUseId ?? null, @@ -7559,6 +7790,7 @@ export function createAgentChatService(args: { map.set(key, { taskId: adoptEventTaskId ? event.taskId : previous?.taskId ?? event.taskId, agentId: event.agentId ?? previous?.agentId, + parentAgentId: event.parentAgentId ?? previous?.parentAgentId ?? null, agentType: event.agentType ?? previous?.agentType, label: event.label?.trim() || previous?.label, parentToolUseId: event.parentToolUseId ?? previous?.parentToolUseId ?? null, @@ -7695,6 +7927,7 @@ export function createAgentChatService(args: { rewindFiles?: (userMessageId: string, options?: { dryRun?: boolean }) => Promise; interrupt?: ClaudeQuery["interrupt"]; stopTask?: ClaudeQuery["stopTask"]; + cancelAsyncMessage?: (uuid: string) => Promise; } => { return { setPermissionMode: typeof sessionQuery?.setPermissionMode === "function" @@ -7721,6 +7954,9 @@ export function createAgentChatService(args: { stopTask: typeof sessionQuery?.stopTask === "function" ? sessionQuery.stopTask.bind(sessionQuery) : undefined, + cancelAsyncMessage: typeof (sessionQuery as ClaudeQuery & { cancelAsyncMessage?: unknown } | null | undefined)?.cancelAsyncMessage === "function" + ? (sessionQuery as ClaudeQuery & { cancelAsyncMessage: (uuid: string) => Promise }).cancelAsyncMessage.bind(sessionQuery) + : undefined, }; }; @@ -10061,7 +10297,11 @@ export function createAgentChatService(args: { ...(managed.session.capabilityMode ? { capabilityMode: managed.session.capabilityMode } : {}), ...(managed.session.completion ? { completion: managed.session.completion } : {}), ...(managed.session.codexGoal ? { codexGoal: normalizeAdeCodexGoal(managed.session.codexGoal) } : {}), + ...(managed.session.claudeGoal ? { claudeGoal: managed.session.claudeGoal } : {}), ...(managed.session.codexTokenUsage ? { codexTokenUsage: managed.session.codexTokenUsage } : {}), + ...(managed.session.protocolCapabilities + ? { protocolCapabilities: managed.session.protocolCapabilities } + : {}), ...(managed.session.threadId ? { threadId: managed.session.threadId } : {}), ...(managed.session.continuityRecovery ? { continuityRecovery: managed.session.continuityRecovery } : {}), ...(managed.session.recoveredFromSessionId ? { recoveredFromSessionId: managed.session.recoveredFromSessionId } : {}), @@ -10090,6 +10330,7 @@ export function createAgentChatService(args: { ? { pendingSteers: managed.runtime.pendingSteers.map((s): PersistedPendingSteer => ({ steerId: s.steerId, + uuid: s.uuid, text: s.text, ...(s.displayText ? { displayText: s.displayText } : {}), ...(s.attachments.length ? { attachments: s.attachments } : {}), @@ -10291,6 +10532,14 @@ export function createAgentChatService(args: { const capabilityMode = normalizeCapabilityMode(record.capabilityMode); const completion = normalizePersistedCompletion(record.completion); const codexGoal = normalizeCodexGoalPayload(record.codexGoal); + const normalizedClaudeGoal = normalizeClaudeActiveGoalPayload(record.claudeGoal); + const claudeGoal = normalizedClaudeGoal + ? { + ...normalizedClaudeGoal, + updatedAt: numberOrNull(asRecord(record.claudeGoal)?.updatedAt) ?? Date.now(), + } + : null; + const protocolCapabilities = normalizeProtocolCapabilities(record.protocolCapabilities); const codexTokenUsageRecord = asRecord(record.codexTokenUsage); const codexTokenUsage = codexTokenUsageRecord ? normalizeCodexThreadTokenUsage(codexTokenUsageRecord) : null; const importedFrom = normalizeImportedFrom(record.importedFrom); @@ -10415,7 +10664,9 @@ export function createAgentChatService(args: { ...(capabilityMode ? { capabilityMode } : {}), ...(completion ? { completion } : {}), ...(codexGoal ? { codexGoal } : {}), + ...(claudeGoal ? { claudeGoal } : {}), ...(codexTokenUsage ? { codexTokenUsage } : {}), + ...(protocolCapabilities.length ? { protocolCapabilities } : {}), ...(importedFrom ? { importedFrom } : {}), ...(typeof record.threadId === "string" && record.threadId.trim().length ? { threadId: record.threadId.trim() } @@ -11181,6 +11432,7 @@ export function createAgentChatService(args: { type: "text", text: buffered.text, ...(buffered.messageId ? { messageId: buffered.messageId } : {}), + ...(buffered.originTimestamp ? { originTimestamp: buffered.originTimestamp } : {}), ...(buffered.turnId ? { turnId: buffered.turnId } : {}), ...(buffered.itemId ? { itemId: buffered.itemId } : {}), }); @@ -11386,6 +11638,365 @@ export function createAgentChatService(args: { } }; + const applyClaudeActiveGoal = ( + managed: ManagedChatSession, + value: unknown, + turnId?: string, + ): void => { + if (value === null) { + if (managed.session.claudeGoal == null) return; + managed.session.claudeGoal = null; + emitChatEvent(managed, { + type: "claude_goal_cleared", + ...(turnId ? { turnId } : {}), + }); + persistChatState(managed); + return; + } + const normalized = normalizeClaudeActiveGoalPayload(value); + if (!normalized || claudeActiveGoalsEqual(managed.session.claudeGoal, normalized)) return; + const goal: ClaudeActiveGoal = { ...normalized, updatedAt: Date.now() }; + managed.session.claudeGoal = goal; + emitChatEvent(managed, { + type: "claude_goal_updated", + goal, + ...(turnId ? { turnId } : {}), + }); + persistChatState(managed); + }; + + const applyClaudeProtocolCapabilities = (managed: ManagedChatSession, value: unknown): void => { + const capabilities = normalizeProtocolCapabilities(value); + managed.session.protocolCapabilities = capabilities; + }; + + const emitClaudeApiRetry = ( + managed: ManagedChatSession, + record: Record, + ): void => { + emitChatEvent(managed, { + type: "api_retry", + attempt: numberOrNull(record.attempt) ?? 0, + maxRetries: numberOrNull(record.max_retries) ?? 0, + retryDelayMs: numberOrNull(record.retry_delay_ms) ?? 0, + errorStatus: numberOrNull(record.error_status), + }); + }; + + const emitClaudeInterruptReceipt = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + response: SDKControlInterruptResponse | undefined, + ): void => { + if (!response || !Array.isArray(response.still_queued) || response.still_queued.length === 0) return; + const stillQueuedUuids = response.still_queued + .filter((uuid): uuid is string => typeof uuid === "string" && uuid.trim().length > 0) + .map((uuid) => uuid.trim()); + if (!stillQueuedUuids.length) return; + emitChatEvent(managed, { + type: "interrupt_receipt", + stillQueuedUuids, + known: stillQueuedUuids.flatMap((uuid) => { + const known = runtime.knownQueuedMessages.get(uuid); + return known ? [{ uuid, preview: known.preview, ...(known.steerId ? { steerId: known.steerId } : {}) }] : []; + }), + }); + }; + + const emitClaudeCommandLifecycle = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + record: Record, + turnId?: string, + ): boolean => { + if (record.type !== "command_lifecycle") return false; + const commandUuid = compactString(record.command_uuid); + const status = compactString(record.status); + if ( + !commandUuid + || (status !== "queued" && status !== "started" && status !== "completed" && status !== "cancelled" && status !== "discarded") + ) { + return true; + } + const known = runtime.knownQueuedMessages.get(commandUuid); + if (!known || runtime.commandLifecycleByUuid.get(commandUuid) === status) return true; + runtime.commandLifecycleByUuid.set(commandUuid, status); + if (runtime.commandLifecycleByUuid.size > 256) { + const oldest = runtime.commandLifecycleByUuid.keys().next().value; + if (typeof oldest === "string") runtime.commandLifecycleByUuid.delete(oldest); + } + emitChatEvent(managed, { + type: "command_lifecycle", + commandUuid, + status, + preview: known.preview, + ...(known.steerId ? { steerId: known.steerId } : {}), + ...(turnId ? { turnId } : {}), + }); + if (status === "completed" || status === "cancelled" || status === "discarded") { + runtime.knownQueuedMessages.delete(commandUuid); + } + return true; + }; + + const updateClaudeContextEpisode = ( + guardrail: ClaudeContextGuardrailState, + occupancyPct: number, + ): void => { + guardrail.occupancyPct = occupancyPct; + if (occupancyPct < 80) { + guardrail.highWaterEpisode = false; + guardrail.naturalCompactSeen = false; + guardrail.fallbackIssuedThisEpisode = false; + return; + } + if (occupancyPct >= 90 && !guardrail.highWaterEpisode) { + guardrail.highWaterEpisode = true; + guardrail.naturalCompactSeen = false; + guardrail.fallbackIssuedThisEpisode = false; + } + }; + + const updateClaudeLiveContextUsage = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + usage: Record | null, + mode: "message_start" | "message_delta", + turnId?: string, + ): void => { + if (!usage) return; + const guardrail = runtime.contextGuardrail; + if (mode === "message_start") { + guardrail.inputTokens = Math.max(0, numberOrNull(usage.input_tokens) ?? 0); + guardrail.cacheReadTokens = Math.max(0, numberOrNull(usage.cache_read_input_tokens) ?? 0); + guardrail.cacheCreationTokens = Math.max(0, numberOrNull(usage.cache_creation_input_tokens) ?? 0); + guardrail.outputTokens = Math.max(0, numberOrNull(usage.output_tokens) ?? 0); + } else { + const streamedOutputTokens = numberOrNull(usage.output_tokens); + if (streamedOutputTokens != null) guardrail.outputTokens = Math.max(0, streamedOutputTokens); + } + const maxTokens = resolveSessionModelDescriptor(managed.session)?.contextWindow ?? 0; + if (!Number.isFinite(maxTokens) || maxTokens <= 0) return; + guardrail.maxTokens = maxTokens; + guardrail.totalTokens = guardrail.inputTokens + + guardrail.cacheReadTokens + + guardrail.cacheCreationTokens + + guardrail.outputTokens; + const occupancyPct = Math.max(0, Math.min(100, (guardrail.totalTokens / maxTokens) * 100)); + updateClaudeContextEpisode(guardrail, occupancyPct); + + const now = Date.now(); + const movedEnough = guardrail.lastUsageEmitPct == null + || Math.abs(occupancyPct - guardrail.lastUsageEmitPct) >= 1; + const waitedEnough = guardrail.lastUsageEmitAt == null + || now - guardrail.lastUsageEmitAt >= 5_000; + if (!movedEnough || !waitedEnough) return; + guardrail.lastUsageEmitAt = now; + guardrail.lastUsageEmitPct = occupancyPct; + const category = (name: string, tokens: number) => ({ + name, + tokens, + percentage: maxTokens > 0 ? (tokens / maxTokens) * 100 : 0, + }); + emitChatEvent(managed, { + type: "context_usage", + origin: "live", + usage: { + categories: [ + category("Input", guardrail.inputTokens), + category("Cache read", guardrail.cacheReadTokens), + category("Cache creation", guardrail.cacheCreationTokens), + category("Output", guardrail.outputTokens), + ].filter((entry) => entry.tokens > 0), + totalTokens: guardrail.totalTokens, + maxTokens, + rawMaxTokens: maxTokens, + percentage: occupancyPct, + model: managed.session.model, + }, + ...(turnId ? { turnId } : {}), + }); + }; + + const observeClaudeCompaction = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + options: { completed: boolean; postTokens?: number | null }, + ): void => { + const guardrail = runtime.contextGuardrail; + const internalTrigger = guardrail.pendingInternalCompactionTrigger; + const occupancyPctAtTrigger = guardrail.occupancyPct; + if (guardrail.highWaterEpisode && !internalTrigger) { + guardrail.naturalCompactSeen = true; + } + if (options.postTokens != null && guardrail.maxTokens > 0) { + guardrail.totalTokens = Math.max(0, options.postTokens); + updateClaudeContextEpisode( + guardrail, + Math.max(0, Math.min(100, (guardrail.totalTokens / guardrail.maxTokens) * 100)), + ); + } + if (!options.completed) return; + if (!internalTrigger) { + logger.info("agent_chat.claude_context_compaction_observed", { + sessionId: managed.session.id, + trigger: "natural", + occupancyPctAtTrigger, + }); + } + guardrail.pendingInternalCompactionTrigger = null; + guardrail.pendingInternalCompactionId = null; + }; + + const issueClaudeInternalCompaction = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + trigger: "ade_fallback" | "recovery", + turnId?: string, + ): Promise => { + const guardrail = runtime.contextGuardrail; + if (guardrail.pendingInternalCompactionTrigger) return true; + if (!runtime.inputPump) await ensureClaudeQuery(managed, runtime); + if (!runtime.inputPump) return false; + const message = await buildClaudeV2MessageAsync("/compact", [], { + baseDir: managed.laneWorktreePath, + sessionId: runtime.sdkSessionId, + forceUserMessage: true, + getDirtyFileTextForPath, + }) as unknown as SDKUserMessage; + message.uuid = randomUUID(); + message.timestamp = new Date().toISOString(); + message.priority = "next"; + message.shouldQuery = true; + runtime.inputPump.push(message); + guardrail.pendingInternalCompactionTrigger = trigger; + guardrail.pendingInternalCompactionId = randomUUID(); + guardrail.fallbackIssuedThisEpisode = true; + emitChatEvent(managed, { + type: "context_compact", + trigger: "ade_fallback", + state: "started", + compactionId: guardrail.pendingInternalCompactionId, + ...(turnId ? { turnId } : {}), + }); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + severity: "info", + message: trigger === "recovery" + ? "context overflowed — compacted; please re-send your last message" + : "ADE compacted the context because Claude's automatic compaction had not run.", + ...(turnId ? { turnId } : {}), + }); + logger.info("agent_chat.claude_context_compaction_observed", { + sessionId: managed.session.id, + trigger, + occupancyPctAtTrigger: guardrail.occupancyPct, + }); + return true; + }; + + const maybeIssueClaudeBoundaryCompaction = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + options: { recoverFromOverflow: boolean; turnId?: string }, + ): Promise => { + const guardrail = runtime.contextGuardrail; + logger.debug("agent_chat.claude_context_compaction_fallback_gate", { + sessionId: managed.session.id, + occupancyPct: guardrail.occupancyPct, + occupancyGate: (guardrail.occupancyPct ?? 0) >= 97, + highWaterEpisode: guardrail.highWaterEpisode, + naturalCompactSeen: guardrail.naturalCompactSeen, + fallbackIssuedThisEpisode: guardrail.fallbackIssuedThisEpisode, + recovery: options.recoverFromOverflow, + }); + if (options.recoverFromOverflow) { + return issueClaudeInternalCompaction(managed, runtime, "recovery", options.turnId); + } + if ( + (guardrail.occupancyPct ?? 0) < 97 + || !guardrail.highWaterEpisode + || guardrail.naturalCompactSeen + || guardrail.fallbackIssuedThisEpisode + ) { + return false; + } + return issueClaudeInternalCompaction(managed, runtime, "ade_fallback", options.turnId); + }; + + const emitClaudeStructuredToolResult = ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + record: Record, + openToolUses: Map, + turnId?: string, + ): boolean => { + if (!Object.prototype.hasOwnProperty.call(record, "tool_use_result")) return false; + const payload = claudeToolResultPayload(record.message); + if (!payload) return false; + const toolMeta = openToolUses.get(payload.toolUseId); + if (!toolMeta) return false; + openToolUses.delete(payload.toolUseId); + const structured = record.tool_use_result; + emitChatEvent(managed, { + type: "tool_result", + tool: toolMeta.toolName, + result: payload.result ?? structured, + structured, + ...claudeStructuredToolResultFields(toolMeta.toolName, structured), + itemId: payload.toolUseId, + ...(turnId ? { turnId } : {}), + status: "completed", + }); + + if (!isClaudeSubagentToolName(toolMeta.toolName)) return true; + const enrichment = claudeStructuredSubagentResult(structured); + if (!enrichment) return true; + const active = [...runtime.activeSubagents.values()].find((entry) => + entry.parentToolUseId === payload.toolUseId); + const snapshotMap = ensureSubagentSnapshotMap(managed.session.id); + const snapshotMatch = findSubagentSnapshotByParent(snapshotMap, payload.toolUseId); + const snapshot = snapshotMatch?.[1]; + const taskId = active?.taskId ?? snapshot?.taskId; + if (!taskId) return true; + const event: Extract = { + type: "subagent_result", + taskId, + ...(active?.agentId ?? snapshot?.agentId ? { agentId: active?.agentId ?? snapshot?.agentId } : {}), + ...(active?.parentAgentId ?? snapshot?.parentAgentId + ? { parentAgentId: active?.parentAgentId ?? snapshot?.parentAgentId } + : {}), + ...(active?.agentType ?? snapshot?.agentType ? { agentType: active?.agentType ?? snapshot?.agentType } : {}), + parentToolUseId: payload.toolUseId, + status: "completed", + summary: enrichment.summary, + finalSummary: enrichment.summary, + ...(active?.taskType ? { taskType: active.taskType } : {}), + ...(active?.workflowName ? { workflowName: active.workflowName } : {}), + ...(enrichment.worktreePath ? { worktreePath: enrichment.worktreePath } : {}), + ...(enrichment.worktreeBranch ? { worktreeBranch: enrichment.worktreeBranch } : {}), + ...(enrichment.totalTokens != null ? { totalTokens: enrichment.totalTokens } : {}), + ...(enrichment.toolUseCount != null ? { toolUseCount: enrichment.toolUseCount } : {}), + ...(enrichment.totalTokens != null || enrichment.toolUseCount != null + ? { usage: { + ...(enrichment.totalTokens != null ? { totalTokens: enrichment.totalTokens } : {}), + ...(enrichment.toolUseCount != null ? { toolUses: enrichment.toolUseCount } : {}), + } } + : {}), + ...(turnId ? { turnId } : {}), + }; + if ( + runtime.emittedSubagentStartIds.has(taskId) + || Boolean(event.agentId && runtime.emittedSubagentStartIds.has(event.agentId)) + ) { + emitClaudeSubagentResult(managed, runtime, event); + } else if (snapshot) { + emitChatEvent(managed, event); + } + return true; + }; + type ScheduledWorkEvent = Extract; const compactString = (value: unknown): string | undefined => { @@ -11911,6 +12522,33 @@ export function createAgentChatService(args: { persistChatState(managed); }; + const adoptClaudeConversationReset = async ( + managed: ManagedChatSession, + runtime: ClaudeRuntime, + newConversationId: string, + ): Promise => { + await adoptClaudeProviderSessionId(managed, runtime, newConversationId); + managed.autoTitleSeed = null; + managed.autoTitleStage = "none"; + managed.runtimeTitleAdopted = false; + managed.recentConversationEntries = []; + managed.pendingReconstructionContext = null; + managed.continuitySummary = null; + managed.continuitySummaryUpdatedAt = null; + if (!sessionIsManuallyNamed(managed)) { + const title = defaultChatSessionTitle("claude"); + if ((sessionService.get(managed.session.id)?.title?.trim() ?? "") !== title) { + sessionService.updateMeta({ sessionId: managed.session.id, title, manuallyNamed: false }); + emitTransientChatEnvelope(managed.session.id, { + type: "session_meta_updated", + title, + manuallyNamed: false, + }); + } + } + persistChatState(managed); + }; + const maybeEmitClaudeScheduledWorkFromToolCall = ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -13657,7 +14295,11 @@ export function createAgentChatService(args: { capabilityMode: persisted?.capabilityMode ?? inferCapabilityMode(provider), completion: persisted?.completion ?? null, codexGoal: persisted?.codexGoal ?? null, + claudeGoal: persisted?.claudeGoal ?? null, codexTokenUsage: persisted?.codexTokenUsage ?? null, + ...(persisted?.protocolCapabilities + ? { protocolCapabilities: persisted.protocolCapabilities } + : {}), ...(persisted?.importedFrom ? { importedFrom: persisted.importedFrom } : {}), status: mapTerminalStatusToChatStatus(row.status), idleSinceAt: persisted?.idleSinceAt ?? null, @@ -14595,6 +15237,8 @@ export function createAgentChatService(args: { const turnId = `claude-idle-${randomUUID()}`; state.turnId = turnId; captureTurnBeforeSha(managed); + runtime.interrupted = false; + runtime.interruptEventsEmitted = false; runtime.busy = true; runtime.activeTurnId = turnId; setSessionActive(managed); @@ -14653,6 +15297,8 @@ export function createAgentChatService(args: { runtime: ClaudeRuntime, state: ClaudeIdleTurnState, status: "completed" | "interrupted" | "failed" = "completed", + terminalReason?: string, + recoverFromOverflow = false, ): Promise => { const turnId = state.turnId; if (!turnId) return; @@ -14695,6 +15341,7 @@ export function createAgentChatService(args: { ...resolveClaudeTurnModelPayload(managed.session, []), ...(state.usage ? { usage: state.usage } : {}), ...(state.costUsd != null ? { costUsd: state.costUsd } : {}), + ...(terminalReason ? { terminalReason, terminalReasonSource: "sdk" as const } : {}), }); if (state.assistantText.trim().length > 0) { appendCtoTurnJournal(managed, { assistantText: state.assistantText }); @@ -14704,11 +15351,15 @@ export function createAgentChatService(args: { sessionService.setHeadShaEnd(managed.session.id, endSha); } persistChatState(managed); + const compactionIssued = await maybeIssueClaudeBoundaryCompaction(managed, runtime, { + recoverFromOverflow, + turnId, + }); state.turnId = null; state.currentStreamMessageId = null; state.streamedTextByContentIndex.clear(); state.recentTextDeltaBuffer = ""; - if (runtime.pendingSteers.length && managed.runtime === runtime) { + if (!compactionIssued && runtime.pendingSteers.length && managed.runtime === runtime) { runtime.idleReaderPromise = null; const delivered = await deliverNextQueuedSteer(managed, runtime); if (!delivered) startClaudeIdleReader(managed, runtime, "turn_completed"); @@ -15061,6 +15712,22 @@ export function createAgentChatService(args: { await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } + if (record.type === "conversation_reset") { + const newConversationId = compactString(record.new_conversation_id); + if (newConversationId) { + await adoptClaudeConversationReset(managed, runtime, newConversationId); + emitChatEvent(managed, { type: "conversation_reset", newConversationId }); + } + return; + } + + if (emitClaudeCommandLifecycle(managed, runtime, record, state.turnId ?? undefined)) return; + + if (record.type === "active_goal") { + applyClaudeActiveGoal(managed, record.value, state.turnId ?? undefined); + return; + } + // Native background-subagent assistant/tool frames are forwarded over the // parent Query for observation. They belong to the subagent transcript, // which ADE reads through getSubagentTranscript, not the main chat. @@ -15069,6 +15736,7 @@ export function createAgentChatService(args: { if (msg.type === "system" && record.subtype === "init") { const initCommands = Array.isArray(record.slash_commands) ? record.slash_commands : []; if (initCommands.length) applyClaudeSlashCommands(runtime, initCommands as any[]); + applyClaudeProtocolCapabilities(managed, record.capabilities); persistChatState(managed); return; } @@ -15086,6 +15754,55 @@ export function createAgentChatService(args: { return; } + if (msg.type === "system" && record.subtype === "status" && record.status === "compacting") { + const turnId = startClaudeIdleTurn(managed, runtime, state, "Claude is compacting context"); + const internalCompactionPending = runtime.contextGuardrail.pendingInternalCompactionTrigger !== null; + observeClaudeCompaction(managed, runtime, { completed: false }); + if (!internalCompactionPending) { + emitChatEvent(managed, { + type: "context_compact", + trigger: "auto", + state: "started", + turnId, + }); + } + return; + } + + if (msg.type === "system" && record.subtype === "compact_boundary") { + const metadata = asRecord(record.compact_metadata); + const turnId = state.turnId ?? startClaudeIdleTurn(managed, runtime, state, "Claude compacted context"); + const postTokens = numberOrNull(metadata?.post_tokens); + const internalTrigger = runtime.contextGuardrail.pendingInternalCompactionTrigger; + const internalCompactionId = runtime.contextGuardrail.pendingInternalCompactionId; + observeClaudeCompaction(managed, runtime, { completed: true, postTokens }); + emitChatEvent(managed, { + type: "context_compact", + trigger: internalTrigger ? "ade_fallback" : metadata?.trigger === "manual" ? "manual" : "auto", + ...(internalCompactionId ? { compactionId: internalCompactionId } : {}), + preTokens: numberOrNull(metadata?.pre_tokens) ?? undefined, + postTokens: postTokens ?? undefined, + durationMs: numberOrNull(metadata?.duration_ms) ?? undefined, + state: "completed", + turnId, + }); + return; + } + + if (msg.type === "system" && record.subtype === "api_retry") { + emitClaudeApiRetry(managed, record); + const error = compactString(record.error) ?? "transient_error"; + 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 ${record.attempt ?? "?"}/${record.max_retries ?? "?"}: ${error.replace(/_/g, " ")}`, + ...(state.turnId ? { turnId: state.turnId } : {}), + }); + return; + } + if (msg.type === "system" && record.subtype === "model_refusal_fallback") { emitClaudeTranscriptRetraction(managed, record.retracted_message_uuids, "model_refusal_fallback", state.turnId ?? undefined); return; @@ -15146,12 +15863,25 @@ export function createAgentChatService(args: { return; } + if (msg.type === "user") { + emitClaudeStructuredToolResult( + managed, + runtime, + record, + state.openToolUses, + state.turnId ?? undefined, + ); + return; + } + if (msg.type === "assistant") { const assistantMsg = record; const betaMessage = asRecord(assistantMsg.message); const assistantWireUuid = compactString(assistantMsg.uuid); const assistantMessageId = compactString(betaMessage?.id); const providerMessageId = assistantMessageId ?? assistantWireUuid ?? null; + const originTimestamp = compactString(assistantMsg.timestamp); + const resumedFromIncompleteThinking = assistantMsg.resumed_from_incomplete_thinking === true; const snapshotMatchesCurrentStream = assistantMessageId != null && assistantMessageId === state.currentStreamMessageId; const turnId = startClaudeIdleTurn(managed, runtime, state); @@ -15170,7 +15900,9 @@ export function createAgentChatService(args: { } if (block.type === "text") { const text = typeof block.text === "string" ? block.text : ""; - const emittedRecord = providerMessageId ? claudeEmittedTextRecord(runtime, providerMessageId) : null; + const emittedRecord = providerMessageId && !resumedFromIncompleteThinking + ? claudeEmittedTextRecord(runtime, providerMessageId) + : null; const streamedPrefix = emittedRecord?.get(index) ?? (snapshotMatchesCurrentStream ? state.streamedTextByContentIndex.get(index) ?? "" : ""); const replayedStreamPrefix = state.recentTextDeltaBuffer.length > 0 @@ -15178,7 +15910,9 @@ export function createAgentChatService(args: { const replayedSnapshotPrefix = state.recentTextDeltaBuffer.length > 0 && state.recentTextDeltaBuffer.startsWith(text); let textToEmit: string; - if (replayedSnapshotPrefix) { + if (resumedFromIncompleteThinking) { + textToEmit = text; + } else if (replayedSnapshotPrefix) { textToEmit = ""; } else if (!streamedPrefix.length && replayedStreamPrefix) { textToEmit = text.slice(state.recentTextDeltaBuffer.length); @@ -15199,6 +15933,7 @@ export function createAgentChatService(args: { type: "text", text: textToEmit, ...(providerMessageId ? { messageId: providerMessageId } : {}), + ...(originTimestamp ? { originTimestamp } : {}), turnId, }); } @@ -15263,6 +15998,23 @@ export function createAgentChatService(args: { state.streamedTextByContentIndex.clear(); state.recentTextDeltaBuffer = ""; state.currentStreamMessageId = compactString(message?.id) ?? compactString(streamMsg.uuid) ?? null; + updateClaudeLiveContextUsage( + managed, + runtime, + asRecord(message?.usage), + "message_start", + turnId, + ); + return; + } + if (event.type === "message_delta") { + updateClaudeLiveContextUsage( + managed, + runtime, + asRecord(event.usage), + "message_delta", + turnId, + ); return; } if (event.type === "content_block_delta") { @@ -15388,6 +16140,8 @@ export function createAgentChatService(args: { && resultErrors.all.length > 0 && resultErrors.userFacing.length === 0; const resultIsError = resultMsg.is_error === true && !diagnosticOnlyError; + const terminalStatus = classifyClaudeResultStatus(resultMsg); + const terminalReason = compactString(resultMsg.terminal_reason); if (resultErrors.internalDiagnostics.length > 0) { logger.debug("agent_chat.claude_internal_diagnostic", { ...CLAUDE_AGENT_SDK_TELEMETRY_TAGS, @@ -15414,7 +16168,14 @@ export function createAgentChatService(args: { emitChatEvent(managed, { type: "error", message: error, turnId }); } } - await finishClaudeIdleTurn(managed, runtime, state, resultIsError ? "failed" : "completed"); + await finishClaudeIdleTurn( + managed, + runtime, + state, + terminalStatus, + terminalReason, + isClaudeContextOverflowResult(resultMsg, resultErrors.all), + ); return; } @@ -15648,6 +16409,7 @@ export function createAgentChatService(args: { providerSlashCommand?: boolean; forceClaudeUserMessage?: boolean; steerId?: string; + messageUuid?: string; onDispatched?: () => void; onBackendDispatched?: () => void; }, @@ -15663,7 +16425,7 @@ export function createAgentChatService(args: { } const turnId = randomUUID(); - const userMessageId = randomUUID(); + const userMessageId = args.messageUuid?.trim() || randomUUID(); runtime.busy = true; runtime.activeTurnId = turnId; settleClaudeInitialInputDispatch(runtime, new Error("A newer Claude turn replaced the pending input dispatch.")); @@ -15697,6 +16459,10 @@ export function createAgentChatService(args: { })); const displayText = args.displayText?.trim().length ? args.displayText.trim() : args.promptText; const userText = args.userText?.trim().length ? args.userText.trim() : displayText; + rememberClaudeKnownQueuedMessage(runtime.knownQueuedMessages, userMessageId, { + preview: claudeQueuedMessagePreview(displayText), + ...(args.steerId ? { steerId: args.steerId } : {}), + }); emitPreparedUserMessage(managed, { text: userText, displayText, @@ -15720,6 +16486,9 @@ export function createAgentChatService(args: { let assistantText = ""; let usage: { inputTokens?: number | null; outputTokens?: number | null; cacheReadTokens?: number | null; cacheCreationTokens?: number | null } | undefined; let costUsd: number | null = null; + let resultTerminalStatus: ClaudeTerminalStatus = "completed"; + let resultTerminalReason: string | undefined; + let recoverFromContextOverflow = false; let reportedAssistantModel: string | null = null; let reportedInitModel: string | null = null; const reportedUsageModels = new Set(); @@ -15938,7 +16707,7 @@ export function createAgentChatService(args: { forceUserMessage: true, getDirtyFileTextForPath, }) as unknown as SDKUserMessage; - messageToSend.uuid = userMessageId; + messageToSend.uuid = userMessageId as NonNullable; messageToSend.timestamp = new Date().toISOString(); const pendingPostResultSettledBeforeInput = runtime.pendingPostResultNextSettledAt != null; @@ -16122,6 +16891,24 @@ export function createAgentChatService(args: { await adoptClaudeProviderSessionId(managed, runtime, messageSessionId); } + if ((msg as any).type === "conversation_reset") { + const newConversationId = compactString((msg as any).new_conversation_id); + if (newConversationId) { + await adoptClaudeConversationReset(managed, runtime, newConversationId); + emitChatEvent(managed, { type: "conversation_reset", newConversationId }); + } + continue; + } + + if (emitClaudeCommandLifecycle(managed, runtime, msg as unknown as Record, turnId)) { + continue; + } + + if ((msg as any).type === "active_goal") { + applyClaudeActiveGoal(managed, (msg as any).value, turnId); + continue; + } + if (isClaudeForwardedSubagentMessage(msg)) { continue; } @@ -16144,6 +16931,7 @@ export function createAgentChatService(args: { if (Array.isArray(initMsg.slash_commands)) { applyClaudeSlashCommands(runtime, initMsg.slash_commands); } + applyClaudeProtocolCapabilities(managed, initMsg.capabilities); try { const control = getClaudeQueryControl(runtime.query); if (typeof control.supportedCommands === "function") { @@ -16203,15 +16991,19 @@ export function createAgentChatService(args: { } } if (statusMsg.status === "compacting") { + const internalCompactionPending = runtime.contextGuardrail.pendingInternalCompactionTrigger !== null; + observeClaudeCompaction(managed, runtime, { completed: false }); // Begin marker so the UI shows a live "compacting…" indicator instead of // feeling stuck until the compact_boundary lands. The real trigger arrives // with the boundary (completed) event, so default to "auto" here. - emitChatEvent(managed, { - type: "context_compact", - trigger: "auto", - state: "started", - turnId, - }); + if (!internalCompactionPending) { + emitChatEvent(managed, { + type: "context_compact", + trigger: "auto", + state: "started", + turnId, + }); + } } continue; } @@ -16219,9 +17011,16 @@ export function createAgentChatService(args: { // system:compact_boundary — context window compaction (end marker) if (msg.type === "system" && (msg as any).subtype === "compact_boundary") { const compactMsg = msg as any; + const internalTrigger = runtime.contextGuardrail.pendingInternalCompactionTrigger; + const internalCompactionId = runtime.contextGuardrail.pendingInternalCompactionId; + observeClaudeCompaction(managed, runtime, { + completed: true, + postTokens: numberOrNull(compactMsg.compact_metadata?.post_tokens), + }); emitChatEvent(managed, { type: "context_compact", - trigger: compactMsg.compact_metadata?.trigger === "manual" ? "manual" : "auto", + trigger: internalTrigger ? "ade_fallback" : compactMsg.compact_metadata?.trigger === "manual" ? "manual" : "auto", + ...(internalCompactionId ? { compactionId: internalCompactionId } : {}), preTokens: typeof compactMsg.compact_metadata?.pre_tokens === "number" ? compactMsg.compact_metadata.pre_tokens : undefined, postTokens: typeof compactMsg.compact_metadata?.post_tokens === "number" ? compactMsg.compact_metadata.post_tokens : undefined, durationMs: typeof compactMsg.compact_metadata?.duration_ms === "number" ? compactMsg.compact_metadata.duration_ms : undefined, @@ -16390,6 +17189,7 @@ export function createAgentChatService(args: { ].filter((entry): entry is string => Boolean(entry)).join(" | ") || undefined, turnId, }); + emitClaudeApiRetry(managed, retryMsg); continue; } @@ -17055,6 +17855,17 @@ export function createAgentChatService(args: { continue; } + if (msg.type === "user") { + emitClaudeStructuredToolResult( + managed, + runtime, + msg as unknown as Record, + openClaudeToolUses, + turnId, + ); + continue; + } + // assistant message — process content blocks if (msg.type === "assistant") { const assistantMsg = msg as any; @@ -17072,6 +17883,8 @@ export function createAgentChatService(args: { const assistantMessageId = typeof betaMessage?.id === "string" ? betaMessage.id : null; const assistantWireUuid = compactString(assistantMsg.uuid); const assistantProviderMessageId = assistantMessageId ?? assistantWireUuid ?? null; + const assistantOriginTimestamp = compactString(assistantMsg.timestamp); + const resumedFromIncompleteThinking = assistantMsg.resumed_from_incomplete_thinking === true; emitClaudeTranscriptRetraction( managed, assistantMsg.supersedes, @@ -17108,9 +17921,13 @@ export function createAgentChatService(args: { // snapshot then arrives with a real id and would otherwise miss // the dedup, re-emitting the same text and producing a doubled // bubble in the renderer. - const textKey = claudeDedupeKey(assistantMessageId, blockIndex); - const fallbackTextKey = assistantMessageId ? claudeDedupeKey(null, blockIndex) : null; - const emittedRecord = assistantProviderMessageId + const textKey = resumedFromIncompleteThinking + ? null + : claudeDedupeKey(assistantMessageId, blockIndex); + const fallbackTextKey = assistantMessageId && !resumedFromIncompleteThinking + ? claudeDedupeKey(null, blockIndex) + : null; + const emittedRecord = assistantProviderMessageId && !resumedFromIncompleteThinking ? claudeEmittedTextRecord(runtime, assistantProviderMessageId) : null; const previouslyEmitted = emittedRecord?.get(blockIndex) ?? ""; @@ -17121,7 +17938,9 @@ export function createAgentChatService(args: { const replayedStreamPrefix = recentClaudeTextDeltaBuffer.length > 0 && blockText.startsWith(recentClaudeTextDeltaBuffer); const replayedSnapshotPrefix = recentClaudeTextDeltaBuffer.length > 0 && recentClaudeTextDeltaBuffer.startsWith(blockText); const replayedRecordPrefix = previouslyEmitted.length > 0 && blockText.startsWith(previouslyEmitted); - const textToEmit = alreadyStreamed || replayedSnapshotPrefix + const textToEmit = resumedFromIncompleteThinking + ? blockText + : alreadyStreamed || replayedSnapshotPrefix ? "" : replayedStreamPrefix ? blockText.slice(recentClaudeTextDeltaBuffer.length) @@ -17134,6 +17953,7 @@ export function createAgentChatService(args: { type: "text", text: textToEmit, ...(assistantProviderMessageId ? { messageId: assistantProviderMessageId } : {}), + ...(assistantOriginTimestamp ? { originTimestamp: assistantOriginTimestamp } : {}), turnId, }); } @@ -17414,6 +18234,13 @@ export function createAgentChatService(args: { currentClaudeStreamMessageId = typeof event.message?.id === "string" ? event.message.id : null; recentClaudeTextDeltaBuffer = ""; const msgUsage = event.message?.usage; + updateClaudeLiveContextUsage( + managed, + runtime, + asRecord(msgUsage), + "message_start", + turnId, + ); if (msgUsage) { usage = { inputTokens: msgUsage.input_tokens ?? null, @@ -17422,6 +18249,13 @@ export function createAgentChatService(args: { } } else if (event.type === "message_delta") { const deltaUsage = event.usage; + updateClaudeLiveContextUsage( + managed, + runtime, + asRecord(deltaUsage), + "message_delta", + turnId, + ); if (deltaUsage) { usage = { inputTokens: usage?.inputTokens ?? null, @@ -17453,6 +18287,9 @@ export function createAgentChatService(args: { && resultErrors.all.length > 0 && resultErrors.userFacing.length === 0; const resultIsError = resultMsg.is_error === true && !diagnosticOnlyError; + resultTerminalStatus = classifyClaudeResultStatus(resultMsg); + resultTerminalReason = compactString(resultMsg.terminal_reason); + recoverFromContextOverflow = isClaudeContextOverflowResult(resultMsg, resultErrors.all); if (resultErrors.internalDiagnostics.length > 0) { logger.debug("agent_chat.claude_internal_diagnostic", { ...CLAUDE_AGENT_SDK_TELEMETRY_TAGS, @@ -17483,9 +18320,6 @@ export function createAgentChatService(args: { if (isClaudeRuntimeAuthError(resultErrors.userFacing.join(" "))) { failClaudeTurnUnauthenticated(); } - if (onBackendDispatched) { - throw new Error(resultErrors.userFacing.join("\n")); - } for (const err of resultErrors.userFacing) { emitChatEvent(managed, { type: "error", @@ -17494,12 +18328,11 @@ export function createAgentChatService(args: { }); } } - if (resultIsError && onBackendDispatched) { - throw new Error("Claude rejected the prompt before starting the turn."); - } - if (!resultIsError) { - markBackendDispatched(); - } + // A result frame is the provider's definitive acknowledgement of the + // submitted prompt even when it reports a terminal failure. Keep the + // turn on the normal result path so terminal_reason and overflow + // recovery reach the done event instead of being lost in catch. + markBackendDispatched(); if (Array.isArray(resultMsg.permission_denials) && resultMsg.permission_denials.length > 0) { const denials = resultMsg.permission_denials as Array<{ tool_name: string; tool_use_id?: string }>; // Skip denials we already resolved inline via canUseTool (e.g. ExitPlanMode @@ -17615,8 +18448,9 @@ export function createAgentChatService(args: { if (runtime.interrupted) { await stopActiveClaudeSubagents(managed, runtime, turnId, "Interrupted"); } - flushOpenClaudeToolUses(runtime.interrupted ? "interrupted" : "completed"); - flushClaudeStructuredActivities(runtime.interrupted ? "interrupted" : "completed"); + const finalStatus: ClaudeTerminalStatus = runtime.interrupted ? "interrupted" : resultTerminalStatus; + flushOpenClaudeToolUses(finalStatus); + flushClaudeStructuredActivities(finalStatus); // Note: query is NOT closed here — it stays alive for the next turn. const runtimeStillCurrent = managed.runtime === runtime; runtime.busy = false; @@ -17635,7 +18469,6 @@ export function createAgentChatService(args: { } const doneModel = buildDoneModelPayload(); - const finalStatus = runtime.interrupted ? "interrupted" : "completed"; // A background shell survives the turn boundary as a "running" row on // purpose: the query is NOT closed here (see above), so its real // completion still arrives on a later turn via system:task_notification. @@ -17652,6 +18485,9 @@ export function createAgentChatService(args: { ...doneModel, ...(usage ? { usage } : {}), ...(costUsd != null ? { costUsd } : {}), + ...(resultTerminalReason + ? { terminalReason: resultTerminalReason, terminalReasonSource: "sdk" as const } + : {}), }); } else { void emitTurnDiffSummaryIfChanged(managed, turnId); @@ -17671,10 +18507,18 @@ export function createAgentChatService(args: { } persistChatState(managed); + const compactionIssued = runtimeStillCurrent + ? await maybeIssueClaudeBoundaryCompaction(managed, runtime, { + recoverFromOverflow: recoverFromContextOverflow, + turnId, + }) + : false; // Process queued steers (skip if session was disposed during execution) if (managed.runtime === runtime) { - if (runtime.pendingSteers.length) { + if (compactionIssued) { + startClaudeIdleReader(managed, runtime, "turn_completed"); + } else if (runtime.pendingSteers.length) { const delivered = await deliverNextQueuedSteer(managed, runtime); if (!delivered) startClaudeIdleReader(managed, runtime, "turn_completed"); } else { @@ -24421,6 +25265,8 @@ export function createAgentChatService(args: { promptText, userText: trimmed, displayText, + steerId: nextSteer.steerId, + messageUuid: nextSteer.uuid, attachments: nextSteer.attachments, contextAttachments: nextSteer.contextAttachments, resolvedAttachments: nextSteer.resolvedAttachments, @@ -24468,7 +25314,7 @@ export function createAgentChatService(args: { /** Enqueue a steer or drop it if the queue is full. Returns true if queued. */ const enqueueSteerOrDrop = ( managed: ManagedChatSession, - runtime: Pick, + runtime: ClaudeRuntime | OpenCodeRuntime, sessionId: string, steerId: string, text: string, @@ -24494,8 +25340,10 @@ export function createAgentChatService(args: { return false; } const displayText = extra?.displayText?.trim().length ? extra.displayText.trim() : text; + const uuid = randomUUID(); runtime.pendingSteers.push({ steerId, + uuid, text, attachments, contextAttachments, @@ -24506,6 +25354,12 @@ export function createAgentChatService(args: { ...(extra?.executionMode ? { executionMode: extra.executionMode } : {}), ...(extra?.interactionMode ? { interactionMode: extra.interactionMode } : {}), }); + if (runtime.kind === "claude") { + rememberClaudeKnownQueuedMessage(runtime.knownQueuedMessages, uuid, { + preview: claudeQueuedMessagePreview(displayText), + steerId, + }); + } const queuedMetadata = metadata?.scheduledWake ? Object.fromEntries(Object.entries(metadata).filter(([key]) => key !== "scheduledWake")) : metadata; @@ -24699,6 +25553,7 @@ export function createAgentChatService(args: { } out.push({ steerId: entry.steerId, + uuid: typeof entry.uuid === "string" && entry.uuid.trim().length ? entry.uuid.trim() : randomUUID(), text, ...(entry.displayText?.trim().length ? { displayText: entry.displayText.trim() } : {}), attachments, @@ -24900,6 +25755,24 @@ export function createAgentChatService(args: { initialInputDispatchGate: null, dispatchingSteerIds: new Set(), pendingSteers: hydratePersistedPendingSteers(persisted, managed), + knownQueuedMessages: new Map(), + commandLifecycleByUuid: new Map(), + contextGuardrail: { + inputTokens: 0, + cacheReadTokens: 0, + cacheCreationTokens: 0, + outputTokens: 0, + totalTokens: 0, + maxTokens: 0, + occupancyPct: null, + highWaterEpisode: false, + naturalCompactSeen: false, + fallbackIssuedThisEpisode: false, + pendingInternalCompactionTrigger: null, + pendingInternalCompactionId: null, + lastUsageEmitAt: null, + lastUsageEmitPct: null, + }, approvals: new Map(), interrupted: false, interruptEventsEmitted: false, @@ -24907,6 +25780,12 @@ export function createAgentChatService(args: { resolvedToolUseIds: new Set(), rateLimitWarningEmitted: managed.claudeRateLimitWarningEmitted, }; + for (const steer of runtime.pendingSteers) { + rememberClaudeKnownQueuedMessage(runtime.knownQueuedMessages, steer.uuid, { + preview: claudeQueuedMessagePreview(steer.displayText ?? steer.text), + steerId: steer.steerId, + }); + } managed.runtime = runtime; managed.runtimeInvalidated = false; @@ -31539,7 +32418,7 @@ export function createAgentChatService(args: { throw new Error("Claude's live input stream is not ready."); } - const dispatchUuid = randomUUID(); + const dispatchUuid = steer.uuid; const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); const promptText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; const sdkMsg = await buildClaudeV2MessageAsync(promptText, steer.resolvedAttachments, { @@ -31554,8 +32433,12 @@ export function createAgentChatService(args: { // tearing down the SDK query or killing unrelated background work. sdkMsg.priority = mode === "interrupt" ? "now" : "next"; sdkMsg.shouldQuery = true; - sdkMsg.uuid = dispatchUuid; + sdkMsg.uuid = dispatchUuid as NonNullable; + rememberClaudeKnownQueuedMessage(runtime.knownQueuedMessages, dispatchUuid, { + preview: claudeQueuedMessagePreview(steer.displayText ?? steer.text), + steerId: steer.steerId, + }); runtime.inputPump.push(sdkMsg); onAccepted?.(); @@ -31671,6 +32554,7 @@ export function createAgentChatService(args: { } rt.pendingSteers.push({ steerId, + uuid: randomUUID(), text: preparedSteer.submittedText, ...(preparedSteer.visibleText !== preparedSteer.submittedText ? { displayText: preparedSteer.visibleText } : {}), attachments: preparedSteer.attachments, @@ -31743,6 +32627,7 @@ export function createAgentChatService(args: { } rt.pendingSteers.push({ steerId, + uuid: randomUUID(), text: preparedSteer.submittedText, ...(preparedSteer.visibleText !== preparedSteer.submittedText ? { displayText: preparedSteer.visibleText } : {}), attachments: [], @@ -31899,6 +32784,7 @@ export function createAgentChatService(args: { if (dispatchMode) { const immediateSteer: QueuedSteer = { steerId, + uuid: randomUUID(), text: preparedSteer.submittedText, ...(preparedSteer.visibleText !== preparedSteer.submittedText ? { displayText: preparedSteer.visibleText } : {}), attachments: preparedSteer.attachments, @@ -32078,7 +32964,8 @@ export function createAgentChatService(args: { } const idx = queue.findIndex((s) => s.steerId === steerId); if (idx !== -1) { - queue.splice(idx, 1); + const [removed] = queue.splice(idx, 1); + if (runtime.kind === "claude" && removed) runtime.knownQueuedMessages.delete(removed.uuid); } else if (requireQueued) { throw new Error("This message is no longer queued."); } @@ -32105,7 +32992,8 @@ export function createAgentChatService(args: { if (idx === -1) return; if (!trimmed.length) { - runtime.pendingSteers.splice(idx, 1); + const [removed] = runtime.pendingSteers.splice(idx, 1); + if (runtime.kind === "claude" && removed) runtime.knownQueuedMessages.delete(removed.uuid); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", @@ -32260,8 +33148,28 @@ export function createAgentChatService(args: { if (!runtime || runtime.kind !== "claude") { return { cancelled: false }; } - logger.warn("agent_chat.cancel_dispatched_steer_unavailable", { sessionId, steerId }); - return { cancelled: false }; + const queuedEntry = [...runtime.knownQueuedMessages.entries()] + .find(([, known]) => known.steerId === steerId); + const commandUuid = queuedEntry?.[0]; + if (!commandUuid) return { cancelled: false }; + const control = getClaudeQueryControl(runtime.query); + if (!control.cancelAsyncMessage) return { cancelled: false }; + const cancelled = await awaitClaudeControlCall( + "Claude queued message cancellation", + CLAUDE_INTERRUPT_REQUEST_TIMEOUT_MS, + () => control.cancelAsyncMessage!(commandUuid), + ); + if (!cancelled) return { cancelled: false }; + runtime.knownQueuedMessages.delete(commandUuid); + emitChatEvent(managed, { + type: "system_notice", + noticeKind: "info", + steerId, + message: "Queued message cancelled.", + turnId: runtime.activeTurnId ?? undefined, + }); + persistChatState(managed); + return { cancelled: true }; }; const interrupt = async ( @@ -32460,15 +33368,17 @@ export function createAgentChatService(args: { warmupInFlight: Boolean(runtime.warmupDone), }); const claudeControl = getClaudeQueryControl(runtime.query); + let interruptResponse: SDKControlInterruptResponse | undefined; if (internalOptions.requireClaudeProviderInterrupt) { if (!claudeControl.interrupt) { throw new Error("Claude interrupt is unavailable; the replacement was not sent."); } - await awaitClaudeControlCall( + interruptResponse = await awaitClaudeControlCall( "Claude interrupt", CLAUDE_INTERRUPT_REQUEST_TIMEOUT_MS, () => claudeControl.interrupt!(), ); + emitClaudeInterruptReceipt(managed, runtime, interruptResponse); } // Set interrupted before touching the runtime so the streaming loop can // break cleanly while the underlying SDK stream is aborted below. @@ -32488,16 +33398,15 @@ export function createAgentChatService(args: { cancelClaudeWarmup(managed, runtime, "interrupt"); settleClaudeInitialInputDispatch(runtime, new Error("Claude turn was interrupted before its input was dispatched.")); await stopActiveClaudeSubagents(managed, runtime, interruptedTurnId ?? undefined, "Interrupted by user"); - runtime.queryGeneration += 1; - runtime.queryStartPromise = null; if (!internalOptions.requireClaudeProviderInterrupt) { try { if (claudeControl.interrupt) { - await awaitClaudeControlCall( + interruptResponse = await awaitClaudeControlCall( "Claude interrupt", CLAUDE_INTERRUPT_REQUEST_TIMEOUT_MS, () => claudeControl.interrupt!(), ); + emitClaudeInterruptReceipt(managed, runtime, interruptResponse); } } catch (error) { logger.warn("agent_chat.claude_interrupt_failed", { @@ -32506,17 +33415,22 @@ export function createAgentChatService(args: { }); } } - try { runtime.query?.close(); } catch { /* ignore */ } - // close() only ends stream iteration — it does not guarantee the SDK - // subprocess exits. Reap it so an interrupted turn never leaves a live - // `claude --resume` twin streaming into a dead reader (the same enforcement - // resetClaudeQuerySession applies on reset/remodel). - claudeSubprocessReaper.reapForSession(managed.session.id, "claude_interrupt"); - runtime.inputPump?.close(); - resetClaudeProcessBackgroundLevel(runtime); - runtime.query = null; - runtime.inputPump = null; - runtime.warmupDone = null; + const preserveQueryForQueuedMessages = (interruptResponse?.still_queued?.length ?? 0) > 0; + if (!preserveQueryForQueuedMessages) { + runtime.queryGeneration += 1; + runtime.queryStartPromise = null; + try { runtime.query?.close(); } catch { /* ignore */ } + // close() only ends stream iteration — it does not guarantee the SDK + // subprocess exits. Reap it so an interrupted turn never leaves a live + // `claude --resume` twin streaming into a dead reader (the same enforcement + // resetClaudeQuerySession applies on reset/remodel). + claudeSubprocessReaper.reapForSession(managed.session.id, "claude_interrupt"); + runtime.inputPump?.close(); + resetClaudeProcessBackgroundLevel(runtime); + runtime.query = null; + runtime.inputPump = null; + runtime.warmupDone = null; + } cancelQueuedSteers(managed, runtime, "interrupted"); // Drain pending approvals so their promises settle instead of hanging forever for (const pending of runtime.approvals.values()) { @@ -33405,7 +34319,11 @@ export function createAgentChatService(args: { capabilityMode: liveSession?.capabilityMode ?? persisted?.capabilityMode ?? inferCapabilityMode(provider), completion: liveSession?.completion ?? persisted?.completion ?? null, codexGoal: normalizeAdeCodexGoal(liveSession?.codexGoal ?? persisted?.codexGoal ?? null), + claudeGoal: liveSession?.claudeGoal ?? persisted?.claudeGoal ?? null, codexTokenUsage: liveSession?.codexTokenUsage ?? persisted?.codexTokenUsage ?? null, + ...(liveSession?.protocolCapabilities || persisted?.protocolCapabilities + ? { protocolCapabilities: liveSession?.protocolCapabilities ?? persisted?.protocolCapabilities } + : {}), ...(liveSession?.importedFrom || persisted?.importedFrom ? { importedFrom: liveSession?.importedFrom ?? persisted?.importedFrom } : {}), @@ -36103,12 +37021,14 @@ export function createAgentChatService(args: { message: ClaudeSdkSessionMessage, ): AgentChatClaudeSessionMessage => { const parentToolUseId = (message as unknown as { parent_tool_use_id?: unknown }).parent_tool_use_id; + const parentAgentId = (message as unknown as { parent_agent_id?: unknown }).parent_agent_id; const text = extractClaudeSessionMessageText(message.message); return { type: message.type, uuid: message.uuid, sessionId: message.session_id, parentToolUseId: typeof parentToolUseId === "string" ? parentToolUseId : null, + parentAgentId: typeof parentAgentId === "string" ? parentAgentId : null, message: message.message, ...(text ? { text } : {}), }; @@ -36781,12 +37701,14 @@ export function createAgentChatService(args: { }); return messages.map((message: ClaudeSdkSessionMessage) => { const parentToolUseId = (message as unknown as { parent_tool_use_id?: unknown }).parent_tool_use_id; + const parentAgentId = (message as unknown as { parent_agent_id?: unknown }).parent_agent_id; const text = extractClaudeSessionMessageText(message.message); return { type: message.type, uuid: message.uuid, sessionId: message.session_id, parentToolUseId: typeof parentToolUseId === "string" ? parentToolUseId : null, + parentAgentId: typeof parentAgentId === "string" ? parentAgentId : null, message: message.message, ...(text ? { text } : {}), }; diff --git a/apps/desktop/src/main/services/chat/chatTextBatching.ts b/apps/desktop/src/main/services/chat/chatTextBatching.ts index a41b20f0c..c388590cc 100644 --- a/apps/desktop/src/main/services/chat/chatTextBatching.ts +++ b/apps/desktop/src/main/services/chat/chatTextBatching.ts @@ -3,6 +3,7 @@ import type { AgentChatEvent } from "../../../shared/types"; export type BufferedAssistantText = { text: string; messageId?: string; + originTimestamp?: string; turnId?: string; itemId?: string; }; @@ -42,12 +43,14 @@ export function appendBufferedAssistantText( return { ...buffered!, text: `${buffered!.text}${event.text}`, + ...(event.originTimestamp ? { originTimestamp: event.originTimestamp } : {}), }; } return { text: event.text, ...(event.messageId ? { messageId: event.messageId } : {}), + ...(event.originTimestamp ? { originTimestamp: event.originTimestamp } : {}), ...(event.turnId ? { turnId: event.turnId } : {}), ...(event.itemId ? { itemId: event.itemId } : {}), }; diff --git a/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts index e1355e366..3a56563f2 100644 --- a/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts +++ b/apps/desktop/src/main/services/chat/claudeAssistantTextDedup.test.ts @@ -265,9 +265,15 @@ function textDelta(messageId: string, text: string) { }; } -function assistantSnapshot(messageId: string, text: string) { +function assistantSnapshot( + messageId: string, + text: string, + extras: { timestamp?: string; resumedFromIncompleteThinking?: boolean } = {}, +) { return { type: "assistant", + ...(extras.timestamp ? { timestamp: extras.timestamp } : {}), + ...(extras.resumedFromIncompleteThinking ? { resumed_from_incomplete_thinking: true } : {}), message: { id: messageId, content: [{ type: "text", text }], @@ -332,4 +338,23 @@ describe("Claude assistant text snapshot deduplication", () => { expect(textEvents[0]).toMatchObject({ text: fullText, messageId }); expect(textEvents.map((event) => event.text).join("")).toBe(fullText); }); + + it("preserves resumed incomplete-thinking text and its provider timestamp", async () => { + const messageId = "msg-resumed-incomplete-thinking"; + const fullText = "Recovered text must not be discarded."; + const originTimestamp = "2026-07-16T14:15:16.000Z"; + const textEvents = await runTextFixture([ + assistantSnapshot(messageId, fullText), + assistantSnapshot(messageId, fullText, { + timestamp: originTimestamp, + resumedFromIncompleteThinking: true, + }), + ]); + + expect(textEvents.map((event) => event.text).join("")).toBe(`${fullText}${fullText}`); + expect(textEvents.at(-1)).toMatchObject({ + messageId, + originTimestamp, + }); + }); }); diff --git a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts index a26c19d07..4f979747f 100644 --- a/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts +++ b/apps/desktop/src/main/services/chat/contextCompactionEmitter.ts @@ -38,7 +38,7 @@ export function buildContextCompactEvent( state: CompactionEmitterState, session: AgentChatSession, input: { - trigger: "manual" | "auto"; + trigger: "manual" | "auto" | "ade_fallback"; state?: "started" | "completed"; turnId?: string; compactionId?: string; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 5e2414f49..4964fc395 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -119,6 +119,7 @@ import { CodexPlanCard } from "./codex/CodexPlanCard"; import { CodexImageGenerationCard } from "./codex/CodexImageGenerationCard"; import { CodexImageViewLine } from "./codex/CodexImageViewLine"; import { ContextCompactDivider } from "./ContextCompactDivider"; +import { terminalReasonLabel, formatTimedOutAfter, formatGrepTotalsPrefix } from "./chatEventDisplay"; import { peekPendingSessionAnchor, takePendingSessionAnchor } from "../terminals/pendingSessionAnchors"; import { ChatTurnFileChangesPanel, aggregateFiles } from "./ChatFileChangesPanel"; @@ -1787,7 +1788,13 @@ function ToolResultCard({ event }: { event: Extract TOOL_RESULT_TRUNCATE_LIMIT; const displayStr = !expanded && isTruncated ? `${resultStr.slice(0, TOOL_RESULT_TRUNCATE_LIMIT)}...` : resultStr; - const preview = summarizeStructuredValue(event.result, 180); + const rawPreview = summarizeStructuredValue(event.result, 180); + // Grep: prefix the preview with the match/file totals the service extracted. + const preview = `${formatGrepTotalsPrefix(event.grepTotals)}${rawPreview}`; + // Bash: a command that auto-backgrounded on timeout carries the elapsed ms; + // surface it as a calm chip instead of the generic status word. + const timedOutMs = typeof event.timedOutAfterMs === "number" ? event.timedOutAfterMs : null; + const timedOutLabel = timedOutMs != null ? formatTimedOutAfter(timedOutMs) : null; return ( {toolDisplay.secondaryLabel} ) : null} {preview.length ? {preview} : null} - {event.status ? ( + {timedOutLabel ? ( + + auto-backgrounded after {timedOutLabel} + + ) : event.status ? ( {event.status} @@ -2737,6 +2751,23 @@ function InlineQuestionRequestCard({ // replay read as a flicker once the bubble settled. const animatedUserMessageKeys = new Set(); +/** Stable-ish identity for an interrupt receipt row (no id on the event). */ +function interruptReceiptIdentity(event: Extract): string { + return `${event.turnId ?? ""}:${(event.stillQueuedUuids ?? []).join(",")}`; +} + +/** Muted per-row timestamp, revealed on row hover only (lives inside an existing + * group-hover toolbar so it adds zero layout shift). */ +function RowHoverTimestamp({ iso, className }: { iso: string; className?: string }) { + const label = formatTime(iso); + if (!label) return null; + return ( + + {label} + + ); +} + function renderEvent( envelope: RenderEnvelope, options?: { @@ -2765,6 +2796,10 @@ function renderEvent( /** Scroll a row into view by its stable render key (subagent jump affordances). */ onScrollToRowKey?: (rowKey: string) => void; assistantTurnCopy?: { text: string } | null; + /** Interrupt-receipt identities whose queued messages already ran → collapse. */ + staleInterruptReceipts?: Set; + /** Cancel an ADE-owned queued message by uuid (stop-receipt affordance). */ + onCancelQueuedMessage?: (uuid: string) => void; } ) { const event = envelope.event; @@ -2841,6 +2876,7 @@ function renderEvent( ) : null}
+ {event.messageId && options?.onRewindFiles ? ( + ) : null} + + ))} + + ) : null} +
+ ); + } + + if (event.type === "command_lifecycle") { + if (event.status !== "cancelled" && event.status !== "discarded") return null; + const label = event.status === "discarded" ? "Queued message discarded" : "Queued message cancelled"; + return ( +
+ + {event.preview ? `${label}: ${event.preview}` : label} +
+ ); + } + + /* ── Conversation reset / API retry: surfaced elsewhere, no inline row. ── */ + if (event.type === "conversation_reset" || event.type === "api_retry") { + return null; + } + /* ── System Notice ── */ if (event.type === "system_notice") { // Spawn notices. The "spawned" announcement is now carried by the unified, @@ -4276,6 +4388,7 @@ function DoneTurnDivider({ }) { const completed = event.status === "completed"; const { label: modelLabel } = resolveModelMeta(event.modelId, event.model); + const reasonLabel = completed ? null : terminalReasonLabel(event.terminalReason); const workedFor = durationMs !== null && durationMs > 1500 ? `Worked for ${formatTurnDuration(durationMs)}` : null; @@ -4299,6 +4412,12 @@ function DoneTurnDivider({ ) : ( {event.status} )} + {reasonLabel ? ( + <> + · + {reasonLabel} + + ) : null} {workedFor ? ( <> · @@ -4323,6 +4442,35 @@ function deriveLatestActivity(events: AgentChatEventEnvelope[]): { activity: str return null; } +// The latest `api_retry` for the live turn, but only while it is the newest +// signal — an assistant/stream/activity event after it means the retry +// resolved, so the verb clears. Drives a better working-indicator verb than the +// generic "taking longer than usual". +function deriveActiveApiRetry( + events: AgentChatEventEnvelope[], + activeTurnId: string | null, +): { attempt: number; maxRetries: number; retryDelayMs: number } | null { + if (!activeTurnId) return null; + for (let i = events.length - 1; i >= 0; i--) { + const evt = events[i]!.event; + if (evt.type === "api_retry") { + if (evt.turnId && evt.turnId !== activeTurnId) continue; + return { attempt: evt.attempt, maxRetries: evt.maxRetries, retryDelayMs: evt.retryDelayMs }; + } + if ( + evt.type === "text" + || evt.type === "reasoning" + || evt.type === "activity" + || evt.type === "tool_call" + || evt.type === "tool_result" + || evt.type === "done" + ) { + return null; + } + } + return null; +} + function deriveActiveTurnId(events: AgentChatEventEnvelope[]): string | null { const completedTurnIds = new Set(); for (let i = events.length - 1; i >= 0; i--) { @@ -4401,6 +4549,8 @@ type EventRowProps = { anchored?: boolean; onScrollToRowKey?: (rowKey: string) => void; assistantTurnCopy?: { text: string } | null; + staleInterruptReceipts?: Set; + onCancelQueuedMessage?: (uuid: string) => void; }; const EventRow = React.memo(function EventRow({ @@ -4438,6 +4588,8 @@ const EventRow = React.memo(function EventRow({ anchored, onScrollToRowKey, assistantTurnCopy, + staleInterruptReceipts, + onCancelQueuedMessage, }: EventRowProps) { const workLogAnimate = Boolean(turnActive) && !sessionEnded @@ -4517,6 +4669,8 @@ const EventRow = React.memo(function EventRow({ mosaic, onScrollToRowKey, assistantTurnCopy, + staleInterruptReceipts, + onCancelQueuedMessage, })} {envelope.event.type === "done" ? ( void; onRevealChatTerminal?: (terminal: { terminalId: string; ptyId: string; label: string }) => void; onRewindFiles?: (request: { messageId: string; timestamp: string; text: string }) => void; + /** Cancel an ADE-owned queued message by uuid (stop-receipt affordance). */ + onCancelQueuedMessage?: (uuid: string) => void; turnDiffSummaries?: TurnDiffSummary[]; respondingApprovalIds?: Set; pendingApprovalIds?: Set; @@ -5025,6 +5182,37 @@ function AgentChatMessageListMain({ } const latestActivity = useMemo(() => (showStreamingIndicator ? deriveLatestActivity(events) : null), [events, showStreamingIndicator]); const activeTurnId = useMemo(() => (showStreamingIndicator ? deriveActiveTurnId(events) : null), [events, showStreamingIndicator]); + const activeApiRetry = useMemo( + () => (showStreamingIndicator ? deriveActiveApiRetry(events, activeTurnId) : null), + [events, showStreamingIndicator, activeTurnId], + ); + // A stop receipt auto-collapses once its queued messages have run — best-effort: + // once a *later* turn (different turnId, i.e. the next turn) completes. The + // interrupted turn's own `done` does not count. + const staleInterruptReceipts = useMemo(() => { + const stale = new Set(); + const pending: { identity: string; turnId: string | undefined; donesAfter: number }[] = []; + for (const envelope of events) { + const evt = envelope.event; + if (evt.type === "interrupt_receipt") { + pending.push({ identity: interruptReceiptIdentity(evt), turnId: evt.turnId, donesAfter: 0 }); + } else if (evt.type === "done") { + for (const p of pending) { + if (stale.has(p.identity)) continue; + if (p.turnId && evt.turnId && evt.turnId !== p.turnId) { + stale.add(p.identity); + } else if (p.turnId && evt.turnId === p.turnId) { + // Interrupted turn's own done — wait for the next turn. + } else { + // Missing turnId on either side: fall back to "second done after". + p.donesAfter += 1; + if (p.donesAfter >= 2) stale.add(p.identity); + } + } + } + } + return stale; + }, [events]); const activeTurnStartedAt = useMemo( () => (showStreamingIndicator ? deriveTurnStartedAt(events, activeTurnId) : null), [events, showStreamingIndicator, activeTurnId], @@ -5730,9 +5918,11 @@ function AgentChatMessageListMain({ anchored={anchored} onScrollToRowKey={scrollToRowKey} assistantTurnCopy={assistantTurnCopy} + staleInterruptReceipts={staleInterruptReceipts} + onCancelQueuedMessage={onCancelQueuedMessage} /> ); - }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRetryProviderFailure, onChooseProviderFailureModel, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey]); + }, [activeTurnId, anchoredRowKey, assistantLabel, assistantTurnCopyByRowKey, surfaceMode, surfaceProfile, groupedRows, latestWorkLogIndex, turnModelState, handleApproval, handleMeasure, openWorkspacePath, handleNavigateSuggestion, handleReviewChanges, onCodexRecovery, onRetryProviderFailure, onChooseProviderFailureModel, onInsertDraft, onRevealChatTerminal, onRewindFiles, turnDiffSummaries, respondingApprovalIds, pendingApprovalIds, resolvedInputStates, laneId, sessionId, sessionTurnActive, sessionEnded, runtimeName, mosaic, scrollToRowKey, forkHistoryDividerRowKey, staleInterruptReceipts, onCancelQueuedMessage]); // Compute the bottom spacer height for virtualized mode. const bottomSpacerHeight = useMemo(() => { @@ -5756,7 +5946,11 @@ function AgentChatMessageListMain({ transition={{ duration: 0.12, ease: "easeOut" }} >
diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx index ae527c7e3..83984c440 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.test.tsx @@ -33,6 +33,7 @@ import { buildParallelLaunchPrompt, cleanupChatActionsAutoOpenStorage, cleanupTransientParallelLaunchLanes, + deriveRuntimeState, formatParallelLaunchFailureMessage, getChatActionsAutoOpenStorageKey, advanceOlderHistoryCursor, @@ -7531,6 +7532,32 @@ describe("shouldPromoteSessionForComputerUse", () => { }); }); +describe("deriveRuntimeState", () => { + it("clears a staged steer when Claude reports that its command started", () => { + const events: AgentChatEventEnvelope[] = [ + { + sessionId: "session-1", + timestamp: "2026-07-16T12:00:00.000Z", + sequence: 1, + event: { type: "user_message", text: "queued", steerId: "steer-1", deliveryState: "queued" }, + }, + { + sessionId: "session-1", + timestamp: "2026-07-16T12:00:01.000Z", + sequence: 2, + event: { + type: "command_lifecycle", + commandUuid: "command-1", + status: "started", + steerId: "steer-1", + }, + }, + ]; + + expect(deriveRuntimeState(events).pendingSteers).toEqual([]); + }); +}); + describe("mergeChatHistorySnapshot", () => { function envelope( timestamp: string, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index 41fb88b33..189a82711 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -37,6 +37,7 @@ import { type ChatSurfacePresentation, type AgentChatSessionSummary, type CodexThreadGoal, + type ClaudeActiveGoal, type BuiltInBrowserContextItem, type ComputerUseOwnerSnapshot, type AppControlContextItem, @@ -1058,6 +1059,9 @@ export function deriveRuntimeState(events: AgentChatEventEnvelope[]): { steerMap.delete(event.steerId); resolvedSteerIds.add(event.steerId); } + } else if (event.type === "command_lifecycle" && event.steerId && event.status !== "queued") { + steerMap.delete(event.steerId); + resolvedSteerIds.add(event.steerId); } } @@ -3801,6 +3805,30 @@ export function AgentChatPane({ } return sawGoalEvent ? goalFromEvents : (selectedSession?.codexGoal ?? null); }, [selectedEventsForDisplay, selectedSession?.codexGoal]); + // Claude goal is owned by the CLI's /goal loop — read-only here. Mirror the + // Codex derivation: latest claude_goal_updated/cleared wins, else the snapshot. + const selectedClaudeGoal = useMemo(() => { + let goalFromEvents: ClaudeActiveGoal | null = null; + let sawGoalEvent = false; + for (const envelope of selectedEventsForDisplay) { + const event = envelope.event; + if (event.type === "claude_goal_updated") { + goalFromEvents = event.goal; + sawGoalEvent = true; + } + if (event.type === "claude_goal_cleared") { + goalFromEvents = null; + sawGoalEvent = true; + } + } + return sawGoalEvent ? goalFromEvents : (selectedSession?.claudeGoal ?? null); + }, [selectedEventsForDisplay, selectedSession?.claudeGoal]); + const cancelQueuedMessageFromReceipt = useCallback((steerId: string) => { + if (!selectedSessionId) return; + void window.ade.agentChat + .cancelDispatchedSteer({ sessionId: selectedSessionId, steerId }) + .catch(() => { /* best-effort: already delivered or unknown steer */ }); + }, [selectedSessionId]); const selectedSubagentSnapshots = useMemo(() => deriveChatSubagentSnapshots(selectedEvents), [selectedEvents]); const selectedScheduledWorkSnapshots = useMemo( () => mergeManagedScheduledWorkSnapshots(selectedEvents, selectedSession?.scheduledWork), @@ -9713,6 +9741,7 @@ export function AgentChatPane({ capability={selectedSubagentCapability} selectedTaskId={subagentView?.taskId ?? null} goal={selectedSession?.provider === "codex" ? selectedCodexGoal : null} + claudeGoal={selectedSession?.provider === "claude" ? selectedClaudeGoal : null} goalPending={selectedCodexGoalPending} onEditGoal={ selectedSession?.provider === "codex" && selectedSessionId @@ -11511,6 +11540,7 @@ export function AgentChatPane({ onRevealChatTerminal={revealChatTerminal} turnDiffSummaries={selectedTurnDiffSummaries} onRewindFiles={selectedSession?.provider === "claude" || selectedSession?.provider === "codex" ? rewindFilesFromMessage : undefined} + onCancelQueuedMessage={!subagentView && selectedSessionId ? cancelQueuedMessageFromReceipt : undefined} onApproval={handleListApproval} onCodexRecovery={handleListCodexRecovery} onRetryProviderFailure={handleListRetryProviderFailure} diff --git a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx index 4b534c9d9..9717fa5aa 100644 --- a/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatSubagentsPanel.tsx @@ -14,7 +14,7 @@ import { import { cn } from "../ui/cn"; import { formatDurationMs, formatSubagentDurationMs } from "../../lib/format"; import type { ChatScheduledWorkSnapshot, ChatSubagentSnapshot } from "./chatExecutionSummary"; -import { derivePlan } from "./chatExecutionSummary"; +import { derivePlan, subagentTreeDepth } from "./chatExecutionSummary"; import type { TodoItemSnapshot } from "./chatExecutionSummary"; import { ChatTaskList } from "./ChatTasksPanel"; import type { ChatInfoPlanStep, PaneSectionKey } from "../../../shared/chatSubagents"; @@ -37,10 +37,10 @@ import { isFiredOneShotWakeup, scheduledNextFireLabel, } from "../../../shared/chatScheduledWork"; -import type { AgentChatEventEnvelope, CodexThreadGoal } from "../../../shared/types"; +import type { AgentChatEventEnvelope, ClaudeActiveGoal, CodexThreadGoal } from "../../../shared/types"; import type { SubagentCapability } from "../../../shared/subagentCapabilities"; import { BottomDrawerSection } from "./BottomDrawerSection"; -import { CodexGoalCard } from "./codex/CodexGoalCard"; +import { GoalCard } from "./GoalCard"; import { ChatSubagentGlyph, chatSubagentColor, chatSubagentDisplayName } from "./chatSubagentIdentity"; import { navigateToSpawnedChat } from "./spawnNavigation"; @@ -780,11 +780,14 @@ function SubagentRow({ probing, canViewFullTranscript, onClick, + depth = 0, }: { snapshot: ChatSubagentSnapshot; selected: boolean; category: GlyphCategory; expanded: boolean; + /** Nesting depth (parentAgentId chain); indents the row one level per ancestor. */ + depth?: number; /** True while we're checking whether this agent has a pullable transcript. */ probing: boolean; /** Whether this runtime can surface a full child transcript (drives the @@ -823,7 +826,7 @@ function SubagentRow({ const summaryText = summaryRaw && summaryRaw !== snapshot.description?.trim() ? summaryRaw : null; return ( -
+
0 ? { paddingLeft: depth * 14 } : undefined}> + ) : null} + {event.parentLabel ? ( + spawned by {event.parentLabel} + ) : null}
{event.summaryPreview?.trim() ? (
diff --git a/apps/desktop/src/renderer/components/chat/chatEventDisplay.test.ts b/apps/desktop/src/renderer/components/chat/chatEventDisplay.test.ts new file mode 100644 index 000000000..4755c267b --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatEventDisplay.test.ts @@ -0,0 +1,46 @@ +import { describe, it, expect } from "vitest"; +import { terminalReasonLabel, formatTimedOutAfter, formatGrepTotalsPrefix } from "./chatEventDisplay"; + +describe("terminalReasonLabel", () => { + it("maps known SDK terminal reasons to terse labels", () => { + expect(terminalReasonLabel("budget_exhausted")).toBe("budget limit reached"); + expect(terminalReasonLabel("max_turns")).toBe("max turns reached"); + expect(terminalReasonLabel("prompt_too_long")).toBe("context window overflow"); + expect(terminalReasonLabel("api_error")).toBe("API error after retries"); + expect(terminalReasonLabel("malformed_tool_use_exhausted")).toBe("tool-call retries exhausted"); + expect(terminalReasonLabel("structured_output_retry_exhausted")).toBe("output retries exhausted"); + expect(terminalReasonLabel("model_error")).toBe("model error"); + expect(terminalReasonLabel("turn_setup_failed")).toBe("turn setup failed"); + expect(terminalReasonLabel("tool_deferred_unavailable")).toBe("deferred tool unavailable"); + }); + + it("shows nothing for unknown, completed, or missing reasons (open set)", () => { + expect(terminalReasonLabel("completed")).toBeNull(); + expect(terminalReasonLabel("some_future_reason")).toBeNull(); + expect(terminalReasonLabel(undefined)).toBeNull(); + expect(terminalReasonLabel("")).toBeNull(); + }); +}); + +describe("formatTimedOutAfter", () => { + it("formats sub-minute and minute+second durations", () => { + expect(formatTimedOutAfter(8_000)).toBe("8s"); + expect(formatTimedOutAfter(0)).toBe("0s"); + expect(formatTimedOutAfter(60_000)).toBe("1m"); + expect(formatTimedOutAfter(125_000)).toBe("2m 5s"); + expect(formatTimedOutAfter(-500)).toBe("0s"); + }); +}); + +describe("formatGrepTotalsPrefix", () => { + it("builds a pluralized 'N matches in M files · ' prefix", () => { + expect(formatGrepTotalsPrefix({ lines: 12, files: 3 })).toBe("12 matches in 3 files · "); + expect(formatGrepTotalsPrefix({ lines: 1, files: 1 })).toBe("1 match in 1 file · "); + expect(formatGrepTotalsPrefix({ lines: 0, files: 0 })).toBe("0 matches in 0 files · "); + }); + + it("returns an empty prefix when totals are absent", () => { + expect(formatGrepTotalsPrefix(undefined)).toBe(""); + expect(formatGrepTotalsPrefix({})).toBe("0 matches in 0 files · "); + }); +}); diff --git a/apps/desktop/src/renderer/components/chat/chatEventDisplay.ts b/apps/desktop/src/renderer/components/chat/chatEventDisplay.ts new file mode 100644 index 000000000..55c63b5e1 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/chatEventDisplay.ts @@ -0,0 +1,44 @@ +/** + * Pure display helpers for the new SDK event surfaces (terminal reasons, Bash + * auto-background timeout, Grep totals). Kept free of React so they are cheap to + * unit-test and shared by the message list. + */ + +// Terse human label for a non-completed turn's SDK terminal reason. Open set: +// unknown reason strings return null so we show nothing extra. Completed turns +// never pass a reason (avoid noise). +export const TERMINAL_REASON_LABELS: Record = { + budget_exhausted: "budget limit reached", + max_turns: "max turns reached", + prompt_too_long: "context window overflow", + api_error: "API error after retries", + malformed_tool_use_exhausted: "tool-call retries exhausted", + structured_output_retry_exhausted: "output retries exhausted", + model_error: "model error", + turn_setup_failed: "turn setup failed", + tool_deferred_unavailable: "deferred tool unavailable", +}; + +export function terminalReasonLabel(reason: string | undefined): string | null { + if (!reason) return null; + return TERMINAL_REASON_LABELS[reason] ?? null; +} + +/** Compact `Nm Ns` / `Ns` label for an auto-backgrounded-on-timeout Bash chip. */ +export function formatTimedOutAfter(ms: number): string { + const secs = Math.max(0, Math.round(ms / 1000)); + if (secs < 60) return `${secs}s`; + const minutes = Math.floor(secs / 60); + const remainder = secs % 60; + return remainder ? `${minutes}m ${remainder}s` : `${minutes}m`; +} + +/** `N matches in M files · ` prefix for a Grep tool result, or "" when absent. */ +export function formatGrepTotalsPrefix( + grepTotals: { files?: number; lines?: number } | undefined, +): string { + if (!grepTotals) return ""; + const matches = grepTotals.lines ?? 0; + const files = grepTotals.files ?? 0; + return `${matches} match${matches === 1 ? "" : "es"} in ${files} file${files === 1 ? "" : "s"} · `; +} diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts index db9cd5a57..43f180073 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.test.ts @@ -4,8 +4,60 @@ import { deriveChatSubagentSnapshots, deriveScheduledWorkSnapshots, deriveTodoItems, + subagentTreeDepth, + type ChatSubagentSnapshot, } from "./chatExecutionSummary"; +describe("subagentTreeDepth", () => { + const snap = (agentId: string, parentAgentId: string | null): ChatSubagentSnapshot => ({ + taskId: agentId, + agentId, + parentAgentId, + description: agentId, + status: "running", + startedAt: "2026-03-10T12:00:00.000Z", + updatedAt: "2026-03-10T12:00:00.000Z", + summary: null, + }); + + it("returns 0 for a top-level agent with no known parent", () => { + const list = [snap("root", null)]; + expect(subagentTreeDepth(list[0]!, list)).toBe(0); + }); + + it("indents one level per ancestor present in the list", () => { + const root = snap("root", null); + const child = snap("child", "root"); + const grandchild = snap("grandchild", "child"); + const list = [root, child, grandchild]; + expect(subagentTreeDepth(child, list)).toBe(1); + expect(subagentTreeDepth(grandchild, list)).toBe(2); + }); + + it("ignores a parentAgentId that is not in the list (depth 1 fallback)", () => { + const orphan = snap("orphan", "missing-parent"); + const list = [orphan]; + expect(subagentTreeDepth(orphan, list)).toBe(0); + }); + + it("caps depth at 3 for deep chains", () => { + const a = snap("a", null); + const b = snap("b", "a"); + const c = snap("c", "b"); + const d = snap("d", "c"); + const e = snap("e", "d"); + const list = [a, b, c, d, e]; + expect(subagentTreeDepth(e, list)).toBe(3); + }); + + it("is cycle-guarded", () => { + const x = snap("x", "y"); + const y = snap("y", "x"); + const list = [x, y]; + expect(subagentTreeDepth(x, list)).toBeLessThanOrEqual(3); + }); +}); + describe("deriveChatSubagentSnapshots", () => { it("keeps running subagents ahead of completed ones and preserves descriptions", () => { const events: AgentChatEventEnvelope[] = [ diff --git a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts index c5c6a1202..576da5c4c 100644 --- a/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts +++ b/apps/desktop/src/renderer/components/chat/chatExecutionSummary.ts @@ -32,6 +32,8 @@ export type ChatSubagentSnapshot = { model?: string | null; reasoningEffort?: string | null; parentToolUseId?: string | null; + /** agentId of the subagent that spawned this one (nested trees); null at depth 1. */ + parentAgentId?: string | null; description: string; status: "running" | "completed" | "failed" | "stopped"; turnId?: string; @@ -56,6 +58,35 @@ function compareIsoDesc(left: string, right: string): number { return Date.parse(right) - Date.parse(left); } +/** + * Visual nesting depth for a subagent row: the number of ancestors, reached via + * `parentAgentId`, that are present in `snapshots` — capped at `cap` (default 3). + * Depth 0 = top-level (no known parent). Pure; cycle-guarded. + */ +export function subagentTreeDepth( + snapshot: ChatSubagentSnapshot, + snapshots: ChatSubagentSnapshot[], + cap = 3, +): number { + const byAgentId = new Map(); + for (const candidate of snapshots) { + if (candidate.agentId) byAgentId.set(candidate.agentId, candidate); + } + const seen = new Set(); + let depth = 0; + let current: ChatSubagentSnapshot | undefined = snapshot; + while (current?.parentAgentId && depth < cap) { + const id = current.agentId ?? current.taskId; + if (seen.has(id)) break; // cycle guard + seen.add(id); + const parent = byAgentId.get(current.parentAgentId); + if (!parent || parent === current) break; + depth += 1; + current = parent; + } + return depth; +} + type ChatSubagentEvent = NormalizedSubagentLifecycleEvent; function subagentIdentityKey(event: ChatSubagentEvent): string { @@ -153,6 +184,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C model: event.model?.trim() || existing?.model, reasoningEffort: event.reasoningEffort?.trim() || existing?.reasoningEffort, parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, + parentAgentId: event.parentAgentId ?? existing?.parentAgentId ?? null, description: event.description.trim() || existing?.description || "Subagent task", status: "running", turnId: event.turnId ?? existing?.turnId, @@ -179,6 +211,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C model: event.model?.trim() || existing?.model, reasoningEffort: event.reasoningEffort?.trim() || existing?.reasoningEffort, parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, + parentAgentId: event.parentAgentId ?? existing?.parentAgentId ?? null, description: event.description?.trim() || existing?.description || "Subagent task", status: "running", turnId: event.turnId ?? existing?.turnId, @@ -205,6 +238,7 @@ export function deriveChatSubagentSnapshots(events: AgentChatEventEnvelope[]): C model: event.model?.trim() || existing?.model, reasoningEffort: event.reasoningEffort?.trim() || existing?.reasoningEffort, parentToolUseId: subagentParentKey(event) ?? existing?.parentToolUseId ?? null, + parentAgentId: event.parentAgentId ?? existing?.parentAgentId ?? null, description: existing?.description ?? "Subagent task", status: event.status, turnId: event.turnId ?? existing?.turnId, diff --git a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts index e4de1dbfc..663a2126e 100644 --- a/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts +++ b/apps/desktop/src/renderer/components/chat/chatTranscriptRows.ts @@ -152,6 +152,8 @@ export type SubagentSpawnAnchorRenderEvent = { spawnKind: AgentChatSpawnKind | null; /** Final result summary, surfaced on the card once the agent settles. */ resultSummary: string | null; + /** Best-effort parent label (parent's description/agentType); null/absent if unknown. */ + parentLabel?: string | null; }; /** @@ -170,6 +172,11 @@ export type SubagentResultCardRenderEvent = { startedAt: string | null; endedAt: string; durationMs: number | null; + totalTokens: number | null; + toolUseCount: number | null; + worktreeBranch: string | null; + worktreePath: string | null; + parentLabel: string | null; }; /** One folded agent inside a {@link SubagentStoppedGroupEvent}. */ @@ -303,6 +310,13 @@ type SubagentAnchorState = { childSessionId: string | null; /** Spawn-kind carried on the `subagent_started` event; null for runtime-native subagents. */ spawnKind: AgentChatSpawnKind | null; + /** Result-only usage/worktree metadata surfaced on the result card. */ + totalTokens: number | null; + toolUseCount: number | null; + worktreeBranch: string | null; + worktreePath: string | null; + /** Parent agent id carried on any lifecycle event; resolves the parent label. */ + parentAgentId: string | null; }; type CollapseTranscriptContext = { @@ -945,7 +959,22 @@ function backgroundExitCode(event: NormalizedSubagentLifecycleEvent): number | n return typeof value === "number" && Number.isFinite(value) ? value : null; } -function spawnAnchorEvent(state: SubagentAnchorState): SubagentSpawnAnchorRenderEvent { +// Best-effort parent label from the anchors map — the parent's description or +// agentType. Null when the parent isn't (yet) in the map; renders nothing. +function resolveParentLabel( + state: SubagentAnchorState, + anchors: Map, +): string | null { + if (!state.parentAgentId) return null; + const parent = anchors.get(state.parentAgentId); + const label = parent?.description?.trim() || parent?.agentType?.trim() || null; + return label; +} + +function spawnAnchorEvent( + state: SubagentAnchorState, + anchors?: Map, +): SubagentSpawnAnchorRenderEvent { // agentKey mirrors the stable renderKeyBase so the jump affordances derive the // same row keys the collapse pass assigned (see subagentSpawnKey/subagentResultKey). return { @@ -963,6 +992,7 @@ function spawnAnchorEvent(state: SubagentAnchorState): SubagentSpawnAnchorRender childSessionId: state.childSessionId, spawnKind: state.spawnKind, resultSummary: state.resultSummary, + parentLabel: anchors ? resolveParentLabel(state, anchors) : null, }; } @@ -999,6 +1029,8 @@ function enrichSubagentStateFromEvent( if (typeof record.spawnKind === "string" && (record.spawnKind === "subagent" || record.spawnKind === "peer" || record.spawnKind === "none")) { state.spawnKind = record.spawnKind; } + const parentAgentId = subagentText((event as { parentAgentId?: unknown }).parentAgentId); + if (parentAgentId) state.parentAgentId = parentAgentId; } function classificationInput(state: SubagentAnchorState) { @@ -1067,6 +1099,11 @@ function handleSubagentLifecycleEvent( error: null, childSessionId: null, spawnKind: null, + totalTokens: null, + toolUseCount: null, + worktreeBranch: null, + worktreePath: null, + parentAgentId: null, }; anchors.set(agentKey, state); if (taskId) anchors.set(taskId, state); @@ -1085,7 +1122,7 @@ function handleSubagentLifecycleEvent( rows.push({ key: subagentSpawnKey(state.renderKeyBase), timestamp, - event: spawnAnchorEvent(state), + event: spawnAnchorEvent(state, anchors), }); } @@ -1106,7 +1143,7 @@ function handleSubagentLifecycleEvent( rows[rowIndex] = { key: expectedKey, timestamp, - event: spawnAnchorEvent(state), + event: spawnAnchorEvent(state, anchors), }; } } @@ -1146,6 +1183,14 @@ function handleSubagentLifecycleEvent( // Real subagent terminal: flip the anchor + push/mutate the result card. state.status = terminalStatus; + if (event.type === "subagent_result") { + if (typeof event.totalTokens === "number") state.totalTokens = event.totalTokens; + if (typeof event.toolUseCount === "number") state.toolUseCount = event.toolUseCount; + const wb = subagentText(event.worktreeBranch); + if (wb) state.worktreeBranch = wb; + const wp = subagentText(event.worktreePath); + if (wp) state.worktreePath = wp; + } state.endedAt = timestamp; state.resultSummary = preferSubagentSummary(state.resultSummary, incomingSummary); if (terminalStatus === "failed") { @@ -1156,7 +1201,7 @@ function handleSubagentLifecycleEvent( if (state.rowIndex != null) { const expectedKey = subagentSpawnKey(state.renderKeyBase); const rowIndex = resolveSubagentRowPosition(rows, state, "rowIndex", expectedKey); - if (rowIndex != null) rows[rowIndex] = { key: expectedKey, timestamp, event: spawnAnchorEvent(state) }; + if (rowIndex != null) rows[rowIndex] = { key: expectedKey, timestamp, event: spawnAnchorEvent(state, anchors) }; } const resultEvent: SubagentResultCardRenderEvent = { @@ -1169,6 +1214,11 @@ function handleSubagentLifecycleEvent( startedAt: state.startedAt, endedAt: timestamp, durationMs: durationMsBetween(state.startedAt, timestamp), + totalTokens: state.totalTokens, + toolUseCount: state.toolUseCount, + worktreeBranch: state.worktreeBranch, + worktreePath: state.worktreePath, + parentLabel: resolveParentLabel(state, anchors), }; if (state.resultRowIndex == null) { state.resultRowIndex = rows.length; diff --git a/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.test.tsx b/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.test.tsx index c28b450b9..3790c55e4 100644 --- a/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.test.tsx +++ b/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.test.tsx @@ -38,6 +38,11 @@ describe("ContextUsageDial", () => { expect(container.querySelector('circle[stroke="#fb7185"]')).toBeTruthy(); }); + it("uses amber when the displayed percentage rounds up to 80", () => { + const { container } = render(); + expect(container.querySelector('circle[stroke="#fbbf24"]')).toBeTruthy(); + }); + it("falls back to a tokens-only readout when the window is unknown", () => { const { getByText, container } = render( , diff --git a/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.tsx b/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.tsx index 4fbd2c0d8..a6e594eaf 100644 --- a/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.tsx +++ b/apps/desktop/src/renderer/components/chat/usage/ContextUsageDial.tsx @@ -17,7 +17,9 @@ const RING_CIRCUMFERENCE = 2 * Math.PI * RING_RADIUS; // ≈ 50.27 function ratioColor(ratio: number): string { if (ratio >= 0.9) return "#fb7185"; // rose-400 — nearing the limit - if (ratio >= 0.7) return "#fbbf24"; // amber-400 + // Amber tier aligns with the ≥80% tooltip warning so the color shift is + // visible without opening the tooltip. + if (Math.round(ratio * 100) >= 80) return "#fbbf24"; // amber-400 — context may soon be trimmed/compacted return "#38bdf8"; // sky-400 } diff --git a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts index 211e2db22..b63e62666 100644 --- a/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts +++ b/apps/desktop/src/renderer/components/chat/usage/contextUsageModel.test.ts @@ -234,6 +234,18 @@ describe("latestContextUsageInput", () => { expect(viewModel?.contextWindow).toBe(200_000); expect(viewModel?.ratio).toBeCloseTo(0.155, 5); }); + + it("consumes live context_usage (origin: 'live') the same as an exact snapshot", () => { + const input = latestContextUsageInput([ + envelope(1, { type: "context_usage", origin: "command", usage: { totalTokens: 20_000, maxTokens: 200_000 }, turnId: "turn-1" }), + // A live mid-turn sample updates the meter immediately, like an exact one. + envelope(2, { type: "context_usage", origin: "live", usage: { totalTokens: 170_000, maxTokens: 200_000 }, turnId: "turn-1" }), + ] as any, "claude"); + const viewModel = toUsageViewModel(input); + expect(viewModel?.usedTokens).toBe(170_000); + expect(viewModel?.contextWindow).toBe(200_000); + expect(viewModel?.ratio).toBeCloseTo(0.85, 5); + }); }); describe("formatContextTokens", () => { diff --git a/apps/desktop/src/shared/chatSubagents.test.ts b/apps/desktop/src/shared/chatSubagents.test.ts index 45298ebe5..fc04a7f8a 100644 --- a/apps/desktop/src/shared/chatSubagents.test.ts +++ b/apps/desktop/src/shared/chatSubagents.test.ts @@ -83,6 +83,27 @@ describe("chat pane scalability helpers", () => { }); }); + it("preserves parentAgentId while deriving nested subagent snapshots", () => { + const events: AgentChatEventEnvelope[] = [{ + sessionId: "session-nested", + timestamp: "2026-07-16T12:00:00.000Z", + event: { + type: "subagent_started", + taskId: "nested-task", + agentId: "nested-agent", + parentAgentId: "parent-agent", + description: "Inspect the nested transcript", + }, + }]; + + expect(subagentSnapshotsFromEvents(events)).toEqual([ + expect.objectContaining({ + id: "nested-agent", + parentAgentId: "parent-agent", + }), + ]); + }); + it("partitions in source order while pins override earlier and cleared membership", () => { const running = paneSnapshot("running", "running"); const completed = paneSnapshot("completed", "completed"); diff --git a/apps/desktop/src/shared/chatSubagents.ts b/apps/desktop/src/shared/chatSubagents.ts index d96c98042..9013f942c 100644 --- a/apps/desktop/src/shared/chatSubagents.ts +++ b/apps/desktop/src/shared/chatSubagents.ts @@ -12,6 +12,7 @@ export type SubagentSnapshot = { status: "running" | "completed" | "failed" | "stopped"; summary: string; parentToolUseId?: string | null; + parentAgentId?: string | null; turnId?: string | null; label?: string | null; model?: string | null; @@ -761,6 +762,7 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S const id = agentId ?? taskId; if (!id) continue; const incomingParentToolUseId = textField(event.parentToolUseId) ?? textField(event.parentAgentId); + const incomingParentAgentId = textField(event.parentAgentId); const parentPlaceholder = incomingParentToolUseId ? snapshots.get(incomingParentToolUseId) : undefined; const parentResolvedIds = incomingParentToolUseId ? resolvedIdsByParent.get(incomingParentToolUseId) : undefined; const parentIsPlaceholder = Boolean( @@ -802,6 +804,7 @@ export function subagentSnapshotsFromEvents(events: AgentChatEventEnvelope[]): S status: existing?.status ?? "running", summary, parentToolUseId, + parentAgentId: incomingParentAgentId ?? existing?.parentAgentId ?? null, turnId: typeof event.turnId === "string" ? event.turnId : existing?.turnId ?? null, label: typeof event.label === "string" ? event.label : existing?.label ?? null, model: typeof event.model === "string" ? event.model : existing?.model ?? null, diff --git a/apps/desktop/src/shared/contextCompaction.ts b/apps/desktop/src/shared/contextCompaction.ts index f6ffb48cb..dd7cb632b 100644 --- a/apps/desktop/src/shared/contextCompaction.ts +++ b/apps/desktop/src/shared/contextCompaction.ts @@ -5,7 +5,7 @@ export type ContextCompactProvider = "claude" | "codex" | "opencode" | "cursor" export type ContextCompactEvent = Extract; export type NormalizedContextCompact = { - trigger: "manual" | "auto"; + trigger: "manual" | "auto" | "ade_fallback"; state: "started" | "completed"; turnId?: string; compactionId?: string; diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index 752431836..5523baca4 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -411,6 +411,15 @@ export type CodexThreadGoal = { export type CodexThreadGoalUpdateKind = "set" | "status" | "sync" | "budget"; +export type ClaudeActiveGoal = { + condition: string; + iterations: number; + setAt: number; + tokensAtStart: number; + lastReason?: string; + updatedAt: number; +}; + export type AgentChatCompletionArtifact = { type: string; description: string; @@ -506,6 +515,8 @@ export type AgentChatEvent = type: "text"; text: string; messageId?: string; + /** Provider-origin timestamp. Display-only; transcript ordering remains envelope-based. */ + originTimestamp?: string; turnId?: string; itemId?: string; runtime?: AgentChatRuntime; @@ -533,6 +544,13 @@ export type AgentChatEvent = parentItemId?: string; turnId?: string; status?: "running" | "completed" | "failed" | "interrupted"; + structured?: unknown; + timedOutAfterMs?: number; + backgroundCwdHint?: string; + grepTotals?: { + files?: number; + lines?: number; + }; runtime?: AgentChatRuntime; } | { @@ -654,6 +672,8 @@ export type AgentChatEvent = // Set only at render time when multiple done events from one cancellation // (parent + subagents) are consolidated into a single row. subagentStoppedCount?: number; + terminalReason?: string; + terminalReasonSource?: "sdk"; runtime?: AgentChatRuntime; } | { @@ -773,6 +793,10 @@ export type AgentChatEvent = }; taskType?: "subagent" | "background" | "local_workflow" | "cron" | "other"; workflowName?: string; + worktreePath?: string; + worktreeBranch?: string; + totalTokens?: number; + toolUseCount?: number; turnId?: string; } | { @@ -881,7 +905,7 @@ export type AgentChatEvent = } | { type: "context_compact"; - trigger: "manual" | "auto"; + trigger: "manual" | "auto" | "ade_fallback"; preTokens?: number; postTokens?: number; tokensRemoved?: number; @@ -926,6 +950,43 @@ export type AgentChatEvent = | { type: "context_usage"; usage: AgentChatContextUsage; + origin?: "command" | "live" | "compact"; + turnId?: string; + } + | { + type: "conversation_reset"; + newConversationId: string; + turnId?: string; + } + | { + type: "interrupt_receipt"; + stillQueuedUuids: string[]; + known: Array<{ uuid: string; preview: string; steerId?: string }>; + turnId?: string; + } + | { + type: "command_lifecycle"; + commandUuid: string; + status: "queued" | "started" | "completed" | "cancelled" | "discarded"; + preview?: string; + steerId?: string; + turnId?: string; + } + | { + type: "claude_goal_updated"; + goal: ClaudeActiveGoal; + turnId?: string; + } + | { + type: "claude_goal_cleared"; + turnId?: string; + } + | { + type: "api_retry"; + attempt: number; + maxRetries: number; + retryDelayMs: number; + errorStatus: number | null; turnId?: string; } | { @@ -1253,7 +1314,9 @@ export type AgentChatSession = { capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; codexGoal?: CodexThreadGoal | null; + claudeGoal?: ClaudeActiveGoal | null; codexTokenUsage?: CodexThreadTokenUsage | null; + protocolCapabilities?: string[]; runtimeMode?: AgentChatRuntimeMode; status: AgentChatSessionStatus; idleSinceAt?: string | null; @@ -1304,7 +1367,9 @@ export type AgentChatSessionSummary = { capabilityMode?: CtoCapabilityMode; completion?: AgentChatCompletionReport | null; codexGoal?: CodexThreadGoal | null; + claudeGoal?: ClaudeActiveGoal | null; codexTokenUsage?: CodexThreadTokenUsage | null; + protocolCapabilities?: string[]; status: AgentChatSessionStatus; idleSinceAt?: string | null; startedAt: string; @@ -1349,6 +1414,7 @@ export type AgentChatTranscriptEntry = { export type AgentChatSubagentSnapshot = { taskId: string; agentId?: string; + parentAgentId?: string | null; agentType?: string; label?: string | null; parentToolUseId?: string | null; @@ -1514,6 +1580,7 @@ export type AgentChatClaudeSessionMessage = { uuid: string; sessionId: string; parentToolUseId: string | null; + parentAgentId?: string | null; message: unknown; text?: string | null; subagentMetadata?: AgentChatSubagentMetadata | null; diff --git a/apps/ios/ADE/Models/RemoteModels.swift b/apps/ios/ADE/Models/RemoteModels.swift index a5aa711f2..82fb8210b 100644 --- a/apps/ios/ADE/Models/RemoteModels.swift +++ b/apps/ios/ADE/Models/RemoteModels.swift @@ -781,6 +781,9 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { var capabilityMode: String? var computerUse: RemoteJSONValue? var completion: ChatCompletionReport? + /// Read-only Claude `/goal` state mirrored by the paired host. Older hosts + /// omit this additive snapshot field. + var claudeGoal: AgentChatClaudeGoal? = nil var status: String var idleSinceAt: String? var startedAt: String @@ -829,6 +832,7 @@ struct AgentChatSessionSummary: Codable, Identifiable, Equatable { && lhs.cursorConfigValues == rhs.cursorConfigValues && lhs.computerUse == rhs.computerUse && lhs.completion == rhs.completion + && lhs.claudeGoal == rhs.claudeGoal && lhs.identityKey == rhs.identityKey && lhs.surface == rhs.surface && lhs.automationId == rhs.automationId @@ -1675,6 +1679,16 @@ enum AgentChatAutoApprovalReviewStatus: String, Codable, Equatable { enum AgentChatContextCompactTrigger: String, Codable, Equatable { case manual case auto + case adeFallback = "ade_fallback" +} + +struct AgentChatClaudeGoal: Codable, Equatable { + var condition: String + var iterations: Int + var setAt: Double + var tokensAtStart: Int + var lastReason: String? + var updatedAt: Double } /// Lifecycle state for a context-compaction event. Hosts emit `started` when @@ -2021,10 +2035,15 @@ enum AgentChatEvent: Decodable, Equatable { case status(turnStatus: AgentChatTurnStatus, turnId: String?, message: String?) case delegationState(contract: RemoteJSONValue, message: String?, turnId: String?) case error(message: String, detail: String?, turnId: String?, itemId: String?, errorInfo: RemoteJSONValue?) - case done(turnId: String, status: AgentChatTurnStatus, model: String?, modelId: String?, usage: AgentChatTurnUsage?, costUsd: Double?) + case done(turnId: String, status: AgentChatTurnStatus, model: String?, modelId: String?, usage: AgentChatTurnUsage?, costUsd: Double?, terminalReason: String? = nil) case tokens(turnId: String, itemId: String?, inputTokens: Int?, outputTokens: Int?, cacheReadTokens: Int?, cacheWriteTokens: Int?, contextWindow: Int?) case codexTokenUsage(usage: AgentChatCodexThreadTokenUsage, turnId: String?) - case contextUsage(usage: AgentChatContextUsage, turnId: String?) + case contextUsage(usage: AgentChatContextUsage, turnId: String?, origin: String?) + case conversationReset(newConversationId: String) + case interruptReceipt(stillQueuedUuids: [String]) + case commandLifecycle(commandUuid: String, status: String, preview: String?, steerId: String?, turnId: String?) + case claudeGoalUpdated(goal: AgentChatClaudeGoal, turnId: String?) + case claudeGoalCleared(turnId: String?) case activity(activity: AgentChatActivityKind, detail: String?, turnId: String?) case stepBoundary(stepNumber: Int, turnId: String?) case todoUpdate(items: [AgentChatTodoItem], turnId: String?) @@ -2113,6 +2132,7 @@ extension AgentChatEvent { case usage case tokens case costUsd + case terminalReason case inputTokens case outputTokens case cacheReadTokens @@ -2132,6 +2152,11 @@ extension AgentChatEvent { case options case id case origin + case newConversationId + case stillQueuedUuids + case commandUuid + case preview + case goal case title case prompt case cron @@ -2311,7 +2336,8 @@ extension AgentChatEvent { model: try container.decodeIfPresent(String.self, forKey: .model), modelId: try container.decodeIfPresent(String.self, forKey: .modelId), usage: try container.decodeIfPresent(AgentChatTurnUsage.self, forKey: .usage), - costUsd: try container.decodeIfPresent(Double.self, forKey: .costUsd) + costUsd: try container.decodeIfPresent(Double.self, forKey: .costUsd), + terminalReason: try container.decodeIfPresent(String.self, forKey: .terminalReason) ) case "tokens": self = .tokens( @@ -2331,6 +2357,32 @@ extension AgentChatEvent { case "context_usage": self = .contextUsage( usage: try container.decode(AgentChatContextUsage.self, forKey: .usage), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId), + origin: try container.decodeIfPresent(String.self, forKey: .origin) + ) + case "conversation_reset": + self = .conversationReset( + newConversationId: try container.decode(String.self, forKey: .newConversationId) + ) + case "interrupt_receipt": + self = .interruptReceipt( + stillQueuedUuids: try container.decodeIfPresent([String].self, forKey: .stillQueuedUuids) ?? [] + ) + case "command_lifecycle": + self = .commandLifecycle( + commandUuid: try container.decode(String.self, forKey: .commandUuid), + status: try container.decode(String.self, forKey: .status), + preview: try container.decodeIfPresent(String.self, forKey: .preview), + steerId: try container.decodeIfPresent(String.self, forKey: .steerId), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId) + ) + case "claude_goal_updated": + self = .claudeGoalUpdated( + goal: try container.decode(AgentChatClaudeGoal.self, forKey: .goal), + turnId: try container.decodeIfPresent(String.self, forKey: .turnId) + ) + case "claude_goal_cleared": + self = .claudeGoalCleared( turnId: try container.decodeIfPresent(String.self, forKey: .turnId) ) case "activity": @@ -2634,6 +2686,11 @@ extension AgentChatEvent { case .tokens: return "tokens" case .codexTokenUsage: return "codex_token_usage" case .contextUsage: return "context_usage" + case .conversationReset: return "conversation_reset" + case .interruptReceipt: return "interrupt_receipt" + case .commandLifecycle: return "command_lifecycle" + case .claudeGoalUpdated: return "claude_goal_updated" + case .claudeGoalCleared: return "claude_goal_cleared" case .activity: return "activity" case .stepBoundary: return "step_boundary" case .todoUpdate: return "todo_update" diff --git a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift index d035bca7a..f16733850 100644 --- a/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift +++ b/apps/ios/ADE/Views/Work/WorkActivityIndicator.swift @@ -259,7 +259,8 @@ struct WorkActivityIndicator: View { .autoApprovalReview, .pendingInputResolved, .subagentResult, .codexState, .codexTurnStalled, .scheduledWorkUpdate, .transcriptRetraction, - .completionReport, .tokens, .unknown: + .completionReport, .tokens, .claudeGoalUpdated, + .claudeGoalCleared, .unknown: continue } } @@ -269,7 +270,7 @@ struct WorkActivityIndicator: View { private static func endedTurnId(from envelope: WorkChatEnvelope) -> String? { switch envelope.event { - case .done(_, _, _, let turnId, _, _): + case .done(_, _, _, let turnId, _, _, _): return normalizedActivityTurnId(turnId) case .status(let turnStatus, _, let turnId) where isTerminalStatus(turnStatus): return normalizedActivityTurnId(turnId) diff --git a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift index 7dec13c69..c1535a65a 100644 --- a/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatHeaderAndMessageViews.swift @@ -1006,6 +1006,18 @@ struct WorkTurnEndMarkerView: View { ADEColor.chatSurfaceAccent(modelId: marker.modelId, provider: marker.provider) } + private var markerAccessibilityLabel: String { + if completed { + return "Turn ended at \(workTurnSeparatorTimeLabel(marker.time)). Worked for \(marker.workedDurationLabel)" + } + return [ + "Turn \(status)", + marker.terminalReasonLabel, + marker.modelLabel.isEmpty ? nil : marker.modelLabel, + "Worked for \(marker.workedDurationLabel)", + ].compactMap { $0 }.joined(separator: ". ") + } + var body: some View { HStack(spacing: 10) { hairline @@ -1015,11 +1027,7 @@ struct WorkTurnEndMarkerView: View { .frame(maxWidth: .infinity) .padding(.vertical, 8) .accessibilityElement(children: .combine) - .accessibilityLabel( - completed - ? "Turn ended at \(workTurnSeparatorTimeLabel(marker.time)). Worked for \(marker.workedDurationLabel)" - : "Turn \(status). \(marker.modelLabel). Worked for \(marker.workedDurationLabel)" - ) + .accessibilityLabel(markerAccessibilityLabel) } @ViewBuilder @@ -1042,6 +1050,12 @@ struct WorkTurnEndMarkerView: View { Text(status.uppercased()) .font(.caption2.weight(.semibold)) .tracking(0.5) + if let terminalReasonLabel = marker.terminalReasonLabel { + Text("·") + .opacity(0.42) + Text(terminalReasonLabel) + .font(.caption2) + } Text("·") .opacity(0.42) Text("Worked for \(marker.workedDurationLabel)") @@ -1077,6 +1091,38 @@ struct WorkTurnEndMarkerView: View { } } +struct WorkClaudeGoalPill: View { + let goal: AgentChatClaudeGoal + + private var iterationLabel: String { + "\(goal.iterations) iteration\(goal.iterations == 1 ? "" : "s")" + } + + var body: some View { + HStack(spacing: 7) { + Image(systemName: "target") + .font(.caption2.weight(.bold)) + Text(goal.condition) + .font(.caption.weight(.medium)) + .lineLimit(1) + Text("·") + .foregroundStyle(ADEColor.textMuted) + Text(iterationLabel) + .font(.caption2.monospacedDigit()) + .foregroundStyle(ADEColor.textMuted) + .lineLimit(1) + } + .foregroundStyle(ADEColor.textSecondary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .frame(maxWidth: .infinity, alignment: .leading) + .background(ADEColor.accent.opacity(0.07), in: Capsule(style: .continuous)) + .overlay(Capsule(style: .continuous).stroke(ADEColor.accent.opacity(0.16), lineWidth: 0.5)) + .accessibilityElement(children: .combine) + .accessibilityLabel("Goal: \(goal.condition). \(iterationLabel).") + } +} + private func workTurnSeparatorTimeLabel(_ iso: String) -> String { // Matches desktop's "01:34 AM" turn separator format. Falls back to the raw // string when the input isn't an ISO date so we never crash on host quirks. diff --git a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift index 02449dd3c..ac442b36d 100644 --- a/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift +++ b/apps/ios/ADE/Views/Work/WorkChatRichCardViews.swift @@ -1061,6 +1061,8 @@ struct WorkEventCardView: View { var body: some View { if card.kind == "contextCompact" { WorkContextCompactDivider(summary: card.body, isInProgress: card.isInProgress) + } else if card.kind == "conversationReset" { + WorkConversationResetDivider() } else if card.kind == "status" { statusRibbonBody } else if isRibbonKind(card.kind) { diff --git a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift index 0dbd5e557..cfef70689 100644 --- a/apps/ios/ADE/Views/Work/WorkChatSessionView.swift +++ b/apps/ios/ADE/Views/Work/WorkChatSessionView.swift @@ -29,6 +29,7 @@ struct WorkChatSummaryRenderContext: Equatable { let requestedCwd: String? let modelLabel: String let contextWindowFallback: Int? + let claudeGoal: AgentChatClaudeGoal? init(_ summary: AgentChatSessionSummary?) { guard let summary else { @@ -46,6 +47,7 @@ struct WorkChatSummaryRenderContext: Equatable { self.requestedCwd = nil self.modelLabel = "Model" self.contextWindowFallback = nil + self.claudeGoal = nil return } @@ -63,6 +65,7 @@ struct WorkChatSummaryRenderContext: Equatable { self.requestedCwd = summary.requestedCwd self.modelLabel = prettyWorkChatModelName(summary.model) self.contextWindowFallback = workContextWindowFallback(modelId: summary.modelId, model: summary.model) + self.claudeGoal = summary.claudeGoal } var currentModelId: String { @@ -646,6 +649,10 @@ struct WorkChatSessionView: View { // The redundant ENDED/RUNNING status pill row has been retired. Chat // lifecycle controls live outside the composer; this space is reserved // for pending input and send feedback. + if let claudeGoal = workClaudeGoal(snapshot: chatSummaryContext.claudeGoal, transcript: transcript) { + WorkClaudeGoalPill(goal: claudeGoal) + } + let subagentCount = subagentSnapshots.count let activeScheduledWorkCount = workScheduledWorkActiveCount(scheduledWorkSnapshots) let showsChatInfoBadge = inputLockMessage == nil && activeScheduledWorkCount > 0 && onOpenChatInfo != nil diff --git a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift index ce404918c..9f4b4ea59 100644 --- a/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift +++ b/apps/ios/ADE/Views/Work/WorkContextCompactDivider.swift @@ -174,6 +174,39 @@ struct WorkContextCompactDivider: View { } } +/// Calm transcript boundary used when Claude starts a fresh conversation +/// while retaining the same ADE chat session. +struct WorkConversationResetDivider: View { + var body: some View { + HStack(spacing: 8) { + hairline + HStack(spacing: 6) { + Image(systemName: "arrow.counterclockwise") + .font(.caption2.weight(.bold)) + Text("New conversation") + .font(.caption2.weight(.semibold)) + .tracking(0.3) + } + .foregroundStyle(ADEColor.textMuted) + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(ADEColor.textMuted.opacity(0.07), in: Capsule()) + .overlay(Capsule().stroke(ADEColor.glassBorder, lineWidth: 0.5)) + hairline + } + .padding(.vertical, 4) + .accessibilityElement(children: .combine) + .accessibilityLabel("New conversation") + } + + private var hairline: some View { + Rectangle() + .fill(ADEColor.glassBorder) + .frame(minWidth: 8, maxWidth: .infinity) + .frame(height: 0.6) + } +} + /// Parses the free-form summary string emitted by `contextCompact` events /// into a display label. Looks for two hints: a "~Ntokens" style fragment /// and an "auto" / "manual" trigger tag. Anything else falls back to just diff --git a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift index d6c82686f..2bfbc8a57 100644 --- a/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkErrorAndMessageHelpers.swift @@ -1682,7 +1682,7 @@ func pendingWorkInputItemIds(from transcript: [WorkChatEnvelope]) -> Set .fileChange(_, _, _, _, let itemId, _): approvals.removeValue(forKey: itemId) questions.removeValue(forKey: itemId) - case .done(let status, _, _, let turnId, _, _): + case .done(let status, _, _, let turnId, _, _, _): if status == "completed" { approvals = approvals.filter { $0.value != turnId } } else { @@ -1793,8 +1793,18 @@ func workChatEventMergeKey(_ event: WorkChatEvent) -> String { return ["system_notice", turnId ?? "", steerId ?? "", kind, message, detail ?? ""].joined(separator: "|") case .error(let message, let detail, let category, let turnId): return ["error", turnId ?? "", category, message, detail ?? ""].joined(separator: "|") - case .done(let status, let summary, let usage, let turnId, let model, let modelId): - return ["done", turnId, status, model ?? "", modelId ?? "", summary, workUsageSummaryMergeKey(usage)].joined(separator: "|") + case .done(let status, let summary, let usage, let turnId, let model, let modelId, let terminalReason): + return ["done", turnId, status, model ?? "", modelId ?? "", terminalReason ?? "", summary, workUsageSummaryMergeKey(usage)].joined(separator: "|") + case .claudeGoalUpdated(let goal, let turnId): + return [ + "claude_goal_updated", + turnId ?? "", + goal.condition, + String(goal.iterations), + String(goal.updatedAt), + ].joined(separator: "|") + case .claudeGoalCleared(let turnId): + return ["claude_goal_cleared", turnId ?? ""].joined(separator: "|") case .tokens(let usage, let turnId, let itemId): return ["tokens", turnId, itemId ?? "", workUsageSummaryMergeKey(usage)].joined(separator: "|") case .promptSuggestion(let text, let turnId): diff --git a/apps/ios/ADE/Views/Work/WorkEventMapping.swift b/apps/ios/ADE/Views/Work/WorkEventMapping.swift index a245ee8c9..82c596607 100644 --- a/apps/ios/ADE/Views/Work/WorkEventMapping.swift +++ b/apps/ios/ADE/Views/Work/WorkEventMapping.swift @@ -304,7 +304,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { case .error(let message, let detail, let turnId, _, let errorInfo): let detailText = detail ?? prettyPrintedRemoteJSONValue(errorInfo) return .error(message: message, detail: detailText, category: workErrorCategory(message: message, detail: detailText), turnId: turnId) - case .done(let turnId, let status, let model, let modelId, let usage, let costUsd): + case .done(let turnId, let status, let model, let modelId, let usage, let costUsd, let terminalReason): var parts = [status.rawValue.replacingOccurrences(of: "_", with: " ").capitalized] if let model, !model.isEmpty { parts.append(model) @@ -343,7 +343,8 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { ), turnId: turnId, model: model, - modelId: modelId + modelId: modelId, + terminalReason: terminalReason ) case .tokens(let turnId, let itemId, let inputTokens, let outputTokens, let cacheReadTokens, let cacheWriteTokens, let contextWindow): return .tokens( @@ -397,7 +398,7 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { turnId: turnId ?? usage.turnId ?? "", itemId: nil ) - case .contextUsage(let usage, let turnId): + case .contextUsage(let usage, let turnId, _): return .tokens( usage: WorkUsageSummary( turnCount: 1, @@ -413,6 +414,39 @@ func makeWorkChatEvent(from event: AgentChatEvent) -> WorkChatEvent { turnId: turnId ?? "", itemId: nil ) + case .conversationReset: + return .systemNotice( + kind: "conversation_reset", + message: "New conversation", + detail: nil, + turnId: nil, + steerId: nil + ) + case .interruptReceipt(let stillQueuedUuids): + let count = stillQueuedUuids.count + return .systemNotice( + kind: "interrupt_receipt", + message: "Stopped — \(count) queued will still run", + detail: nil, + turnId: nil, + steerId: nil + ) + case .commandLifecycle(_, let status, let preview, let steerId, let turnId): + guard status == "cancelled" || status == "discarded" else { + return .unknown(type: "command_lifecycle") + } + let verb = status == "discarded" ? "discarded" : "cancelled" + return .systemNotice( + kind: "command_lifecycle", + message: preview.map { "Queued message \(verb): \($0)" } ?? "Queued message \(verb)", + detail: nil, + turnId: turnId, + steerId: steerId + ) + case .claudeGoalUpdated(let goal, let turnId): + return .claudeGoalUpdated(goal: goal, turnId: turnId) + case .claudeGoalCleared(let turnId): + return .claudeGoalCleared(turnId: turnId) case .promptSuggestion(let suggestion, let turnId): return .promptSuggestion(text: suggestion, turnId: turnId) case .contextCompact( diff --git a/apps/ios/ADE/Views/Work/WorkModels.swift b/apps/ios/ADE/Views/Work/WorkModels.swift index 36f1478d9..2d9e7a495 100644 --- a/apps/ios/ADE/Views/Work/WorkModels.swift +++ b/apps/ios/ADE/Views/Work/WorkModels.swift @@ -566,6 +566,7 @@ struct WorkTurnEndMarker: Equatable { let time: String let workedDurationLabel: String let status: String + let terminalReasonLabel: String? let provider: String let modelLabel: String let modelId: String? @@ -911,10 +912,12 @@ enum WorkChatEvent: Equatable { case todoUpdate(items: [String], turnId: String?) case systemNotice(kind: String, message: String, detail: String?, turnId: String?, steerId: String?) case error(message: String, detail: String?, category: String, turnId: String?) - case done(status: String, summary: String, usage: WorkUsageSummary?, turnId: String, model: String?, modelId: String?) + case done(status: String, summary: String, usage: WorkUsageSummary?, turnId: String, model: String?, modelId: String?, terminalReason: String? = nil) case tokens(usage: WorkUsageSummary, turnId: String, itemId: String?) case promptSuggestion(text: String, turnId: String?) case contextCompact(summary: String, isInProgress: Bool, postTokens: Int?, turnId: String?, compactionId: String?) + case claudeGoalUpdated(goal: AgentChatClaudeGoal, turnId: String?) + case claudeGoalCleared(turnId: String?) case autoApprovalReview(summary: String, turnId: String?) case webSearch(query: String, action: String?, actions: [CodexWebSearchAction]?, results: [CodexWebSearchResult]?, status: WorkToolCardStatus, itemId: String, turnId: String?) case codexState(title: String, message: String, icon: String, turnId: String?) @@ -951,6 +954,8 @@ enum WorkChatEvent: Equatable { case .tokens: return "tokens" case .promptSuggestion: return "prompt_suggestion" case .contextCompact: return "context_compact" + case .claudeGoalUpdated: return "claude_goal_updated" + case .claudeGoalCleared: return "claude_goal_cleared" case .autoApprovalReview: return "auto_approval_review" case .webSearch: return "web_search" case .codexState: return "codex_state" diff --git a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift index 272899926..a053290fc 100644 --- a/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift +++ b/apps/ios/ADE/Views/Work/WorkTimelineHelpers.swift @@ -265,13 +265,24 @@ private func combineWorkChatEventSignature(_ event: WorkChatEvent, into hasher: combineOptionalText(detail, into: &hasher) hasher.combine(category) combineOptional(turnId, into: &hasher) - case .done(let status, let summary, let usage, let turnId, let model, let modelId): + case .done(let status, let summary, let usage, let turnId, let model, let modelId, let terminalReason): hasher.combine(status) combineLongTextSignature(summary, into: &hasher) combineUsageSummary(usage, into: &hasher) hasher.combine(turnId) combineOptional(model, into: &hasher) combineOptional(modelId, into: &hasher) + combineOptional(terminalReason, into: &hasher) + case .claudeGoalUpdated(let goal, let turnId): + combineLongTextSignature(goal.condition, into: &hasher) + hasher.combine(goal.iterations) + hasher.combine(goal.setAt) + hasher.combine(goal.tokensAtStart) + combineOptionalText(goal.lastReason, into: &hasher) + hasher.combine(goal.updatedAt) + combineOptional(turnId, into: &hasher) + case .claudeGoalCleared(let turnId): + combineOptional(turnId, into: &hasher) case .tokens(let usage, let turnId, let itemId): combineUsageSummary(usage, into: &hasher) hasher.combine(turnId) @@ -467,7 +478,7 @@ func workTranscriptIndicatesActiveTurn(_ transcript: [WorkChatEnvelope]) -> Bool default: break } - case .done(_, _, _, let turnId, _, _): + case .done(_, _, _, let turnId, _, _, _): activeTurnIds.remove(turnId) bootstrapStartOpen = false default: @@ -1490,7 +1501,7 @@ func buildWorkTimeline( // transcript. let turnUsageSummaries = transcript.compactMap { envelope -> (id: String, timestamp: String, usage: WorkUsageSummary)? in - guard case .done(_, _, let usage, _, _, _) = envelope.event, let usage else { return nil } + guard case .done(_, _, let usage, _, _, _, _) = envelope.event, let usage else { return nil } return (envelope.id, envelope.timestamp, usage) } @@ -2267,7 +2278,7 @@ func buildWorkEventCards( private func workTerminalDoneTurnIds(from transcript: [WorkChatEnvelope]) -> Set { Set(transcript.compactMap { envelope in - guard case .done(_, _, _, let turnId, _, _) = envelope.event else { return nil } + guard case .done(_, _, _, let turnId, _, _, _) = envelope.event else { return nil } return normalizedWorkTurnId(turnId) }) } @@ -2675,7 +2686,7 @@ private func eventCard( guard !isLowSignalWorkSystemNotice(kind: kind, message: message, detail: detail) else { return nil } return WorkEventCardModel( id: envelope.id, - kind: "notice", + kind: kind == "conversation_reset" ? "conversationReset" : "notice", title: noticeTitle(for: kind), icon: noticeIcon(for: kind), tint: noticeTint(for: kind), @@ -2985,7 +2996,7 @@ func workTurnEndMarkers(from transcript: [WorkChatEnvelope]) -> [WorkTurnEndMark default: continue } - case .done(let status, _, _, let turnId, let model, let modelId): + case .done(let status, _, _, let turnId, let model, let modelId, let terminalReason): guard let key = normalizedWorkTurnId(turnId) else { continue } guard !seenEndedTurns.contains(key), let start = startByTurn[key] else { continue } seenEndedTurns.insert(key) @@ -2995,6 +3006,7 @@ func workTurnEndMarkers(from transcript: [WorkChatEnvelope]) -> [WorkTurnEndMark time: envelope.timestamp, workedDurationLabel: formattedSessionDuration(startedAt: start, endedAt: envelope.timestamp), status: status, + terminalReasonLabel: workTerminalReasonLabel(terminalReason), provider: metadata.provider, modelLabel: metadata.modelLabel, modelId: metadata.modelId @@ -3008,6 +3020,45 @@ func workTurnEndMarkers(from transcript: [WorkChatEnvelope]) -> [WorkTurnEndMark return markers } +/// Keep this map aligned with the desktop renderer's terse terminal-reason +/// labels. The SDK reason is intentionally an open string; unknown values add +/// no secondary label instead of leaking wire names into the UI. +func workTerminalReasonLabel(_ reason: String?) -> String? { + switch reason?.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { + case "budget_exhausted": return "budget limit reached" + case "max_turns": return "max turns reached" + case "prompt_too_long": return "context window overflow" + case "api_error": return "API error after retries" + case "malformed_tool_use_exhausted": return "tool-call retries exhausted" + case "structured_output_retry_exhausted": return "output retries exhausted" + case "model_error": return "model error" + case "turn_setup_failed": return "turn setup failed" + case "tool_deferred_unavailable": return "deferred tool unavailable" + default: return nil + } +} + +/// Resolve the current Claude goal from the session snapshot, then replay +/// transcript updates in wire order so a newer update/clear wins over a stale +/// summary refresh. +func workClaudeGoal( + snapshot: AgentChatClaudeGoal?, + transcript: [WorkChatEnvelope] +) -> AgentChatClaudeGoal? { + var current = snapshot + for envelope in sortedWorkChatEnvelopes(transcript) { + switch envelope.event { + case .claudeGoalUpdated(let goal, _): + current = goal + case .claudeGoalCleared: + current = nil + default: + continue + } + } + return current +} + private func normalizedWorkTurnId(_ turnId: String?) -> String? { let key = turnId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" return key.isEmpty ? nil : key @@ -3044,11 +3095,13 @@ private func workTurnId(for event: WorkChatEvent) -> String? { .reasoning(_, let turnId, _, _), .completionReport(_, _, _, _, let turnId), .command(_, _, _, _, _, _, _, let turnId), - .fileChange(_, _, _, _, _, let turnId): + .fileChange(_, _, _, _, _, let turnId), + .claudeGoalUpdated(_, let turnId), + .claudeGoalCleared(let turnId): return turnId case .tokens(_, let turnId, _): return turnId - case .done(_, _, _, let turnId, _, _): + case .done(_, _, _, let turnId, _, _, _): return turnId case .unknown: return nil @@ -3070,7 +3123,7 @@ func workTurnModelMetadataByTurn( if let matchingTurnIds { guard !matchingTurnIds.isEmpty else { return metadataByTurn } for envelope in transcript.reversed() { - guard case .done(_, _, _, let turnId, let model, let modelId) = envelope.event else { continue } + guard case .done(_, _, _, let turnId, let model, let modelId, _) = envelope.event else { continue } let normalizedTurnId = turnId.trimmingCharacters(in: .whitespacesAndNewlines) guard matchingTurnIds.contains(normalizedTurnId), metadataByTurn[normalizedTurnId] == nil else { continue } @@ -3087,7 +3140,7 @@ func workTurnModelMetadataByTurn( } for envelope in transcript { - guard case .done(_, _, _, let turnId, let model, let modelId) = envelope.event else { continue } + guard case .done(_, _, _, let turnId, let model, let modelId, _) = envelope.event else { continue } let normalizedTurnId = turnId.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedTurnId.isEmpty else { continue } metadataByTurn[normalizedTurnId] = workTurnModelMetadata( @@ -3202,7 +3255,7 @@ func summarizeWorkSessionUsage(from transcript: [WorkChatEnvelope]) -> WorkUsage ) for envelope in transcript { - guard case .done(_, _, let usage, _, _, _) = envelope.event, let usage else { continue } + guard case .done(_, _, let usage, _, _, _, _) = envelope.event, let usage else { continue } summary.turnCount += usage.turnCount summary.inputTokens += usage.inputTokens summary.outputTokens += usage.outputTokens @@ -3262,7 +3315,7 @@ func workContextUsageViewModel( case .tokens(let usage, _, _): if compactionProtected && !usage.isContextSnapshot { continue } latestUsage = usage - case .done(_, _, let usage, _, _, _): + case .done(_, _, let usage, _, _, _, _): if let usage { if compactionProtected && !usage.isContextSnapshot { continue } latestUsage = usage diff --git a/apps/ios/ADETests/ADETests.swift b/apps/ios/ADETests/ADETests.swift index 3365c712f..503f0c1ad 100644 --- a/apps/ios/ADETests/ADETests.swift +++ b/apps/ios/ADETests/ADETests.swift @@ -3463,6 +3463,62 @@ final class ADETests: XCTestCase { } } + func testChatEventHistorySnapshotToleratesUnknownEventBetweenKnownEvents() throws { + let json = """ + { + "sessionId": "chat-1", + "events": [ + { + "sessionId": "chat-1", + "timestamp": "2026-07-16T00:00:00.000Z", + "sequence": 1, + "event": { + "type": "text", + "text": "Before", + "messageId": "message-1", + "futureOptionalField": "ignored" + } + }, + { + "sessionId": "chat-1", + "timestamp": "2026-07-16T00:00:01.000Z", + "sequence": 2, + "event": { "type": "zz_future_event", "foo": 1 } + }, + { + "sessionId": "chat-1", + "timestamp": "2026-07-16T00:00:02.000Z", + "sequence": 3, + "event": { + "type": "done", + "turnId": "turn-1", + "status": "completed", + "futureOptionalField": { "enabled": true } + } + } + ], + "truncated": false + } + """ + + let snapshot = try JSONDecoder().decode(AgentChatEventHistorySnapshot.self, from: Data(json.utf8)) + + XCTAssertEqual(snapshot.events.count, 3) + guard case .text(let text, _, _, _) = snapshot.events[0].event else { + return XCTFail("Expected the known text event before the unknown event to survive.") + } + XCTAssertEqual(text, "Before") + guard case .unknown(let type) = snapshot.events[1].event else { + return XCTFail("Expected the future event to decode as an inert unknown event.") + } + XCTAssertEqual(type, "zz_future_event") + guard case .done(let turnId, let status, _, _, _, _, _) = snapshot.events[2].event else { + return XCTFail("Expected the known done event after the unknown event to survive.") + } + XCTAssertEqual(turnId, "turn-1") + XCTAssertEqual(status, .completed) + } + func testMakeWorkChatTranscriptDropsCodexSubagentChildThreadMessages() throws { let json = """ { @@ -3775,6 +3831,7 @@ final class ADETests: XCTestCase { "event": { "type": "context_usage", "turnId": "turn-usage", + "origin": "live", "usage": { "totalTokens": 31000, "maxTokens": 200000 @@ -3784,7 +3841,7 @@ final class ADETests: XCTestCase { """ let envelope = try JSONDecoder().decode(AgentChatEventEnvelope.self, from: Data(json.utf8)) - guard case .contextUsage(let decodedUsage, let decodedTurnId) = envelope.event else { + guard case .contextUsage(let decodedUsage, let decodedTurnId, let origin) = envelope.event else { return XCTFail("Expected a context usage event.") } XCTAssertTrue(decodedUsage.categories.isEmpty) @@ -3792,6 +3849,7 @@ final class ADETests: XCTestCase { XCTAssertNil(decodedUsage.rawMaxTokens) XCTAssertNil(decodedUsage.model) XCTAssertEqual(decodedTurnId, "turn-usage") + XCTAssertEqual(origin, "live") let event = makeWorkChatEvent(from: envelope.event) guard case .tokens(let usage, let turnId, let itemId) = event else { @@ -3832,6 +3890,64 @@ final class ADETests: XCTestCase { XCTAssertEqual(viewModel?.ratio ?? 0, 0.155, accuracy: 0.0001) } + func testAgentChatParityEventsDecodeAndMapToMobileSurfaces() throws { + let json = """ + { + "sessionId": "session-parity", + "capturedAt": "2026-07-16T12:00:00.000Z", + "events": [ + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:00.000Z","sequence":1,"event":{"type":"conversation_reset","newConversationId":"conversation-2"}}, + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:01.000Z","sequence":2,"event":{"type":"interrupt_receipt","stillQueuedUuids":["queued-1","queued-2"],"known":[{"uuid":"queued-1","preview":"First"}]}}, + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:02.000Z","sequence":3,"event":{"type":"command_lifecycle","commandUuid":"command-1","status":"discarded","preview":"Run the old request","steerId":"steer-1","turnId":"turn-1"}}, + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:03.000Z","sequence":4,"event":{"type":"claude_goal_updated","turnId":"turn-1","goal":{"condition":"All tests pass","iterations":3,"setAt":100,"tokensAtStart":200,"lastReason":"One failure left","updatedAt":300}}}, + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:04.000Z","sequence":5,"event":{"type":"claude_goal_cleared","turnId":"turn-1"}}, + {"sessionId":"session-parity","timestamp":"2026-07-16T12:00:05.000Z","sequence":6,"event":{"type":"done","turnId":"turn-1","status":"failed","terminalReason":"prompt_too_long"}} + ], + "truncated": false + } + """ + + let snapshot = try JSONDecoder().decode(AgentChatEventHistorySnapshot.self, from: Data(json.utf8)) + XCTAssertEqual(snapshot.events.count, 6) + + guard case .conversationReset(let conversationId) = snapshot.events[0].event else { + return XCTFail("Expected conversation reset.") + } + XCTAssertEqual(conversationId, "conversation-2") + + guard case .interruptReceipt(let stillQueued) = snapshot.events[1].event else { + return XCTFail("Expected interrupt receipt.") + } + XCTAssertEqual(stillQueued, ["queued-1", "queued-2"]) + + guard case .commandLifecycle(let commandUuid, let status, let preview, let steerId, _) = snapshot.events[2].event else { + return XCTFail("Expected command lifecycle event.") + } + XCTAssertEqual(commandUuid, "command-1") + XCTAssertEqual(status, "discarded") + XCTAssertEqual(preview, "Run the old request") + XCTAssertEqual(steerId, "steer-1") + + guard case .claudeGoalUpdated(let goal, let turnId) = snapshot.events[3].event else { + return XCTFail("Expected Claude goal update.") + } + XCTAssertEqual(goal.condition, "All tests pass") + XCTAssertEqual(goal.iterations, 3) + XCTAssertEqual(goal.lastReason, "One failure left") + XCTAssertEqual(turnId, "turn-1") + + guard case .done(_, _, _, _, _, _, let terminalReason) = snapshot.events[5].event else { + return XCTFail("Expected terminal done event.") + } + XCTAssertEqual(terminalReason, "prompt_too_long") + + let transcript = makeWorkChatTranscript(from: snapshot.events) + let cards = buildWorkEventCards(from: transcript) + XCTAssertEqual(cards.map(\.kind), ["conversationReset", "notice", "notice"]) + XCTAssertEqual(cards.last?.body, "Queued message discarded: Run the old request") + XCTAssertNil(workClaudeGoal(snapshot: nil, transcript: transcript)) + } + @MainActor func testChatSubscriptionStateSurvivesDisconnectAndReplaysPayloads() async throws { let service = SyncService(database: makeDatabase(baseURL: makeTemporaryDirectory())) @@ -12106,7 +12222,7 @@ final class ADETests: XCTestCase { XCTAssertNil(blockerDescription) XCTAssertEqual(reportTurnId, nil) - guard case .done(let doneStatus, let doneSummary, let usage, let doneTurnId, let doneModel, let doneModelId) = transcript[3].event else { + guard case .done(let doneStatus, let doneSummary, let usage, let doneTurnId, let doneModel, let doneModelId, _) = transcript[3].event else { return XCTFail("Expected done event.") } XCTAssertEqual(doneStatus, "completed") @@ -15383,7 +15499,7 @@ final class ADETests: XCTestCase { sessionId: "chat-1", timestamp: "2026-03-25T00:00:05.000Z", sequence: 2, - event: .done(status: "interrupted", summary: "Interrupted\ngpt-5.5", usage: nil, turnId: "turn-1", model: "gpt-5.5", modelId: "openai/gpt-5.5") + event: .done(status: "interrupted", summary: "Interrupted\ngpt-5.5", usage: nil, turnId: "turn-1", model: "gpt-5.5", modelId: "openai/gpt-5.5", terminalReason: "prompt_too_long") ), ] @@ -15395,6 +15511,54 @@ final class ADETests: XCTestCase { XCTAssertEqual(markers.map(\.modelLabel), ["GPT-5.5"]) XCTAssertEqual(markers.map(\.provider), ["codex"]) XCTAssertEqual(markers.map(\.workedDurationLabel), ["5s"]) + XCTAssertEqual(markers.map(\.terminalReasonLabel), ["context window overflow"]) + } + + func testWorkTerminalReasonLabelsStayTerseAndIgnoreUnknownValues() { + XCTAssertEqual(workTerminalReasonLabel("budget_exhausted"), "budget limit reached") + XCTAssertEqual(workTerminalReasonLabel("max_turns"), "max turns reached") + XCTAssertEqual(workTerminalReasonLabel("prompt_too_long"), "context window overflow") + XCTAssertEqual(workTerminalReasonLabel("api_error"), "API error after retries") + XCTAssertEqual(workTerminalReasonLabel("malformed_tool_use_exhausted"), "tool-call retries exhausted") + XCTAssertEqual(workTerminalReasonLabel("structured_output_retry_exhausted"), "output retries exhausted") + XCTAssertEqual(workTerminalReasonLabel("model_error"), "model error") + XCTAssertEqual(workTerminalReasonLabel("turn_setup_failed"), "turn setup failed") + XCTAssertEqual(workTerminalReasonLabel("tool_deferred_unavailable"), "deferred tool unavailable") + XCTAssertNil(workTerminalReasonLabel("future_reason")) + } + + func testWorkClaudeGoalReplaysUpdatesAndClearsOverSnapshot() { + let snapshot = AgentChatClaudeGoal( + condition: "Snapshot goal", + iterations: 1, + setAt: 100, + tokensAtStart: 200, + lastReason: nil, + updatedAt: 300 + ) + let updated = AgentChatClaudeGoal( + condition: "Updated goal", + iterations: 4, + setAt: 100, + tokensAtStart: 200, + lastReason: "Keep iterating", + updatedAt: 400 + ) + let updateEnvelope = WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-16T12:00:00.000Z", + sequence: 1, + event: .claudeGoalUpdated(goal: updated, turnId: "turn-1") + ) + XCTAssertEqual(workClaudeGoal(snapshot: snapshot, transcript: [updateEnvelope]), updated) + + let clearEnvelope = WorkChatEnvelope( + sessionId: "chat-1", + timestamp: "2026-07-16T12:00:01.000Z", + sequence: 2, + event: .claudeGoalCleared(turnId: "turn-1") + ) + XCTAssertNil(workClaudeGoal(snapshot: snapshot, transcript: [updateEnvelope, clearEnvelope])) } func testWorkTimelineSnapshotCachesTranscriptActiveTurnState() { diff --git a/docs/features/agents/README.md b/docs/features/agents/README.md index 653419987..b4589ff7b 100644 --- a/docs/features/agents/README.md +++ b/docs/features/agents/README.md @@ -39,6 +39,12 @@ it and GPT-5.5 retained. Provider-native web, MCP/connector, image, and subagent activity is normalized into compact transcript events so every client can show what the agent did without dumping raw SDK/app-server envelopes. +Claude SDK session-message snapshots include `parent_agent_id`. ADE preserves +it as `parentAgentId` on subagent snapshots so desktop surfaces can build the +real nested hierarchy; `null` or absent means a depth-one child of the main +session. Live frames remain defensive because not every SDK start event carries +the parent id. + The `ade --socket browser ...` driver is available only to an ADE-launched, chat-bound agent or owned terminal. Its opaque browser actor capability binds the call to that chat's lane/project or personal tab collection. The runtime diff --git a/docs/features/chat/README.md b/docs/features/chat/README.md index 2416d122e..bdec1f498 100644 --- a/docs/features/chat/README.md +++ b/docs/features/chat/README.md @@ -248,7 +248,7 @@ Controls and summaries project this runtime state rather than owning it: ## Key concepts - **Claude Agent SDK pipeline.** The Claude adapter is built on the - stable `query()` API (SDK 0.3.207): every chat owns a `ClaudeQuery`, + stable `query()` API (SDK 0.3.211): 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 @@ -261,8 +261,10 @@ Controls and summaries project this runtime state rather than owning it: binary, then detected auth/PATH/common locations, and the resolved 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.207 event surfaces.** ADE enables SDK hook events, + `Query` handle. The `claude_sdk.version` telemetry tag is derived from the + resolved runtime package metadata (with `0.3.211` only as the partial-install + fallback), so packaged telemetry reports the SDK that actually shipped. +- **Claude SDK 0.3.211 event surfaces.** ADE enables SDK hook events, agent progress summaries, prompt suggestions, defensive filtering of tagged child frames, file checkpointing, and all skills for full Claude chats. The adapter translates SDK retry/refusal/notification/memory/mirror/permission events @@ -275,7 +277,11 @@ Controls and summaries project this runtime state rather than owning it: disabled; any tagged child tool/stream frames the parent query still emits carry `parent_tool_use_id` and are kept out of the parent transcript. The full child transcript is read with the backing Claude session id. Claude sessions keep a long-lived idle reader - attached after a visible turn completes. The SDK's level-triggered + attached after a visible turn completes. ADE also consumes `conversation_reset` + as a real SDK continuity change, deduplicates ADE-owned `command_lifecycle` + frames, preserves the query when an interrupt receipt reports queued messages, + and calls the runtime's capability-probed `cancelAsyncMessage` control for + attributed queued steers. The SDK's level-triggered `background_tasks_changed` set and active subagents protect that query from idle-TTL cleanup and runtime-budget eviction until the work actually ends. That level set is also authoritative for whether a `local_bash` task is @@ -878,7 +884,7 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. | `ade.agentChat.steer` | invoke | Send a follow-up message mid-turn. Claude callers may pass `dispatchMode: "inline" | "interrupt"` for atomic SDK `priority: "next" | "now"` delivery; omitting it stages the message. Returns `AgentChatSteerResult` (`{ steerId, queued, reason?: "queue_full" }`) — `queued: false, reason: "queue_full"` when the queue is at its cap. | | `ade.agentChat.cancelSteer` / `ade.agentChat.editSteer` | invoke | Queue management for queued steers. `cancelSteer({ requireQueued: true })` rejects if delivery already claimed the row; desktop Edit uses that guarded form before restoring the message and attachments to the composer. | | `ade.agentChat.dispatchSteer` | invoke | Claude-only: deliver an already staged steer as SDK `priority: "next"` (`mode: "inline"`) or `priority: "now"` (`mode: "interrupt"`), both with `shouldQuery: true`. The staged row is removed only after the input pump accepts the message. Throws on Codex/OpenCode/Cursor. | -| `ade.agentChat.cancelDispatchedSteer` | invoke | Claude-only compatibility endpoint. The public SDK query does not expose cancellation for an already pushed priority message, so it reports `cancelled: false` and leaves the delivered transcript intact. | +| `ade.agentChat.cancelDispatchedSteer` | invoke | Claude-only cancellation for an attributed SDK-queued priority message. Resolves the ADE `steerId` to its bounded command UUID, capability-probes the runtime `cancelAsyncMessage` control, and returns `{ cancelled: true }` only after Claude confirms cancellation. | | `ade.agentChat.interrupt` | invoke | Provider-specific interruption of the in-flight turn. | | `ade.agentChat.recoverCodexTurn` | invoke | Execute one guarded recovery action for the currently active stalled Codex turn: `wait`, `steer`, `interrupt_retry_same_thread`, or `restart_resume_thread`. Calls are single-flight per session/turn and reject stale cards once that turn is no longer active. | | `ade.agentChat.approve` | invoke | Legacy approval channel (pre-pending-input). | @@ -1024,10 +1030,12 @@ handlers live in `apps/desktop/src/main/services/ipc/registerIpc.ts`. shared with the live queue (`MAX_PENDING_STEERS`), so a corrupt persisted record can't grow it beyond the in-memory budget. - **Dispatched steer cancellation.** Every priority message receives a fresh - client-side SDK UUID, but the public 0.3.207 `Query` surface does not expose - cancellation after the input pump accepts it. ADE therefore retains no - growing cancellation registry; the compatibility endpoint returns - `cancelled: false`. + client-side SDK UUID. ADE keeps a bounded UUID-to-`steerId` attribution map, + returns those attributed messages in interrupt receipts, and capability-probes + the runtime `cancelAsyncMessage(uuid)` control (present in the 0.3.211 runtime + even though its public declaration currently omits it). Successful cancellation + clears the receipt/staged row through the existing `cancelDispatchedSteer` + desktop, web, mobile, and ADE Code action. - **Pending input derivation.** The renderer's `derivePendingInputRequests` in `pendingInput.ts` must handle: (a) legacy `askUser` tool calls, (b) Claude `AskUserQuestion` SDK events, (c) Codex `permissions` requests, diff --git a/docs/features/chat/tool-system.md b/docs/features/chat/tool-system.md index 4906fec8d..aba3900ca 100644 --- a/docs/features/chat/tool-system.md +++ b/docs/features/chat/tool-system.md @@ -35,6 +35,19 @@ Built by `createUniversalToolSet()` in `universalTools.ts`. | `askUser` (universal form) | Legacy ask-user helper. Claude SDK sessions use their native `AskUserQuestion` tool instead; see [ask-user](#ask-user-handling). | Always allowed. | | `TodoWrite`, `TodoRead` | Session-state todo list. Writes emit `todo_update` events. | Always allowed. | +### Structured SDK tool results + +Claude user/replay messages may carry the full SDK `tool_use_result`. ADE +preserves that value as `tool_result.structured` and also extracts narrow +fields for thin clients: Bash exposes `timedOutAfterMs` and +`backgroundCwdHint`, while Grep exposes `grepTotals.files` and +`grepTotals.lines`. + +Completed Agent/Task results use the SDK's enriched +`AgentToolCompletedOutput` when present. Their `subagent_result` rows can carry +`worktreePath`, `worktreeBranch`, `totalTokens`, and `toolUseCount`; clients do +not need to parse the raw structured object to show those values. + ### Permission gate `PermissionMode` (`plan | edit | full-auto`) maps to tool categories diff --git a/docs/features/chat/transcript-and-turns.md b/docs/features/chat/transcript-and-turns.md index e31f1069b..27b1e87ae 100644 --- a/docs/features/chat/transcript-and-turns.md +++ b/docs/features/chat/transcript-and-turns.md @@ -83,7 +83,7 @@ Two helpers summarise a parsed stream: | `structured_question` | Claude SDK `AskUserQuestion` tool surface. | | `pending_input_resolved` | Hidden row; consumed by pending-input derivation to clear UI state. | | `status` | Turn-level lifecycle: `started`, `completed`, `interrupted`, `failed`. | -| `done` | Final turn marker with model, model id, usage, cost. Also clears non-question pending inputs when status is not `completed`. | +| `done` | Final turn marker with model, model id, usage, cost, and optional open-string `terminalReason`. Failed/interrupted dividers translate known reasons into a short explanation; completed turns omit the reason. Also clears non-question pending inputs when status is not `completed`. | | `error` | Provider/runtime failure with message, detail, and semantic `errorInfo`. Codex can report the same terminal failure first as an app-server `error` notification and again on failed `turn/completed`; ADE keeps one visible row for the same turn/error identity while preserving distinct failures. | | `activity` | Ephemeral UI hint (thinking, searching, running_command). Hidden from the transcript. | | `todo_update` | Task-list snapshot; consumed by `ChatTasksPanel`. | @@ -92,17 +92,35 @@ Two helpers summarise a parsed stream: | `tool_use_start` / `tool_use_complete` / `tool_use_summary` | Claude SDK tool lifecycle tracking (see [Claude tool-use tracking](#claude-tool-use-tracking)). | | `step_boundary` | Workflow step boundary marker. | | `system_notice` | Non-transcript chrome: auth errors, rate limits, and file persistence hints. Special-cased renders: the "Promoted to Cursor Cloud" pill, and the `status:"subagent_spawned"` chip (emitted into the parent when a child chat session is created with a parent lineage; `detail.spawnedSession` carries the child sessionId/laneId/title and the chip deep-links via `ade:work:select-session`; the TUI shows the message line; iOS renders it through its existing system_notice mapping). | +| `conversation_reset` | Marks a fresh Claude conversation inside the same ADE session. ADE adopts `newConversationId` as the next SDK resume pointer, clears conversation-scoped auto-title/continuity caches while preserving a manual title, and renderers show a `New conversation` divider. | +| `interrupt_receipt` | Records SDK UUIDs that remain queued after an interrupt. Clients show the full queued count; ADE-attributed messages include their `steerId` and offer cancellation through the SDK control channel. The long-lived query remains attached while those messages are queued so the receipt stays actionable. | +| `command_lifecycle` | Ground-truth lifecycle for ADE-owned Claude messages (`queued`, `started`, `completed`, `cancelled`, `discarded`). ADE ignores internal UUIDs it cannot attribute, deduplicates repeated states, clears staged composer rows once execution begins, and renders only cancelled/discarded terminal anomalies to keep transcripts quiet. | +| `claude_goal_updated` / `claude_goal_cleared` | Read-only Claude `/goal` lifecycle. The update carries the condition, iteration count, token baseline, timestamps, and optional last reason; the session summary mirrors the latest value as `claudeGoal`. | | `session_meta_updated` | Runtime-native session metadata update. Carries title / manual-name state, and — when a client changes the session's mode via `updateSession` — the permission/interaction mode fields (`permissionMode`, `interactionMode`, `claudePermissionMode`, `codexApprovalPolicy`/`codexSandbox`/`codexConfigSource`, `opencodePermissionMode`, `droidPermissionMode`, `cursorModeId`, `cursorModeSnapshot`). The renderer treats it as a local-touch event so Work lists and grid tiles refresh when a provider renames a session, patches the session summary with any mode fields present, and re-seeds the selected chat's composer mode controls so a mode change on another client (desktop ↔ iOS) shows up live without a refetch. All mode fields are optional; a title-only emit carries none of them. | | `completion_report` | Structured closeout produced by the `reportCompletion` workflow tool. | | `turn_diff_summary` | Git-level before/after SHA + per-file stats for a completed turn. | | `delegation_state` | Delegated worker state updates. | -| `context_compact` | Provider-neutral manual/automatic compaction lifecycle. `state: "started"` begins the boundary and `state: "completed"` may carry `preTokens`, `postTokens`, `tokensRemoved`, `durationMs`, provider, and per-session count. A completed boundary invalidates older context-meter usage on desktop, ADE Code, and iOS; exact post-compaction snapshots may refill the meter immediately, while stale same-turn aggregate counters are ignored. | +| `context_usage` | Provider-neutral context occupancy. `origin: "live"` identifies throttled per-API-call Claude usage; absent origin remains compatible with legacy command snapshots. | +| `context_compact` | Provider-neutral manual/automatic compaction lifecycle. `state: "started"` begins the boundary and `state: "completed"` may carry `preTokens`, `postTokens`, `tokensRemoved`, `durationMs`, provider, and per-session count. `trigger: "ade_fallback"` identifies ADE's guarded fallback. A completed boundary invalidates older context-meter usage on desktop, ADE Code, and iOS; exact post-compaction snapshots may refill the meter immediately, while stale same-turn aggregate counters are ignored. | | `web_search` | Provider-neutral web-search/fetch lifecycle; renderers group these with other tool calls instead of showing them as standalone event cards. Actions can carry `query`, `queries`, `title`, `url`, and `snippet`; desktop and iOS render URL actions as in-app-browser result chips, while the TUI keeps a concise one-line action summary. Codex 0.145 additionally emits structured `results` (an array of `{ url, title, snippet }` capped at 8 by the adapter) plus `resultsTotal` (the pre-cap hit count). Renderers thread these onto the same grouped row — desktop/iOS surface them as `Sources` chips (deduped against the action URLs) and the Sources tab, and the TUI shows up to three `title — domain` preview lines with a `+N more` tail. Codex emits native web-search items; `claudeStructuredActivity.ts` maps Claude server-tool blocks into the same event. | | `codex_image_generation` / `codex_image_view` | Compact generated/viewed-image lifecycle used across providers despite the legacy type prefix. Codex emits native image items, Cursor maps `generateImage`, OpenCode maps image `file` parts, and Droid maps assistant image blocks. Large stored data URIs are removed with original/omitted byte metadata. | | `codex_safety_buffering` / `codex_moderation_metadata` / `codex_sleep` / `codex_thread_deleted` / `codex_turn_stalled` | Codex app-server runtime state. Safety buffering, moderation metadata, and sleep are compact status rows; `codex_thread_deleted` clears the stored upstream thread; `codex_turn_stalled` is the structured recovery event shown when a turn produced no useful output after app-server reconciliation. Its actions are `wait`, `steer`, `interrupt_retry_same_thread`, and `restart_resume_thread`. | | `auto_approval_review` | When auto-approval policy kicks in, this event carries the review text. | | `prompt_suggestion` | Suggested follow-up prompts for the user. | +## Claude context guardrails + +Live Claude occupancy emits at most once every five seconds and only after a +one-point percentage change. ADE never preempts the SDK's natural compaction. +It sends a fallback `/compact` only at a turn boundary when all three gates +hold: occupancy is at least 97%, no natural `context_compact` has appeared +since occupancy crossed 90%, and no fallback was already issued in the same +high-water episode. The episode resets below 80%. + +If a turn ends with `terminalReason: "prompt_too_long"` (or the equivalent +overflow signature), ADE compacts once and asks the user to re-send the last +message. It does not automatically replay that message. + ## Canonical runtime events `apps/desktop/src/main/services/chat/runtimeEvents.ts` defines the diff --git a/docs/logging.md b/docs/logging.md index 1d3aa447a..a51ae233e 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -22,6 +22,14 @@ Only closed event names, closed property keys, and coarse allowlisted values may Operational logs use ADE's local logging services and may include bounded diagnostic context appropriate for the local machine. They are for debugging a specific installation and must not be forwarded to PostHog. +Claude compaction observations use the local structured line +`agent_chat.claude_context_compaction_observed` with `sessionId`, `trigger` +(`natural`, `ade_fallback`, or `recovery`), and `occupancyPctAtTrigger`. Record +every natural and ADE-issued compaction so production logs can verify that the +SDK still compacts naturally above its high-water mark. The fallback gate's +debug line is `agent_chat.claude_context_compaction_fallback_gate`; neither line +is a PostHog event. + Product analytics records a small number of meaningful product facts such as "an anonymous installation opened the Work screen" or "a chat session started." It must never inherit arbitrary fields from a log record, exception, IPC payload, database row, or UI component props. Log calls and product-analytics calls should remain separate at the call site. ## Source file map