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
1 change: 1 addition & 0 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2230,6 +2230,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
enrichedReadCache: this.enrichedReadCache,
logger: this.logger,
registerHooks: false,
isImportReplay: true,
};

for (const msg of messages) {
Expand Down
101 changes: 101 additions & 0 deletions packages/agent/src/adapters/claude/conversion/sdk-to-acp.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -260,3 +261,103 @@ describe("assembled assistant text fallback", () => {
expect(chunkTexts(updates, "agent_message_chunk")).toEqual([]);
});
});

function userMessage(
content: string | Array<Record<string, unknown>>,
): 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: "<command-message>review</command-message>\n<command-name>/review</command-name>\n<command-args>#2198 - findings first</command-args>",
expected: "/review #2198 - findings first",
},
{
name: "no args",
raw: "<command-message>compact</command-message>\n<command-name>/compact</command-name>\n<command-args></command-args>",
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 <command-args>stray</command-args>"),
context,
);
const [text] = userChunkTexts(updates);
expect(text).not.toContain("<command-args>");
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("<command-args>stray</command-args>"),
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([]);
});
});
62 changes: 56 additions & 6 deletions packages/agent/src/adapters/claude/conversion/sdk-to-acp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -1062,6 +1065,19 @@ function stripLocalCommandMetadata(content: string): string | null {
return stripped.trim() === "" ? null : stripped;
}

/** `<command-name>/review</command-name><command-args>#2198</command-args>` → `/review #2198`; null if no command-name. */
function extractSlashCommandInvocation(content: string): string | null {
if (!content.includes("<command-name>")) return null;
const name = content
.match(/<command-name>([\s\S]*?)<\/command-name>/)?.[1]
?.trim();
if (!name) return null;
const args = content
.match(/<command-args>([\s\S]*?)<\/command-args>/)?.[1]
?.trim();
return args ? `${name} ${args}` : name;
}

function isLoginRequiredMessage(message: AnthropicMessageWithContent): boolean {
return (
message.type === "assistant" &&
Expand Down Expand Up @@ -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",
);
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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<string, unknown> })._meta = {
...(notification.update as { _meta?: Record<string, unknown> })._meta,
[IMPORTED_USER_PROMPT_META_KEY]: true,
};
}
await client.sessionUpdate(notification);
session.notificationHistory.push(notification);
}
Expand Down
17 changes: 13 additions & 4 deletions packages/agent/src/adapters/claude/session/jsonl-hydration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,7 @@ export {
} from "./seat";
export {
type AcpMessage,
IMPORTED_USER_PROMPT_META_KEY,
isJsonRpcNotification,
isJsonRpcRequest,
isJsonRpcResponse,
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
6 changes: 6 additions & 0 deletions packages/shared/src/task-creation-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading