From 153d55c356309bb8aaf7b0129ce3e6004e4fff6a Mon Sep 17 00:00:00 2001 From: Adam Bowker Date: Tue, 23 Jun 2026 13:58:43 -0600 Subject: [PATCH] feat(agent): replay imported Claude Code CLI transcripts Teach the Claude adapter to replay an imported CLI transcript into a fresh session: emit user prompts and top-level assistant text/thinking (no client history to dedupe against), recover typed slash-command invocations, and mark replayed user chunks so the load path can promote them into user bubbles. Also extracts encodeCwdToProjectKey from the JSONL path helper and adds the shared IMPORTED_USER_PROMPT_META_KEY marker plus the importedClaudeSession task-creation field. Part 1/3 of splitting #2873 (import Claude Code sessions). Generated-By: PostHog Code Task-Id: 6c93b6e8-27b6-45c8-8135-73a09076ea93 --- .../agent/src/adapters/claude/claude-agent.ts | 1 + .../claude/conversion/sdk-to-acp.test.ts | 101 ++++++++++++++++++ .../adapters/claude/conversion/sdk-to-acp.ts | 62 +++++++++-- .../claude/session/jsonl-hydration.ts | 17 ++- packages/shared/src/index.ts | 1 + packages/shared/src/session-events.ts | 3 + packages/shared/src/task-creation-domain.ts | 6 ++ 7 files changed, 181 insertions(+), 10 deletions(-) diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 7c6d8220f4..12311f071d 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -2230,6 +2230,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { enrichedReadCache: this.enrichedReadCache, logger: this.logger, registerHooks: false, + isImportReplay: true, }; for (const msg of messages) { diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts index 7e9d15709b..1b30a17d1e 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts @@ -5,6 +5,7 @@ import type { import type { SDKAssistantMessage, SDKPartialAssistantMessage, + SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; import { describe, expect, it } from "vitest"; import { Logger } from "../../../utils/logger"; @@ -260,3 +261,103 @@ describe("assembled assistant text fallback", () => { expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]); }); }); + +function userMessage( + content: string | Array>, +): SDKUserMessage { + return { + type: "user", + parent_tool_use_id: null, + uuid: "00000000-0000-0000-0000-000000000003", + session_id: "test-session", + message: { role: "user", content }, + } as unknown as SDKUserMessage; +} + +function userChunkTexts(updates: SessionNotification[]): string[] { + return updates + .filter((u) => u.update.sessionUpdate === "user_message_chunk") + .map((u) => (u.update as { content: { text: string } }).content.text); +} + +describe("import replay (no client-side history)", () => { + function createImportReplayContext() { + const { context, updates } = createHandlerContext(); + context.streamedAssistantBlocks = undefined; + context.isImportReplay = true; + return { context, updates }; + } + + it("forwards top-level assistant text during import replay", async () => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage( + assistantMessage("msg_1", [{ type: "text", text: "replayed answer" }]), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual([ + "replayed answer", + ]); + }); + + it("emits and marks plain-text user prompts during import replay", async () => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage(userMessage("my earlier prompt"), context); + expect(userChunkTexts(updates)).toEqual(["my earlier prompt"]); + const chunk = updates.find( + (u) => u.update.sessionUpdate === "user_message_chunk", + ); + expect( + (chunk?.update as { _meta?: { importedUserPrompt?: boolean } })._meta + ?.importedUserPrompt, + ).toBe(true); + }); + + it.each([ + { + name: "with args", + raw: "review\n/review\n#2198 - findings first", + expected: "/review #2198 - findings first", + }, + { + name: "no args", + raw: "compact\n/compact\n", + expected: "/compact", + }, + ])( + "surfaces a typed slash command ($name), not its raw markers", + async ({ raw, expected }) => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage(userMessage(raw), context); + expect(userChunkTexts(updates)).toEqual([expected]); + }, + ); + + it("strips stray markers from a non-command prompt instead of leaking them", async () => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage( + userMessage("note stray"), + context, + ); + const [text] = userChunkTexts(updates); + expect(text).not.toContain(""); + expect(text).toContain("note"); + }); + + it("skips a pure-marker user prompt instead of emitting a hollow chunk", async () => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage( + userMessage("stray"), + context, + ); + expect(userChunkTexts(updates)).toEqual([]); + }); + + it("still drops subagent assistant text during import replay", async () => { + const { context, updates } = createImportReplayContext(); + await handleUserAssistantMessage( + assistantMessage("msg_1", [{ type: "text", text: "subagent" }], "tool_1"), + context, + ); + expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]); + }); +}); diff --git a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts index 3d5dc0e28b..fe4197dacb 100644 --- a/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts +++ b/packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts @@ -21,6 +21,7 @@ import type { BetaContentBlock, BetaRawContentBlockDelta, } from "@anthropic-ai/sdk/resources/beta.mjs"; +import { IMPORTED_USER_PROMPT_META_KEY } from "@posthog/shared"; import { POSTHOG_NOTIFICATIONS } from "@/acp-extensions"; import { image, text } from "../../../utils/acp-content"; import { unreachable } from "../../../utils/common"; @@ -109,6 +110,8 @@ export interface MessageHandlerContext { supportsTerminalOutput?: boolean; /** Absent on replay, where the legacy drop-all text/thinking filter applies. */ streamedAssistantBlocks?: StreamedAssistantBlocks; + /** Replaying an imported transcript: client has no history, so emit user/assistant text instead of dropping it. */ + isImportReplay?: boolean; } function messageUpdateType(role: Role) { @@ -1062,6 +1065,19 @@ function stripLocalCommandMetadata(content: string): string | null { return stripped.trim() === "" ? null : stripped; } +/** `/review#2198` → `/review #2198`; null if no command-name. */ +function extractSlashCommandInvocation(content: string): string | null { + if (!content.includes("")) return null; + const name = content + .match(/([\s\S]*?)<\/command-name>/)?.[1] + ?.trim(); + if (!name) return null; + const args = content + .match(/([\s\S]*?)<\/command-args>/)?.[1] + ?.trim(); + return args ? `${name} ${args}` : name; +} + function isLoginRequiredMessage(message: AnthropicMessageWithContent): boolean { return ( message.type === "assistant" && @@ -1117,11 +1133,20 @@ function logSpecialMessages( function filterAssistantContent( message: SDKAssistantMessage, streamed: StreamedAssistantBlocks | undefined, + isImportReplay?: boolean, ): SDKAssistantMessage["message"]["content"] { const content = message.message.content; const isTopLevel = "parent_tool_use_id" in message && message.parent_tool_use_id === null; if (!streamed || !isTopLevel) { + // No client history to dedupe against: keep top-level text/thinking. + if (isImportReplay && isTopLevel) { + return content.filter((block) => { + if (block.type !== "text" && block.type !== "thinking") return true; + const blockText = block.type === "text" ? block.text : block.thinking; + return blockText.length > 0; + }); + } return content.filter( (block) => block.type !== "text" && block.type !== "thinking", ); @@ -1197,16 +1222,30 @@ export async function handleUserAssistantMessage( return {}; } - // Skip plain text user messages (already displayed by the ACP client) - if (isPlainTextUserMessage(message)) { + // Skip plain-text user messages (already shown by the client) — except on import replay, which has no history. + if (!context.isImportReplay && isPlainTextUserMessage(message)) { return {}; } const content = message.message.content; - const contentToProcess = - message.type === "assistant" - ? filterAssistantContent(message, context.streamedAssistantBlocks) - : content; + let contentToProcess: typeof content; + if (message.type === "assistant") { + contentToProcess = filterAssistantContent( + message, + context.streamedAssistantBlocks, + context.isImportReplay, + ); + } else if (context.isImportReplay && typeof content === "string") { + // Surface the typed slash command from its persisted markers; else strip stray markers. + const surfaced = + extractSlashCommandInvocation(content) ?? + stripLocalCommandMetadata(content); + // Nothing renderable (pure-marker payload): skip rather than emit a hollow chunk. + if (surfaced === null) return {}; + contentToProcess = surfaced; + } else { + contentToProcess = content; + } const parentToolCallId = "parent_tool_use_id" in message ? (message.parent_tool_use_id ?? undefined) @@ -1235,6 +1274,17 @@ export async function handleUserAssistantMessage( context.enrichedReadCache, session.taskState, )) { + // The renderer ignores raw user chunks; mark imported ones so the load path can promote them. + if ( + context.isImportReplay && + message.type === "user" && + notification.update.sessionUpdate === "user_message_chunk" + ) { + (notification.update as { _meta?: Record })._meta = { + ...(notification.update as { _meta?: Record })._meta, + [IMPORTED_USER_PROMPT_META_KEY]: true, + }; + } await client.sessionUpdate(notification); session.notificationHistory.push(notification); } diff --git a/packages/agent/src/adapters/claude/session/jsonl-hydration.ts b/packages/agent/src/adapters/claude/session/jsonl-hydration.ts index 14b39a71b6..19f62e097d 100644 --- a/packages/agent/src/adapters/claude/session/jsonl-hydration.ts +++ b/packages/agent/src/adapters/claude/session/jsonl-hydration.ts @@ -55,14 +55,23 @@ function hashString(s: string): string { return Math.abs(hash).toString(36); } -export function getSessionJsonlPath(sessionId: string, cwd: string): string { - const configDir = - process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); +export function encodeCwdToProjectKey(cwd: string): string { let projectKey = cwd.replace(/[^a-zA-Z0-9]/g, "-"); if (projectKey.length > MAX_PROJECT_KEY_LENGTH) { projectKey = `${projectKey.slice(0, MAX_PROJECT_KEY_LENGTH)}-${hashString(cwd)}`; } - return path.join(configDir, "projects", projectKey, `${sessionId}.jsonl`); + return projectKey; +} + +export function getSessionJsonlPath(sessionId: string, cwd: string): string { + const configDir = + process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), ".claude"); + return path.join( + configDir, + "projects", + encodeCwdToProjectKey(cwd), + `${sessionId}.jsonl`, + ); } export function rebuildConversation( diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 8ecefc4d5e..d9909e5bfe 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -134,6 +134,7 @@ export { } from "./seat"; export { type AcpMessage, + IMPORTED_USER_PROMPT_META_KEY, isJsonRpcNotification, isJsonRpcRequest, isJsonRpcResponse, diff --git a/packages/shared/src/session-events.ts b/packages/shared/src/session-events.ts index eb0949d24f..c2a4a375b3 100644 --- a/packages/shared/src/session-events.ts +++ b/packages/shared/src/session-events.ts @@ -64,6 +64,9 @@ export interface AcpMessage { message: JsonRpcMessage; } +/** Marks a replayed `user_message_chunk` from an imported transcript so the load path promotes it into a user bubble. */ +export const IMPORTED_USER_PROMPT_META_KEY = "importedUserPrompt"; + /** * S3 log entry format for stored session logs. * Used when fetching historical logs and appending new entries. diff --git a/packages/shared/src/task-creation-domain.ts b/packages/shared/src/task-creation-domain.ts index 93001c0d53..0dfe60222a 100644 --- a/packages/shared/src/task-creation-domain.ts +++ b/packages/shared/src/task-creation-domain.ts @@ -53,6 +53,12 @@ export interface TaskCreationInput { // Label of the Home-tab quick action that started this run (e.g. "Fix CI"), so the // workstream can show which quick actions have been run against it. homeQuickActionLabel?: string; + /** + * Continue a Claude Code CLI session by importing its transcript and resuming + * with replay. Local mode only; forces the claude adapter. `branch` is what the + * session last worked on, linked so the branch-mismatch prompt can fire. + */ + importedClaudeSession?: { sourceSessionId: string; branch?: string | null }; } export interface TaskCreationOutput {