Skip to content
Draft
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
45 changes: 44 additions & 1 deletion packages/core/src/sessions/sessionEvents.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import type { ContentBlock } from "@agentclientprotocol/sdk";
import type { AcpMessage } from "@posthog/shared";
import type { AcpMessage, StoredLogEntry } from "@posthog/shared";
import { describe, expect, it } from "vitest";

import { makeAttachmentUri } from "./promptContent";
import {
convertStoredEntriesToEvents,
extractUserPromptsFromEvents,
hasSessionPromptEvent,
isAbsoluteFolderPath,
Expand Down Expand Up @@ -196,6 +197,48 @@ describe("hasSessionPromptEvent", () => {
});
});

describe("convertStoredEntriesToEvents — imported user prompts", () => {
const userChunkEntry = (
text: string,
meta?: Record<string, unknown>,
): StoredLogEntry =>
({
timestamp: "2026-06-22T00:00:00.000Z",
notification: {
jsonrpc: "2.0",
method: "session/update",
params: {
update: {
sessionUpdate: "user_message_chunk",
content: { type: "text", text },
...(meta ? { _meta: meta } : {}),
},
},
},
}) as unknown as StoredLogEntry;

it("promotes a marked imported user prompt into a session/prompt event", () => {
const events = convertStoredEntriesToEvents([
userChunkEntry("my earlier prompt", { importedUserPrompt: true }),
]);
const msg = events[0].message;
expect("method" in msg && msg.method).toBe("session/prompt");
const params = (msg as { params?: { prompt?: ContentBlock[] } }).params;
expect(params?.prompt?.[0]).toEqual({
type: "text",
text: "my earlier prompt",
});
});

it("leaves an unmarked user_message_chunk as a raw notification", () => {
const events = convertStoredEntriesToEvents([
userChunkEntry("internal user content"),
]);
const msg = events[0].message;
expect("method" in msg && msg.method).toBe("session/update");
});
});

