Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
}
Expand Down Expand Up @@ -539,7 +539,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {

async prompt(params: PromptRequest): Promise<PromptResponse> {
// 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
Expand Down
54 changes: 54 additions & 0 deletions products/desktop/packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1345,6 +1345,60 @@ describe("PostHogAPIClient", () => {
});
});

describe("clearTaskRunConversation", () => {
function makeClient(fetch: ReturnType<typeof vi.fn>) {
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/";

Expand Down
32 changes: 31 additions & 1 deletion products/desktop/packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [<status>] <json>` into the endpoint's clean message. */
function extractRequestErrorMessage(error: unknown, fallback: string): string {
const raw = error instanceof Error ? error.message : String(error);
Expand All @@ -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 {
Expand Down Expand Up @@ -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<void> {
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: [<status>] <json-body>` 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."),
);
}
Comment thread
charlesvien marked this conversation as resolved.
}

async getTaskRunSessionLogs(
taskId: string,
runId: string,
Expand Down
32 changes: 30 additions & 2 deletions products/desktop/packages/core/src/sessions/sessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down
103 changes: 101 additions & 2 deletions products/desktop/packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
isPersistedOptionSupported,
isRateLimitError,
isTransientUpstreamError,
leadingSlashCommand,
mergeConfigOptions,
type OptimisticItem,
type PermissionRequest,
Expand Down Expand Up @@ -101,6 +102,7 @@ import {
} from "./permissionResponse";
import {
convertStoredEntriesToEvents,
createConversationClearedEvents,
createUserShellExecuteEvent,
extractPromptText,
getStoredLogEventPosition,
Expand Down Expand Up @@ -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.",
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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" };
Comment thread
posthog[bot] marked this conversation as resolved.
}
}
// 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[],
Expand Down
Loading
Loading