From 7c6caba5bdceece20a2fad374c5450257b79497b Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 3 Aug 2026 13:23:55 -0700 Subject: [PATCH 1/9] feat(agent): implement /clear and advertise it as a capability Intercept /clear in the Claude adapter instead of forwarding it to the SDK: retire the current query and swap in a brand-new SDK session (fresh id, no resume) under the same ACP session. A _posthog/conversation_cleared marker records the boundary in the append-only session log, and the rehydration paths (jsonl hydration, ResumeSaga) treat it as a conversation boundary so desktop reconnects and cloud resumes rebuild only the post-clear conversation. The UI renders a "Conversation cleared" divider and resets the context indicator. Session.clearing (a promise, claimed synchronously) serializes the swap: a second /clear is refused, cancel/interrupt is ignored mid-clear, refreshSession refuses, and a racing prompt waits for the clear to settle instead of pushing into the retired input stream. The "/clear" prompt is broadcast only once the new session is confirmed live, so a timeout leaves no orphaned entry in the log, and any error terminates the unproven replacement query, closes the session, and resolves the spinner with clearing_failed. The command is read off the ACP prompt rather than the converted SDK message, skipping blocks the host injected rather than the user: promptToClaude prepends detected-PR and local-skill context, and cloud prompts lead with hidden blocks (a resume preamble; on desktop, shell-execute recaps). Matching the first text block of either read host context as the user's command and missed the command entirely, which is why /clear never fired on a resumed cloud run. The adapter advertises conversationClear in its initialize capabilities and the cloud agent-server relays it on _posthog/run_started, so a host can tell whether the agent it is talking to honours the boundary. Hosts that record one without an agent gate on it; an agent that predates the marker ignores it on resume and would rebuild the conversation the boundary was meant to retire. Claude-Session: https://claude.ai/code/session_01HJQHhq27qXnKGj98x7UrXZ --- .../packages/agent/src/acp-extensions.ts | 4 + .../agent/src/adapters/base-acp-agent.ts | 6 +- .../agent/src/adapters/claude/UPSTREAM.md | 6 +- .../claude/claude-agent.clear.test.ts | 670 ++++++++++++++++++ .../claude/claude-agent.refresh.test.ts | 1 + .../claude/claude-agent.slash-command.test.ts | 1 + .../agent/src/adapters/claude/claude-agent.ts | 431 +++++++++-- .../adapters/claude/session/commands.test.ts | 46 ++ .../src/adapters/claude/session/commands.ts | 13 +- .../claude/session/jsonl-hydration.test.ts | 42 ++ .../claude/session/jsonl-hydration.ts | 12 +- .../agent/src/adapters/claude/types.ts | 7 + .../codex-app-server-agent.ts | 12 +- .../agent/src/adapters/prompt-blocks.ts | 21 + .../agent/src/sagas/resume-saga.test.ts | 68 ++ .../packages/agent/src/sagas/resume-saga.ts | 36 +- .../packages/agent/src/server/agent-server.ts | 17 + .../core/src/sessions/acpNotifications.ts | 1 + .../core/src/sessions/sessionService.ts | 12 +- .../desktop/packages/shared/src/sessions.ts | 6 + .../sessions/components/ConversationView.tsx | 2 + .../sessions/components/SessionFooter.tsx | 6 +- .../components/buildConversationItems.test.ts | 98 +++ .../components/buildConversationItems.ts | 36 + .../chat-thread/ChatThreadFooter.tsx | 2 + .../incrementalConversationItems.ts | 2 + .../ConversationClearedView.tsx | 31 + .../session-update/SessionUpdateView.tsx | 6 + .../session-update/StatusNotificationView.tsx | 68 +- 29 files changed, 1574 insertions(+), 89 deletions(-) create mode 100644 products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts create mode 100644 products/desktop/packages/agent/src/adapters/claude/session/commands.test.ts create mode 100644 products/desktop/packages/agent/src/adapters/prompt-blocks.ts create mode 100644 products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx diff --git a/products/desktop/packages/agent/src/acp-extensions.ts b/products/desktop/packages/agent/src/acp-extensions.ts index 3a3bd9dab5ed..c2214053d15f 100644 --- a/products/desktop/packages/agent/src/acp-extensions.ts +++ b/products/desktop/packages/agent/src/acp-extensions.ts @@ -72,6 +72,10 @@ export const POSTHOG_NOTIFICATIONS = { /** Marks a boundary for log compaction */ COMPACT_BOUNDARY: "_posthog/compact_boundary", + /** Conversation history was cleared via /clear. Carries the fresh SDK + * session id; rehydration treats the entry as a conversation boundary. */ + CONVERSATION_CLEARED: "_posthog/conversation_cleared", + /** Token usage update for a session turn */ USAGE_UPDATE: "_posthog/usage_update", diff --git a/products/desktop/packages/agent/src/adapters/base-acp-agent.ts b/products/desktop/packages/agent/src/adapters/base-acp-agent.ts index eddfcf2db710..2e04a702526a 100644 --- a/products/desktop/packages/agent/src/adapters/base-acp-agent.ts +++ b/products/desktop/packages/agent/src/adapters/base-acp-agent.ts @@ -74,7 +74,7 @@ export abstract class BaseAcpAgent implements Agent { protected abstract interrupt(): Promise; async cancel(params: CancelNotification): Promise { - if (this.sessionId !== params.sessionId) { + if (!this.hasSession(params.sessionId)) { throw new Error("Session ID mismatch"); } this.session.cancelled = true; @@ -102,6 +102,8 @@ export abstract class BaseAcpAgent implements Agent { } } + /** Adapters may widen this to accept alternate ids for the live session + * (e.g. the Claude adapter's post-/clear SDK session id). */ hasSession(sessionId: string): boolean { return this.sessionId === sessionId; } @@ -110,7 +112,7 @@ export abstract class BaseAcpAgent implements Agent { sessionId: string, notification: SessionNotification, ): void { - if (this.sessionId === sessionId) { + if (this.hasSession(sessionId)) { this.session.notificationHistory.push(notification); } } diff --git a/products/desktop/packages/agent/src/adapters/claude/UPSTREAM.md b/products/desktop/packages/agent/src/adapters/claude/UPSTREAM.md index 06a42998437e..1c066cc0c94d 100644 --- a/products/desktop/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/products/desktop/packages/agent/src/adapters/claude/UPSTREAM.md @@ -365,7 +365,11 @@ Fork of `@anthropic-ai/claude-agent-acp`. Upstream repo: https://github.com/anth - **Model alias version match** (#702, e1e1c69): Refuse cross-version alias matches in `resolveModelPreference` so `claude-opus-4-6` doesn't get copied onto the `opus` alias when it resolves to 4.7. - **Hide /clear** (#705, cfce130): `/clear` removed from advertised commands; clients should use - `session/new` for the same effect. + `session/new` for the same effect. Superseded: PostHog Code now implements `/clear` itself in + `clearConversation` (prompt() intercepts it and swaps in a fresh SDK session; a + `_posthog/conversation_cleared` log marker bounds rehydration), still never forwarding it to the SDK. + A build that predates this marker skips it as an unrecognized notification and keeps rendering + pre-clear history on rehydration, so the history-drop only renders correctly on builds that ship it. - **No-op ping events** (#698, 694221a): `streamEventToAcpNotifications` no-ops `ping` keep-alive events instead of falling through to `unreachable` and spamming stderr. diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts new file mode 100644 index 000000000000..1e908c3943bc --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts @@ -0,0 +1,670 @@ +import * as fs from "node:fs"; +import type { AgentSideConnection } from "@agentclientprotocol/sdk"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { POSTHOG_METHODS, POSTHOG_NOTIFICATIONS } from "../../acp-extensions"; +import { Pushable } from "../../utils/streams"; +import { getSessionJsonlPath } from "./session/jsonl-hydration"; + +type InitResult = { + result: "success"; + commands?: unknown[]; + models?: unknown[]; +}; + +type SdkQueryHandle = { + interrupt: ReturnType; + setModel: ReturnType; + setMcpServers: ReturnType; + mcpServerStatus: ReturnType; + supportedCommands: ReturnType; + initializationResult: ReturnType; + close: ReturnType; + [Symbol.asyncIterator]: () => AsyncIterator; +}; + +let nextInitPromise: Promise = Promise.resolve({ + result: "success", + commands: [], + models: [], +}); + +function makeQueryHandle(): SdkQueryHandle { + return { + interrupt: vi.fn().mockResolvedValue(undefined), + setModel: vi.fn().mockResolvedValue(undefined), + setMcpServers: vi.fn().mockResolvedValue(undefined), + mcpServerStatus: vi.fn().mockResolvedValue([]), + supportedCommands: vi.fn().mockResolvedValue([]), + initializationResult: vi.fn().mockImplementation(() => nextInitPromise), + close: vi.fn(), + [Symbol.asyncIterator]: async function* () { + /* never yields */ + } as never, + }; +} + +/** Points nextInitPromise at a deferred the test settles once the clear has + * reached its init await (after `vi.waitFor` on createdQueries). */ +function deferInit() { + let resolve!: (result: InitResult) => void; + let reject!: (error: Error) => void; + nextInitPromise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { resolve, reject }; +} + +const lastQueryCall: { options?: Record } = {}; +const createdQueries: SdkQueryHandle[] = []; + +vi.mock("@anthropic-ai/claude-agent-sdk", () => ({ + query: vi.fn((params: { options: Record }) => { + lastQueryCall.options = params.options; + const handle = makeQueryHandle(); + createdQueries.push(handle); + return handle; + }), +})); + +vi.mock("./mcp/tool-metadata", () => ({ + fetchMcpToolMetadata: vi.fn().mockResolvedValue(undefined), + getConnectedMcpServerNames: vi.fn().mockReturnValue([]), + getCachedMcpTools: vi.fn().mockReturnValue([]), + clearMcpToolMetadataCache: vi.fn(), +})); + +// Import after the mocks so ClaudeAcpAgent resolves the mocked SDK +const { ClaudeAcpAgent } = await import("./claude-agent"); +type Agent = InstanceType; + +interface ClientMocks { + sessionUpdate: ReturnType; + extNotification: ReturnType; +} + +function makeAgent(): { agent: Agent; client: ClientMocks } { + const client: ClientMocks = { + sessionUpdate: vi.fn().mockResolvedValue(undefined), + extNotification: vi.fn().mockResolvedValue(undefined), + }; + const agent = new ClaudeAcpAgent(client as unknown as AgentSideConnection); + return { agent, client }; +} + +function installFakeSession(agent: Agent, sessionId: string) { + const oldQuery = makeQueryHandle(); + const input = new Pushable(); + const endSpy = vi.spyOn(input, "end"); + const abortController = new AbortController(); + + const session = { + query: oldQuery, + sdkSessionId: sessionId, + queryOptions: { + sessionId, + cwd: "/tmp/repo", + model: "claude-sonnet-4-6", + mcpServers: { + posthog: { type: "http", url: "https://posthog" }, + "posthog-code-tools": { + type: "sdk", + name: "posthog-code-tools", + instance: { stale: true }, + }, + }, + abortController, + }, + buildInProcessMcpServers: vi.fn(() => ({ + "posthog-code-tools": { + type: "sdk" as const, + name: "posthog-code-tools", + instance: { fresh: true }, + }, + })), + localToolsServerNames: ["posthog-code-tools"], + input, + cancelled: false, + settingsManager: { dispose: vi.fn() }, + permissionMode: "default", + abortController, + accumulatedUsage: { + inputTokens: 0, + outputTokens: 0, + cachedReadTokens: 0, + cachedWriteTokens: 0, + }, + sessionResources: new Set(), + configOptions: [], + turnQueue: [] as unknown[], + activeTurn: null as unknown, + pendingOrphanResults: 0, + queryGeneration: 0, + cwd: "/tmp/repo", + notificationHistory: [] as unknown[], + taskRunId: "run-1", + lastContextWindowSize: 200_000, + modelId: "claude-sonnet-4-6", + taskState: new Map(), + }; + + (agent as unknown as { session: typeof session }).session = session; + (agent as unknown as { sessionId: string }).sessionId = sessionId; + + return { session, oldQuery, endSpy, abortController }; +} + +function findUpdate( + client: ClientMocks, + sessionUpdate: string, +): Record | undefined { + const match = client.sessionUpdate.mock.calls.find( + ([call]) => + (call as { update?: { sessionUpdate?: string } }).update + ?.sessionUpdate === sessionUpdate, + ); + return (match?.[0] as { update: Record } | undefined) + ?.update; +} + +function findExtNotification( + client: ClientMocks, + method: string, +): Record | undefined { + const match = client.extNotification.mock.calls.find( + ([calledMethod]) => calledMethod === method, + ); + return match?.[1] as Record | undefined; +} + +function findAllExtNotifications( + client: ClientMocks, + method: string, +): Record[] { + return client.extNotification.mock.calls + .filter(([calledMethod]) => calledMethod === method) + .map(([, params]) => params as Record); +} + +describe("ClaudeAcpAgent /clear", () => { + beforeEach(() => { + vi.clearAllMocks(); + lastQueryCall.options = undefined; + createdQueries.length = 0; + nextInitPromise = Promise.resolve({ + result: "success", + commands: [], + models: [], + }); + }); + + it("swaps in a fresh SDK session and emits the clear marker", async () => { + const { agent, client } = makeAgent(); + const { session, oldQuery, endSpy } = installFakeSession(agent, "s-1"); + session.taskState.set("task-1", { title: "old task" }); + + const result = await agent.prompt({ + sessionId: "s-1", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(result.stopReason).toBe("end_turn"); + + // Old query retired, new query started fresh (no resume, new id). + expect(oldQuery.interrupt).toHaveBeenCalledTimes(1); + expect(endSpy).toHaveBeenCalledTimes(1); + expect(createdQueries).toHaveLength(1); + expect(lastQueryCall.options?.resume).toBeUndefined(); + const newSessionId = lastQueryCall.options?.sessionId as string; + expect(newSessionId).toBeDefined(); + expect(newSessionId).not.toBe("s-1"); + + // The in-process local-tools server is rebuilt fresh. + const servers = lastQueryCall.options?.mcpServers as Record< + string, + { instance?: unknown } + >; + expect(servers["posthog-code-tools"].instance).toEqual({ fresh: true }); + expect(servers.posthog).toMatchObject({ type: "http" }); + + // ACP identity is stable; the SDK session id diverges underneath. + expect((agent as unknown as { sessionId: string }).sessionId).toBe("s-1"); + expect(session.sdkSessionId).toBe(newSessionId); + expect(agent.hasSession("s-1")).toBe(true); + expect(agent.hasSession(newSessionId)).toBe(true); + + // Repoints stored session ids and marks the boundary in the log. + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.SDK_SESSION), + ).toMatchObject({ + taskRunId: "run-1", + sessionId: newSessionId, + adapter: "claude", + }); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toMatchObject({ sessionId: newSessionId }); + + // A "clearing" status opens immediately and closes on success, so the + // user sees feedback for the whole swap even if it's slow. + const statusNotifications = findAllExtNotifications( + client, + POSTHOG_NOTIFICATIONS.STATUS, + ); + expect(statusNotifications).toEqual([ + { sessionId: "s-1", status: "clearing" }, + { sessionId: "s-1", status: "clearing", isComplete: true }, + ]); + + // The /clear prompt is echoed to the transcript, the plan panel resets, + // and the context indicator drops to zero. + expect(findUpdate(client, "user_message_chunk")).toMatchObject({ + content: { type: "text", text: "/clear" }, + }); + expect(session.taskState.size).toBe(0); + expect(findUpdate(client, "plan")).toMatchObject({ entries: [] }); + expect(findUpdate(client, "usage_update")).toMatchObject({ + used: 0, + size: 200_000, + }); + }); + + it("clears when the host prepends hidden context ahead of the /clear, as cloud resumes do", async () => { + // The cloud agent-server wraps a pending user message in a hidden resume + // preamble. Reading the command off the first block of the prompt (or of + // the converted SDK message, which also leads with host context) would + // miss it and send "/clear" to the model as an ordinary turn. + const { agent, client } = makeAgent(); + installFakeSession(agent, "s-cloud"); + + const result = await agent.prompt({ + sessionId: "s-cloud", + prompt: [ + { + type: "text", + text: "You are resuming a previous conversation. …", + _meta: { ui: { hidden: true } }, + }, + { type: "text", text: "/clear" }, + ], + }); + + expect(result.stopReason).toBe("end_turn"); + expect(createdQueries).toHaveLength(1); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeDefined(); + }); + + it("deletes the stale local jsonl for the stable ACP id after a successful clear", async () => { + // A cold reconnect hydrates by the stable ACP id (clients never learn the + // internal SDK id). If the SDK's original file under that id survived a + // /clear, a future hydration would find it, skip re-fetching the + // authoritative log, and resume the pre-clear conversation. + const unlinkSpy = vi + .spyOn(fs.promises, "unlink") + .mockResolvedValue(undefined); + const { agent } = makeAgent(); + installFakeSession(agent, "s-stale"); + + await agent.prompt({ + sessionId: "s-stale", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(unlinkSpy).toHaveBeenCalledWith( + getSessionJsonlPath("s-stale", "/tmp/repo"), + ); + unlinkSpy.mockRestore(); + }); + + it("still completes the clear if removing the stale jsonl fails for a reason other than a missing file", async () => { + const unlinkSpy = vi + .spyOn(fs.promises, "unlink") + .mockRejectedValue( + Object.assign(new Error("EACCES"), { code: "EACCES" }), + ); + const { agent } = makeAgent(); + installFakeSession(agent, "s-unlink-fails"); + + const result = await agent.prompt({ + sessionId: "s-unlink-fails", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(result.stopReason).toBe("end_turn"); + unlinkSpy.mockRestore(); + }); + + it("emits the marker after the user message so /clear sits before the boundary", async () => { + const { agent, client } = makeAgent(); + installFakeSession(agent, "s-order"); + + let clearedAt = -1; + let userMessageAt = -1; + let order = 0; + client.extNotification.mockImplementation(async (method: string) => { + if (method === POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED) { + clearedAt = order++; + } + }); + client.sessionUpdate.mockImplementation( + async (call: { update?: { sessionUpdate?: string } }) => { + if (call.update?.sessionUpdate === "user_message_chunk") { + userMessageAt = order++; + } + }, + ); + + await agent.prompt({ + sessionId: "s-order", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(userMessageAt).toBeGreaterThanOrEqual(0); + expect(clearedAt).toBeGreaterThan(userMessageAt); + }); + + it.each([ + { + name: "an active turn", + setup: (session: ReturnType["session"]) => { + session.activeTurn = { promptUuid: "u-1", settled: false }; + }, + }, + { + name: "a queued turn", + setup: (session: ReturnType["session"]) => { + session.turnQueue.push({ promptUuid: "u-2" }); + }, + }, + ])("refuses to clear while $name is in flight", async ({ setup }) => { + const { agent, client } = makeAgent(); + const { session, oldQuery } = installFakeSession(agent, "s-busy"); + setup(session); + + const result = await agent.prompt({ + sessionId: "s-busy", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(result.stopReason).toBe("end_turn"); + expect(oldQuery.interrupt).not.toHaveBeenCalled(); + expect(createdQueries).toHaveLength(0); + const chunk = findUpdate(client, "agent_message_chunk"); + expect((chunk?.content as { text?: string })?.text).toMatch( + /Cannot clear the conversation/, + ); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeUndefined(); + }); + + it("refuses a second /clear while one is already in progress", async () => { + // ACP handlers are not serialized, so a second /clear can arrive at any + // await point of the first. Racing two swaps against the same session + // would orphan a live SDK query; the second must be refused. + const { agent, client } = makeAgent(); + installFakeSession(agent, "s-concurrent"); + const init = deferInit(); + + const first = agent.prompt({ + sessionId: "s-concurrent", + prompt: [{ type: "text", text: "/clear" }], + }); + // Let the first clear reach its init await (one replacement query live). + await vi.waitFor(() => expect(createdQueries).toHaveLength(1)); + + const second = await agent.prompt({ + sessionId: "s-concurrent", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(second.stopReason).toBe("end_turn"); + const chunk = findUpdate(client, "agent_message_chunk"); + expect((chunk?.content as { text?: string })?.text).toMatch( + /already in progress/, + ); + // The refused clear started no second swap. + expect(createdQueries).toHaveLength(1); + + init.resolve({ result: "success", commands: [], models: [] }); + await expect(first).resolves.toMatchObject({ stopReason: "end_turn" }); + expect( + findAllExtNotifications( + client, + POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, + ), + ).toHaveLength(1); + }); + + it("ignores a cancel that arrives while a clear is in progress", async () => { + // cancel() → interrupt() targets session.query, which mid-clear is the + // half-initialized replacement; interrupting it would corrupt the swap. + const { agent, client } = makeAgent(); + installFakeSession(agent, "s-cancel-mid-clear"); + const init = deferInit(); + + const clearPromise = agent.prompt({ + sessionId: "s-cancel-mid-clear", + prompt: [{ type: "text", text: "/clear" }], + }); + await vi.waitFor(() => expect(createdQueries).toHaveLength(1)); + + await agent.cancel({ sessionId: "s-cancel-mid-clear" }); + expect(createdQueries[0].interrupt).not.toHaveBeenCalled(); + + init.resolve({ result: "success", commands: [], models: [] }); + await expect(clearPromise).resolves.toMatchObject({ + stopReason: "end_turn", + }); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeDefined(); + }); + + it("closes the session and reports clearing_failed when the fresh session fails to initialize", async () => { + // A non-timeout failure (SDK subprocess crash) must get the same + // treatment as a timeout: terminate the unproven replacement, close the + // session, and resolve the "Clearing…" spinner as failed — never leave + // it spinning with the session half-swapped. + const { agent, client } = makeAgent(); + const { session } = installFakeSession(agent, "s-init-crash"); + const init = deferInit(); + + const promptPromise = agent.prompt({ + sessionId: "s-init-crash", + prompt: [{ type: "text", text: "/clear" }], + }); + const rejection = expect(promptPromise).rejects.toThrow( + /SDK subprocess crashed/, + ); + await vi.waitFor(() => expect(createdQueries).toHaveLength(1)); + init.reject(new Error("SDK subprocess crashed")); + await rejection; + + expect((session as unknown as { queryClosed: boolean }).queryClosed).toBe( + true, + ); + // The failed replacement query is torn down, not leaked. + expect(createdQueries).toHaveLength(1); + expect(createdQueries[0].close).toHaveBeenCalled(); + // No trace of the /clear in the log, and the spinner resolves as failed. + expect(findUpdate(client, "user_message_chunk")).toBeUndefined(); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeUndefined(); + expect( + findAllExtNotifications(client, POSTHOG_NOTIFICATIONS.STATUS), + ).toEqual([ + { sessionId: "s-init-crash", status: "clearing" }, + { + sessionId: "s-init-crash", + status: "clearing_failed", + error: "SDK subprocess crashed", + }, + ]); + }); + + it("rejects a prompt that arrives mid-clear once the clear fails", async () => { + // A prompt racing the swap waits for the clear to settle instead of + // pushing into the retired input stream; a failed clear then surfaces + // as the usual session-ended rejection. + const { agent } = makeAgent(); + installFakeSession(agent, "s-prompt-mid-clear"); + const init = deferInit(); + + const clearPromise = agent.prompt({ + sessionId: "s-prompt-mid-clear", + prompt: [{ type: "text", text: "/clear" }], + }); + await vi.waitFor(() => expect(createdQueries).toHaveLength(1)); + + const followUp = agent.prompt({ + sessionId: "s-prompt-mid-clear", + prompt: [{ type: "text", text: "hello" }], + }); + + const clearRejection = expect(clearPromise).rejects.toThrow( + /SDK subprocess crashed/, + ); + const followUpRejection = + expect(followUp).rejects.toThrow(/session has ended/); + init.reject(new Error("SDK subprocess crashed")); + await clearRejection; + await followUpRejection; + }); + + it("rejects /clear after the session has ended", async () => { + const { agent } = makeAgent(); + const { session } = installFakeSession(agent, "s-ended"); + (session as unknown as { queryClosed: boolean }).queryClosed = true; + + await expect( + agent.prompt({ + sessionId: "s-ended", + prompt: [{ type: "text", text: "/clear" }], + }), + ).rejects.toThrow(/session has ended/); + expect(createdQueries).toHaveLength(0); + }); + + it("refreshSession resumes the post-clear SDK session", async () => { + const { agent } = makeAgent(); + installFakeSession(agent, "s-refresh"); + + await agent.prompt({ + sessionId: "s-refresh", + prompt: [{ type: "text", text: "/clear" }], + }); + const newSessionId = lastQueryCall.options?.sessionId as string; + + await agent.extMethod(POSTHOG_METHODS.REFRESH_SESSION, { + mcpServers: [ + { name: "posthog", type: "http" as const, url: "https://fresh" }, + ], + }); + + expect(lastQueryCall.options?.resume).toBe(newSessionId); + expect(lastQueryCall.options?.sessionId).toBeUndefined(); + }); + + it("times out, closes the query, and never logs the /clear prompt if the fresh session never finishes initializing", async () => { + vi.useFakeTimers(); + try { + const { agent, client } = makeAgent(); + const { session } = installFakeSession(agent, "s-timeout"); + nextInitPromise = new Promise(() => { + // Never resolves, forcing the initializationResult() race to time out. + }); + + const promptPromise = agent.prompt({ + sessionId: "s-timeout", + prompt: [{ type: "text", text: "/clear" }], + }); + const rejection = expect(promptPromise).rejects.toThrow(/timed out/); + + // Matches the module-private SESSION_VALIDATION_TIMEOUT_MS in claude-agent.ts. + await vi.advanceTimersByTimeAsync(30_000); + await rejection; + + expect((session as unknown as { queryClosed: boolean }).queryClosed).toBe( + true, + ); + // The /clear prompt is only broadcast (and thus logged) once the new + // session is confirmed live, so a timeout must leave no trace of it. + expect(findUpdate(client, "user_message_chunk")).toBeUndefined(); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeUndefined(); + // The "clearing" spinner opened, then the failure closes it out. + expect( + findAllExtNotifications(client, POSTHOG_NOTIFICATIONS.STATUS), + ).toEqual([ + { sessionId: "s-timeout", status: "clearing" }, + { + sessionId: "s-timeout", + status: "clearing_failed", + error: "Conversation clear timed out after 30000ms", + }, + ]); + } finally { + vi.useRealTimers(); + } + }); + + it("resumeSession after /clear echoes the canonical ACP id when matched via the new SDK id", async () => { + const { agent } = makeAgent(); + installFakeSession(agent, "s-reconnect"); + + await agent.prompt({ + sessionId: "s-reconnect", + prompt: [{ type: "text", text: "/clear" }], + }); + const newSessionId = lastQueryCall.options?.sessionId as string; + + const response = await agent.resumeSession({ + sessionId: newSessionId, + cwd: "/tmp/repo", + }); + + expect((response as unknown as { sessionId: string }).sessionId).toBe( + "s-reconnect", + ); + }); + + it("resets pre-clear plan and notification state so it can't resurface after /clear", async () => { + // ExitPlanMode falls back to lastPlanContent/fileContentCache/ + // notificationHistory when its tool input omits an explicit plan; left + // untouched, a plan written before /clear (possibly carrying + // repo-injected content) could resurface in the fresh session. + const { agent } = makeAgent(); + const { session } = installFakeSession(agent, "s-plan"); + (session as unknown as { lastPlanContent?: string }).lastPlanContent = + "stale pre-clear plan"; + (session as unknown as { lastPlanFilePath?: string }).lastPlanFilePath = + "/tmp/repo/.claude/plans/old.md"; + session.notificationHistory.push({ type: "assistant", text: "old" }); + agent.fileContentCache["/tmp/repo/.claude/plans/old.md"] = "stale content"; + + await agent.prompt({ + sessionId: "s-plan", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect( + (session as unknown as { lastPlanContent?: string }).lastPlanContent, + ).toBeUndefined(); + expect( + (session as unknown as { lastPlanFilePath?: string }).lastPlanFilePath, + ).toBeUndefined(); + // The reset happens before broadcastUserMessage, which legitimately logs + // the "/clear" command itself afterward — assert the stale pre-clear + // entry is gone rather than the history being empty. + expect(session.notificationHistory).not.toContainEqual({ + type: "assistant", + text: "old", + }); + expect(agent.fileContentCache).toEqual({}); + }); +}); diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index 4222b68dd5b4..a85e6852379a 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts @@ -99,6 +99,7 @@ function installFakeSession( const session = { query: oldQuery, + sdkSessionId: sessionId, queryOptions: { sessionId, cwd: "/tmp/repo", diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 0e9f422a089f..a8fff01c3a74 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts @@ -45,6 +45,7 @@ function installFakeSession( const session = { query, + sdkSessionId: sessionId, queryOptions: { sessionId, cwd: "/tmp/repo", abortController }, buildInProcessMcpServers: () => ({}), localToolsServerNames: [] as string[], diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index b0984bac654a..af0c2d5e262d 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -66,6 +66,7 @@ import { type PostHogProductId, } from "../../posthog-products"; import type { PostHogAPIConfig } from "../../types"; +import { text } from "../../utils/acp-content"; import { isCloudRun, unreachable, @@ -76,7 +77,9 @@ import { resolveGithubToken } from "../../utils/github-token"; import { Logger } from "../../utils/logger"; import { Pushable } from "../../utils/streams"; import { BaseAcpAgent } from "../base-acp-agent"; +import { isLocalSkillCommandChunk } from "../local-skill"; import { LOCAL_TOOLS_MCP_NAME, type LocalToolCtx } from "../local-tools"; +import { visiblePromptBlocks } from "../prompt-blocks"; import { resolveSpokenNarration, resolveTaskId } from "../session-meta"; import { buildBreakdown, @@ -109,6 +112,7 @@ import { } from "./mcp/tool-metadata"; import { canUseTool } from "./permissions/permission-handlers"; import { getAvailableSlashCommands } from "./session/commands"; +import { getSessionJsonlPath } from "./session/jsonl-hydration"; import { parseMcpServers } from "./session/mcp-config"; import { applyAvailableModelsAllowlist, @@ -170,6 +174,33 @@ const SESSION_ENDED_MESSAGE = const MAX_TITLE_LENGTH = 256; const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); +/** + * The `/command` a prompt leads with, if any. + * + * Read from the ACP prompt rather than the converted SDK message, and skipping + * the blocks the host injected rather than the user: `promptToClaude` prepends + * detected-PR and local-skill context, and cloud prompts lead with hidden + * blocks (the resume preamble; on desktop, shell-execute recaps). Matching the + * first text block of either would read host context as the user's command and + * miss the command entirely. + */ +function leadingSlashCommand(params: PromptRequest): string | undefined { + const meta = params._meta as { localSkillName?: unknown } | undefined; + const localSkillName = + typeof meta?.localSkillName === "string" ? meta.localSkillName : null; + + for (const chunk of visiblePromptBlocks(params.prompt)) { + if (chunk.type !== "text") continue; + // `promptToClaude` consumes this chunk and sends the skill's context in its + // place, so the SDK never sees a command — neither should we. + if (localSkillName && isLocalSkillCommandChunk(chunk, localSkillName)) { + return undefined; + } + return chunk.text.match(/^(\/\S+)/)?.[1]; + } + return undefined; +} + function isSdkMcpServer( cfg: McpServerConfig, ): cfg is McpSdkServerConfigWithInstance { @@ -322,6 +353,14 @@ export class ClaudeAcpAgent extends BaseAcpAgent { posthog: { resumeSession: true, steering: "native", + // This build implements `/clear` itself and treats a + // `_posthog/conversation_cleared` marker as a rehydration boundary. + // Hosts that record the boundary without an agent (the backend does + // it for a finished cloud run) gate on this: an older agent ignores + // the marker and would resume the conversation it was meant to + // retire, so the clear has to look unavailable rather than silently + // not take. + conversationClear: true, }, claudeCode: { promptQueueing: true, @@ -455,31 +494,30 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } async prompt(params: PromptRequest): Promise { + // Detect local-only slash commands that return results without model invocation + const command = leadingSlashCommand(params); + + if (command === "/clear") { + // Handled by the adapter, never forwarded to the SDK (whose own /clear + // is unreliable in this embedding — see UPSTREAM.md "Hide /clear"). + // Ahead of the SDK conversion below, which this path never reads. + return this.clearConversation(params); + } + const userMessage = promptToClaude(params); const promptUuid = randomUUID(); userMessage.uuid = promptUuid; - let isLocalOnlyCommand = false; + const isLocalOnlyCommand = !!command && LOCAL_ONLY_COMMANDS.has(command); - // Detect local-only slash commands that return results without model invocation - const msgContent = userMessage.message.content; - let firstTextPart = ""; - if (typeof msgContent === "string") { - firstTextPart = msgContent; - } else if (Array.isArray(msgContent)) { - for (const block of msgContent) { - if ("type" in block && block.type === "text" && "text" in block) { - firstTextPart = block.text as string; - break; - } - } - } - const commandMatch = firstTextPart.match(/^(\/\S+)/); - if (commandMatch && LOCAL_ONLY_COMMANDS.has(commandMatch[1])) { - isLocalOnlyCommand = true; + if (this.session.clearing) { + // A /clear is swapping the SDK query underneath. Wait for it to settle + // so this prompt lands on the fresh input stream, not the retired one + // (a failed clear sets queryClosed, which the check below rejects). + await this.session.clearing; } - if (commandMatch && !isLocalOnlyCommand) { - await this.refreshSlashCommandsForPrompt(commandMatch[1]); + if (command && !isLocalOnlyCommand) { + await this.refreshSlashCommandsForPrompt(command); } if (this.session.queryClosed) { @@ -524,7 +562,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { promptUuid, pendingSteerUuids: new Set(), isLocalOnlyCommand, - commandName: commandMatch?.[1], + commandName: command, broadcast: () => this.broadcastUserMessage(params), settled: false, resolve: () => {}, @@ -1435,6 +1473,15 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (session.queryClosed) { return; } + if (session.clearing) { + // A /clear is swapping the SDK query: there is no turn to cancel, and + // interrupting the half-initialized replacement would corrupt the swap. + // A wedged clear self-limits via SESSION_VALIDATION_TIMEOUT_MS. + this.logger.debug("Ignoring cancel while a /clear is in progress", { + sessionId: this.sessionId, + }); + return; + } session.cancelled = true; // Settle not-yet-echoed turns immediately; the SDK still runs their @@ -1520,10 +1567,304 @@ export class ClaudeAcpAgent extends BaseAcpAgent { return { refreshed: true }; } + /** Retire the current consumer and SDK query so a replacement Query can be + * swapped in place (refreshSession, clearConversation). The generation bump + * makes the retired consumer exit quietly. */ + private async retireQuery(session: Session): Promise { + session.queryGeneration += 1; + const oldConsumer = session.consumer; + session.consumer = undefined; + session.cancelController?.abort(); + session.cancelController = undefined; + + // Abort FIRST so any stuck in-flight HTTP request unblocks — otherwise + // interrupt() can deadlock waiting on an API call that never returns. + // Callers allocate a fresh controller for the new Query so aborting + // the old one doesn't poison it. + session.abortController.abort(); + try { + await session.query.interrupt(); + } catch (error) { + this.logger.debug("Ignoring interrupt error while retiring query", { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + session.input.end(); + if (oldConsumer) { + // Bounded so a wedged old query can't block the swap. + await withTimeout(oldConsumer, 5_000); + } + } + + /** + * `/clear` — drop the conversation and start over in place. + * + * The SDK's own /clear is not forwarded (see UPSTREAM.md "Hide /clear"); + * instead the current Query is retired and a brand-new SDK session (fresh + * session id, no resume) is swapped in under the same ACP session. The + * session log stays append-only: a `conversation_cleared` marker records + * the boundary (rehydration rebuilds only post-clear turns) and an updated + * `sdk_session` mapping points future resumes at the fresh SDK session. + */ + private async clearConversation( + params: PromptRequest, + ): Promise { + const session = this.session; + if (session.queryClosed) { + throw RequestError.internalError(undefined, SESSION_ENDED_MESSAGE); + } + // A second /clear mid-swap would race the same session fields + // (query/input/abortController) and orphan a live SDK query; a clear + // mid-turn would rip the query out from under the active prompt. + const refusal = session.clearing + ? "A conversation clear is already in progress." + : session.activeTurn !== null || session.turnQueue.length > 0 + ? "Cannot clear the conversation while a turn is in progress. Wait for it to finish (or cancel it) and try again." + : null; + if (refusal) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: text(refusal), + }, + }); + return { stopReason: "end_turn" }; + } + + // Claim the session synchronously, before the first await: ACP handlers + // are not serialized, so a prompt/cancel/second clear can arrive at any + // await point of the swap. They key off this flag (see Session.clearing). + // performClear runs synchronously up to its first await, so the claim is + // visible before any other handler can interleave. Waiters only need + // settlement; a failure still surfaces through the returned promise. + const clear = this.performClear(params, session); + session.clearing = clear.then( + () => undefined, + () => undefined, + ); + try { + return await clear; + } finally { + session.clearing = undefined; + } + } + + /** Body of {@link clearConversation}; only runs holding `session.clearing`. */ + private async performClear( + params: PromptRequest, + session: Session, + ): Promise { + this.logger.info("Clearing conversation", { sessionId: params.sessionId }); + + // Signal the in-progress state immediately (mirrors the "compacting" + // status row): the swap below is normally sub-second, but on a slow or + // timed-out clear the user would otherwise see no feedback at all + // between typing `/clear` and either the divider or an error appearing. + await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: params.sessionId, + status: "clearing", + }); + + let newQuery: Query | undefined; + let newAbortController: AbortController | undefined; + try { + await this.retireQuery(session); + + const newSdkSessionId = uuidv7(); + newAbortController = new AbortController(); + const { + resume: _dropResume, + forkSession: _dropFork, + ...rest + } = session.queryOptions; + + // Rebuild the in-process ("sdk") server fresh; reusing the prior instance + // throws "Already connected to a transport". + const freshInProcess = session.buildInProcessMcpServers(); + + const newOptions: Options = { + ...rest, + mcpServers: { + ...externalMcpServers(rest.mcpServers), + ...freshInProcess, + }, + sessionId: newSdkSessionId, + abortController: newAbortController, + // `rest.model` is the creation-time value; the user may have switched + // models since, so re-root the new Query on the live session model. + ...(session.modelId && { model: toSdkModelId(session.modelId) }), + }; + + const newInput = new Pushable(); + newQuery = query({ prompt: newInput, options: newOptions }); + + session.query = newQuery; + session.input = newInput; + session.queryOptions = newOptions; + session.abortController = newAbortController; + + const result = await withTimeout( + newQuery.initializationResult(), + SESSION_VALIDATION_TIMEOUT_MS, + ); + if (result.result === "timeout") { + throw new Error( + `Conversation clear timed out after ${SESSION_VALIDATION_TIMEOUT_MS}ms`, + ); + } + return await this.finishClear( + params, + session, + newQuery, + newSdkSessionId, + result.value, + ); + } catch (error) { + // The old query is already retired and the new one is unproven, so any + // failure here — timeout, SDK init rejection, a consumer that died while + // being retired — leaves the session unusable. Close it out and report + // the outcome (same as a failed compaction) rather than leaving the + // "Clearing…" spinner unresolved and the session half-swapped. + if (newQuery && newAbortController) { + this.terminateQuery(newQuery, newAbortController); + } + session.queryClosed = true; + const message = error instanceof Error ? error.message : String(error); + try { + await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: params.sessionId, + status: "clearing_failed", + error: message, + }); + } catch { + // The client transport itself is failing; don't mask the cause. + } + throw new RequestError(-32603, message, { sessionId: params.sessionId }); + } + } + + /** Post-swap bookkeeping and notifications once the fresh SDK session is + * confirmed live. Failures still propagate to performClear's catch: the + * log may already show the /clear, but the session state is authoritative + * only once everything (marker included) has been persisted. */ + private async finishClear( + params: PromptRequest, + session: Session, + newQuery: Query, + newSdkSessionId: string, + initResult: Awaited>, + ): Promise { + session.knownSlashCommands = collectKnownSlashCommands(initResult.commands); + session.fastModeEnabled = fastModeStateEnabled(initResult.fast_mode_state); + + // Future resumes (refreshSession, desktop reconnect, cloud rehydration) + // must target the fresh SDK session. `this.sessionId` (the ACP-visible + // id) stays stable — clients keep addressing the session with it. + session.sdkSessionId = newSdkSessionId; + + // Invalidate the local jsonl the SDK wrote under the stable ACP id + // (non-empty only before a session's first /clear — later clears never + // write there again). Left in place, a cold reconnect that hydrates by + // this id would find it, skip re-fetching the authoritative log, and + // resume the pre-clear conversation instead of the cleared one. + try { + await fs.promises.unlink( + getSessionJsonlPath(this.sessionId, session.cwd), + ); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + this.logger.warn("Failed to remove stale session jsonl after /clear", { + sessionId: this.sessionId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + const hadTasks = session.taskState.size > 0; + session.taskState.clear(); + this.toolUseStreamCache.clear(); + this.emittedToolCalls.clear(); + // Nothing from before the boundary should be able to reach the fresh + // session: reset the plan/notification state ExitPlanMode falls back + // to when its tool input omits an explicit plan, so a stale (possibly + // repo-injected) pre-clear plan can't resurface after approval. + session.notificationHistory.length = 0; + session.lastPlanFilePath = undefined; + session.lastPlanContent = undefined; + this.fileContentCache = {}; + + // Only broadcast (and thus persist) the "/clear" prompt once the new + // session is confirmed live — the log must never show a "/clear" whose + // clear never actually happened. Broadcast before the marker so it lands + // on the pre-clear side of the rehydration boundary and gets dropped + // rather than replayed as a turn after resume. + await this.broadcastUserMessage(params); + + // These notifications are independent of one another (only the + // user-message broadcast above must precede them); issue them + // concurrently rather than paying sequential round trips. + const postClearNotifications: Promise[] = [ + // Clear the "Clearing…" spinner. `conversation_cleared` normally + // supersedes it visually, but signal completion explicitly (same + // rationale as the compacting spinner) rather than relying on that. + this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: params.sessionId, + status: "clearing", + isComplete: true, + }), + ]; + if (session.taskRunId) { + postClearNotifications.push( + this.client.extNotification(POSTHOG_NOTIFICATIONS.SDK_SESSION, { + taskRunId: session.taskRunId, + sessionId: newSdkSessionId, + adapter: "claude", + }), + ); + } + postClearNotifications.push( + this.client.extNotification(POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, { + sessionId: newSdkSessionId, + }), + ); + if (hadTasks) { + postClearNotifications.push( + this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { sessionUpdate: "plan", entries: [] }, + }), + ); + } + postClearNotifications.push( + this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "usage_update", + used: 0, + size: + session.lastContextWindowSize ?? + this.getContextWindowForModel(session.modelId ?? ""), + }, + }), + ); + await Promise.all(postClearNotifications); + + this.refreshMcpMetadata(newQuery); + return { stopReason: "end_turn" }; + } + private async refreshSession( mcpServers: Record, ): Promise { const prev = this.session; + if (prev.clearing) { + throw new RequestError( + -32002, + "Cannot refresh session while a conversation clear is in progress", + ); + } if (prev.activeTurn !== null || prev.turnQueue.length > 0) { throw new RequestError( -32002, @@ -1542,31 +1883,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { sessionId: this.sessionId, }); - // Retire the old consumer: the generation bump makes it exit quietly. - prev.queryGeneration += 1; - const oldConsumer = prev.consumer; - prev.consumer = undefined; - prev.cancelController?.abort(); - prev.cancelController = undefined; - - // Abort FIRST so any stuck in-flight HTTP request unblocks — otherwise - // interrupt() can deadlock waiting on an API call that never returns. - // We allocate a fresh controller for the new Query below so aborting - // the old one doesn't poison it. - prev.abortController.abort(); - try { - await prev.query.interrupt(); - } catch (error) { - this.logger.debug("Ignoring interrupt error during session refresh", { - sessionId: this.sessionId, - error: error instanceof Error ? error.message : String(error), - }); - } - prev.input.end(); - if (oldConsumer) { - // Bounded so a wedged old query can't block the refresh. - await withTimeout(oldConsumer, 5_000); - } + await this.retireQuery(prev); // Reuse every option from the running session; swap mcpServers, re-root // identity on `resume` instead of `sessionId`, and give the new Query a @@ -1587,7 +1904,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const newOptions: Options = { ...rest, mcpServers: { ...mcpServers, ...freshInProcess }, - resume: this.sessionId, + resume: prev.sdkSessionId, forkSession: false, abortController: newAbortController, // `rest.model` is the creation-time value; the user may have switched @@ -2108,6 +2425,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const session: Session = { query: q, + sdkSessionId: sessionId, queryOptions: options, buildInProcessMcpServers, localToolsServerNames, @@ -2426,10 +2744,18 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }); } + /** Matches the ACP session id, or the underlying SDK session id after a + * /clear (desktop hosts re-key on the sdk_session notification). */ + hasSession(sessionId: string): boolean { + return ( + super.hasSession(sessionId) || this.session?.sdkSessionId === sessionId + ); + } + private getExistingSessionState( sessionId: string, ): NewSessionResponse | null { - if (this.sessionId !== sessionId || !this.session) return null; + if (!this.hasSession(sessionId) || !this.session) return null; const availableModes = getAvailableModes(); const modes: SessionModeState = { @@ -2442,7 +2768,10 @@ export class ClaudeAcpAgent extends BaseAcpAgent { }; return { - sessionId, + // Echo the canonical ACP session id even if the caller matched via the + // post-/clear SDK session id (see hasSession) — clients must keep + // addressing the session with the stable id. + sessionId: this.sessionId, modes, configOptions: this.session.configOptions, }; @@ -2603,7 +2932,11 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ): Promise { let info: Awaited>; try { - info = await getSessionInfo(sessionId, { dir: session.cwd }); + // The SDK stores session info under the SDK session id, which diverges + // from the client-addressed id after a /clear. + info = await getSessionInfo(session.sdkSessionId, { + dir: session.cwd, + }); } catch (error) { this.logger.warn("Failed to read session info for title update", { sessionId, diff --git a/products/desktop/packages/agent/src/adapters/claude/session/commands.test.ts b/products/desktop/packages/agent/src/adapters/claude/session/commands.test.ts new file mode 100644 index 000000000000..ccda4d779320 --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/claude/session/commands.test.ts @@ -0,0 +1,46 @@ +import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; +import { describe, expect, it } from "vitest"; +import { getAvailableSlashCommands } from "./commands"; + +function sdkCommand(name: string, description = ""): SlashCommand { + return { name, description, argumentHint: null } as unknown as SlashCommand; +} + +describe("getAvailableSlashCommands", () => { + it("filters unsupported commands", () => { + const available = getAvailableSlashCommands([ + sdkCommand("compact"), + sdkCommand("context"), + sdkCommand("cost"), + sdkCommand("login"), + ]); + const names = available.map((c) => c.name); + expect(names).toContain("compact"); + expect(names).not.toContain("context"); + expect(names).not.toContain("cost"); + expect(names).not.toContain("login"); + }); + + it("passes the SDK's /clear entry through", () => { + const available = getAvailableSlashCommands([ + sdkCommand("clear", "Clear conversation history"), + ]); + const clear = available.filter((c) => c.name === "clear"); + expect(clear).toHaveLength(1); + expect(clear[0].description).toBe("Clear conversation history"); + }); + + it("injects /clear when the SDK does not advertise it", () => { + const available = getAvailableSlashCommands([sdkCommand("compact")]); + const clear = available.find((c) => c.name === "clear"); + expect(clear).toBeDefined(); + expect(clear?.description).toMatch(/clear conversation history/i); + }); + + it("renames MCP commands to the mcp: prefix", () => { + const available = getAvailableSlashCommands([ + sdkCommand("linear (MCP)", "Linear tools"), + ]); + expect(available.map((c) => c.name)).toContain("mcp:linear"); + }); +}); diff --git a/products/desktop/packages/agent/src/adapters/claude/session/commands.ts b/products/desktop/packages/agent/src/adapters/claude/session/commands.ts index 47037142cf3e..7bfc2f95c8a4 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/commands.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/commands.ts @@ -2,7 +2,6 @@ import type { AvailableCommand } from "@agentclientprotocol/sdk"; import type { SlashCommand } from "@anthropic-ai/claude-agent-sdk"; const UNSUPPORTED_COMMANDS = [ - "clear", "context", "cost", "keybindings-help", @@ -16,7 +15,7 @@ const UNSUPPORTED_COMMANDS = [ export function getAvailableSlashCommands( commands: SlashCommand[], ): AvailableCommand[] { - return commands + const available = commands .map((command) => { const input = command.argumentHint != null @@ -40,4 +39,14 @@ export function getAvailableSlashCommands( (command: AvailableCommand) => !UNSUPPORTED_COMMANDS.includes(command.name), ); + // /clear is implemented by the adapter (clearConversation), not forwarded + // to the SDK — advertise it even when the SDK doesn't list it. + if (!available.some((command) => command.name === "clear")) { + available.push({ + name: "clear", + description: "Clear conversation history and free up context", + input: null, + }); + } + return available; } diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts index 9e094a5c8a30..ff92a4b6730f 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts @@ -3,6 +3,7 @@ import * as os from "node:os"; import * as path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { PostHogAPIClient } from "../../../posthog-api"; +import { createNotification } from "../../../sagas/test-fixtures"; import type { StoredEntry } from "../../../types"; import { conversationTurnsToJsonlEntries, @@ -117,6 +118,47 @@ describe("rebuildConversation", () => { expect(rebuildConversation(entries)).toEqual([]); }); + it.each([ + { method: "_posthog/conversation_cleared" }, + { method: "__posthog/conversation_cleared" }, + ])("drops turns before a $method marker (/clear boundary)", ({ method }) => { + const turns = rebuildConversation([ + entry("user_message", { content: { type: "text", text: "old" } }), + entry("agent_message", { + content: { type: "text", text: "old reply" }, + }), + createNotification(method, { sessionId: "sdk-new" }), + entry("user_message", { content: { type: "text", text: "new" } }), + entry("agent_message", { + content: { type: "text", text: "new reply" }, + }), + ]); + + expect(turns).toHaveLength(2); + expect(turns[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "new" }], + }); + expect(turns[1]).toMatchObject({ + role: "assistant", + content: [{ type: "text", text: "new reply" }], + }); + }); + + it("drops an in-progress assistant turn at a clear marker", () => { + const turns = rebuildConversation([ + entry("user_message", { content: { type: "text", text: "old" } }), + entry("agent_message_chunk", { + content: { type: "text", text: "partial" }, + }), + createNotification("_posthog/conversation_cleared", { + sessionId: "sdk-new", + }), + ]); + + expect(turns).toEqual([]); + }); + it("produces a single user turn from user_message", () => { const turns = rebuildConversation([ entry("user_message", { diff --git a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 6f9d5f492099..a9fb6f0268f9 100644 --- a/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/products/desktop/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -3,6 +3,7 @@ import * as fs from "node:fs/promises"; import * as os from "node:os"; import * as path from "node:path"; import type { ContentBlock } from "@agentclientprotocol/sdk"; +import { isNotification, POSTHOG_NOTIFICATIONS } from "../../../acp-extensions"; import { DEFAULT_GATEWAY_MODEL } from "../../../gateway-models"; import type { PostHogAPIClient } from "../../../posthog-api"; import type { StoredEntry } from "../../../types"; @@ -115,7 +116,7 @@ export function getSessionJsonlPath(sessionId: string, cwd: string): string { export function rebuildConversation( entries: StoredEntry[], ): ConversationTurn[] { - const turns: ConversationTurn[] = []; + let turns: ConversationTurn[] = []; let currentAssistantContent: ContentBlock[] = []; let currentToolCalls: ToolCallInfo[] = []; @@ -123,6 +124,15 @@ export function rebuildConversation( const method = entry.notification?.method; const params = entry.notification?.params as Record; + // /clear starts an empty conversation: everything before the marker is + // gone from the model's context and must not be rehydrated. + if (isNotification(method, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED)) { + turns = []; + currentAssistantContent = []; + currentToolCalls = []; + continue; + } + if (method === "session/update" && params?.update) { const update = params.update as SessionUpdate; diff --git a/products/desktop/packages/agent/src/adapters/claude/types.ts b/products/desktop/packages/agent/src/adapters/claude/types.ts index 9e9fae49d742..197126c4caf4 100644 --- a/products/desktop/packages/agent/src/adapters/claude/types.ts +++ b/products/desktop/packages/agent/src/adapters/claude/types.ts @@ -56,6 +56,9 @@ export type Turn = { export type Session = BaseSession & { query: Query; + /** Id of the underlying SDK session. Equal to the ACP session id until a + * /clear swaps in a fresh SDK session; resume/refresh must target this id. */ + sdkSessionId: string; /** The Options object passed to query() — mutating it affects subsequent prompts */ queryOptions: Options; /** Rebuilds the in-process ("sdk") signed-commit server with a fresh instance @@ -107,6 +110,10 @@ export type Session = BaseSession & { queryGeneration: number; /** The query iterator ended and can't be revived; new prompts reject. */ queryClosed?: boolean; + /** Set while a /clear is swapping the SDK query; resolves when it settles + * (success or failure). Prompts await it, cancel/refresh refuse during it, + * and a second /clear is rejected — the swap must never be raced. */ + clearing?: Promise; cancelController?: AbortController; forceCancelTimer?: ReturnType; emitRawSDKMessages: boolean | SDKMessageFilter[]; diff --git a/products/desktop/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/products/desktop/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index b9fefdbc1f71..996cd6d6a0b9 100644 --- a/products/desktop/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/products/desktop/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -54,6 +54,7 @@ import { } from "../claude/context-breakdown"; import { isLocalSkillCommandChunk } from "../local-skill"; import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; +import { visiblePromptBlocks } from "../prompt-blocks"; import { resolveSpokenNarration } from "../session-meta"; import { AppServerClient, @@ -155,17 +156,6 @@ const GOAL_COMMAND = { input: { hint: "[|clear|pause|resume]" }, }; -function isHiddenPromptBlock(block: PromptRequest["prompt"][number]): boolean { - const meta = block._meta as { ui?: { hidden?: boolean } } | undefined; - return meta?.ui?.hidden === true; -} - -function visiblePromptBlocks( - prompt: PromptRequest["prompt"], -): PromptRequest["prompt"] { - return prompt.filter((block) => !isHiddenPromptBlock(block)); -} - function parseGoalCommand(prompt: PromptRequest["prompt"]): GoalCommand | null { const visible = visiblePromptBlocks(prompt); if (visible.some((block) => block.type !== "text")) return null; diff --git a/products/desktop/packages/agent/src/adapters/prompt-blocks.ts b/products/desktop/packages/agent/src/adapters/prompt-blocks.ts new file mode 100644 index 000000000000..f511ebfe93ce --- /dev/null +++ b/products/desktop/packages/agent/src/adapters/prompt-blocks.ts @@ -0,0 +1,21 @@ +import type { PromptRequest } from "@agentclientprotocol/sdk"; + +/** + * True when a prompt block was injected by the host rather than typed by the + * user — a cloud resume preamble, a shell-execute recap. Hosts mark their own + * injections with `_meta.ui.hidden` so the parts that reason about what the user + * actually said (slash-command detection, transcript echoes) can skip them. + */ +export function isHiddenPromptBlock( + block: PromptRequest["prompt"][number], +): boolean { + const meta = block._meta as { ui?: { hidden?: boolean } } | undefined; + return meta?.ui?.hidden === true; +} + +/** The prompt with host-injected blocks dropped: what the user actually sent. */ +export function visiblePromptBlocks( + prompt: PromptRequest["prompt"], +): PromptRequest["prompt"] { + return prompt.filter((block) => !isHiddenPromptBlock(block)); +} diff --git a/products/desktop/packages/agent/src/sagas/resume-saga.test.ts b/products/desktop/packages/agent/src/sagas/resume-saga.test.ts index a3919c6de729..9d6bc24bb2b8 100644 --- a/products/desktop/packages/agent/src/sagas/resume-saga.test.ts +++ b/products/desktop/packages/agent/src/sagas/resume-saga.test.ts @@ -112,6 +112,41 @@ describe("ResumeSaga", () => { expect(result.data.conversation[3].role).toBe("assistant"); }); + it("drops turns before a conversation_cleared marker (/clear boundary)", async () => { + (mockApiClient.getTaskRun as ReturnType).mockResolvedValue( + createTaskRun(), + ); + ( + mockApiClient.fetchTaskRunLogs as ReturnType + ).mockResolvedValue([ + createUserMessage("Old prompt"), + createAgentChunk("Old reply"), + createNotification(POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, { + sessionId: "session-cleared", + }), + createUserMessage("New prompt"), + createAgentChunk("New reply"), + ]); + + const saga = new ResumeSaga(mockLogger); + const result = await saga.run({ + taskId: "task-1", + runId: "run-1", + repositoryPath: repo.path, + apiClient: mockApiClient, + }); + + expect(result.success).toBe(true); + if (!result.success) return; + + expect(result.data.conversation).toHaveLength(2); + expect(result.data.conversation[0]).toMatchObject({ + role: "user", + content: [{ type: "text", text: "New prompt" }], + }); + expect(result.data.conversation[1].role).toBe("assistant"); + }); + it("merges consecutive text chunks", async () => { (mockApiClient.getTaskRun as ReturnType).mockResolvedValue( createTaskRun(), @@ -595,6 +630,39 @@ describe("ResumeSaga", () => { entries: () => [createUserMessage("Hello"), createAgentChunk("Hi")], expected: null, }, + { + name: "a conversation_cleared marker supersedes an earlier run_started", + entries: () => [ + runStarted("session-old"), + createUserMessage("Hello"), + createNotification(POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, { + sessionId: "session-cleared", + }), + ], + expected: "session-cleared", + }, + { + // The backend writes this marker for a /clear on a finished run, where there + // is no sandbox and so no session to name. Scanning past it would resume the + // conversation the marker retired — on a warm sandbox, natively. + name: "a conversation_cleared marker naming no session resumes nothing", + entries: () => [ + runStarted("session-old"), + createUserMessage("Hello"), + createNotification(POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, {}), + ], + expected: null, + }, + { + name: "a run_started after a clear wins (later run restarted)", + entries: () => [ + createNotification(`_${POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED}`, { + sessionId: "session-cleared", + }), + runStarted("session-restarted"), + ], + expected: "session-restarted", + }, ])("$name", async ({ entries, expected }) => { (mockApiClient.getTaskRun as ReturnType).mockResolvedValue( createTaskRun(), diff --git a/products/desktop/packages/agent/src/sagas/resume-saga.ts b/products/desktop/packages/agent/src/sagas/resume-saga.ts index 06aad4c4a60a..d6491a2f0654 100644 --- a/products/desktop/packages/agent/src/sagas/resume-saga.ts +++ b/products/desktop/packages/agent/src/sagas/resume-saga.ts @@ -1,6 +1,10 @@ import type { ContentBlock } from "@agentclientprotocol/sdk"; import { Saga } from "@posthog/shared"; -import { type NativeGoalState, POSTHOG_NOTIFICATIONS } from "../acp-extensions"; +import { + isNotification, + type NativeGoalState, + POSTHOG_NOTIFICATIONS, +} from "../acp-extensions"; import type { PostHogAPIClient } from "../posthog-api"; import type { DeviceInfo, @@ -167,16 +171,25 @@ export class ResumeSaga extends Saga { } private findSessionId(entries: StoredNotification[]): string | null { - const runStarted = POSTHOG_NOTIFICATIONS.RUN_STARTED; + // RUN_STARTED carries the session id the run booted with; a later + // CONVERSATION_CLEARED (/clear) supersedes it with the fresh SDK session + // id it swapped in. Latest entry of either kind wins outright — including + // when it names no session: a /clear recorded without a sandbox (the + // backend writes the marker straight to the log for a finished run) has no + // session to continue, and scanning past it would find an earlier + // RUN_STARTED and resume the very conversation the marker retired. for (let i = entries.length - 1; i >= 0; i--) { const method = entries[i].notification?.method; - if (method === runStarted || method === `_${runStarted}`) { + if ( + isNotification(method, POSTHOG_NOTIFICATIONS.RUN_STARTED) || + isNotification(method, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED) + ) { const params = entries[i].notification?.params as | { sessionId?: string } | undefined; - if (typeof params?.sessionId === "string" && params.sessionId) { - return params.sessionId; - } + return typeof params?.sessionId === "string" && params.sessionId + ? params.sessionId + : null; } } return null; @@ -225,7 +238,7 @@ export class ResumeSaga extends Saga { private rebuildConversation( entries: StoredNotification[], ): ConversationTurn[] { - const turns: ConversationTurn[] = []; + let turns: ConversationTurn[] = []; let currentAssistantContent: ContentBlock[] = []; let currentToolCalls: ToolCallInfo[] = []; @@ -233,6 +246,15 @@ export class ResumeSaga extends Saga { const method = entry.notification?.method; const params = entry.notification?.params as Record; + // /clear starts an empty conversation: everything before the marker is + // gone from the model's context and must not be rehydrated. + if (isNotification(method, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED)) { + turns = []; + currentAssistantContent = []; + currentToolCalls = []; + continue; + } + if (method === "session/update" && params?.update) { const update = params.update as Record; const sessionUpdate = update.sessionUpdate as string; diff --git a/products/desktop/packages/agent/src/server/agent-server.ts b/products/desktop/packages/agent/src/server/agent-server.ts index 445266009908..64667b2c9881 100644 --- a/products/desktop/packages/agent/src/server/agent-server.ts +++ b/products/desktop/packages/agent/src/server/agent-server.ts @@ -318,6 +318,19 @@ function isManualCompactPrompt(prompt: ContentBlock[]): boolean { return /^\/compact(?:\s|$)/.test(promptBlocksToText(prompt).trimStart()); } +/** True when the agent implements `/clear` and honours the conversation-cleared boundary. */ +function extractConversationClearCapability(result: unknown): boolean { + return ( + ( + result as { + agentCapabilities?: { + _meta?: { posthog?: { conversationClear?: unknown } }; + }; + } + )?.agentCapabilities?._meta?.posthog?.conversationClear === true + ); +} + function extractSteeringCapability(result: unknown): string | undefined { const steering = ( result as { @@ -1726,6 +1739,8 @@ export class AgentServer { clientCapabilities: {}, }); const steering = extractSteeringCapability(initializeResult); + const conversationClear = + extractConversationClearCapability(initializeResult); const runState = preTaskRun?.state as Record | undefined; // Preserve native Codex modes for cloud runs so they behave the same as @@ -1924,6 +1939,8 @@ export class AgentServer { taskId: payload.task_id, agentVersion: this.config.version ?? packageJson.version, ...(steering ? { steering } : {}), + // Absent on older agents, which is exactly what the host gates on. + ...(conversationClear ? { conversationClear } : {}), }, }; this.broadcastEvent({ diff --git a/products/desktop/packages/core/src/sessions/acpNotifications.ts b/products/desktop/packages/core/src/sessions/acpNotifications.ts index 242e69ad1482..80642db79b7d 100644 --- a/products/desktop/packages/core/src/sessions/acpNotifications.ts +++ b/products/desktop/packages/core/src/sessions/acpNotifications.ts @@ -17,6 +17,7 @@ export const POSTHOG_NOTIFICATIONS = { PROGRESS: "_posthog/progress", TASK_NOTIFICATION: "_posthog/task_notification", COMPACT_BOUNDARY: "_posthog/compact_boundary", + CONVERSATION_CLEARED: "_posthog/conversation_cleared", USAGE_UPDATE: "_posthog/usage_update", PERMISSION_RESPONSE: "_posthog/permission_response", PERMISSION_REQUEST: "_posthog/permission_request", diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index cf59a5d3196e..c5318d7625fc 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -3002,7 +3002,11 @@ export class SessionService { const session = this.d.store.getSessions()[taskRunId]; const params = ( msg as { - params?: { agentVersion?: unknown; steering?: unknown }; + params?: { + agentVersion?: unknown; + steering?: unknown; + conversationClear?: unknown; + }; } ).params; const agentVersion = @@ -3019,6 +3023,12 @@ export class SessionService { ) { updates.steering = params.steering; } + if ( + params?.conversationClear === true && + session?.conversationClear !== true + ) { + updates.conversationClear = true; + } if (session?.isCloud && session.status !== "connected") { updates.status = "connected"; } diff --git a/products/desktop/packages/shared/src/sessions.ts b/products/desktop/packages/shared/src/sessions.ts index c682edb6948c..dbf6449db6bd 100644 --- a/products/desktop/packages/shared/src/sessions.ts +++ b/products/desktop/packages/shared/src/sessions.ts @@ -82,6 +82,12 @@ export interface AgentSession { * means the host must cancel + resend. Drives the steer-vs-resend decision. */ steering?: string; + /** + * Adapter's `/clear` capability (`_meta.posthog.conversationClear`, relayed on + * `_posthog/run_started`). Absent on agents that predate it — the host must not + * record a conversation-cleared boundary they would ignore on resume. + */ + conversationClear?: boolean; pendingPermissions: Map; pausedDurationMs: number; messageQueue: QueuedMessage[]; diff --git a/products/desktop/packages/ui/src/features/sessions/components/ConversationView.tsx b/products/desktop/packages/ui/src/features/sessions/components/ConversationView.tsx index e935e8ef9768..566d4374f5f0 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/ConversationView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/ConversationView.tsx @@ -147,6 +147,7 @@ export function ConversationView({ items: conversationItems, lastTurnInfo, isCompacting, + isClearing, completedToolCallCount, } = useConversationItems(events, isPromptPending, { showDebugLogs, @@ -491,6 +492,7 @@ export function ConversationView({ hasPendingPermission={pendingPermissionsCount > 0} pausedDurationMs={pausedDurationMs} isCompacting={isCompacting} + isClearing={isClearing} completedToolCallCount={completedToolCallCount} /> diff --git a/products/desktop/packages/ui/src/features/sessions/components/SessionFooter.tsx b/products/desktop/packages/ui/src/features/sessions/components/SessionFooter.tsx index 788da9abe0f4..8e0e72b58e19 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/SessionFooter.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/SessionFooter.tsx @@ -19,6 +19,9 @@ interface SessionFooterProps { hasPendingPermission?: boolean; pausedDurationMs?: number; isCompacting?: boolean; + /** A /clear is in flight; its dedicated "Clearing…" row replaces the + * generic generating indicator, same as compaction. */ + isClearing?: boolean; /** Number of tool calls finished so far; the generating indicator advances * its status word each time this changes. */ completedToolCallCount?: number; @@ -34,6 +37,7 @@ export function SessionFooter({ hasPendingPermission = false, pausedDurationMs, isCompacting = false, + isClearing = false, completedToolCallCount, }: SessionFooterProps) { const rightSide = ( @@ -44,7 +48,7 @@ export function SessionFooter({ {task && } ); - if (isPromptPending && !isCompacting) { + if (isPromptPending && !isCompacting && !isClearing) { if (hasPendingPermission) { return ( diff --git a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 69fb1d2f0b10..bbe73397b713 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -302,6 +302,104 @@ describe("buildConversationItems", () => { expect(result.isCompacting).toBe(false); }); + it("clears the clearing spinner on a successful completion status, without duplicating the row", () => { + // A successful /clear sends a terminal `status: clearing, isComplete: + // true`. It must flip the existing status row, not append a second one. + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "/clear"), + statusMsg(2, "clearing"), + statusMsg(3, "clearing", true), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + expect(statusItems).toHaveLength(1); + expect((statusItems[0].update as { isComplete?: boolean }).isComplete).toBe( + true, + ); + // The completion resets the flag that suppressed the generic + // "Generating…" footer while the clear ran. + expect(result.isClearing).toBe(false); + expect(result.isCompacting).toBe(false); + }); + + it("flags isClearing while a clear is in flight so the generic generating footer is suppressed", () => { + // The /clear prompt RPC keeps isPromptPending true for the entire swap, + // so the footer needs this flag to avoid rendering "Generating…" next to + // the dedicated "Clearing…" row (mirrors isCompacting). + const result = buildConversationItems( + [userPromptMsg(1, 1, "/clear"), statusMsg(2, "clearing")], + true, + ); + expect(result.isClearing).toBe(true); + expect(result.isCompacting).toBe(false); + }); + + it("renders a timed-out clear as a clearing_failed status row and clears the spinner", () => { + // A timed-out clear emits no conversation_cleared marker, so the adapter + // sends a structured `clearing_failed` status: it clears the spinner (the + // original clearing row goes complete) and adds the outcome row. + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "/clear"), + statusMsg(2, "clearing"), + statusMsg(3, "clearing_failed", undefined, "Timed out after 30000ms."), + ], + null, + ); + + const statusItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && i.update.sessionUpdate === "status", + ); + // Spinner row (now complete) + the failure row. + expect(statusItems.map((i) => i.update)).toEqual([ + { + sessionUpdate: "status", + status: "clearing", + isComplete: true, + startedAt: 2, + }, + { + sessionUpdate: "status", + status: "clearing_failed", + error: "Timed out after 30000ms.", + }, + ]); + // The failure also releases the generating-footer suppression. + expect(result.isClearing).toBe(false); + }); + + it("renders a conversation_cleared divider after a /clear", () => { + const result = buildConversationItems( + [ + userPromptMsg(1, 1, "/clear"), + { + type: "acp_message", + ts: 2, + message: { + jsonrpc: "2.0", + method: "_posthog/conversation_cleared", + params: { sessionId: "sdk-new" }, + }, + }, + ], + null, + ); + + const clearedItems = result.items.filter( + (i): i is Extract => + i.type === "session_update" && + i.update.sessionUpdate === "conversation_cleared", + ); + expect(clearedItems).toHaveLength(1); + }); + it("renders a terminal refusal as a status row carrying the explanation", () => { const result = buildConversationItems( [ diff --git a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts index 44042029b485..fae0e2a4b5fc 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -79,6 +79,9 @@ export interface BuildResult { items: ConversationItem[]; lastTurnInfo: LastTurnInfo | null; isCompacting: boolean; + /** A `/clear` is in flight (its status row shows the dedicated spinner), so + * the generic "Generating…" footer must stay hidden — same as compaction. */ + isClearing: boolean; /** Number of tool calls settled into a terminal status so far. Monotonic * within a thread; consumers treat a change as "a tool/MCP call finished". */ completedToolCallCount: number; @@ -122,6 +125,7 @@ export interface ItemBuilder { pendingPrompts: Map; shellExecutes: Map; isCompacting: boolean; + isClearing: boolean; nextId: () => number; /** Progress cards keyed by the backend-supplied `group` id. The first event * for a group opens the card inline where it arrived; every subsequent @@ -152,6 +156,7 @@ export function createItemBuilder(): ItemBuilder { pendingPrompts: new Map(), shellExecutes: new Map(), isCompacting: false, + isClearing: false, nextId: () => idCounter++, progressCards: new Map(), lowestTouchedProgressIndex: Number.POSITIVE_INFINITY, @@ -253,6 +258,7 @@ export function buildConversationItems( items: b.items, lastTurnInfo, isCompacting: b.isCompacting, + isClearing: b.isClearing, completedToolCallCount: b.completedToolCallCount, }; } @@ -309,6 +315,7 @@ export function buildAgentConversationItems( items: b.items, lastTurnInfo: readLastTurnInfo(b), isCompacting: b.isCompacting, + isClearing: b.isClearing, completedToolCallCount: b.completedToolCallCount, }; } @@ -701,6 +708,12 @@ function handleNotification( return; } + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED)) { + ensureImplicitTurn(b, ts); + pushItem(b, { sessionUpdate: "conversation_cleared" }); + return; + } + if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.STATUS)) { ensureImplicitTurn(b, ts); const params = msg.params as { @@ -762,6 +775,26 @@ function handleRuntimeStatus( } else if (status.status === "retrying" && status.isComplete) { markRuntimeStatusComplete(b, "retrying"); return; + } else if (status.status === "clearing") { + if (status.isComplete) { + markRuntimeStatusComplete(b, "clearing"); + return; + } + // The /clear prompt RPC keeps isPromptPending true for the whole swap, + // so without this flag the generic "Generating…" footer would render + // alongside the dedicated "Clearing…" row (compaction has the same + // gate via isCompacting). + b.isClearing = true; + } else if (status.status === "clearing_failed") { + // A timed-out clear emits no `conversation_cleared` marker, so clear + // the spinner and render the outcome as its own status row. + markRuntimeStatusComplete(b, "clearing"); + pushItem(b, { + sessionUpdate: "status", + status: "clearing_failed", + error: status.error, + }); + return; } pushItem(b, { @@ -881,6 +914,9 @@ function markRuntimeStatusComplete(b: ItemBuilder, status: string) { if (status === "compacting") { b.isCompacting = false; } + if (status === "clearing") { + b.isClearing = false; + } for (let i = b.items.length - 1; i >= 0; i--) { const item = b.items[i]; if ( diff --git a/products/desktop/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx b/products/desktop/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx index 009798ad8d98..0ad46cbc1704 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/chat-thread/ChatThreadFooter.tsx @@ -44,6 +44,7 @@ export function ChatThreadFooter({ footerState?.lastTurnInfo ?? eventFooterState.lastTurnInfo; const isCompacting = footerState?.isCompacting ?? eventFooterState.isCompacting; + const isClearing = footerState?.isClearing ?? eventFooterState.isClearing; const completedToolCallCount = footerState?.completedToolCallCount ?? eventFooterState.completedToolCallCount; @@ -68,6 +69,7 @@ export function ChatThreadFooter({ hasPendingPermission={pendingPermissions.size > 0} pausedDurationMs={pausedDurationMs} isCompacting={isCompacting} + isClearing={isClearing} completedToolCallCount={completedToolCallCount} /> diff --git a/products/desktop/packages/ui/src/features/sessions/components/incrementalConversationItems.ts b/products/desktop/packages/ui/src/features/sessions/components/incrementalConversationItems.ts index b4bb784120a8..8d4631fed34f 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/incrementalConversationItems.ts +++ b/products/desktop/packages/ui/src/features/sessions/components/incrementalConversationItems.ts @@ -82,6 +82,7 @@ export function createIncrementalConversationBuilder() { items: builder.items, lastTurnInfo: readLastTurnInfo(builder), isCompacting: builder.isCompacting, + isClearing: builder.isClearing, completedToolCallCount: builder.completedToolCallCount, }; // A finalized builder can't be safely continued; the next streaming @@ -147,6 +148,7 @@ export function createIncrementalConversationBuilder() { items: assembleItems(builder, activeStart), lastTurnInfo: readLastTurnInfoForOutput(builder), isCompacting: builder.isCompacting, + isClearing: builder.isClearing, completedToolCallCount: builder.completedToolCallCount, }; } diff --git a/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx b/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx new file mode 100644 index 000000000000..569660f4478b --- /dev/null +++ b/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx @@ -0,0 +1,31 @@ +import { Eraser } from "@phosphor-icons/react"; +import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; +import { Box, Flex, Text } from "@radix-ui/themes"; +import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; + +// New thread renders the boundary as a centered separator marker; the legacy +// thread keeps a bordered row so ConversationView is unchanged when the chat +// thread is off (mirrors CompactBoundaryView). +export function ConversationClearedView() { + const chatChrome = useChatThreadChrome(); + + if (chatChrome) { + return ( + + Conversation cleared + + ); + } + + return ( + + + + Conversation cleared + + (earlier messages are no longer in the agent's context) + + + + ); +} diff --git a/products/desktop/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/products/desktop/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index 43fbe742b636..efe24ada31f3 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx @@ -1,6 +1,7 @@ import { AgentMessage } from "@posthog/ui/features/sessions/components/session-update/AgentMessage"; import { CompactBoundaryView } from "@posthog/ui/features/sessions/components/session-update/CompactBoundaryView"; import { ConsoleMessage } from "@posthog/ui/features/sessions/components/session-update/ConsoleMessage"; +import { ConversationClearedView } from "@posthog/ui/features/sessions/components/session-update/ConversationClearedView"; import { ErrorNotificationView } from "@posthog/ui/features/sessions/components/session-update/ErrorNotificationView"; import { ProgressGroupView } from "@posthog/ui/features/sessions/components/session-update/ProgressGroupView"; import { StatusNotificationView } from "@posthog/ui/features/sessions/components/session-update/StatusNotificationView"; @@ -25,6 +26,9 @@ export type RenderItem = timestamp?: string; } | CompactBoundaryUpdate + | { + sessionUpdate: "conversation_cleared"; + } | { sessionUpdate: "status"; status: string; @@ -127,6 +131,8 @@ export const SessionUpdateView = memo(function SessionUpdateView({ contextSize={item.contextSize} /> ); + case "conversation_cleared": + return ; case "status": return ( + {message} + + ); + } + return ( + + + + {message} + + + ); + } + + if (status === "clearing") { + if (isComplete) { + return null; + } + return ( + + ); + } + // Generic status display for other statuses return ( @@ -222,20 +257,27 @@ function RetryingStatusView({ } /** - * In-flight compaction row. Compaction is a single streaming summarization call - * with no measurable percentage, so we pair the spinner with an indeterminate - * progress bar (constant motion, so it never reads as frozen) and a live - * elapsed-time counter, which is the one honest progress signal we have. + * In-flight row for a single streaming operation with no measurable + * percentage (compaction, `/clear`), so we pair the spinner with an + * indeterminate progress bar (constant motion, so it never reads as frozen) + * and a live elapsed-time counter, which is the one honest progress signal + * we have. */ -function CompactingStatusView({ startedAt }: { startedAt?: number }) { +function CompactingStatusView({ + startedAt, + label = "Compacting conversation history...", +}: { + startedAt?: number; + label?: string; +}) { const [elapsed, setElapsed] = useState(() => startedAt ? Date.now() - startedAt : 0, ); useEffect(() => { - // Anchor to the persisted compaction start time so remounting this row - // (e.g. scrolling it out of and back into the virtualized list while - // compaction runs) keeps counting from when compaction began rather than + // Anchor to the persisted start time so remounting this row (e.g. + // scrolling it out of and back into the virtualized list while the + // operation runs) keeps counting from when it began rather than // resetting to zero. Fall back to mount time only if it's missing. const start = startedAt ?? Date.now(); const tick = () => setElapsed(Date.now() - start); @@ -248,9 +290,7 @@ function CompactingStatusView({ startedAt }: { startedAt?: number }) { - - Compacting conversation history... - + {label} {formatDuration(elapsed, 1)} From e44103f9c29e1c30208acb9f8372fee890875625 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 3 Aug 2026 13:53:26 -0700 Subject: [PATCH 2/9] fix(agent): carry the live permission mode into a rebuilt query applySessionMode updated the running query and session.permissionMode but left session.queryOptions.permissionMode at the value the session was created with. Both query rebuilds (/clear, refreshSession) seed the replacement from queryOptions, so a session that started in bypassPermissions and was later narrowed silently returned to bypassPermissions after a clear, with nothing on screen to say the mode had moved. Sync the mode into queryOptions when it changes, matching how effort and the 1M-context beta already do it, so every rebuild inherits the live value rather than each one re-rooting its own fields. Reported by veria-ai on #76457. --- .../claude/claude-agent.clear.test.ts | 22 +++++++++++++++++++ .../agent/src/adapters/claude/claude-agent.ts | 9 ++++++++ 2 files changed, 31 insertions(+) diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts index 1e908c3943bc..1a26a007af04 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts @@ -14,6 +14,7 @@ type InitResult = { type SdkQueryHandle = { interrupt: ReturnType; setModel: ReturnType; + setPermissionMode: ReturnType; setMcpServers: ReturnType; mcpServerStatus: ReturnType; supportedCommands: ReturnType; @@ -32,6 +33,7 @@ function makeQueryHandle(): SdkQueryHandle { return { interrupt: vi.fn().mockResolvedValue(undefined), setModel: vi.fn().mockResolvedValue(undefined), + setPermissionMode: vi.fn().mockResolvedValue(undefined), setMcpServers: vi.fn().mockResolvedValue(undefined), mcpServerStatus: vi.fn().mockResolvedValue([]), supportedCommands: vi.fn().mockResolvedValue([]), @@ -104,6 +106,7 @@ function installFakeSession(agent: Agent, sessionId: string) { queryOptions: { sessionId, cwd: "/tmp/repo", + permissionMode: "bypassPermissions", model: "claude-sonnet-4-6", mcpServers: { posthog: { type: "http", url: "https://posthog" }, @@ -296,6 +299,25 @@ describe("ClaudeAcpAgent /clear", () => { ).toBeDefined(); }); + it("carries the live permission mode into the fresh session, not the creation-time one", async () => { + // A mode change updates the running query; queryOptions keeps the mode the session + // was created with. Rebuilding from it silently hands back permissions the user had + // since narrowed, and nothing on screen says the mode moved. + const { agent } = makeAgent(); + const { session } = installFakeSession(agent, "s-mode"); + await ( + agent as unknown as { applySessionMode: (m: string) => Promise } + ).applySessionMode("default"); + + await agent.prompt({ + sessionId: "s-mode", + prompt: [{ type: "text", text: "/clear" }], + }); + + expect(lastQueryCall.options?.permissionMode).toBe("default"); + expect(session.queryOptions.permissionMode).toBe("default"); + }); + it("deletes the stale local jsonl for the stable ACP id after a successful clear", async () => { // A cold reconnect hydrates by the stable ACP id (clients never learn the // internal SDK id). If the SDK's original file under that id survived a diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index af0c2d5e262d..cec56a6823c6 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -2154,6 +2154,13 @@ export class ClaudeAcpAgent extends BaseAcpAgent { } const previousMode = this.session.permissionMode; this.session.permissionMode = modeId as CodeExecutionMode; + // queryOptions seeds every later query rebuild (/clear, refreshSession), so the + // mode has to land there too. Left stale, a rebuild restores the creation-time + // mode — handing back permissions the user had since narrowed, with nothing on + // screen to say so. + this.session.queryOptions.permissionMode = toSdkPermissionMode( + modeId as CodeExecutionMode, + ); if (modeId === "plan" && previousMode !== "plan") { this.session.modeBeforePlan = previousMode; } @@ -2163,6 +2170,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ); } catch (error) { this.session.permissionMode = previousMode; + this.session.queryOptions.permissionMode = + toSdkPermissionMode(previousMode); if (error instanceof Error) { if (!error.message) { error.message = "Invalid Mode"; From c1c0f722e88d1f7438ff4414ea245e6b4da015cd Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 3 Aug 2026 13:57:45 -0700 Subject: [PATCH 3/9] fix(agent): fail /clear when the stale session file survives finishClear unlinks the jsonl the SDK wrote under the stable ACP id, so a cold reconnect re-fetches the authoritative log instead of finding that file and resuming the pre-clear conversation. A non-ENOENT failure was logged and the clear still reported success, which is the one case where the file survives: the next reconnect quietly restored the context the user asked to drop. Treat it as a failed clear instead. performClear already terminates the unproven query, closes the session, and resolves the spinner with clearing_failed, so the user sees the outcome rather than a clear that unwinds itself later. Reported by greptile on #76457. --- .../claude/claude-agent.clear.test.ts | 24 +++++++++++++------ .../agent/src/adapters/claude/claude-agent.ts | 9 +++---- 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts index 1a26a007af04..1794e3b3ef16 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts @@ -340,21 +340,31 @@ describe("ClaudeAcpAgent /clear", () => { unlinkSpy.mockRestore(); }); - it("still completes the clear if removing the stale jsonl fails for a reason other than a missing file", async () => { + it("fails the clear when the stale jsonl survives for a reason other than a missing file", async () => { + // The file outliving the clear means a cold reconnect hydrates by the stable ACP + // id, finds it, and restores the pre-clear conversation. Reporting success here + // would hand back the context the user asked to drop, silently. const unlinkSpy = vi .spyOn(fs.promises, "unlink") .mockRejectedValue( Object.assign(new Error("EACCES"), { code: "EACCES" }), ); - const { agent } = makeAgent(); + const { agent, client } = makeAgent(); installFakeSession(agent, "s-unlink-fails"); - const result = await agent.prompt({ - sessionId: "s-unlink-fails", - prompt: [{ type: "text", text: "/clear" }], - }); + await expect( + agent.prompt({ + sessionId: "s-unlink-fails", + prompt: [{ type: "text", text: "/clear" }], + }), + ).rejects.toThrow("EACCES"); - expect(result.stopReason).toBe("end_turn"); + expect( + findExtNotification(client, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED), + ).toBeUndefined(); + expect( + findAllExtNotifications(client, POSTHOG_NOTIFICATIONS.STATUS).at(-1), + ).toMatchObject({ status: "clearing_failed" }); unlinkSpy.mockRestore(); }); diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index cec56a6823c6..3ba42d3a8e21 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -1774,11 +1774,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { getSessionJsonlPath(this.sessionId, session.cwd), ); } catch (error) { + // Already gone is the common case (every clear after the first). Anything + // else means the file survives, and a cold reconnect that finds it resumes + // the pre-clear conversation — so the clear has to fail rather than report + // a success the next reconnect quietly undoes. if ((error as NodeJS.ErrnoException).code !== "ENOENT") { - this.logger.warn("Failed to remove stale session jsonl after /clear", { - sessionId: this.sessionId, - error: error instanceof Error ? error.message : String(error), - }); + throw error; } } From 93b4c867a586c0a7bd002bb0a138a464dce0702f Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Mon, 3 Aug 2026 14:29:49 -0700 Subject: [PATCH 4/9] fix(agent): sync the plan-mode path into a rebuilt query createOnModeChange updated session.permissionMode but not session.queryOptions.permissionMode, so a /clear while the agent sat in plan mode rebuilt the query under the creation-time mode while the UI still reported plan. Writing the mode at the mutation site keeps both rebuild paths (/clear and refreshSession) on the live mode. Claude-Session: https://claude.ai/code/session_019in9sZiQW9VRhMcg2q3369 --- .../claude/claude-agent.clear.test.ts | 55 +++++++++++++------ .../agent/src/adapters/claude/claude-agent.ts | 3 + 2 files changed, 41 insertions(+), 17 deletions(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts index 1794e3b3ef16..84361c7343d1 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.clear.test.ts @@ -299,24 +299,45 @@ describe("ClaudeAcpAgent /clear", () => { ).toBeDefined(); }); - it("carries the live permission mode into the fresh session, not the creation-time one", async () => { - // A mode change updates the running query; queryOptions keeps the mode the session - // was created with. Rebuilding from it silently hands back permissions the user had - // since narrowed, and nothing on screen says the mode moved. - const { agent } = makeAgent(); - const { session } = installFakeSession(agent, "s-mode"); - await ( - agent as unknown as { applySessionMode: (m: string) => Promise } - ).applySessionMode("default"); - - await agent.prompt({ - sessionId: "s-mode", - prompt: [{ type: "text", text: "/clear" }], - }); + // A mode change updates the running query; queryOptions keeps the mode the session + // was created with. Rebuilding from it silently hands back permissions the user had + // since narrowed, and nothing on screen says the mode moved. Both paths that move + // the mode have to keep queryOptions in step — setSessionMode and the plan-mode hook. + it.each([ + { + path: "applySessionMode", + mode: "default", + apply: (agent: Agent, mode: string) => + ( + agent as unknown as { applySessionMode: (m: string) => Promise } + ).applySessionMode(mode), + }, + { + path: "onModeChange", + mode: "plan", + apply: (agent: Agent, mode: string) => + ( + agent as unknown as { + createOnModeChange: () => (m: string) => Promise; + } + ).createOnModeChange()(mode), + }, + ])( + "carries the live permission mode into the fresh session via $path, not the creation-time one", + async ({ mode, apply }) => { + const { agent } = makeAgent(); + const { session } = installFakeSession(agent, "s-mode"); + await apply(agent, mode); + + await agent.prompt({ + sessionId: "s-mode", + prompt: [{ type: "text", text: "/clear" }], + }); - expect(lastQueryCall.options?.permissionMode).toBe("default"); - expect(session.queryOptions.permissionMode).toBe("default"); - }); + expect(lastQueryCall.options?.permissionMode).toBe(mode); + expect(session.queryOptions.permissionMode).toBe(mode); + }, + ); it("deletes the stale local jsonl for the stable ACP id after a successful clear", async () => { // A cold reconnect hydrates by the stable ACP id (clients never learn the diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index 3ba42d3a8e21..54a2320ea269 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -2709,6 +2709,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (this.session) { const previousMode = this.session.permissionMode; this.session.permissionMode = newMode; + // Same reason as applySessionMode: queryOptions seeds every later + // rebuild, and this path (the EnterPlanMode hook) moves the mode too. + this.session.queryOptions.permissionMode = toSdkPermissionMode(newMode); if (newMode === "plan" && previousMode !== "plan") { this.session.modeBeforePlan = previousMode; } From c25f007cfbd9103355564e2276f9c49a1701942f Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 5 Aug 2026 12:16:17 -0700 Subject: [PATCH 5/9] fix(core): stop conversationClear from ratcheting permanently true RUN_STARTED only ever set session.conversationClear to true and never reset it, so once a newer agent reported the capability, a later resume by an older agent kept the stale true value instead of reflecting that the connected agent doesn't support /clear's marker. Sync the field to the current run's reported value in both directions instead. Generated-By: PostHog Code Task-Id: 154fe1bc-5425-4660-9641-1169a3773d02 --- .../desktop/packages/core/src/sessions/sessionService.ts | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index c5318d7625fc..34818d7e8881 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -3023,11 +3023,9 @@ export class SessionService { ) { updates.steering = params.steering; } - if ( - params?.conversationClear === true && - session?.conversationClear !== true - ) { - updates.conversationClear = true; + const conversationClear = params?.conversationClear === true; + if (Boolean(session?.conversationClear) !== conversationClear) { + updates.conversationClear = conversationClear; } if (session?.isCloud && session.status !== "connected") { updates.status = "connected"; From 8d72056502a83e652625a0d713104ef759381935 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 5 Aug 2026 12:16:28 -0700 Subject: [PATCH 6/9] fix(agent): bound retireQuery's interrupt() and fix the /clear jsonl leak MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retireQuery awaited session.query.interrupt() with no timeout, unlike the bounded oldConsumer drain a few lines below — and cancel() now no-ops during a clear, so a wedged interrupt() had no recovery path. Bound it with the same withTimeout used for oldConsumer. finishClear always unlinked the jsonl keyed by the stable ACP session id, which is only the file the SDK wrote before a session's first /clear. From the second clear onward that unlink was a no-op and the jsonl the SDK actually just finished writing (keyed by the retired sdkSessionId) was orphaned on disk. Capture that id before it's overwritten and unlink it instead. Generated-By: PostHog Code Task-Id: 154fe1bc-5425-4660-9641-1169a3773d02 --- .../agent/src/adapters/claude/claude-agent.ts | 30 +++++++++++-------- 1 file changed, 18 insertions(+), 12 deletions(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index 54a2320ea269..11bcdf48527b 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -1476,7 +1476,8 @@ export class ClaudeAcpAgent extends BaseAcpAgent { if (session.clearing) { // A /clear is swapping the SDK query: there is no turn to cancel, and // interrupting the half-initialized replacement would corrupt the swap. - // A wedged clear self-limits via SESSION_VALIDATION_TIMEOUT_MS. + // A wedged clear self-limits: retireQuery's interrupt() and the new + // query's init are both time-bounded (see retireQuery, performClear). this.logger.debug("Ignoring cancel while a /clear is in progress", { sessionId: this.sessionId, }); @@ -1583,7 +1584,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // the old one doesn't poison it. session.abortController.abort(); try { - await session.query.interrupt(); + // Bounded so a wedged interrupt() can't block the swap indefinitely — + // the abort above should already unblock it; this is the backstop. + await withTimeout(session.query.interrupt(), 5_000); } catch (error) { this.logger.debug("Ignoring interrupt error while retiring query", { sessionId: this.sessionId, @@ -1762,22 +1765,25 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // Future resumes (refreshSession, desktop reconnect, cloud rehydration) // must target the fresh SDK session. `this.sessionId` (the ACP-visible // id) stays stable — clients keep addressing the session with it. + const previousSdkSessionId = session.sdkSessionId; session.sdkSessionId = newSdkSessionId; - // Invalidate the local jsonl the SDK wrote under the stable ACP id - // (non-empty only before a session's first /clear — later clears never - // write there again). Left in place, a cold reconnect that hydrates by - // this id would find it, skip re-fetching the authoritative log, and - // resume the pre-clear conversation instead of the cleared one. + // Invalidate the jsonl the SDK just finished writing for the retired + // session — the stable ACP id on a session's first /clear, or that + // clear's own sdkSessionId on every clear after. Left in place, a cold + // reconnect that hydrates by that id would find it, skip re-fetching the + // authoritative log, and resume the pre-clear conversation instead of the + // cleared one; left on disk, it also orphans one file per clear. try { await fs.promises.unlink( - getSessionJsonlPath(this.sessionId, session.cwd), + getSessionJsonlPath(previousSdkSessionId, session.cwd), ); } catch (error) { - // Already gone is the common case (every clear after the first). Anything - // else means the file survives, and a cold reconnect that finds it resumes - // the pre-clear conversation — so the clear has to fail rather than report - // a success the next reconnect quietly undoes. + // Already gone is the common case (a resumed session that never wrote a + // local jsonl this run). Anything else means the file survives, and a + // cold reconnect that finds it resumes the pre-clear conversation — so + // the clear has to fail rather than report a success the next + // reconnect quietly undoes. if ((error as NodeJS.ErrnoException).code !== "ENOENT") { throw error; } From 9476608b13d6d537b131eace1715dc5d5fdb69c7 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 5 Aug 2026 12:16:39 -0700 Subject: [PATCH 7/9] fix(ui): complete the clearing status from the boundary handler MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conversation_cleared handler never called markRuntimeStatusComplete("clearing"), unlike the analogous compact_boundary handler a few lines above. If the paired "clearing" status-complete event were ever dropped or reordered, the "Clearing…" spinner would stay stuck forever in the replayed transcript. Mirror the compaction pattern so the boundary marker is authoritative on its own. Generated-By: PostHog Code Task-Id: 154fe1bc-5425-4660-9641-1169a3773d02 --- .../src/features/sessions/components/buildConversationItems.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts index fae0e2a4b5fc..25519120cfe8 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/products/desktop/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -710,6 +710,7 @@ function handleNotification( if (isNotification(msg.method, POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED)) { ensureImplicitTurn(b, ts); + markRuntimeStatusComplete(b, "clearing"); pushItem(b, { sessionUpdate: "conversation_cleared" }); return; } From d7b56229f2f1d6e7cefc882f90d9a46dfb97ff9a Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 5 Aug 2026 12:16:49 -0700 Subject: [PATCH 8/9] fix(ui): drop the radix import from ConversationClearedView @radix-ui/themes is banned for any file, with no exception for mirroring an existing legacy file. Replace Box/Flex with a plain div and Tailwind, and Text with the quill primitive. Generated-By: PostHog Code Task-Id: 154fe1bc-5425-4660-9641-1169a3773d02 --- .../ConversationClearedView.tsx | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx b/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx index 569660f4478b..95667fcd18a1 100644 --- a/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx +++ b/products/desktop/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx @@ -1,6 +1,5 @@ import { Eraser } from "@phosphor-icons/react"; -import { ChatMarker, ChatMarkerContent } from "@posthog/quill"; -import { Box, Flex, Text } from "@radix-ui/themes"; +import { ChatMarker, ChatMarkerContent, Text } from "@posthog/quill"; import { useChatThreadChrome } from "../chat-thread/chatThreadChrome"; // New thread renders the boundary as a centered separator marker; the legacy @@ -18,14 +17,12 @@ export function ConversationClearedView() { } return ( - - - - Conversation cleared - - (earlier messages are no longer in the agent's context) - - - +
+ + Conversation cleared + + (earlier messages are no longer in the agent's context) + +
); } From 5ae51a71c18a8ac368a9b5687c16bd4b1c6f3275 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Thu, 13 Aug 2026 11:28:02 -0700 Subject: [PATCH 9/9] chore(desktop): fix claude-agent formatting Claude-Session: https://claude.ai/code/session_014UoYbR3MTpEp5pKmgiYBLo --- .../desktop/packages/agent/src/adapters/claude/claude-agent.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts index 71a345f33e8b..9b5237f9b030 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -237,7 +237,6 @@ function declinePendingSteers(turn: Turn): void { turn.pendingSteers.clear(); } - function isSdkMcpServer( cfg: McpServerConfig, ): cfg is McpSdkServerConfigWithInstance {