From 5efd5136a913822350624a6130718d78c103a0a9 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Sat, 15 Aug 2026 11:19:11 -0700 Subject: [PATCH 1/3] feat(desktop): /clear a finished cloud run without booting a sandbox The session service routes a /clear on a finished cloud run to POST runs/{id}/clear_conversation instead of resuming into a sandbox. After the call succeeds, it rehydrates the session from the updated run log, so the painted /clear message and the cleared divider carry the backend's persisted timestamps; that lets resume-time reconciliation fold them into a single copy instead of rendering the pair twice. A locally stamped fallback pair covers the rare case where the log fetch cannot confirm the boundary landed. The path is gated on the agent's conversationClear capability from the run log. An older agent ignores the marker and resumes the conversation itself, so an ordinary resume is the honest degradation when the capability is absent. A shared tokenizer (leadingSlashCommand in @posthog/shared) keeps the desktop client's /clear detection identical to the agent adapter's, and clear_conversation errors surface through the existing extractRequestErrorMessage helper. Generated-By: PostHog Desktop Task-Id: 2578f561-b94e-46ac-8546-d3368f5098bb --- .../agent/src/adapters/claude/claude-agent.ts | 8 +- .../packages/api-client/src/posthog-client.ts | 21 ++ .../core/src/sessions/sessionEvents.ts | 32 +- .../core/src/sessions/sessionService.ts | 103 +++++- .../sessions/sessionServiceCloudClear.test.ts | 326 ++++++++++++++++++ products/desktop/packages/shared/src/index.ts | 1 + .../shared/src/slash-commands.test.ts | 18 + .../packages/shared/src/slash-commands.ts | 19 + 8 files changed, 520 insertions(+), 8 deletions(-) create mode 100644 products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts create mode 100644 products/desktop/packages/shared/src/slash-commands.test.ts create mode 100644 products/desktop/packages/shared/src/slash-commands.ts 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 187cd3289ddb..9d96a5523df6 100644 --- a/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts +++ b/products/desktop/packages/agent/src/adapters/claude/claude-agent.ts @@ -44,7 +44,7 @@ import { type SDKUserMessage, type SlashCommand, } from "@anthropic-ai/claude-agent-sdk"; -import { serializeError } from "@posthog/shared"; +import { leadingSlashCommand, serializeError } from "@posthog/shared"; import { v7 as uuidv7 } from "uuid"; import packageJson from "../../../package.json" with { type: "json" }; import { @@ -188,7 +188,7 @@ const LOCAL_ONLY_COMMANDS = new Set(["/context", "/heapdump", "/extra-usage"]); * 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 { +function promptSlashCommand(params: PromptRequest): string | undefined { const meta = params._meta as { localSkillName?: unknown } | undefined; const localSkillName = typeof meta?.localSkillName === "string" ? meta.localSkillName : null; @@ -200,7 +200,7 @@ function leadingSlashCommand(params: PromptRequest): string | undefined { if (localSkillName && isLocalSkillCommandChunk(chunk, localSkillName)) { return undefined; } - return chunk.text.match(/^(\/\S+)/)?.[1]; + return leadingSlashCommand(chunk.text); } return undefined; } @@ -531,7 +531,7 @@ 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); + const command = promptSlashCommand(params); if (command === "/clear") { // Handled by the adapter, never forwarded to the SDK (whose own /clear diff --git a/products/desktop/packages/api-client/src/posthog-client.ts b/products/desktop/packages/api-client/src/posthog-client.ts index a218a8fef4c3..75600432bb48 100644 --- a/products/desktop/packages/api-client/src/posthog-client.ts +++ b/products/desktop/packages/api-client/src/posthog-client.ts @@ -3643,6 +3643,27 @@ export class PostHogAPIClient { } } + /** + * Record a `/clear` boundary in a finished run's log, so the next run in the + * chain resumes past it with an empty conversation. Only valid for a finished + * run, because an active one has an agent that owns the clear (409 otherwise). + */ + async clearTaskRunConversation(taskId: string, runId: string): Promise { + const teamId = await this.getTeamId(); + const path = `/api/projects/${teamId}/tasks/${taskId}/runs/${runId}/clear_conversation/`; + const url = new URL(`${this.api.baseUrl}${path}`); + + // The shared fetcher throws `Failed request: [] ` for any non-2xx, so + // unwrap that into the endpoint's clean `error` message rather than surfacing the raw string. + try { + await this.api.fetcher.fetch({ method: "post", url, path }); + } catch (error) { + throw new Error( + extractRequestErrorMessage(error, "Couldn’t clear the conversation."), + ); + } + } + async getTaskRunSessionLogs( taskId: string, runId: string, diff --git a/products/desktop/packages/core/src/sessions/sessionEvents.ts b/products/desktop/packages/core/src/sessions/sessionEvents.ts index 8c5e906fe4a9..814606586cdf 100644 --- a/products/desktop/packages/core/src/sessions/sessionEvents.ts +++ b/products/desktop/packages/core/src/sessions/sessionEvents.ts @@ -83,8 +83,9 @@ function storedEntryToAcpMessage( * A typed user prompt replayed from an imported Claude Code session arrives as * a `user_message_chunk` tagged with `_meta.importedUserPrompt`. The renderer * ignores raw user_message_chunks (live, user turns render from session/prompt - * requests), so promote the tagged ones into a session/prompt user event. Only - * affects imported sessions; normal logs carry no such marker. + * requests), so promote the tagged ones into a session/prompt user event. + * Imported sessions and the backend-recorded `/clear` on a finished cloud run + * carry the tag; normal logs don't. */ function promoteImportedUserPrompt( entry: StoredLogEntry, @@ -135,6 +136,33 @@ export function createUserMessageEvent(text: string, ts: number): AcpMessage { return createUserPromptEvent([{ type: "text", text }], ts); } +/** + * Fallback `/clear` frames for a finished cloud run, used only when the + * post-clear log repaint cannot confirm the persisted boundary. The backend + * has already written the same pair into the run log with its own timestamps, + * so this locally stamped copy never reconciles against the log-derived one + * and can render a duplicate divider after a later resume. + * + * The painted user message is a `session/prompt` request because that is the + * shape the renderer displays; the persisted copy is a `user_message_chunk` + * tagged `importedUserPrompt`, which log replay promotes back into this same + * request shape (see {@link promoteImportedUserPrompt}). + */ +export function createConversationClearedEvents(ts: number): AcpMessage[] { + return [ + createUserMessageEvent("/clear", ts), + { + type: "acp_message", + ts, + message: { + jsonrpc: "2.0", + method: POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, + params: {}, + }, + }, + ]; +} + /** * Create a user shell execute event. * When id is provided, it's used to track async execution (start/complete). diff --git a/products/desktop/packages/core/src/sessions/sessionService.ts b/products/desktop/packages/core/src/sessions/sessionService.ts index 8d694aff5f15..766fd033f6a0 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -32,6 +32,7 @@ import { isPersistedOptionSupported, isRateLimitError, isTransientUpstreamError, + leadingSlashCommand, mergeConfigOptions, type OptimisticItem, type PermissionRequest, @@ -97,6 +98,7 @@ import { } from "./permissionResponse"; import { convertStoredEntriesToEvents, + createConversationClearedEvents, createUserShellExecuteEvent, extractPromptText, getStoredLogEventPosition, @@ -168,6 +170,34 @@ const SESSION_EVENT_EVICT_GRACE_MS = 20_000; */ const OPEN_TAIL_BYTES = 1_500_000; +/** + * Staggered repaint attempts after a cloud `/clear`. The persisted run log is + * S3-backed, so a read immediately after the boundary POST can miss the + * append; the budget stays small so an explicit user action never waits long. + */ +const CLEAR_REPAINT_ATTEMPT_DELAYS_MS = [0, 250, 750]; + +/** + * Whether the thread already ends at a `/clear` boundary. The backend appends + * the boundary pair at the log tail, but this scans the last few events + * rather than only the very last one so the check survives a backend that + * ever writes the pair in a different order or adds a trailing entry after + * it. The window stays small so an ancestor run's old boundary, buried + * mid-log, cannot satisfy it. + */ +function endsAtConversationClearedBoundary(events: AcpMessage[]): boolean { + return events + .slice(-3) + .some( + (event) => + isJsonRpcNotification(event.message) && + isNotification( + event.message.method, + POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, + ), + ); +} + class GitHubAuthorizationRequiredForCloudHandoffError extends Error { constructor( message = "Connect GitHub before continuing this task in cloud.", @@ -4246,6 +4276,18 @@ export class SessionService { } if (isTerminalStatus(session.cloudStatus)) { + // `/clear` is handled by the agent, not the model, so resuming would spin a + // whole sandbox to clear a conversation the next run rebuilds from the log + // anyway. The backend records the boundary against this run instead, but only + // when the agent understands it. An older one ignores the marker and resumes the + // conversation it was meant to retire, so an ordinary resume is the honest + // degradation: the clear doesn't happen, and nothing claims it did. + if ( + leadingSlashCommand(transport.messageText) === "/clear" && + session.conversationClear + ) { + return this.clearCloudConversation(session); + } // If the agent never booted (no `run_started`), resuming spins another // sandbox that hits the same provisioning failure — surface the error // instead of looping. @@ -4502,8 +4544,9 @@ export class SessionService { try { const session = this.d.store.getSessionByTaskId(taskId); if (!session?.isCloud || session.messageQueue.length === 0) return; - // Terminal cloud runs route through `resumeCloudRun`, which spins a - // new run and consumes the prompt itself — so dispatch is fine. + // Terminal cloud runs are fine to dispatch: they route through + // `resumeCloudRun` (a new run that consumes the prompt), or through + // `clearCloudConversation` for a /clear on a clear-capable run. // Otherwise gate on the agent-ready handshake (`run_started` flips // status to "connected") to avoid racing with `sendInitialTaskMessage`. const isTerminal = isTerminalStatus(session.cloudStatus); @@ -4551,6 +4594,62 @@ export class SessionService { } } + /** + * Records the `/clear` boundary against a finished run and repaints the + * thread from the updated log. + */ + private async clearCloudConversation( + session: AgentSession, + ): Promise<{ stopReason: string }> { + const current = this.d.store.getSessions()[session.taskRunId]; + if (endsAtConversationClearedBoundary(current?.events ?? [])) { + // A previous clear already recorded and painted the boundary, and a + // finished run's thread only grows through another clear, so a repeat + // has nothing to record or repaint. + return { stopReason: "end_turn" }; + } + const client = await this.d.getAuthenticatedClient(); + if (!client) { + throw new Error("Authentication required for cloud commands"); + } + this.d.log.info("Clearing cloud conversation", { + taskId: session.taskId, + taskRunId: session.taskRunId, + }); + await client.clearTaskRunConversation(session.taskId, session.taskRunId); + // The backend appended the boundary pair to this run's log, so repaint + // from the log rather than fabricating the frames locally. Log-derived + // copies carry the backend's timestamps, which is what lets a later + // resume's hydration reconcile them away; a fabricated copy stamped with + // the local clock never matches and renders the pair twice. The log read + // can lag the append (or a stale in-flight hydration can win the memo), + // so give the repaint a few staggered attempts before giving up. + for (const delayMs of CLEAR_REPAINT_ATTEMPT_DELAYS_MS) { + if (delayMs > 0) { + await new Promise((resolve) => setTimeout(resolve, delayMs)); + } + await this.hydrateCloudTaskSessionFromLogs( + session.taskId, + session.taskRunId, + session.logUrl, + undefined, + session.cloudStatus, + ); + const repainted = this.d.store.getSessions()[session.taskRunId]; + if (endsAtConversationClearedBoundary(repainted?.events ?? [])) { + return { stopReason: "end_turn" }; + } + } + // The log never showed the boundary within the retry budget. Paint + // locally so the clear is still visible; this copy can duplicate after a + // later resume, so it stays strictly a fallback. + this.d.store.appendEvents( + session.taskRunId, + createConversationClearedEvents(Date.now()), + ); + return { stopReason: "end_turn" }; + } + private async resumeCloudRun( session: AgentSession, prompt: string | ContentBlock[], diff --git a/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts b/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts new file mode 100644 index 000000000000..ff1342dc0180 --- /dev/null +++ b/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts @@ -0,0 +1,326 @@ +import { + IMPORTED_USER_PROMPT_META_KEY, + type StoredLogEntry, +} from "@posthog/shared"; +import { describe, expect, it, vi } from "vitest"; +import { POSTHOG_NOTIFICATIONS } from "./acpNotifications"; +import { + convertStoredEntriesToEvents, + createConversationClearedEvents, +} from "./sessionEvents"; +import { createBaseSession } from "./sessionFactory"; +import { + reconcileLiveEventsWithHydratedEvents, + SessionService, + type SessionServiceDeps, +} from "./sessionService"; + +const TASK_ID = "task-1"; +const TASK_RUN_ID = `run-${TASK_ID}`; +const CLEAR_LOGGED_AT = "2026-08-14T10:00:00.000Z"; + +// The run's log as it stood before the clear boundary was appended. +const preClearLogEntries: StoredLogEntry[] = [ + { + type: "notification", + timestamp: "2026-08-14T09:59:00.000Z", + notification: { + method: "session/update", + params: { + update: { + sessionUpdate: "agent_message_chunk", + content: { type: "text", text: "done" }, + }, + }, + }, + }, +]; + +// The pair the backend persists into the run log on clear_conversation. +const clearedLogEntries: StoredLogEntry[] = [ + { + type: "notification", + timestamp: CLEAR_LOGGED_AT, + notification: { + method: "session/update", + params: { + update: { + sessionUpdate: "user_message_chunk", + content: { type: "text", text: "/clear" }, + _meta: { [IMPORTED_USER_PROMPT_META_KEY]: true }, + }, + }, + }, + }, + { + type: "notification", + timestamp: "2026-08-14T10:00:00.001Z", + notification: { + method: POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED, + params: {}, + }, + }, +]; + +function createHarness({ + conversationClear = true, + logEntries = clearedLogEntries, +}: { + conversationClear?: boolean; + logEntries?: StoredLogEntry[]; +} = {}) { + const session = { + ...createBaseSession(TASK_RUN_ID, TASK_ID, "Test task"), + status: "connected" as const, + isCloud: true, + cloudStatus: "completed" as const, + conversationClear, + }; + + const appendEvents = vi.fn(); + const clearTaskRunConversation = vi.fn().mockResolvedValue(undefined); + const runTaskInCloud = vi.fn(); + const getTaskRunSessionLogsResult = vi + .fn() + .mockResolvedValue({ entries: logEntries, complete: true }); + + const deps = { + store: { + getSessionByTaskId: (taskId: string) => + taskId === session.taskId ? session : undefined, + getSessions: () => ({ [TASK_RUN_ID]: session }), + updateSession: (_taskRunId: string, updates: object) => { + Object.assign(session, updates); + }, + appendEvents, + clearTailOptimisticItems: vi.fn(), + clearMessageQueue: vi.fn(), + }, + h: { + getCloudPromptTransport: (prompt: string) => ({ + promptText: prompt, + messageText: prompt, + filePaths: [], + skillBundles: [], + }), + }, + log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, + getIsOnline: () => true, + addDirectoryDialog: { open: false }, + getAuthenticatedClient: async () => ({ + clearTaskRunConversation, + runTaskInCloud, + }), + fetchAuthState: async () => ({ + status: "authenticated", + bootstrapComplete: true, + cloudRegion: "us", + currentProjectId: 2, + }), + createAuthenticatedClient: () => ({ getTaskRunSessionLogsResult }), + trpc: { + agent: { + onSessionIdleKilled: { subscribe: () => ({ unsubscribe: vi.fn() }) }, + }, + }, + } as unknown as SessionServiceDeps; + + return { + service: new SessionService(deps), + session, + appendEvents, + clearTaskRunConversation, + runTaskInCloud, + getTaskRunSessionLogsResult, + }; +} + +describe("SessionService /clear on a finished cloud run", () => { + it("records the boundary and repaints the thread from the persisted log", async () => { + const { + service, + session, + appendEvents, + clearTaskRunConversation, + runTaskInCloud, + getTaskRunSessionLogsResult, + } = createHarness(); + + const result = await service.sendPrompt(TASK_ID, "/clear"); + + expect(result).toEqual({ stopReason: "end_turn" }); + expect(clearTaskRunConversation).toHaveBeenCalledWith(TASK_ID, TASK_RUN_ID); + expect(runTaskInCloud).not.toHaveBeenCalled(); + expect(getTaskRunSessionLogsResult).toHaveBeenCalledWith( + TASK_ID, + TASK_RUN_ID, + { limit: 100000 }, + ); + + // A finished run streams nothing back, so the thread is painted from here. + // The user message must be a session/prompt request: the renderer drops raw + // user_message_chunks, so painting one would show only the divider. + expect( + session.events.map((e) => (e.message as { method: string }).method), + ).toEqual(["session/prompt", POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED]); + const prompt = session.events[0].message as { + params: { prompt: Array<{ text: string }> }; + }; + expect(prompt.params.prompt[0].text).toBe("/clear"); + + // The frames must come from the log, carrying the backend's timestamp. + // A locally stamped copy never reconciles against the persisted pair and + // renders the /clear twice after the run is resumed. + expect(session.events[0].ts).toBe(new Date(CLEAR_LOGGED_AT).getTime()); + expect(getTaskRunSessionLogsResult).toHaveBeenCalledTimes(1); + expect(appendEvents).not.toHaveBeenCalled(); + }); + + it("short-circuits a repeat /clear without another backend call", async () => { + const { + service, + session, + appendEvents, + clearTaskRunConversation, + getTaskRunSessionLogsResult, + } = createHarness(); + + await service.sendPrompt(TASK_ID, "/clear"); + await service.sendPrompt(TASK_ID, "/clear"); + + expect(session.events).toHaveLength(2); + expect(clearTaskRunConversation).toHaveBeenCalledTimes(1); + expect(getTaskRunSessionLogsResult).toHaveBeenCalledTimes(1); + expect(appendEvents).not.toHaveBeenCalled(); + }); + + it("retries the repaint when the log read lags the boundary append", async () => { + vi.useFakeTimers(); + try { + const { service, session, appendEvents, getTaskRunSessionLogsResult } = + createHarness(); + // An S3-backed read right after the POST can return the pre-append log + // and still look complete. + getTaskRunSessionLogsResult + .mockResolvedValueOnce({ entries: preClearLogEntries, complete: true }) + .mockResolvedValue({ + entries: [...preClearLogEntries, ...clearedLogEntries], + complete: true, + }); + + const promptPromise = service.sendPrompt(TASK_ID, "/clear"); + await vi.advanceTimersByTimeAsync(2_000); + const result = await promptPromise; + + expect(result).toEqual({ stopReason: "end_turn" }); + expect(getTaskRunSessionLogsResult).toHaveBeenCalledTimes(2); + expect(appendEvents).not.toHaveBeenCalled(); + expect(session.events).toHaveLength(3); + expect( + (session.events.at(-1)?.message as { method: string }).method, + ).toBe(POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED); + } finally { + vi.useRealTimers(); + } + }); + + it("falls back to locally painted frames when the log never shows the boundary", async () => { + vi.useFakeTimers(); + try { + const { service, appendEvents, getTaskRunSessionLogsResult } = + createHarness(); + getTaskRunSessionLogsResult.mockResolvedValue({ + entries: [], + complete: false, + }); + + const promptPromise = service.sendPrompt(TASK_ID, "/clear"); + await vi.advanceTimersByTimeAsync(2_000); + const result = await promptPromise; + + expect(result).toEqual({ stopReason: "end_turn" }); + expect(getTaskRunSessionLogsResult).toHaveBeenCalledTimes(3); + const [, events] = appendEvents.mock.calls[0]; + expect( + events.map((e: { message: { method: string } }) => e.message.method), + ).toEqual(["session/prompt", POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED]); + expect(events[0].message.params.prompt[0].text).toBe("/clear"); + } finally { + vi.useRealTimers(); + } + }); + + it("does not paint a boundary when the backend rejects the clear", async () => { + const { service, session, appendEvents, clearTaskRunConversation } = + createHarness(); + clearTaskRunConversation.mockRejectedValue( + new Error("Couldn’t clear the conversation."), + ); + + await expect(service.sendPrompt(TASK_ID, "/clear")).rejects.toThrow( + "Couldn’t clear the conversation.", + ); + expect(session.events).toHaveLength(0); + expect(appendEvents).not.toHaveBeenCalled(); + }); + + // An older agent ignores the marker and resumes the conversation it was meant to + // retire, so recording a boundary would claim a clear that never happens. Both + // cases fall through to the ordinary resume, which this harness does not fake. + it.each([ + [ + "the agent cannot honour the boundary", + { conversationClear: false }, + "/clear", + ], + ["the message is not a /clear", {}, "keep going"], + ])("does not record a boundary when %s", async (_case, options, prompt) => { + const { service, clearTaskRunConversation } = createHarness(options); + + await service.sendPrompt(TASK_ID, prompt).catch(() => undefined); + + expect(clearTaskRunConversation).not.toHaveBeenCalled(); + }); + + // The cleared run's events are copied into the resumed run's session, then + // resume hydration promotes the same pair from the ancestor log. The two + // conversions carry different position provenance, so reconciliation must + // fall back to message equality and fold them into one copy. + it("reconciles the log-derived clear pair on resume instead of duplicating it", () => { + const paintedOnClearedRun = convertStoredEntriesToEvents( + clearedLogEntries, + undefined, + // Any nonzero ordinal works: this copy is positioned while the resume + // copy below is not, which is what forces the message-equality fallback. + { taskRunId: TASK_RUN_ID, startEntryIndex: 6 }, + ); + const hydratedForResumedRun = convertStoredEntriesToEvents( + clearedLogEntries, + undefined, + { + taskRunId: "run-resumed", + startEntryIndex: 0, + firstPositionedEntryIndex: clearedLogEntries.length, + }, + ); + + const inherited = reconcileLiveEventsWithHydratedEvents( + paintedOnClearedRun, + hydratedForResumedRun, + ); + + expect(inherited).toEqual([]); + + // The fold above depends on the log copy carrying the backend's timestamp + // as the prompt id. The locally stamped fallback never matches, which is + // why clearCloudConversation repaints from the log instead of fabricating. + const fabricatedFallback = createConversationClearedEvents(Date.now()); + const duplicated = reconcileLiveEventsWithHydratedEvents( + fabricatedFallback, + hydratedForResumedRun, + ); + expect( + duplicated.map((e) => (e.message as { method: string }).method), + ).toEqual(["session/prompt", POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED]); + }); +}); diff --git a/products/desktop/packages/shared/src/index.ts b/products/desktop/packages/shared/src/index.ts index 4accd787b9c4..d78fa333e45d 100644 --- a/products/desktop/packages/shared/src/index.ts +++ b/products/desktop/packages/shared/src/index.ts @@ -341,6 +341,7 @@ export { serializeSkillMarkdown, stripFrontmatter, } from "./skills"; +export { leadingSlashCommand } from "./slash-commands"; export type { PostHogAPIConfig } from "./task"; export { type CreateTaskAutomationOptions, diff --git a/products/desktop/packages/shared/src/slash-commands.test.ts b/products/desktop/packages/shared/src/slash-commands.test.ts new file mode 100644 index 000000000000..760de4354b27 --- /dev/null +++ b/products/desktop/packages/shared/src/slash-commands.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it } from "vitest"; +import { leadingSlashCommand } from "./slash-commands"; + +describe("leadingSlashCommand", () => { + it.each([ + ["/clear", "/clear"], + ["/clear keep the branch", "/clear"], + ["/clear\nsecond line", "/clear"], + ["/clearcache", "/clearcache"], + ["/clear,", "/clear,"], + ["", undefined], + [undefined, undefined], + ["not a command", undefined], + [" /clear", undefined], + ])("reads %j as %j", (text, expected) => { + expect(leadingSlashCommand(text)).toBe(expected); + }); +}); diff --git a/products/desktop/packages/shared/src/slash-commands.ts b/products/desktop/packages/shared/src/slash-commands.ts new file mode 100644 index 000000000000..15f9b820e072 --- /dev/null +++ b/products/desktop/packages/shared/src/slash-commands.ts @@ -0,0 +1,19 @@ +/** + * The leading slash command in a line of prompt text, or undefined when the text + * does not open with one. The token runs to the first whitespace, so `/clearcache` + * is its own command rather than a `/clear` carrying trailing text. + * + * The agent adapter and the desktop client both dispatch on this and have to agree. + * When they disagree the failure is silent: either a conversation boundary gets + * recorded that the agent would ignore, or a sandbox boots for a message the agent + * would have handled on its own. + * + * Core's `parseCommandLine` (message-editor/commands.ts) is the other command + * parser: it splits a whole single-line invocation into name and args and rejects + * multiline text, which makes it wrong for dispatch parity with the agent. + */ +export function leadingSlashCommand( + text: string | undefined, +): string | undefined { + return text?.match(/^(\/\S+)/)?.[1]; +} From 1b5533ed5bc646da8db89db9f9cbe01cbccf8a53 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 19 Aug 2026 10:20:35 +0100 Subject: [PATCH 2/3] fix(desktop): don't surface DRF's generic 404 detail as a clear error extractRequestErrorMessage passed through any non-empty error/detail string from the backend, including Django's generic {"detail":"Not found."} for an unmatched route. On a pre-#76943 backend without the clear_conversation endpoint, that surfaced /clear's failure as a bare "Not found." with no indication a clear was attempted or what to do next. Treat that literal DRF placeholder as non-actionable and fall through to the endpoint's own fallback message plus status code. Generated-By: PostHog Desktop Task-Id: 2578f561-b94e-46ac-8546-d3368f5098bb --- .../api-client/src/posthog-client.test.ts | 54 +++++++++++++++++++ .../packages/api-client/src/posthog-client.ts | 11 +++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/products/desktop/packages/api-client/src/posthog-client.test.ts b/products/desktop/packages/api-client/src/posthog-client.test.ts index e9cf38793c7d..4f47abd1e803 100644 --- a/products/desktop/packages/api-client/src/posthog-client.test.ts +++ b/products/desktop/packages/api-client/src/posthog-client.test.ts @@ -1345,6 +1345,60 @@ describe("PostHogAPIClient", () => { }); }); + describe("clearTaskRunConversation", () => { + function makeClient(fetch: ReturnType) { + const client = new PostHogAPIClient( + "http://localhost:8000", + async () => "token", + async () => "token", + 123, + ); + ( + client as unknown as { + api: { baseUrl: string; fetcher: { fetch: typeof fetch } }; + } + ).api = { + baseUrl: "http://localhost:8000", + fetcher: { fetch }, + }; + return client; + } + + it("surfaces the backend's clean error message", async () => { + const fetch = vi + .fn() + .mockRejectedValue( + new Error( + 'Failed request: [409] {"error":"Run is still active; send /clear to its agent instead"}', + ), + ); + const client = makeClient(fetch); + + await expect( + client.clearTaskRunConversation("task-1", "run-1"), + ).rejects.toThrow( + "Run is still active; send /clear to its agent instead", + ); + }); + + it("falls back to a status-coded message on an older backend's generic 404", async () => { + // A pre-#76943 backend has no clear_conversation route, so DRF's router + // returns its generic {"detail":"Not found."} rather than a message + // this endpoint controls. Surfacing that verbatim would read as "Not + // found." with no indication a clear was attempted or what to do next. + const fetch = vi + .fn() + .mockRejectedValue( + new Error('Failed request: [404] {"detail":"Not found."}'), + ); + const client = makeClient(fetch); + + await expect( + client.clearTaskRunConversation("task-1", "run-1"), + ).rejects.toThrow("Couldn’t clear the conversation. (HTTP 404)"); + }); + }); + describe("getTaskSummaries", () => { const SUMMARIES_PATH = "/api/projects/123/tasks/summaries/"; diff --git a/products/desktop/packages/api-client/src/posthog-client.ts b/products/desktop/packages/api-client/src/posthog-client.ts index 2c7e90b3ffd3..ce4612bf76b0 100644 --- a/products/desktop/packages/api-client/src/posthog-client.ts +++ b/products/desktop/packages/api-client/src/posthog-client.ts @@ -954,6 +954,11 @@ function optionalString(value: unknown): string | null { return typeof value === "string" ? value : null; } +// DRF's generic placeholder for "no route matched" and an unhandled NotFound +// alike — never a business-specific message, so it's less actionable than the +// endpoint's own fallback plus status code. +const DRF_GENERIC_NOT_FOUND_DETAIL = "Not found."; + /** Unwrap the shared fetcher's `Failed request: [] ` into the endpoint's clean message. */ function extractRequestErrorMessage(error: unknown, fallback: string): string { const raw = error instanceof Error ? error.message : String(error); @@ -964,7 +969,11 @@ function extractRequestErrorMessage(error: unknown, fallback: string): string { try { const body = JSON.parse(match[2]) as { error?: unknown; detail?: unknown }; const message = body.error ?? body.detail; - if (typeof message === "string" && message.trim()) { + if ( + typeof message === "string" && + message.trim() && + message !== DRF_GENERIC_NOT_FOUND_DETAIL + ) { return message; } } catch { From 5716fe08b78353ce0dcb40459fc1ea0204702bc2 Mon Sep 17 00:00:00 2001 From: Phil Haack Date: Wed, 19 Aug 2026 11:48:45 +0100 Subject: [PATCH 3/3] fix(desktop): stub the chain-window probe in cloud-clear repaint tests hydrateCloudTaskSessionFromLogs probes getTaskRunSessionLogsPage before falling back to getTaskRunSessionLogsResult. The harness only stubbed the latter, so the probe threw, the hydration bailed early, and the repaint assertions never saw a call. Generated-By: PostHog Desktop Task-Id: 2578f561-b94e-46ac-8546-d3368f5098bb --- .../src/sessions/sessionServiceCloudClear.test.ts | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts b/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts index ff1342dc0180..f09b7f191683 100644 --- a/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts +++ b/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts @@ -83,6 +83,12 @@ function createHarness({ const getTaskRunSessionLogsResult = vi .fn() .mockResolvedValue({ entries: logEntries, complete: true }); + // The chain-window probe always reports more-than-a-page with an unknown + // count, forcing hydration onto the getTaskRunSessionLogsResult path this + // harness actually stubs. + const getTaskRunSessionLogsPage = vi + .fn() + .mockResolvedValue({ entries: [], hasMore: true, matchingCount: null }); const deps = { store: { @@ -117,7 +123,10 @@ function createHarness({ cloudRegion: "us", currentProjectId: 2, }), - createAuthenticatedClient: () => ({ getTaskRunSessionLogsResult }), + createAuthenticatedClient: () => ({ + getTaskRunSessionLogsResult, + getTaskRunSessionLogsPage, + }), trpc: { agent: { onSessionIdleKilled: { subscribe: () => ({ unsubscribe: vi.fn() }) },