Skip to content
Open
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
28 changes: 28 additions & 0 deletions products/desktop/packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3431,6 +3431,34 @@ 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 — 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 response = await this.api.fetcher.fetch({
method: "post",
url: new URL(`${this.api.baseUrl}${path}`),
path,
});
if (!response.ok) {
const err = (await response.json().catch(() => ({}))) as {
error?: unknown;
detail?: unknown;
};
const reason =
typeof err.error === "string"
? err.error
: typeof err.detail === "string"
? err.detail
: response.statusText;
throw new Error(`Failed to clear conversation: ${reason}`);
}
}

Comment thread
haacked marked this conversation as resolved.
async getTaskRunSessionLogs(
taskId: string,
runId: string,
Expand Down
30 changes: 28 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,31 @@ export function createUserMessageEvent(text: string, ts: number): AcpMessage {
return createUserPromptEvent([{ type: "text", text }], ts);
}

/**
* The two frames a `/clear` on a finished cloud run paints, in thread order: the
* message the user typed, then the boundary rehydration stops at.
*
* The backend writes the same pair into the run log (there is no sandbox to emit
* them). 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
41 changes: 41 additions & 0 deletions products/desktop/packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ import {
} from "./permissionResponse";
import {
convertStoredEntriesToEvents,
createConversationClearedEvents,
createUserShellExecuteEvent,
extractPromptText,
getStoredLogEventPosition,
Expand Down Expand Up @@ -144,6 +145,17 @@ const SESSION_EVENT_EVICT_GRACE_MS = 20_000;
*/
const OPEN_TAIL_BYTES = 1_500_000;

/**
* Matches a leading `/clear` command the way the agent's own detection does
* (`leadingSlashCommand` in the claude adapter): a longer command such as
* `/clearcache` doesn't match, and trailing text doesn't change the command,
* so the finished-run shortcut and a live agent treat the same message the
* same way.
*/
function isClearCommand(text: string | undefined): boolean {
return /^\/clear(?:\s|$)/.test(text ?? "");
}

class GitHubAuthorizationRequiredForCloudHandoffError extends Error {
constructor(
message = "Connect GitHub before continuing this task in cloud.",
Expand Down Expand Up @@ -4119,6 +4131,15 @@ 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 (isClearCommand(transport.messageText) && session.conversationClear) {
return this.clearCloudConversation(session);
}
Comment thread
haacked marked this conversation as resolved.
// 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 @@ -4424,6 +4445,26 @@ export class SessionService {
}
}

/** Records the `/clear` boundary against a finished run and paints it locally. */
private async clearCloudConversation(
session: AgentSession,
): Promise<{ stopReason: string }> {
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);
this.d.store.appendEvents(
session.taskRunId,
createConversationClearedEvents(Date.now()),
);
return { stopReason: "end_turn" };
}

