diff --git a/packages/agent/src/acp-extensions.ts b/packages/agent/src/acp-extensions.ts index c700c32bb7..6bb9da61fa 100644 --- a/packages/agent/src/acp-extensions.ts +++ b/packages/agent/src/acp-extensions.ts @@ -69,6 +69,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/packages/agent/src/adapters/base-acp-agent.ts b/packages/agent/src/adapters/base-acp-agent.ts index 243228a65a..6e804964c8 100644 --- a/packages/agent/src/adapters/base-acp-agent.ts +++ b/packages/agent/src/adapters/base-acp-agent.ts @@ -72,7 +72,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; @@ -100,6 +100,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; } @@ -108,7 +110,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/packages/agent/src/adapters/claude/UPSTREAM.md b/packages/agent/src/adapters/claude/UPSTREAM.md index b5e9c8af58..51f9edb00e 100644 --- a/packages/agent/src/adapters/claude/UPSTREAM.md +++ b/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/packages/agent/src/adapters/claude/claude-agent.clear.test.ts b/packages/agent/src/adapters/claude/claude-agent.clear.test.ts new file mode 100644 index 0000000000..c8daed17ae --- /dev/null +++ b/packages/agent/src/adapters/claude/claude-agent.clear.test.ts @@ -0,0 +1,461 @@ +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, + }; +} + +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("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("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", + ); + }); +}); diff --git a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts b/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts index 4222b68dd5..a85e685237 100644 --- a/packages/agent/src/adapters/claude/claude-agent.refresh.test.ts +++ b/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/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts b/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts index 0e9f422a08..a8fff01c3a 100644 --- a/packages/agent/src/adapters/claude/claude-agent.slash-command.test.ts +++ b/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/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 402d8dbdd4..ae3041e7b1 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -107,6 +107,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, @@ -471,6 +472,12 @@ export class ClaudeAcpAgent extends BaseAcpAgent { isLocalOnlyCommand = true; } + if (commandMatch?.[1] === "/clear") { + // Handled by the adapter, never forwarded to the SDK (whose own /clear + // is unreliable in this embedding — see UPSTREAM.md "Hide /clear"). + return this.clearConversation(params); + } + if (commandMatch && !isLocalOnlyCommand) { await this.refreshSlashCommandsForPrompt(commandMatch[1]); } @@ -1467,6 +1474,229 @@ 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); + } + if (session.activeTurn !== null || session.turnQueue.length > 0) { + await this.client.sessionUpdate({ + sessionId: params.sessionId, + update: { + sessionUpdate: "agent_message_chunk", + content: { + type: "text", + text: "Cannot clear the conversation while a turn is in progress. Wait for it to finish (or cancel it) and try again.", + }, + }, + }); + return { stopReason: "end_turn" }; + } + + 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", + }); + + await this.retireQuery(session); + + const newSdkSessionId = uuidv7(); + const 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(); + const 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") { + // Nothing has been broadcast or logged yet at this point, so a timeout + // here leaves no orphaned "/clear" turn behind: the log is untouched. + this.terminateQuery(newQuery, newAbortController); + session.queryClosed = true; + const timeoutMessage = `Conversation clear timed out after ${SESSION_VALIDATION_TIMEOUT_MS}ms`; + // Clear the "Clearing…" spinner and report the outcome as its own row, + // same as a failed compaction. + await this.client.extNotification(POSTHOG_NOTIFICATIONS.STATUS, { + sessionId: params.sessionId, + status: "clearing_failed", + error: timeoutMessage, + }); + throw new RequestError(-32603, timeoutMessage, { + sessionId: params.sessionId, + }); + } + session.knownSlashCommands = collectKnownSlashCommands( + result.value.commands, + ); + session.fastModeEnabled = fastModeStateEnabled( + result.value.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(); + + // 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 { @@ -1489,31 +1719,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 @@ -1534,7 +1740,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 @@ -2003,6 +2209,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { const session: Session = { query: q, + sdkSessionId: sessionId, queryOptions: options, buildInProcessMcpServers, localToolsServerNames, @@ -2298,10 +2505,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 = { @@ -2314,7 +2529,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, }; @@ -2431,7 +2649,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/packages/agent/src/adapters/claude/session/commands.test.ts b/packages/agent/src/adapters/claude/session/commands.test.ts new file mode 100644 index 0000000000..ccda4d7793 --- /dev/null +++ b/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/packages/agent/src/adapters/claude/session/commands.ts b/packages/agent/src/adapters/claude/session/commands.ts index 47037142cf..7bfc2f95c8 100644 --- a/packages/agent/src/adapters/claude/session/commands.ts +++ b/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/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts b/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts index 2bb4f3d38e..a99d544b93 100644 --- a/packages/agent/src/adapters/claude/session/jsonl-hydration.test.ts +++ b/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/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 65cf55fe65..0c07f0389b 100644 --- a/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/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"; @@ -110,7 +111,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[] = []; @@ -118,6 +119,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/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 80078dfa8f..1b92b8fede 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -53,6 +53,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 diff --git a/packages/agent/src/sagas/resume-saga.test.ts b/packages/agent/src/sagas/resume-saga.test.ts index a3919c6de7..1f4885fa3a 100644 --- a/packages/agent/src/sagas/resume-saga.test.ts +++ b/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,27 @@ 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", + }, + { + 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/packages/agent/src/sagas/resume-saga.ts b/packages/agent/src/sagas/resume-saga.ts index d18309675f..799bbdf96c 100644 --- a/packages/agent/src/sagas/resume-saga.ts +++ b/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, @@ -163,10 +167,15 @@ 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. 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; @@ -219,7 +228,7 @@ export class ResumeSaga extends Saga { private rebuildConversation( entries: StoredNotification[], ): ConversationTurn[] { - const turns: ConversationTurn[] = []; + let turns: ConversationTurn[] = []; let currentAssistantContent: ContentBlock[] = []; let currentToolCalls: ToolCallInfo[] = []; @@ -227,6 +236,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/packages/core/src/sessions/acpNotifications.ts b/packages/core/src/sessions/acpNotifications.ts index 242e69ad14..80642db79b 100644 --- a/packages/core/src/sessions/acpNotifications.ts +++ b/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/packages/ui/src/features/sessions/components/buildConversationItems.test.ts b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts index 50dba8b580..13f10b4e15 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.test.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.test.ts @@ -272,6 +272,89 @@ 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, + ); + // /clear can't start mid-turn, so unlike compacting it never touches the + // isCompacting flag that gates the generating indicator. + 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.", + }, + ]); + }); + + 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/packages/ui/src/features/sessions/components/buildConversationItems.ts b/packages/ui/src/features/sessions/components/buildConversationItems.ts index 17abfe38e2..cea3374e11 100644 --- a/packages/ui/src/features/sessions/components/buildConversationItems.ts +++ b/packages/ui/src/features/sessions/components/buildConversationItems.ts @@ -695,6 +695,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 { @@ -756,6 +762,22 @@ function handleRuntimeStatus( } else if (status.status === "retrying" && status.isComplete) { markRuntimeStatusComplete(b, "retrying"); return; + } else if (status.status === "clearing" && status.isComplete) { + // `/clear` can't start mid-turn (the adapter refuses while a turn is + // in flight), so unlike compacting there's no isPromptPending race to + // gate — just flip the existing "Clearing…" row to complete. + markRuntimeStatusComplete(b, "clearing"); + return; + } 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, { diff --git a/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx b/packages/ui/src/features/sessions/components/session-update/ConversationClearedView.tsx new file mode 100644 index 0000000000..569660f447 --- /dev/null +++ b/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/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx b/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx index dc49fd9abe..0249b6f30d 100644 --- a/packages/ui/src/features/sessions/components/session-update/SessionUpdateView.tsx +++ b/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"; @@ -29,6 +30,9 @@ export type RenderItem = preTokens: number; contextSize?: number; } + | { + sessionUpdate: "conversation_cleared"; + } | { sessionUpdate: "status"; status: string; @@ -131,6 +135,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 ( @@ -204,20 +239,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); @@ -230,9 +272,7 @@ function CompactingStatusView({ startedAt }: { startedAt?: number }) { - - Compacting conversation history... - + {label} {formatDuration(elapsed, 1)}