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 45b9b1296df6..30b0a172e40b 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; } @@ -539,7 +539,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.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 d0689209cca5..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 { @@ -3688,6 +3697,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 c6d9ff3f8cf8..13fc29ed3432 100644 --- a/products/desktop/packages/core/src/sessions/sessionService.ts +++ b/products/desktop/packages/core/src/sessions/sessionService.ts @@ -36,6 +36,7 @@ import { isPersistedOptionSupported, isRateLimitError, isTransientUpstreamError, + leadingSlashCommand, mergeConfigOptions, type OptimisticItem, type PermissionRequest, @@ -101,6 +102,7 @@ import { } from "./permissionResponse"; import { convertStoredEntriesToEvents, + createConversationClearedEvents, createUserShellExecuteEvent, extractPromptText, getStoredLogEventPosition, @@ -212,6 +214,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.", @@ -4478,6 +4508,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. @@ -4740,8 +4782,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); @@ -4789,6 +4832,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..f09b7f191683 --- /dev/null +++ b/products/desktop/packages/core/src/sessions/sessionServiceCloudClear.test.ts @@ -0,0 +1,335 @@ +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 }); + // 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: { + 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, + getTaskRunSessionLogsPage, + }), + 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 4108c587643d..7db2d3f07553 100644 --- a/products/desktop/packages/shared/src/index.ts +++ b/products/desktop/packages/shared/src/index.ts @@ -342,6 +342,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]; +}