Comment thread
haacked marked this conversation as resolved.
private async resumeCloudRun(
session: AgentSession,
prompt: string | ContentBlock[],
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import type { AgentSession } from "@posthog/shared";
import { describe, expect, it, vi } from "vitest";
import { POSTHOG_NOTIFICATIONS } from "./acpNotifications";
import { SessionService, type SessionServiceDeps } from "./sessionService";

const TASK_ID = "task-1";
const TASK_RUN_ID = `run-${TASK_ID}`;

function createHarness({
conversationClear = true,
}: {
conversationClear?: boolean;
} = {}) {
const sessions: Record<string, AgentSession> = {
[TASK_RUN_ID]: {
taskRunId: TASK_RUN_ID,
taskId: TASK_ID,
taskTitle: "Test task",
channel: "",
events: [],
startedAt: 1,
status: "connected",
isCloud: true,
cloudStatus: "completed",
conversationClear,
isPromptPending: false,
isCompacting: false,
promptStartedAt: null,
pendingPermissions: new Map(),
pausedDurationMs: 0,
messageQueue: [],
optimisticItems: [],
} as unknown as AgentSession,
};

const appendEvents = vi.fn();
const clearTaskRunConversation = vi.fn().mockResolvedValue(undefined);
const runTaskInCloud = vi.fn();

const deps = {
store: {
getSessions: () => sessions,
getSessionByTaskId: (taskId: string) =>
Object.values(sessions).find((s) => s.taskId === taskId),
appendEvents,
updateSession: vi.fn(),
appendOptimisticItem: vi.fn(),
clearTailOptimisticItems: 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() },
toast: { error: vi.fn(), info: vi.fn() },
track: vi.fn(),
getIsOnline: () => true,
addDirectoryDialog: { open: false },
getAuthenticatedClient: async () => ({
clearTaskRunConversation,
runTaskInCloud,
}),
trpc: {
agent: {
onSessionIdleKilled: { subscribe: () => ({ unsubscribe: vi.fn() }) },
},
},
} as unknown as SessionServiceDeps;

return {
service: new SessionService(deps),
appendEvents,
clearTaskRunConversation,
runTaskInCloud,
};
}

describe("SessionService /clear on a finished cloud run", () => {
it("records the boundary and renders it without resuming into a new run", async () => {
const { service, appendEvents, clearTaskRunConversation, runTaskInCloud } =
createHarness();

const result = await service.sendPrompt(TASK_ID, "/clear");

expect(result).toEqual({ stopReason: "end_turn" });
expect(clearTaskRunConversation).toHaveBeenCalledWith(TASK_ID, TASK_RUN_ID);
// Resuming would spin a whole sandbox to clear a conversation the next run
// rebuilds from the log anyway.
expect(runTaskInCloud).not.toHaveBeenCalled();

// 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.
const [, events] = appendEvents.mock.calls[0];
expect(
events.map((e: { message: { method: string } }) => e.message.method),
).toEqual(["session/prompt", POSTHOG_NOTIFICATIONS.CONVERSATION_CLEARED]);
});

it("resumes into a new run when the agent cannot honour the boundary", async () => {
// An older agent ignores the marker and resumes the conversation it was meant to
// retire, so recording one would claim a clear that never happens.
const { service, clearTaskRunConversation } = createHarness({
conversationClear: false,
});

await service.sendPrompt(TASK_ID, "/clear").catch(() => undefined);

expect(clearTaskRunConversation).not.toHaveBeenCalled();
});

it("still resumes into a new run for an ordinary message", async () => {
const { service, clearTaskRunConversation } = createHarness();

await service.sendPrompt(TASK_ID, "keep going").catch(() => undefined);

expect(clearTaskRunConversation).not.toHaveBeenCalled();
});
});
53 changes: 41 additions & 12 deletions products/posthog_ai/frontend/components/ThreadItems.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { IconCheck, IconCollapse, IconWarning, IconX } from '@posthog/icons'
import { IconCheck, IconCircleDashed, IconCollapse, IconWarning, IconX } from '@posthog/icons'
import { Spinner } from '@posthog/lemon-ui'

import { humanFriendlyNumber } from 'lib/utils/numbers'
Expand All @@ -7,23 +7,52 @@ import type { ThreadItem } from '../types/streamTypes'
import { Activity } from './ActivityPrimitives'
import type { ActivityStatus } from './ActivityPrimitives'

/** Inline `_posthog/status` item — a spinner while compacting, a generic status line otherwise. */
export function StatusItem({ item }: { item: ThreadItem }): JSX.Element {
const isCompacting = item.status === 'compacting' && !item.isComplete
/** Statuses that run for a while and get a spinner with their own label, keyed by wire status. */
const IN_PROGRESS_STATUS_LABELS: Record<string, string> = {
compacting: 'Compacting conversation history…',
clearing: 'Clearing conversation…',
}

function StatusLine({ icon, children }: { icon?: JSX.Element; children: React.ReactNode }): JSX.Element {
return (
<div className="flex items-center justify-center gap-2 py-1 text-xs text-muted">
{isCompacting ? (
<>
<Spinner className="size-3" />
<span>Compacting conversation history…</span>
</>
) : (
<span>Status: {item.status}</span>
)}
{icon}
<span>{children}</span>
</div>
)
}

/** Inline `_posthog/status` item — a spinner while an operation runs, a status line otherwise. */
export function StatusItem({ item }: { item: ThreadItem }): JSX.Element {
const inProgressLabel = item.isComplete ? undefined : IN_PROGRESS_STATUS_LABELS[item.status ?? '']
if (inProgressLabel) {
return <StatusLine icon={<Spinner className="size-3" />}>{inProgressLabel}</StatusLine>
}
// A failed clear leaves the agent session closed, so the way forward is a new run, not a retry.
if (item.status === 'clearing_failed') {
const reason = item.errorMessage
? `Couldn't clear the conversation: ${item.errorMessage}`
: "Couldn't clear the conversation"
return <StatusLine icon={<IconX className="size-3" />}>{reason}. Start a new run to keep going.</StatusLine>
}
return <StatusLine>Status: {item.status}</StatusLine>
}

/** Inline `_posthog/conversation_cleared` item — the `/clear` boundary card. */
export function ConversationClearedItem({ item }: { item: ThreadItem }): JSX.Element {
return (
<Activity
id={item.id}
title="Conversation cleared"
subtitle="Earlier messages are no longer in the agent's context"
status="completed"
icon={<IconCircleDashed className="size-4" />}
animate={false}
showCompletionIcon={false}
/>
)
}

/** Inline `_posthog/compact_boundary` item — the post-compaction card. */
export function CompactBoundaryItem({ item }: { item: ThreadItem }): JSX.Element {
const parts = [
Expand Down
5 changes: 4 additions & 1 deletion products/posthog_ai/frontend/components/ThreadRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import type { ProgressStep, ThreadItem } from '../types/streamTypes'
import { resolveToolCall } from '../utils/toolResolver'
import { RunActivity } from './RunActivity'
import { RunAlertActivity } from './RunAlertActivity'
import { CompactBoundaryItem, StatusItem, TaskNotificationItem } from './ThreadItems'
import { CompactBoundaryItem, ConversationClearedItem, StatusItem, TaskNotificationItem } from './ThreadItems'
import { ToolCallCard } from './tool/ToolCallCard'

type ToolInvocations = typeof runStreamLogic.values.toolInvocations
Expand Down Expand Up @@ -161,6 +161,9 @@ export const ThreadRow = memo(function ThreadRow({
if (item.type === 'compact_boundary') {
return <CompactBoundaryItem item={item} />
}
if (item.type === 'conversation_cleared') {
return <ConversationClearedItem item={item} />
}
if (item.type === 'task_notification') {
return <TaskNotificationItem item={item} />
}
Expand Down
1 change: 1 addition & 0 deletions products/posthog_ai/frontend/components/ThreadView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ const THREAD_ITEM_HEIGHT_ESTIMATES: Partial<Record<ThreadItem['type'], number>>
error: 42,
status: 42,
compact_boundary: 42,
conversation_cleared: 42,
task_notification: 26,
progress: 42,
debug: 30,
Expand Down
Loading
Loading