describe("isAbsoluteFolderPath", () => {
it.each(["/Users/x/repo", "~/repo", "C:\\repo", "D:/repo"])(
"treats %s as absolute",
Expand Down
42 changes: 40 additions & 2 deletions packages/core/src/sessions/sessionEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,21 +14,59 @@ import type {
StoredLogEntry,
UserShellExecuteParams,
} from "@posthog/shared";
import { isJsonRpcNotification, isJsonRpcRequest } from "@posthog/shared";
import {
IMPORTED_USER_PROMPT_META_KEY,
isJsonRpcNotification,
isJsonRpcRequest,
} from "@posthog/shared";
import { isNotification, POSTHOG_NOTIFICATIONS } from "./acpNotifications";
import { extractPromptDisplayContent } from "./promptContent";

/**
* Convert a stored log entry to an ACP message.
*/
function storedEntryToAcpMessage(entry: StoredLogEntry): AcpMessage {
const ts = entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now();
const promoted = promoteImportedUserPrompt(entry, ts);
if (promoted) return promoted;
return {
type: "acp_message",
ts: entry.timestamp ? new Date(entry.timestamp).getTime() : Date.now(),
ts,
message: (entry.notification ?? {}) as JsonRpcMessage,
};
}

/**
* 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.
*/
function promoteImportedUserPrompt(
entry: StoredLogEntry,
ts: number,
): AcpMessage | null {
const notification = entry.notification as
| { method?: string; params?: { update?: Record<string, unknown> } }
| undefined;
if (notification?.method !== "session/update") return null;
const update = notification.params?.update;
const meta = update?._meta as Record<string, unknown> | undefined;
if (
!update ||
update.sessionUpdate !== "user_message_chunk" ||
meta?.[IMPORTED_USER_PROMPT_META_KEY] !== true
) {
return null;
}
const content = update.content as
| { type?: string; text?: string }
| undefined;
if (content?.type !== "text" || !content.text) return null;
return createUserMessageEvent(content.text, ts);
}

/**
* Create a user message event for display.
*/
Expand Down
28 changes: 28 additions & 0 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,11 @@ export interface ConnectParams {
adapter?: "claude" | "codex";
model?: string;
reasoningLevel?: string;
/**
* Session ID of an imported Claude Code CLI transcript already copied into
* the app's Claude config dir. The agent loads it and replays its history.
*/
importedSessionId?: string;
}

export interface CloudConnectionAuth {
Expand Down Expand Up @@ -607,6 +612,7 @@ export class SessionService {
adapter,
model,
reasoningLevel,
importedSessionId,
} = params;
const { id: taskId, latest_run: latestRun } = task;
const taskTitle = task.title || task.description || "Task";
Expand Down Expand Up @@ -716,6 +722,7 @@ export class SessionService {
adapter,
model,
reasoningLevel,
importedSessionId,
);
}
} catch (error) {
Expand Down Expand Up @@ -1195,6 +1202,7 @@ export class SessionService {
adapter?: "claude" | "codex",
model?: string,
reasoningLevel?: string,
importedSessionId?: string,
): Promise<void> {
const { client } = auth;
if (!client) {
Expand All @@ -1221,12 +1229,32 @@ export class SessionService {
? (reasoningLevel as EffortLevel)
: undefined,
model: preferredModel,
importedSessionId,
});

const session = createBaseSession(taskRun.id, taskId, taskTitle);
session.channel = result.channel;
session.status = "connected";
session.adapter = adapter;

// An imported CLI session had its history replayed during agent.start;
// the replay is already in the local run log, so load it for the UI.
if (importedSessionId) {
try {
const { rawEntries } = await this.fetchSessionLogs(
undefined,
taskRun.id,
);
session.events = convertStoredEntriesToEvents(rawEntries);
} catch {
this.d.log.warn(
"Failed to load replayed history for imported session",
{
taskRunId: taskRun.id,
},
);
}
}
const configOptions = result.configOptions as
| SessionConfigOption[]
| undefined;
Expand Down
39 changes: 39 additions & 0 deletions packages/core/src/task-detail/taskCreationHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,25 @@ export interface SetupActionDispatch {
label: string;
}

export interface ClaudeCliImportFingerprint {
sourceMtimeMs: number;
sourceSizeBytes: number;
sourceLastEntryUuid: string | null;
}

export interface ImportedClaudeCliSession {
importedSessionId: string;
fingerprint: ClaudeCliImportFingerprint;
}

export interface RecordClaudeCliImportArgs {
sourceSessionId: string;
importedSessionId: string;
repoPath: string;
taskId: string;
fingerprint: ClaudeCliImportFingerprint;
}

export interface ITaskCreationHost {
getAuthenticatedClient(): Promise<TaskCreationApiClient | null>;
assertCloudUsageAvailable(): Promise<void>;
Expand Down Expand Up @@ -92,4 +111,24 @@ export interface ITaskCreationHost {
clearProvisioning(taskId: string): void;
dispatchSetupAction(args: SetupActionDispatch): void;
track(event: string, props?: Record<string, unknown>): void;
importClaudeCliSession(args: {
repoPath: string;
sourceSessionId: string;
}): Promise<ImportedClaudeCliSession>;
/** Compensate the import step: remove the copied transcript on rollback. */
deleteClaudeCliImport(args: {
repoPath: string;
importedSessionId: string;
}): Promise<void>;
recordClaudeCliImport(args: RecordClaudeCliImportArgs): Promise<void>;
/** Compensate the record step: drop the tracking row on rollback. */
deleteClaudeCliImportRecord(args: {
importedSessionId: string;
}): Promise<void>;
/**
* Link the task to the branch the imported session worked on, without
* checking it out. Lets the standard branch-mismatch prompt surface if the
* local checkout is on a different branch.
*/
linkTaskBranch(args: { taskId: string; branchName: string }): Promise<void>;
}
Loading
Loading