diff --git a/.gitignore b/.gitignore index 22d1e3f3..63ef6509 100644 --- a/.gitignore +++ b/.gitignore @@ -190,6 +190,11 @@ package-lock.json # Test output test-results/ +# Local test drafts (force-add intentional new tests with git add -f) +test/ +test.ts +*.test.ts + # Auto-Research-Skills (local-only) Auto-Research-Skills/ diff --git a/scripts/tui-e2e-permission.tsx b/scripts/tui-e2e-permission.tsx index cccdf0e2..ac81e0f0 100644 --- a/scripts/tui-e2e-permission.tsx +++ b/scripts/tui-e2e-permission.tsx @@ -98,6 +98,7 @@ class MockGateway implements Gateway { grantSessionPermission = stub({ granted: false }) as Gateway["grantSessionPermission"]; readSessionMessages = stub({ messages: [], hasMore: false, session: {} as any }) as unknown as Gateway["readSessionMessages"]; readSubagentMessages = stub({ messages: [], total: 0 }) as unknown as Gateway["readSubagentMessages"]; + forkSession = stub({ newSessionKey: "web:s_fork", prefillText: "", carriedMessageCount: 0 }) as unknown as Gateway["forkSession"]; listProjects = stub({ projects: [] }) as Gateway["listProjects"]; describeProject = stub({ projectKey: "", name: "", root: "", fullPath: "", sessionCount: 0 }) as unknown as Gateway["describeProject"]; } diff --git a/src/agent/runtime/AgentRuntimeDependencies.ts b/src/agent/runtime/AgentRuntimeDependencies.ts index 05aba9ea..5b14852f 100644 --- a/src/agent/runtime/AgentRuntimeDependencies.ts +++ b/src/agent/runtime/AgentRuntimeDependencies.ts @@ -69,7 +69,12 @@ export type AgentSubagentTranscriptHooks = { errored?: boolean; }): Promise; subagentTranscriptResolver?(subagentId: string): { - recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise; + recordAcceptedInput( + sessionId: string, + turnId: string, + messages: CanonicalMessage[], + metadata?: Record, + ): Promise; recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): Promise; transcriptRelativePath: string; }; diff --git a/src/agent/sub/SubAgentSession.ts b/src/agent/sub/SubAgentSession.ts index f36a4faf..a44b4fca 100644 --- a/src/agent/sub/SubAgentSession.ts +++ b/src/agent/sub/SubAgentSession.ts @@ -87,7 +87,12 @@ export type SubAgentSessionOptions = { * (the parent constructs the writer and passes it in). */ export type SidechainTranscriptWriter = { - recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise; + recordAcceptedInput( + sessionId: string, + turnId: string, + messages: CanonicalMessage[], + metadata?: Record, + ): Promise; recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): Promise; }; diff --git a/src/agent/turn/TurnRunner.ts b/src/agent/turn/TurnRunner.ts index c258abad..2d503413 100644 --- a/src/agent/turn/TurnRunner.ts +++ b/src/agent/turn/TurnRunner.ts @@ -59,7 +59,12 @@ export class TurnRunner { const messages = [...options.messages, ...accepted.messages]; try { - await this.transcript.recordAcceptedInput(options.sessionId, options.turnId, accepted.messages); + await this.transcript.recordAcceptedInput( + options.sessionId, + options.turnId, + accepted.messages, + acceptedInputMetadata(options), + ); } catch (error) { const agentTranscriptError = agentError("agent_transcript_error", "Failed to record accepted input.", error); const result = this.createErrorResult(options, agentTranscriptError); @@ -156,6 +161,20 @@ export class TurnRunner { } } +function acceptedInputMetadata(options: TurnRunnerOptions): Record | undefined { + const metadata: Record = {}; + if (options.permissionMode) { + metadata.permissionMode = options.permissionMode; + } + if (options.basePermissionMode) { + metadata.basePermissionMode = options.basePermissionMode; + } + if (options.allowPlanModeTools !== undefined) { + metadata.allowPlanModeTools = options.allowPlanModeTools; + } + return Object.keys(metadata).length > 0 ? metadata : undefined; +} + function emptyUsage(): CanonicalUsage { return {}; } diff --git a/src/cli/createLocalGateway.ts b/src/cli/createLocalGateway.ts index af2799d5..42a64344 100644 --- a/src/cli/createLocalGateway.ts +++ b/src/cli/createLocalGateway.ts @@ -64,6 +64,7 @@ import { DEFAULT_JUDGE_TIMEOUT_MS, DEFAULT_SUBAGENT_MAX_TOKENS, DEFAULT_ALLOWED_ import { createAgentProjectSessionStorage, listProjectSessions, resumeAgentSession } from "../session/index.js"; import { sanitizeSessionIdForPath } from "../session/storage/ProjectSessionStorage.js"; import { readWebSessionMessages, readSubagentWebMessages } from "../web/server/readSessionMessages.js"; +import { forkWebSession } from "../web/server/forkSession.js"; import { describeWebProject, listWebProjects } from "../web/server/listProjects.js"; import { BackgroundTaskRuntime } from "../task/runtime/BackgroundTaskRuntime.js"; import { createBuiltinRegistry, createPlanFileManager } from "../tool/index.js"; @@ -269,6 +270,12 @@ export function createLocalGateway(options: CreateLocalGatewayOptions = {}): Cre pilotHome, now, }), + forkSession: (input) => + forkWebSession(input, { + projectRoot: input.projectKey ? input.projectKey : projectRoot, + pilotHome, + now, + }), listProjects: () => listWebProjects({ pilotHome, defaultProjectRoot: options.skipDefaultProject ? undefined : projectRoot }), describeProject: (input) => diff --git a/src/cli/pilotdeck.ts b/src/cli/pilotdeck.ts index 8c6d4226..52c506ca 100644 --- a/src/cli/pilotdeck.ts +++ b/src/cli/pilotdeck.ts @@ -680,6 +680,9 @@ function createFallbackGateway(): Gateway { readSubagentMessages: async () => { throw new Error("read_subagent_messages is not configured."); }, + forkSession: async () => { + throw new Error("fork_session is not configured."); + }, listProjects: async () => ({ projects: [] }), describeProject: async (input) => ({ projectKey: input.projectKey, diff --git a/src/context/DefaultContextRuntime.ts b/src/context/DefaultContextRuntime.ts index a15be07a..b464babc 100644 --- a/src/context/DefaultContextRuntime.ts +++ b/src/context/DefaultContextRuntime.ts @@ -282,7 +282,7 @@ export class DefaultContextRuntime implements ContextRuntime { await this.memoryResolver.captureTurn({ sessionId: input.sessionId, projectRoot: this.projectRoot ?? "", - messages: input.messages, + messages: input.messages.filter((message) => !message.metadata?.forkCarryover), errored: input.errored, }); } catch { diff --git a/src/context/memory/EdgeClawMemoryProvider.ts b/src/context/memory/EdgeClawMemoryProvider.ts index 7ed1c2ea..e880aff8 100644 --- a/src/context/memory/EdgeClawMemoryProvider.ts +++ b/src/context/memory/EdgeClawMemoryProvider.ts @@ -163,7 +163,9 @@ export class EdgeClawMemoryProvider implements MemoryResolver { } async captureTurn(input: MemoryCaptureTurnInput): Promise { - const normalizedMessages = canonicalMessagesToMemoryMessages(input.messages); + const normalizedMessages = canonicalMessagesToMemoryMessages(input.messages, { + includeForkCarryover: false, + }); this.options.telemetry?.trackFeatureLoopStage({ module: "memory", ownerModule: "memory", diff --git a/src/context/memory/MemoryResolver.ts b/src/context/memory/MemoryResolver.ts index 3467f4e4..1ee7dc1e 100644 --- a/src/context/memory/MemoryResolver.ts +++ b/src/context/memory/MemoryResolver.ts @@ -38,8 +38,19 @@ export type MemoryResolver = { captureTurn(input: MemoryCaptureTurnInput): Promise; }; -export function canonicalMessagesToMemoryMessages(messages: CanonicalMessage[]): ContextMemoryMessage[] { +export type CanonicalMessagesToMemoryMessagesOptions = { + includeForkCarryover?: boolean; +}; + +export function canonicalMessagesToMemoryMessages( + messages: CanonicalMessage[], + options: CanonicalMessagesToMemoryMessagesOptions = {}, +): ContextMemoryMessage[] { return messages.flatMap((message, index) => { + if (options.includeForkCarryover === false && message.metadata?.forkCarryover) { + return []; + } + const entries: Array> = []; const pushEntry = (role: string, text: string) => { const content = text.trim(); diff --git a/src/gateway/client/InProcessGateway.ts b/src/gateway/client/InProcessGateway.ts index 6bbdc967..d084e80a 100644 --- a/src/gateway/client/InProcessGateway.ts +++ b/src/gateway/client/InProcessGateway.ts @@ -40,6 +40,8 @@ import type { WebReadSessionMessagesResult, WebReadSubagentMessagesInput, WebReadSubagentMessagesResult, + WebForkSessionInput, + WebForkSessionResult, } from "../protocol/types.js"; import type { CronCreateInput, @@ -92,6 +94,7 @@ export type InProcessGatewayOptions = { */ readSessionMessages?: (input: WebReadSessionMessagesInput) => Promise; readSubagentMessages?: (input: WebReadSubagentMessagesInput) => Promise; + forkSession?: (input: WebForkSessionInput) => Promise; /** * Web Phase 3 — pluggable project enumerator + describer. */ @@ -616,6 +619,15 @@ export class InProcessGateway implements Gateway { return this.options.readSubagentMessages(input); } + async forkSession(input: WebForkSessionInput): Promise { + if (!this.options.forkSession) { + throw new Error( + "fork_session is not configured. Wire `forkSession` via createLocalGateway.", + ); + } + return this.options.forkSession(input); + } + async listProjects(): Promise { if (!this.options.listProjects) { throw new Error("list_projects is not configured."); diff --git a/src/gateway/client/RemoteGateway.ts b/src/gateway/client/RemoteGateway.ts index 9e423196..a88e6e88 100644 --- a/src/gateway/client/RemoteGateway.ts +++ b/src/gateway/client/RemoteGateway.ts @@ -22,6 +22,8 @@ import type { WebReadSessionMessagesResult, WebReadSubagentMessagesInput, WebReadSubagentMessagesResult, + WebForkSessionInput, + WebForkSessionResult, } from "../protocol/types.js"; import type { SkillAddressInput, @@ -134,6 +136,10 @@ export class RemoteGateway implements Gateway { return (await this.client.request("read_subagent_messages", input)) as WebReadSubagentMessagesResult; } + async forkSession(input: WebForkSessionInput): Promise { + return (await this.client.request("fork_session", input)) as WebForkSessionResult; + } + async listProjects(): Promise { return (await this.client.request("list_projects", {})) as WebListProjectsResult; } diff --git a/src/gateway/protocol/frames.ts b/src/gateway/protocol/frames.ts index e5785c73..8cc5d693 100644 --- a/src/gateway/protocol/frames.ts +++ b/src/gateway/protocol/frames.ts @@ -36,6 +36,7 @@ export type WsGatewayMethod = | "grant_session_permission" | "read_session_messages" | "read_subagent_messages" + | "fork_session" | "list_projects" | "describe_project" | "reload_config" diff --git a/src/gateway/protocol/types.ts b/src/gateway/protocol/types.ts index 4fb4e366..37a06486 100644 --- a/src/gateway/protocol/types.ts +++ b/src/gateway/protocol/types.ts @@ -25,6 +25,8 @@ import type { WebReadSessionMessagesResult as WebUiReadSessionMessagesResult, WebReadSubagentMessagesInput as WebUiReadSubagentMessagesInput, WebReadSubagentMessagesResult as WebUiReadSubagentMessagesResult, + WebForkSessionInput as WebUiForkSessionInput, + WebForkSessionResult as WebUiForkSessionResult, } from "../../web/client/protocol.js"; import type { SkillCreateInput, @@ -222,6 +224,8 @@ export type WebReadSessionMessagesInput = WebUiReadSessionMessagesInput; export type WebReadSessionMessagesResult = WebUiReadSessionMessagesResult; export type WebReadSubagentMessagesInput = WebUiReadSubagentMessagesInput; export type WebReadSubagentMessagesResult = WebUiReadSubagentMessagesResult; +export type WebForkSessionInput = WebUiForkSessionInput; +export type WebForkSessionResult = WebUiForkSessionResult; export type WebProjectSummary = WebUiProjectSummary; export type WebListProjectsResult = WebUiListProjectsResult; export type WebDescribeProjectInput = { projectKey: string }; @@ -343,6 +347,10 @@ export interface Gateway { * the Web `WebMessage` DTO. */ readSessionMessages(input: WebReadSessionMessagesInput): Promise; + /** + * Fork a session transcript at a prior user turn into a new session file. + */ + forkSession(input: WebForkSessionInput): Promise; /** * Read a subagent's sidechain transcript and return its messages in WebMessage format. */ diff --git a/src/gateway/server/GatewayWsConnection.ts b/src/gateway/server/GatewayWsConnection.ts index a4932a22..1ac32d49 100644 --- a/src/gateway/server/GatewayWsConnection.ts +++ b/src/gateway/server/GatewayWsConnection.ts @@ -210,6 +210,8 @@ export class GatewayWsConnection { return this.options.gateway.readSessionMessages(frame.params as never); case "read_subagent_messages": return this.options.gateway.readSubagentMessages(frame.params as never); + case "fork_session": + return this.options.gateway.forkSession(frame.params as never); case "list_projects": return this.options.gateway.listProjects(); case "describe_project": diff --git a/src/model/protocol/canonical.ts b/src/model/protocol/canonical.ts index 716c6f35..4e9a245c 100644 --- a/src/model/protocol/canonical.ts +++ b/src/model/protocol/canonical.ts @@ -131,6 +131,10 @@ export type CanonicalMessageMetadata = { /** True for messages injected by the system (e.g. JSON self-correct prompts). */ synthetic?: boolean; purpose?: string; + forkCarryover?: { + sourceSessionId: string; + sourceTurnId?: string; + }; }; export type CanonicalMessage = { diff --git a/src/session/storage/SessionList.ts b/src/session/storage/SessionList.ts index 331bd87c..1cd93589 100644 --- a/src/session/storage/SessionList.ts +++ b/src/session/storage/SessionList.ts @@ -20,6 +20,8 @@ export type SessionInfo = { cwd?: string; tag?: string; createdAt?: number; + parentSessionId?: string; + forkedFromTurnId?: string; }; export type ListProjectSessionsOptions = { @@ -73,6 +75,8 @@ export function parseSessionInfoFromLite( const customTitle = lastMetadataStringField(source, "title"); const aiTitle = lastMetadataStringField(source, "aiTitle"); const tag = lastMetadataStringField(source, "tag"); + const parentSessionId = lastMetadataStringField(source, "parentSessionId"); + const forkedFromTurnId = lastMetadataStringField(source, "forkedFromTurnId"); const firstPrompt = firstAcceptedInputText(lite.head); const lastPrompt = lastAcceptedInputText(lite.tail) ?? firstPrompt; const summary = customTitle ?? aiTitle ?? lastPrompt; @@ -92,6 +96,8 @@ export function parseSessionInfoFromLite( cwd: projectRoot, tag, createdAt: firstCreatedAt ? Date.parse(firstCreatedAt) : undefined, + parentSessionId, + forkedFromTurnId, }; } diff --git a/src/session/transcript/InMemoryTranscriptWriter.ts b/src/session/transcript/InMemoryTranscriptWriter.ts index 3d67e613..bbb1776f 100644 --- a/src/session/transcript/InMemoryTranscriptWriter.ts +++ b/src/session/transcript/InMemoryTranscriptWriter.ts @@ -4,7 +4,13 @@ import type { AgentControlBoundaryTranscriptEntry, SessionMetadataValue } from " import type { AgentTranscriptWriter, AgentTranscriptWriterState } from "./TranscriptWriter.js"; export type InMemoryTranscriptEntry = - | { type: "accepted_input"; sessionId: string; turnId: string; messages: CanonicalMessage[] } + | { + type: "accepted_input"; + sessionId: string; + turnId: string; + messages: CanonicalMessage[]; + metadata?: Record; + } | { type: "durable_message"; sessionId: string; turnId: string; message: CanonicalMessage } | { type: "turn_result"; sessionId: string; turnId: string; result: AgentTurnResult } | { type: "session_metadata"; sessionId: string; turnId: string; metadata: SessionMetadataValue } @@ -18,8 +24,19 @@ export type InMemoryTranscriptEntry = export class InMemoryTranscriptWriter implements AgentTranscriptWriter { readonly entries: InMemoryTranscriptEntry[] = []; - recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): void { - this.entries.push({ type: "accepted_input", sessionId, turnId, messages }); + recordAcceptedInput( + sessionId: string, + turnId: string, + messages: CanonicalMessage[], + metadata?: Record, + ): void { + this.entries.push({ + type: "accepted_input", + sessionId, + turnId, + messages, + ...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}), + }); } recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): void { diff --git a/src/session/transcript/JsonlTranscriptWriter.ts b/src/session/transcript/JsonlTranscriptWriter.ts index 7131e653..31fb9961 100644 --- a/src/session/transcript/JsonlTranscriptWriter.ts +++ b/src/session/transcript/JsonlTranscriptWriter.ts @@ -66,11 +66,17 @@ export class JsonlTranscriptWriter implements AgentTranscriptWriter { }; } - recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): Promise { + recordAcceptedInput( + sessionId: string, + turnId: string, + messages: CanonicalMessage[], + metadata?: Record, + ): Promise { return this.recordEntry({ type: "accepted_input", ...this.baseEntry(sessionId, turnId), messages, + ...(metadata && Object.keys(metadata).length > 0 ? { metadata } : {}), }); } diff --git a/src/session/transcript/TranscriptEntry.ts b/src/session/transcript/TranscriptEntry.ts index 76e34b70..d2c27f89 100644 --- a/src/session/transcript/TranscriptEntry.ts +++ b/src/session/transcript/TranscriptEntry.ts @@ -25,6 +25,7 @@ export type AgentTranscriptEntryBase = { export type AgentAcceptedInputTranscriptEntry = AgentTranscriptEntryBase & { type: "accepted_input"; messages: CanonicalMessage[]; + metadata?: Record; }; export type AgentMessageTranscriptEntry = AgentTranscriptEntryBase & { @@ -97,6 +98,10 @@ export type SessionMetadataValue = { url: string; repository: string; }; + /** Parent session when this transcript was created via history fork. */ + parentSessionId?: string; + /** Turn id of the fork point in the parent session. */ + forkedFromTurnId?: string; updatedAt?: string; }; diff --git a/src/session/transcript/TranscriptWriter.ts b/src/session/transcript/TranscriptWriter.ts index e1398618..f75dc25f 100644 --- a/src/session/transcript/TranscriptWriter.ts +++ b/src/session/transcript/TranscriptWriter.ts @@ -12,7 +12,12 @@ export type AgentTranscriptWriterState = { }; export type AgentTranscriptWriter = { - recordAcceptedInput(sessionId: string, turnId: string, messages: CanonicalMessage[]): void | Promise; + recordAcceptedInput( + sessionId: string, + turnId: string, + messages: CanonicalMessage[], + metadata?: Record, + ): void | Promise; recordDurableMessage(sessionId: string, turnId: string, message: CanonicalMessage): void | Promise; recordTurnResult(sessionId: string, turnId: string, result: AgentTurnResult): void | Promise; recordSessionMetadata?(sessionId: string, turnId: string, metadata: SessionMetadataValue): void | Promise; diff --git a/src/web/client/GatewayBrowserClient.ts b/src/web/client/GatewayBrowserClient.ts index 51398bda..c171a9a2 100644 --- a/src/web/client/GatewayBrowserClient.ts +++ b/src/web/client/GatewayBrowserClient.ts @@ -204,6 +204,15 @@ export class GatewayBrowserClient { ); } + readSubagentMessages( + input: import("./protocol.js").WebReadSubagentMessagesInput, + ) { + return this.request( + "read_subagent_messages", + input, + ); + } + listProjects(): Promise { return this.request("list_projects", {}); } diff --git a/src/web/client/index.ts b/src/web/client/index.ts index 1d97c6ae..c52abee2 100644 --- a/src/web/client/index.ts +++ b/src/web/client/index.ts @@ -26,6 +26,8 @@ export { type WebProjectSummary, type WebReadSessionMessagesInput, type WebReadSessionMessagesResult, + type WebReadSubagentMessagesInput, + type WebReadSubagentMessagesResult, type WebRequestFrame, type WebResponseFrame, type WebSessionInfo, diff --git a/src/web/client/protocol.ts b/src/web/client/protocol.ts index 2bb7a50b..49a7dd37 100644 --- a/src/web/client/protocol.ts +++ b/src/web/client/protocol.ts @@ -108,6 +108,8 @@ export type WebGatewayMethod = | "permission_decide" | "grant_session_permission" | "read_session_messages" + | "read_subagent_messages" + | "fork_session" | "rename_session" | "delete_session" | "list_projects" @@ -159,6 +161,10 @@ export type WebSessionInfo = { cwd?: string; tag?: string; createdAt?: number; + sessionKind?: "background_task"; + parentSessionId?: string; + relativeTranscriptPath?: string; + forkedFromTurnId?: string; }; export type WebListSessionsInput = { @@ -228,6 +234,9 @@ export type WebSessionPermissionGrant = { export type WebReadSessionMessagesInput = { sessionKey: string; projectKey?: string; + sessionKind?: "background_task"; + parentSessionId?: string; + relativeTranscriptPath?: string; limit?: number; cursor?: string; direction?: "forward" | "backward"; @@ -244,6 +253,9 @@ export type WebReadSubagentMessagesInput = { sessionKey: string; subagentId: string; projectKey?: string; + sessionKind?: "background_task"; + parentSessionId?: string; + relativeTranscriptPath?: string; }; export type WebReadSubagentMessagesResult = { @@ -251,6 +263,20 @@ export type WebReadSubagentMessagesResult = { total: number; }; +export type WebForkSessionInput = { + sessionKey: string; + projectKey?: string; + /** Transcript entry id of the user turn to fork from (accepted_input entryId). */ + fromEntryId: string; +}; + +export type WebForkSessionResult = { + newSessionKey: string; + prefillText: string; + carriedMessageCount: number; + mode?: WebGatewayMode; +}; + export type WebActiveTurnSnapshotInput = { sessionKey: string; }; diff --git a/src/web/client/webMessage.ts b/src/web/client/webMessage.ts index 37de31e0..1eebfe22 100644 --- a/src/web/client/webMessage.ts +++ b/src/web/client/webMessage.ts @@ -89,6 +89,8 @@ export type WebMessage = { source: "live" | "history"; finishReason?: string; usage?: Record; + /** Transcript entry id when projected from history (used for history fork). */ + entryId?: string; }; export type WebMessageReducerOptions = { diff --git a/src/web/server/forkSession.ts b/src/web/server/forkSession.ts new file mode 100644 index 00000000..086c1a56 --- /dev/null +++ b/src/web/server/forkSession.ts @@ -0,0 +1,503 @@ +/** + * Fork a web session transcript at a prior user turn. + * + * Creates a new session file containing all entries strictly before the + * fork turn, copies auxiliary session dirs, and returns the forked user + * message text for composer prefill. + */ + +import { randomUUID } from "node:crypto"; +import type { Dirent } from "node:fs"; +import { chmod, cp, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises"; +import { dirname, isAbsolute, join, relative, resolve } from "node:path"; +import { platform } from "node:process"; +import type { CanonicalContentBlock, CanonicalMessage } from "../../model/index.js"; +import { getPilotProjectChatDir } from "../../pilot/index.js"; +import { readTranscript } from "../../session/transcript/TranscriptReader.js"; +import { + sanitizeSessionIdForPath, +} from "../../session/storage/ProjectSessionStorage.js"; +import type { + AgentAcceptedInputTranscriptEntry, + AgentSessionMetadataTranscriptEntry, + AgentTranscriptEntry, +} from "../../session/transcript/TranscriptEntry.js"; +import type { WebGatewayMode, WebForkSessionInput, WebForkSessionResult } from "../client/protocol.js"; + +export type ForkWebSessionOptions = { + projectRoot: string; + pilotHome: string; + now?: () => Date; +}; + +function newWebSessionKey(): string { + const sep = platform === "win32" ? "-" : ":"; + return `web${sep}s_${randomUUID()}`; +} + +function extractAcceptedInputText(entry: AgentAcceptedInputTranscriptEntry): string { + const chunks: string[] = []; + for (const message of entry.messages) { + for (const block of message.content as CanonicalContentBlock[]) { + if (block.type === "text" && block.text.trim()) { + chunks.push(block.text.trim()); + } + } + } + return chunks.join("\n\n").trim(); +} + +function hasUnsupportedPrefillContent(entry: AgentAcceptedInputTranscriptEntry): boolean { + return entry.messages.some((message) => + (message.content as CanonicalContentBlock[]).some((block) => block.type !== "text"), + ); +} + +function getForkMode(entry: AgentAcceptedInputTranscriptEntry): WebGatewayMode | undefined { + return entry.metadata?.permissionMode === "plan" ? "plan" : undefined; +} + +function buildForkTitle( + prefillText: string, + carriedMessageCount: number, + inheritedTitle: string | undefined, +): string { + const normalized = prefillText.replace(/\s+/g, " ").trim(); + if (normalized) { + const max = 48; + const snippet = normalized.length > max ? `${normalized.slice(0, max).trimEnd()}…` : normalized; + // A leading branch glyph keeps forks scannable even when titles collide. + return `⑂ ${snippet}`; + } + if (inheritedTitle) { + return `⑂ ${inheritedTitle}`; + } + return carriedMessageCount > 0 ? "⑂ Forked session" : "⑂ New branch"; +} + +function findForkTurnAcceptedInput( + entries: AgentTranscriptEntry[], + fromEntryId: string, +): AgentAcceptedInputTranscriptEntry { + const target = entries.find((entry) => entry.entryId === fromEntryId); + if (!target) { + throw new ForkSessionError("fork_entry_not_found", `Transcript entry not found: ${fromEntryId}`); + } + + if (target.type === "accepted_input") { + return target; + } + + const accepted = entries.find( + (entry): entry is AgentAcceptedInputTranscriptEntry => + entry.type === "accepted_input" && entry.turnId === target.turnId, + ); + if (!accepted) { + throw new ForkSessionError( + "fork_turn_not_found", + `No accepted_input found for turn ${target.turnId}`, + ); + } + return accepted; +} + +function lastSessionMetadata(entries: AgentTranscriptEntry[]): Record | undefined { + for (let index = entries.length - 1; index >= 0; index -= 1) { + const entry = entries[index]; + if (entry.type === "session_metadata") { + return entry.metadata as Record; + } + } + return undefined; +} + +function countCarriedUserAssistantMessages(entries: AgentTranscriptEntry[]): number { + let count = 0; + for (const entry of entries) { + switch (entry.type) { + case "accepted_input": + count += entry.messages.length; + break; + case "assistant_message": + case "tool_result_message": + case "durable_message": + count += 1; + break; + default: + break; + } + } + return count; +} + +function retargetAuxiliaryPath( + path: string, + sourceSessionDir: string, + targetSessionDir: string, +): string { + const absolutePath = resolve(path); + const relativePath = relative(sourceSessionDir, absolutePath); + if ( + relativePath === "" || + relativePath.startsWith("..") || + isAbsolute(relativePath) + ) { + return path; + } + return resolve(targetSessionDir, relativePath); +} + +function retargetRelativeSessionPath( + path: string, + sourceSafeId: string, + targetSafeId: string, +): string { + const parts = path.split(/[\\/]/); + if (parts[0] !== sourceSafeId) { + return path; + } + return [targetSafeId, ...parts.slice(1)].join("/"); +} + +function retargetContentBlock( + block: CanonicalContentBlock, + sourceSessionDir: string, + targetSessionDir: string, +): CanonicalContentBlock { + if (block.type === "tool_result_reference" || block.type === "media_reference") { + return { + ...block, + path: retargetAuxiliaryPath(block.path, sourceSessionDir, targetSessionDir), + }; + } + return block; +} + +function markMessageAsForkCarryover( + message: CanonicalMessage, + sourceSessionId: string, + sourceTurnId: string, +): CanonicalMessage { + return { + ...message, + metadata: { + ...message.metadata, + forkCarryover: { + sourceSessionId, + sourceTurnId, + }, + }, + }; +} + +function retargetTranscriptEntryAuxiliaryPaths( + entry: AgentTranscriptEntry, + sourceSessionDir: string, + targetSessionDir: string, +): AgentTranscriptEntry { + if (entry.type === "accepted_input") { + return { + ...entry, + messages: entry.messages.map((message) => ({ + ...message, + content: message.content.map((block) => + retargetContentBlock(block, sourceSessionDir, targetSessionDir), + ), + })), + }; + } + if ( + entry.type === "assistant_message" || + entry.type === "tool_result_message" || + entry.type === "durable_message" + ) { + return { + ...entry, + message: { + ...entry.message, + content: entry.message.content.map((block) => + retargetContentBlock(block, sourceSessionDir, targetSessionDir), + ), + }, + }; + } + return entry; +} + +function markTranscriptEntryAsForkCarryover( + entry: AgentTranscriptEntry, + sourceSessionId: string, +): AgentTranscriptEntry { + if (entry.type === "accepted_input") { + return { + ...entry, + messages: entry.messages.map((message) => + markMessageAsForkCarryover(message, sourceSessionId, entry.turnId), + ), + }; + } + if ( + entry.type === "assistant_message" || + entry.type === "tool_result_message" || + entry.type === "durable_message" + ) { + return { + ...entry, + message: markMessageAsForkCarryover(entry.message, sourceSessionId, entry.turnId), + }; + } + return entry; +} + +function retargetAcceptedInputEntry( + entry: AgentAcceptedInputTranscriptEntry, + sessionId: string, + sourceSessionDir: string, + targetSessionDir: string, +): AgentAcceptedInputTranscriptEntry { + const retargeted = retargetTranscriptEntryAuxiliaryPaths( + entry, + sourceSessionDir, + targetSessionDir, + ); + if (retargeted.type !== "accepted_input") { + return entry; + } + return { + ...retargeted, + sessionId, + }; +} + +function retargetEntriesToSession( + entries: AgentTranscriptEntry[], + options: { + sessionId: string; + sourceSafeId: string; + targetSafeId: string; + sourceSessionDir: string; + targetSessionDir: string; + }, +): AgentTranscriptEntry[] { + return entries.map((entry) => { + if (entry.type === "accepted_input") { + const retargeted = retargetAcceptedInputEntry( + entry, + options.sessionId, + options.sourceSessionDir, + options.targetSessionDir, + ); + return markTranscriptEntryAsForkCarryover(retargeted, entry.sessionId); + } + if ( + entry.type === "assistant_message" || + entry.type === "tool_result_message" || + entry.type === "durable_message" + ) { + const retargeted = { + ...retargetTranscriptEntryAuxiliaryPaths( + entry, + options.sourceSessionDir, + options.targetSessionDir, + ), + sessionId: options.sessionId, + }; + return markTranscriptEntryAsForkCarryover(retargeted, entry.sessionId); + } + if (entry.type === "subagent_started") { + return { + ...entry, + sessionId: options.sessionId, + transcriptRelativePath: retargetRelativeSessionPath( + entry.transcriptRelativePath, + options.sourceSafeId, + options.targetSafeId, + ), + }; + } + return { + ...entry, + sessionId: options.sessionId, + }; + }); +} + +function isNotFoundError(error: unknown): boolean { + return Boolean( + error && + typeof error === "object" && + "code" in error && + (error as { code?: unknown }).code === "ENOENT", + ); +} + +async function pathExists(path: string): Promise { + try { + await stat(path); + return true; + } catch (error) { + if (isNotFoundError(error)) { + return false; + } + throw error; + } +} + +async function retargetCopiedSubagentTranscripts( + targetSubagentsDir: string, + sourceSessionDir: string, + targetSessionDir: string, +): Promise { + let entries: Dirent[]; + try { + entries = await readdir(targetSubagentsDir, { withFileTypes: true }); + } catch (error) { + if (isNotFoundError(error)) { + return; + } + throw error; + } + + for (const entry of entries) { + const path = join(targetSubagentsDir, entry.name); + if (entry.isDirectory()) { + await retargetCopiedSubagentTranscripts(path, sourceSessionDir, targetSessionDir); + continue; + } + if (!entry.isFile() || !entry.name.endsWith(".jsonl")) { + continue; + } + const content = await readFile(path, "utf8"); + const rewritten = content + .split(/\r?\n/) + .map((line) => { + if (!line.trim()) { + return line; + } + try { + const parsed = JSON.parse(line) as AgentTranscriptEntry; + return JSON.stringify( + retargetTranscriptEntryAuxiliaryPaths(parsed, sourceSessionDir, targetSessionDir), + ); + } catch { + return line; + } + }) + .join("\n"); + await writeFile(path, rewritten, "utf8"); + } +} + +async function copySessionAuxDirs(sourceSessionDir: string, targetSessionDir: string): Promise { + for (const subdir of ["tool-results", "file-history", "subagents"] as const) { + const source = join(sourceSessionDir, subdir); + const target = join(targetSessionDir, subdir); + if (!(await pathExists(source))) { + continue; + } + await cp(source, target, { recursive: true, force: true }); + if (subdir === "subagents") { + await retargetCopiedSubagentTranscripts(target, sourceSessionDir, targetSessionDir); + } + } +} + +export class ForkSessionError extends Error { + constructor( + readonly code: string, + message: string, + ) { + super(message); + this.name = "ForkSessionError"; + } +} + +export async function forkWebSession( + input: WebForkSessionInput, + options: ForkWebSessionOptions, +): Promise { + const effectiveProjectRoot = input.projectKey ?? options.projectRoot; + const chatDir = getPilotProjectChatDir(effectiveProjectRoot, options.pilotHome); + const sourceSafeId = sanitizeSessionIdForPath(input.sessionKey); + const sourceTranscriptPath = resolve(chatDir, `${sourceSafeId}.jsonl`); + const sourceSessionDir = resolve(chatDir, sourceSafeId); + + const { entries } = await readTranscript(sourceTranscriptPath); + if (entries.length === 0) { + throw new ForkSessionError("fork_empty_transcript", "Cannot fork an empty session transcript."); + } + + const forkAcceptedInput = findForkTurnAcceptedInput(entries, input.fromEntryId); + if (hasUnsupportedPrefillContent(forkAcceptedInput)) { + throw new ForkSessionError( + "fork_unsupported_content", + "Forking messages with attachments or non-text input is not supported yet.", + ); + } + const forkMode = getForkMode(forkAcceptedInput); + const cutoffSequence = forkAcceptedInput.sequence; + const preservedSourceEntries = entries.filter((entry) => entry.sequence < cutoffSequence); + const prefillText = extractAcceptedInputText(forkAcceptedInput); + const carriedMessageCount = countCarriedUserAssistantMessages(preservedSourceEntries); + + const newSessionKey = newWebSessionKey(); + const newSafeId = sanitizeSessionIdForPath(newSessionKey); + const newTranscriptPath = resolve(chatDir, `${newSafeId}.jsonl`); + const newSessionDir = resolve(chatDir, newSafeId); + const preserved = retargetEntriesToSession(preservedSourceEntries, { + sessionId: newSessionKey, + sourceSafeId, + targetSafeId: newSafeId, + sourceSessionDir, + targetSessionDir: newSessionDir, + }); + + await mkdir(chatDir, { recursive: true, mode: 0o700 }); + await mkdir(newSessionDir, { recursive: true, mode: 0o700 }); + await copySessionAuxDirs(sourceSessionDir, newSessionDir); + + const preservedLines = preserved.map((entry) => `${JSON.stringify(entry)}\n`).join(""); + const lastPreserved = preserved[preserved.length - 1]; + const lastEntryId = lastPreserved?.entryId ?? null; + const maxSequence = preserved.reduce((max, entry) => Math.max(max, entry.sequence), 0); + + const parentMetadata = lastSessionMetadata(entries); + const inheritedTitle = + (typeof parentMetadata?.title === "string" && parentMetadata.title) || + (typeof parentMetadata?.aiTitle === "string" && parentMetadata.aiTitle) || + undefined; + + // Title the fork by the message it branches from so siblings are + // distinguishable in the lineage tree (the branch icon + "forked from" + // subtitle already convey that it is a fork). + const forkTitle = buildForkTitle(prefillText, carriedMessageCount, inheritedTitle); + + const now = options.now ?? (() => new Date()); + const metadataEntry: AgentSessionMetadataTranscriptEntry = { + type: "session_metadata", + sessionId: newSessionKey, + turnId: `fork-${randomUUID()}`, + sequence: maxSequence + 1, + createdAt: now().toISOString(), + entryId: randomUUID(), + parentEntryId: lastEntryId, + metadata: { + parentSessionId: input.sessionKey, + forkedFromTurnId: forkAcceptedInput.turnId, + title: forkTitle, + firstPrompt: prefillText || undefined, + updatedAt: now().toISOString(), + }, + }; + + const body = preservedLines + `${JSON.stringify(metadataEntry)}\n`; + await writeFile(newTranscriptPath, body, { encoding: "utf8", mode: 0o600 }); + await chmod(dirname(newTranscriptPath), 0o700); + + return { + newSessionKey, + prefillText, + carriedMessageCount, + ...(forkMode ? { mode: forkMode } : {}), + }; +} diff --git a/src/web/server/readSessionMessages.ts b/src/web/server/readSessionMessages.ts index ad35e1a6..3a25bf28 100644 --- a/src/web/server/readSessionMessages.ts +++ b/src/web/server/readSessionMessages.ts @@ -24,7 +24,7 @@ import { } from "../../model/index.js"; import { listProjectSessions, readTranscript, findLastCompactBoundaryIndex, type SessionInfo } from "../../session/index.js"; import type { AgentTranscriptEntry } from "../../session/transcript/TranscriptEntry.js"; -import { resolve, dirname } from "node:path"; +import { dirname, isAbsolute, relative, resolve } from "node:path"; import { getPilotProjectChatDir } from "../../pilot/index.js"; import { sanitizeSessionIdForPath } from "../../session/storage/ProjectSessionStorage.js"; import type { @@ -45,21 +45,20 @@ export async function readWebSessionMessages( options: ReadWebSessionMessagesOptions, ): Promise { const effectiveProjectRoot = input.projectKey ?? options.projectRoot; - const sessionInfo = await locateSession(input.sessionKey, { + const chatDir = getPilotProjectChatDir(effectiveProjectRoot, options.pilotHome); + const transcriptPath = resolveTranscriptPath(input, chatDir); + const isBackgroundTask = isBackgroundTaskInput(input); + const sessionInfo = isBackgroundTask ? undefined : await locateSession(input.sessionKey, { ...options, projectRoot: effectiveProjectRoot, }); - const transcriptPath = resolve( - getPilotProjectChatDir(effectiveProjectRoot, options.pilotHome), - `${sanitizeSessionIdForPath(input.sessionKey)}.jsonl`, - ); const { entries } = await readTranscript(transcriptPath); const webReplay = extractWebVisibleMessages(entries); const entryTimestamps = webReplay.timestamps; + const entryIds = webReplay.entryIds; const incompleteTurnIds = extractIncompleteTurnIds(entries); const flattenedPerMessage: WebMessage[][] = webReplay.messages - .filter((message) => !message.metadata?.synthetic) .map((message, index) => flattenCanonicalMessage(message, { index, @@ -67,6 +66,8 @@ export async function readWebSessionMessages( projectKey: input.projectKey, now: options.now, entryTimestamp: entryTimestamps[index], + entryId: entryIds[index], + forkUnsupportedContent: webReplay.forkUnsupportedContents[index], }), ); @@ -130,6 +131,10 @@ export async function readWebSessionMessages( cwd: sessionInfo?.cwd, tag: sessionInfo?.tag, createdAt: sessionInfo?.createdAt, + ...(isBackgroundTask ? { sessionKind: "background_task" as const } : {}), + parentSessionId: input.parentSessionId ?? sessionInfo?.parentSessionId, + relativeTranscriptPath: input.relativeTranscriptPath, + forkedFromTurnId: sessionInfo?.forkedFromTurnId, }, }; } @@ -140,15 +145,19 @@ export async function readWebSessionMessages( * session transcript path + subagentId. */ export async function readSubagentWebMessages( - input: { sessionKey: string; subagentId: string; projectKey?: string }, + input: { + sessionKey: string; + subagentId: string; + projectKey?: string; + sessionKind?: "background_task"; + parentSessionId?: string; + relativeTranscriptPath?: string; + }, options: ReadWebSessionMessagesOptions, ): Promise<{ messages: WebMessage[]; total: number }> { const effectiveProjectRoot = input.projectKey ?? options.projectRoot; const chatDir = getPilotProjectChatDir(effectiveProjectRoot, options.pilotHome); - const parentTranscriptPath = resolve( - chatDir, - `${sanitizeSessionIdForPath(input.sessionKey)}.jsonl`, - ); + const parentTranscriptPath = resolveTranscriptPath(input, chatDir); const { entries: parentEntries } = await readTranscript(parentTranscriptPath); let sidechainRelative: string | undefined; @@ -163,7 +172,11 @@ export async function readSubagentWebMessages( return { messages: [], total: 0 }; } - const sidechainPath = resolve(dirname(parentTranscriptPath), sidechainRelative); + const sidechainPath = resolveRelativeTranscriptPath( + sidechainRelative, + dirname(parentTranscriptPath), + chatDir, + ); const { entries } = await readTranscript(sidechainPath); const webReplay = extractSubagentExecutionMessages(entries); @@ -211,6 +224,49 @@ export async function readSubagentWebMessages( return { messages: allMessages, total: allMessages.length }; } +function isBackgroundTaskInput(input: { + sessionKind?: string; + relativeTranscriptPath?: string; +}): input is { sessionKind: "background_task"; relativeTranscriptPath: string } { + return input.sessionKind === "background_task" && + typeof input.relativeTranscriptPath === "string" && + input.relativeTranscriptPath.length > 0; +} + +function resolveTranscriptPath( + input: { + sessionKey: string; + sessionKind?: string; + relativeTranscriptPath?: string; + }, + chatDir: string, +): string { + if (isBackgroundTaskInput(input)) { + return resolveRelativeTranscriptPath(input.relativeTranscriptPath, chatDir, chatDir); + } + return resolve(chatDir, `${sanitizeSessionIdForPath(input.sessionKey)}.jsonl`); +} + +function resolveRelativeTranscriptPath( + path: string, + baseDir: string, + allowedRoot: string, +): string { + if (!path || isAbsolute(path)) { + throw new Error("relativeTranscriptPath must be a relative path."); + } + const candidate = resolve(baseDir, path); + if (!isWithinDirectory(allowedRoot, candidate) || !candidate.endsWith(".jsonl")) { + throw new Error("relativeTranscriptPath points outside the project transcript directory."); + } + return candidate; +} + +function isWithinDirectory(parentDir: string, candidatePath: string): boolean { + const rel = relative(parentDir, candidatePath); + return Boolean(rel) && !rel.startsWith("..") && !isAbsolute(rel); +} + function createIncompleteTurnStatusMessage( input: WebReadSessionMessagesInput, turnIds: string[], @@ -279,6 +335,10 @@ type ProjectionContext = { now?: () => Date; /** Actual transcript entry timestamp — preferred over now(). */ entryTimestamp?: string; + /** Transcript entry id for fork targeting. */ + entryId?: string; + /** True when this entry cannot be fork-prefilled losslessly by the web UI. */ + forkUnsupportedContent?: boolean; }; /** @@ -317,6 +377,15 @@ export function flattenCanonicalMessage( kind: "text", text: textBuffer, ...(pendingImages.length > 0 ? { images: pendingImages } : {}), + ...(context.forkUnsupportedContent + ? { + payload: { + forkUnsupportedContent: true, + forkUnsupportedReason: "This turn contains attachments or media.", + }, + } + : {}), + ...(context.entryId ? { entryId: context.entryId } : {}), source: "history", }); textBuffer = ""; @@ -532,11 +601,15 @@ type CompactBoundaryInfo = { function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { messages: CanonicalMessage[]; timestamps: string[]; + entryIds: Array; + forkUnsupportedContents: boolean[]; compactBoundaries: CompactBoundaryInfo[]; } { const lastBoundaryIndex = findLastCompactBoundaryIndex(entries); const messages: CanonicalMessage[] = []; const timestamps: string[] = []; + const entryIds: Array = []; + const forkUnsupportedContents: boolean[] = []; const compactBoundaries: CompactBoundaryInfo[] = []; for (let index = 0; index < entries.length; index += 1) { @@ -546,9 +619,17 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { switch (entry.type) { case "accepted_input": if (!beforeBoundary) { + const entryForkUnsupported = entry.messages.some((message) => + message.content.some((block) => block.type !== "text"), + ); for (const message of entry.messages) { + if (message.metadata?.synthetic) { + continue; + } messages.push(cloneMessage(message)); timestamps.push(entry.createdAt); + entryIds.push(entry.entryId); + forkUnsupportedContents.push(entryForkUnsupported); } } break; @@ -556,8 +637,13 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { case "tool_result_message": case "durable_message": if (!beforeBoundary) { + if (entry.message.metadata?.synthetic) { + break; + } messages.push(cloneMessage(entry.message)); timestamps.push(entry.createdAt); + entryIds.push(entry.entryId); + forkUnsupportedContents.push(false); } break; case "control_boundary": { @@ -582,7 +668,7 @@ function extractWebVisibleMessages(entries: AgentTranscriptEntry[]): { } } - return { messages, timestamps, compactBoundaries }; + return { messages, timestamps, entryIds, forkUnsupportedContents, compactBoundaries }; } function extractSubagentExecutionMessages(entries: AgentTranscriptEntry[]): { @@ -657,8 +743,13 @@ function attachSubagentIds( entries: AgentTranscriptEntry[], allMessages: WebMessage[], ): void { + const lastBoundaryIndex = findLastCompactBoundaryIndex(entries); const subagentQueue: string[] = []; - for (const entry of entries) { + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (lastBoundaryIndex !== -1 && index < lastBoundaryIndex) { + continue; + } if (entry.type === "subagent_started") { subagentQueue.push(entry.subagentId); } diff --git a/tests/web/forkSession.test.ts b/tests/web/forkSession.test.ts new file mode 100644 index 00000000..c9f5eb9b --- /dev/null +++ b/tests/web/forkSession.test.ts @@ -0,0 +1,888 @@ +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { DefaultContextRuntime } from "../../src/context/DefaultContextRuntime.js"; +import type { MemoryResolver } from "../../src/context/memory/MemoryResolver.js"; +import type { CanonicalMessage } from "../../src/model/index.js"; +import { ForkSessionError, forkWebSession } from "../../src/web/server/forkSession.js"; +import { readSubagentWebMessages, readWebSessionMessages } from "../../src/web/server/readSessionMessages.js"; +import { readTranscript } from "../../src/session/transcript/TranscriptReader.js"; +import { createProjectId } from "../../src/pilot/paths.js"; +import { sanitizeSessionIdForPath } from "../../src/session/storage/ProjectSessionStorage.js"; + +function textFromMessage(message: CanonicalMessage): string { + return message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"); +} + +test("forkWebSession copies history before target turn and returns prefill text", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_parent"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "first question" }] }], + }, + { + type: "assistant_message", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-2", + parentEntryId: "entry-1", + message: { role: "assistant", content: [{ type: "text", text: "first answer" }] }, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "entry-3", + parentEntryId: "entry-2", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 4, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-4", + parentEntryId: "entry-3", + messages: [{ role: "user", content: [{ type: "text", text: "second question" }] }], + }, + ]; + + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await forkWebSession( + { sessionKey, projectKey: projectRoot, fromEntryId: "entry-4" }, + { projectRoot, pilotHome, now: () => new Date("2026-06-24T01:00:00.000Z") }, + ); + + assert.equal(result.prefillText, "second question"); + assert.equal(result.carriedMessageCount, 2); + assert.match(result.newSessionKey, /^web[:-]s_/); + + const newTranscriptPath = join(chatDir, `${result.newSessionKey}.jsonl`); + const { entries } = await readTranscript(newTranscriptPath); + assert.equal(entries.length, 4); + assert.deepEqual(entries.map((entry) => entry.sessionId), [ + result.newSessionKey, + result.newSessionKey, + result.newSessionKey, + result.newSessionKey, + ]); + assert.equal(entries[0].sequence, 1); + assert.equal(entries[2].type, "turn_result"); + assert.equal(entries[3].type, "session_metadata"); + if (entries[0].type === "accepted_input") { + assert.deepEqual(entries[0].messages[0]?.metadata?.forkCarryover, { + sourceSessionId: sessionKey, + sourceTurnId: "turn-1", + }); + } + if (entries[1].type === "assistant_message") { + assert.deepEqual(entries[1].message.metadata?.forkCarryover, { + sourceSessionId: sessionKey, + sourceTurnId: "turn-1", + }); + } + if (entries[3].type === "session_metadata") { + assert.equal(entries[3].metadata.parentSessionId, sessionKey); + assert.equal(entries[3].metadata.forkedFromTurnId, "turn-2"); + } + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("DefaultContextRuntime skips fork carryover messages during memory capture", async () => { + const captured: CanonicalMessage[][] = []; + const memoryResolver: MemoryResolver = { + async retrieve() { + return { diagnostics: [] }; + }, + async captureTurn(input) { + captured.push(input.messages); + }, + }; + const runtime = new DefaultContextRuntime({ memoryResolver }); + + await runtime.captureTurn({ + sessionId: "web:s_fork", + turnId: "turn-2", + errored: false, + messages: [ + { + role: "user", + content: [{ type: "text", text: "parent history" }], + metadata: { + forkCarryover: { + sourceSessionId: "web:s_parent", + sourceTurnId: "turn-1", + }, + }, + }, + { + role: "user", + content: [{ type: "text", text: "new fork turn" }], + }, + ], + }); + + assert.equal(captured.length, 1); + assert.deepEqual(captured[0]?.map(textFromMessage), ["new fork turn"]); +}); + +test("forkWebSession rejects non-text target turns instead of dropping attachments", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-unsupported-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_unsupported"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "first question" }] }], + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-2", + parentEntryId: "entry-1", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 3, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-3", + parentEntryId: "entry-2", + messages: [ + { + role: "user", + content: [ + { type: "text", text: "look at this" }, + { + type: "image", + source: "base64", + data: "aW1hZ2U=", + mimeType: "image/png", + }, + ], + }, + ], + }, + ]; + + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + await assert.rejects( + () => + forkWebSession( + { sessionKey, projectKey: projectRoot, fromEntryId: "entry-3" }, + { projectRoot, pilotHome }, + ), + (error) => { + assert.equal(error instanceof ForkSessionError, true); + assert.equal((error as ForkSessionError).code, "fork_unsupported_content"); + return true; + }, + ); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("forkWebSession preserves plan mode for forked plan turns", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-plan-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_plan_fork"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-plan", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-plan", + parentEntryId: null, + metadata: { + permissionMode: "plan", + basePermissionMode: "default", + allowPlanModeTools: true, + }, + messages: [{ role: "user", content: [{ type: "text", text: "design this safely" }] }], + }, + ]; + + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await forkWebSession( + { sessionKey, projectKey: projectRoot, fromEntryId: "entry-plan" }, + { projectRoot, pilotHome }, + ); + + assert.equal(result.prefillText, "design this safely"); + assert.equal(result.mode, "plan"); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("readWebSessionMessages marks hidden media turns as unsupported fork targets", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-media-marker-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_media_marker"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-1", + parentEntryId: null, + messages: [ + { + role: "user", + content: [ + { type: "text", text: "please inspect this pdf" }, + { + type: "pdf", + source: "base64", + data: "JVBERi0xLjQK", + mimeType: "application/pdf", + bytes: 9, + pages: 1, + }, + ], + }, + ], + }, + ]; + + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await readWebSessionMessages( + { sessionKey, projectKey: projectRoot }, + { projectRoot, pilotHome }, + ); + + const userMessage = result.messages.find( + (message) => message.kind === "text" && message.entryId === "entry-1", + ); + assert.ok(userMessage); + assert.equal((userMessage.payload as { forkUnsupportedContent?: boolean }).forkUnsupportedContent, true); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("readWebSessionMessages reads background task transcripts by relative path", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-background-read-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const parentSessionId = "web:s_parent_background"; + const backgroundSessionId = "background-web-s_parent_background-agent-cron"; + const relativeTranscriptPath = `${parentSessionId}/subagents/agent-cron.jsonl`; + const transcriptPath = join(chatDir, relativeTranscriptPath); + + await mkdir(join(chatDir, parentSessionId, "subagents"), { recursive: true }); + const lines = [ + { + type: "accepted_input", + sessionId: backgroundSessionId, + turnId: "bg-turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "bg-entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "background prompt" }] }], + }, + { + type: "assistant_message", + sessionId: backgroundSessionId, + turnId: "bg-turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "bg-entry-2", + parentEntryId: "bg-entry-1", + message: { role: "assistant", content: [{ type: "text", text: "background result" }] }, + }, + { + type: "turn_result", + sessionId: backgroundSessionId, + turnId: "bg-turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "bg-entry-3", + parentEntryId: "bg-entry-2", + result: { stopReason: "completed", usage: {} }, + }, + ]; + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await readWebSessionMessages( + { + sessionKey: backgroundSessionId, + projectKey: projectRoot, + sessionKind: "background_task", + parentSessionId, + relativeTranscriptPath, + }, + { projectRoot, pilotHome }, + ); + + assert.deepEqual( + result.messages.filter((message) => message.kind === "text").map((message) => message.text), + ["background prompt", "background result"], + ); + assert.equal(result.session.sessionKind, "background_task"); + assert.equal(result.session.parentSessionId, parentSessionId); + assert.equal(result.session.relativeTranscriptPath, relativeTranscriptPath); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("readSubagentWebMessages resolves subagents from background task transcripts", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-background-subagent-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const parentSessionId = "web:s_parent_background_subagent"; + const backgroundSessionId = "background-web-s_parent_background_subagent-agent-cron"; + const relativeTranscriptPath = `${parentSessionId}/subagents/agent-cron.jsonl`; + const backgroundDir = join(chatDir, parentSessionId, "subagents"); + const subagentRelativePath = "agent-cron/subagents/sub-bg.jsonl"; + + await mkdir(join(backgroundDir, "agent-cron", "subagents"), { recursive: true }); + const parentLines = [ + { + type: "accepted_input", + sessionId: backgroundSessionId, + turnId: "bg-turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "bg-entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "background prompt" }] }], + }, + { + type: "subagent_started", + sessionId: backgroundSessionId, + turnId: "bg-turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "bg-entry-2", + parentEntryId: "bg-entry-1", + subagentId: "sub-bg", + subagentType: "general-purpose", + promptPreview: "inspect", + promptTruncated: false, + transcriptRelativePath: subagentRelativePath, + }, + ]; + const sidechainLines = [ + { + type: "accepted_input", + sessionId: `${projectRoot}::sub::sub-bg`, + turnId: "sub-turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:01.100Z", + entryId: "sub-entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "inspect" }] }], + }, + { + type: "assistant_message", + sessionId: `${projectRoot}::sub::sub-bg`, + turnId: "sub-turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.200Z", + entryId: "sub-entry-2", + parentEntryId: "sub-entry-1", + message: { role: "assistant", content: [{ type: "text", text: "subagent result" }] }, + }, + ]; + await writeFile( + join(chatDir, relativeTranscriptPath), + `${parentLines.map((line) => JSON.stringify(line)).join("\n")}\n`, + "utf8", + ); + await writeFile( + join(backgroundDir, subagentRelativePath), + `${sidechainLines.map((line) => JSON.stringify(line)).join("\n")}\n`, + "utf8", + ); + + const result = await readSubagentWebMessages( + { + sessionKey: backgroundSessionId, + subagentId: "sub-bg", + projectKey: projectRoot, + sessionKind: "background_task", + parentSessionId, + relativeTranscriptPath, + }, + { projectRoot, pilotHome }, + ); + + assert.deepEqual( + result.messages.filter((message) => message.kind === "text").map((message) => message.text), + ["subagent result"], + ); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("forkWebSession rewrites copied auxiliary references to the new session", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-aux-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_parent_aux"; + const safeSessionKey = sanitizeSessionIdForPath(sessionKey); + const sourceSessionDir = join(chatDir, safeSessionKey); + const transcriptPath = join(chatDir, `${safeSessionKey}.jsonl`); + const sourceToolResultPath = join(sourceSessionDir, "tool-results", "tc-1.txt"); + const sourceMediaPath = join(sourceSessionDir, "tool-results", "media-1.png"); + const sourceSubagentPath = join(sourceSessionDir, "subagents", "sub-1.jsonl"); + const sourceSubagentRelativePath = `${safeSessionKey}/subagents/sub-1.jsonl`; + + await mkdir(join(sourceSessionDir, "tool-results"), { recursive: true }); + await mkdir(join(sourceSessionDir, "subagents"), { recursive: true }); + await writeFile(sourceToolResultPath, "full tool result", "utf8"); + await writeFile(sourceMediaPath, "base64-media", "utf8"); + const sidechainSessionId = `${projectRoot}::sub::sub-1`; + const sidechainLines = [ + { + type: "accepted_input", + sessionId: sidechainSessionId, + turnId: "sub-turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:02.100Z", + entryId: "sub-entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "inspect" }] }], + }, + { + type: "durable_message", + sessionId: sidechainSessionId, + turnId: "sub-turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:02.200Z", + entryId: "sub-entry-2", + parentEntryId: "sub-entry-1", + message: { + role: "user", + content: [ + { + type: "tool_result_reference", + toolCallId: "sub-tc-1", + path: sourceToolResultPath, + originalBytes: 16, + preview: "full", + hasMore: true, + mimeType: "text/plain", + reason: "tool_result_too_large", + }, + { + type: "media_reference", + toolCallId: "sub-tc-1", + path: sourceMediaPath, + originalBytes: 12, + preview: "media", + hasMore: true, + mimeType: "image/png", + mediaType: "image", + reason: "media_result_too_large", + }, + ], + }, + }, + { + type: "turn_result", + sessionId: sidechainSessionId, + turnId: "sub-turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.300Z", + entryId: "sub-entry-3", + parentEntryId: "sub-entry-2", + result: { stopReason: "completed", usage: {} }, + }, + ]; + await writeFile( + sourceSubagentPath, + `${sidechainLines.map((line) => JSON.stringify(line)).join("\n")}\n`, + "utf8", + ); + + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "first question" }] }], + }, + { + type: "durable_message", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-2", + parentEntryId: "entry-1", + message: { + role: "user", + content: [ + { + type: "tool_result_reference", + toolCallId: "tc-1", + path: sourceToolResultPath, + originalBytes: 16, + preview: "full", + hasMore: true, + mimeType: "text/plain", + reason: "tool_result_too_large", + }, + { + type: "media_reference", + toolCallId: "tc-1", + path: sourceMediaPath, + originalBytes: 12, + preview: "media", + hasMore: true, + mimeType: "image/png", + mediaType: "image", + reason: "media_result_too_large", + }, + ], + }, + }, + { + type: "subagent_started", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "entry-3", + parentEntryId: "entry-2", + subagentId: "sub-1", + subagentType: "general-purpose", + promptPreview: "inspect", + promptTruncated: false, + transcriptRelativePath: sourceSubagentRelativePath, + subagentSessionId: sidechainSessionId, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 4, + createdAt: "2026-06-24T00:00:03.000Z", + entryId: "entry-4", + parentEntryId: "entry-3", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 5, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-5", + parentEntryId: "entry-4", + messages: [{ role: "user", content: [{ type: "text", text: "second question" }] }], + }, + ]; + + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await forkWebSession( + { sessionKey, projectKey: projectRoot, fromEntryId: "entry-5" }, + { projectRoot, pilotHome, now: () => new Date("2026-06-24T01:00:00.000Z") }, + ); + const newSafeId = sanitizeSessionIdForPath(result.newSessionKey); + const { entries } = await readTranscript(join(chatDir, `${newSafeId}.jsonl`)); + const durable = entries.find((entry) => entry.entryId === "entry-2"); + assert.equal(durable?.type, "durable_message"); + if (durable?.type === "durable_message") { + const [toolReference, mediaReference] = durable.message.content; + assert.equal(toolReference.type, "tool_result_reference"); + assert.equal(mediaReference.type, "media_reference"); + if (toolReference.type === "tool_result_reference") { + assert.equal(toolReference.path, join(chatDir, newSafeId, "tool-results", "tc-1.txt")); + assert.equal(await readFile(toolReference.path, "utf8"), "full tool result"); + } + if (mediaReference.type === "media_reference") { + assert.equal(mediaReference.path, join(chatDir, newSafeId, "tool-results", "media-1.png")); + assert.equal(await readFile(mediaReference.path, "utf8"), "base64-media"); + } + } + const subagentStarted = entries.find((entry) => entry.entryId === "entry-3"); + assert.equal(subagentStarted?.type, "subagent_started"); + if (subagentStarted?.type === "subagent_started") { + assert.equal(subagentStarted.transcriptRelativePath, `${newSafeId}/subagents/sub-1.jsonl`); + const { entries: sidechainEntries } = await readTranscript( + join(chatDir, subagentStarted.transcriptRelativePath), + ); + const sidechainDurable = sidechainEntries.find((entry) => entry.entryId === "sub-entry-2"); + assert.equal(sidechainDurable?.type, "durable_message"); + if (sidechainDurable?.type === "durable_message") { + const [toolReference, mediaReference] = sidechainDurable.message.content; + assert.equal(toolReference.type, "tool_result_reference"); + assert.equal(mediaReference.type, "media_reference"); + if (toolReference.type === "tool_result_reference") { + assert.equal(toolReference.path, join(chatDir, newSafeId, "tool-results", "tc-1.txt")); + } + if (mediaReference.type === "media_reference") { + assert.equal(mediaReference.path, join(chatDir, newSafeId, "tool-results", "media-1.png")); + } + } + } + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("readWebSessionMessages keeps entry ids aligned after synthetic messages are hidden", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-entryid-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_entryid"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-visible-1", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "first visible" }] }], + }, + { + type: "durable_message", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-synthetic", + parentEntryId: "entry-visible-1", + message: { + role: "user", + metadata: { synthetic: true, purpose: "json_self_correct" }, + content: [{ type: "text", text: "hidden synthetic" }], + }, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "entry-result", + parentEntryId: "entry-synthetic", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 4, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-visible-2", + parentEntryId: "entry-result", + messages: [{ role: "user", content: [{ type: "text", text: "second visible" }] }], + }, + ]; + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await readWebSessionMessages( + { sessionKey, projectKey: projectRoot }, + { projectRoot, pilotHome, now: () => new Date("2026-06-24T01:00:00.000Z") }, + ); + + assert.deepEqual( + result.messages.map((message) => message.entryId), + ["entry-visible-1", "entry-visible-2"], + ); + + await rm(pilotHome, { recursive: true, force: true }); +}); + +test("readWebSessionMessages ignores pre-compact subagent refs when attaching visible tool rows", async () => { + const pilotHome = await mkdtemp(join(tmpdir(), "pilotdeck-fork-subagent-compact-")); + const projectRoot = join(pilotHome, "workspace"); + const chatDir = join(pilotHome, "projects", createProjectId(projectRoot), "chats"); + const sessionKey = "web:s_subagent_compact"; + const transcriptPath = join(chatDir, `${sessionKey}.jsonl`); + + await mkdir(chatDir, { recursive: true }); + const lines = [ + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 1, + createdAt: "2026-06-24T00:00:00.000Z", + entryId: "entry-old-user", + parentEntryId: null, + messages: [{ role: "user", content: [{ type: "text", text: "old task" }] }], + }, + { + type: "assistant_message", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 2, + createdAt: "2026-06-24T00:00:01.000Z", + entryId: "entry-old-agent", + parentEntryId: "entry-old-user", + message: { + role: "assistant", + content: [{ type: "tool_call", id: "agent-old", name: "agent", input: {} }], + }, + }, + { + type: "subagent_started", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 3, + createdAt: "2026-06-24T00:00:02.000Z", + entryId: "entry-old-subagent", + parentEntryId: "entry-old-agent", + subagentId: "sub-old", + subagentType: "general-purpose", + promptPreview: "old", + promptTruncated: false, + transcriptRelativePath: `${sessionKey}/subagents/sub-old.jsonl`, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-1", + sequence: 4, + createdAt: "2026-06-24T00:00:03.000Z", + entryId: "entry-old-result", + parentEntryId: "entry-old-subagent", + result: { stopReason: "completed", usage: {} }, + }, + { + type: "control_boundary", + sessionId: sessionKey, + turnId: "compact", + sequence: 5, + createdAt: "2026-06-24T00:00:04.000Z", + entryId: "entry-compact", + parentEntryId: "entry-old-result", + boundary: { + kind: "compact", + subtype: "compact_boundary", + compactMetadata: { trigger: "manual", preTokens: 100 }, + }, + }, + { + type: "accepted_input", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 6, + createdAt: "2026-06-24T00:01:00.000Z", + entryId: "entry-new-user", + parentEntryId: "entry-compact", + messages: [{ role: "user", content: [{ type: "text", text: "new task" }] }], + }, + { + type: "assistant_message", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 7, + createdAt: "2026-06-24T00:01:01.000Z", + entryId: "entry-new-agent", + parentEntryId: "entry-new-user", + message: { + role: "assistant", + content: [{ type: "tool_call", id: "agent-new", name: "agent", input: {} }], + }, + }, + { + type: "subagent_started", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 8, + createdAt: "2026-06-24T00:01:02.000Z", + entryId: "entry-new-subagent", + parentEntryId: "entry-new-agent", + subagentId: "sub-new", + subagentType: "general-purpose", + promptPreview: "new", + promptTruncated: false, + transcriptRelativePath: `${sessionKey}/subagents/sub-new.jsonl`, + }, + { + type: "turn_result", + sessionId: sessionKey, + turnId: "turn-2", + sequence: 9, + createdAt: "2026-06-24T00:01:03.000Z", + entryId: "entry-new-result", + parentEntryId: "entry-new-subagent", + result: { stopReason: "completed", usage: {} }, + }, + ]; + await writeFile(transcriptPath, `${lines.map((line) => JSON.stringify(line)).join("\n")}\n`, "utf8"); + + const result = await readWebSessionMessages( + { sessionKey, projectKey: projectRoot }, + { projectRoot, pilotHome, now: () => new Date("2026-06-24T01:00:00.000Z") }, + ); + + const toolUse = result.messages.find((message) => message.kind === "tool_use"); + assert.equal(toolUse?.toolCallId, "agent-new"); + assert.equal(toolUse?.subagentId, "sub-new"); + + await rm(pilotHome, { recursive: true, force: true }); +}); diff --git a/ui/e2e/history-fork.spec.mjs b/ui/e2e/history-fork.spec.mjs new file mode 100644 index 00000000..f25901d1 --- /dev/null +++ b/ui/e2e/history-fork.spec.mjs @@ -0,0 +1,37 @@ +import { test, expect } from '@playwright/test'; + +const API_URL = process.env.PILOTDECK_API_URL; +const PROJECT_PATH = process.env.PILOTDECK_E2E_PROJECT_PATH; +const PARENT_SESSION = process.env.PILOTDECK_E2E_PARENT_SESSION; + +test('history fork API carries prior transcript and exposes entryId on user messages', async ({ request }) => { + test.skip( + !API_URL || !PROJECT_PATH || !PARENT_SESSION, + 'Set PILOTDECK_API_URL, PILOTDECK_E2E_PROJECT_PATH, and PILOTDECK_E2E_PARENT_SESSION to run this environment-backed test.', + ); + + const messagesResponse = await request.get( + `${API_URL}/api/sessions/${encodeURIComponent(PARENT_SESSION)}/messages?projectPath=${encodeURIComponent(PROJECT_PATH)}&limit=200`, + ); + expect(messagesResponse.ok()).toBeTruthy(); + const payload = await messagesResponse.json(); + test.skip(!payload.messages?.length, 'No live session messages available in this environment'); + + const userMessage = payload.messages.find((message) => message.role === 'user' && message.entryId); + expect(userMessage?.entryId).toBeTruthy(); + + const forkResponse = await request.post( + `${API_URL}/api/sessions/${encodeURIComponent(PARENT_SESSION)}/fork`, + { + data: { + projectPath: PROJECT_PATH, + fromEntryId: userMessage.entryId, + }, + }, + ); + expect(forkResponse.ok()).toBeTruthy(); + const forkPayload = await forkResponse.json(); + expect(forkPayload.newSessionId).toMatch(/^web[:-]s_/); + expect(typeof forkPayload.prefillText).toBe('string'); + expect(forkPayload.carriedMessageCount).toBeGreaterThan(0); +}); diff --git a/ui/server/projects.js b/ui/server/projects.js index 04eea58e..cab664e4 100755 --- a/ui/server/projects.js +++ b/ui/server/projects.js @@ -101,6 +101,8 @@ function toLegacySession(session, projectName) { aiTitle: session.aiTitle, firstPrompt: session.firstPrompt, tag: presentation.tag, + parentSessionId: session.parentSessionId, + forkedFromTurnId: session.forkedFromTurnId, __projectName: projectName, }; } diff --git a/ui/server/routes/messages.js b/ui/server/routes/messages.js index d659d948..2e4fc6f2 100644 --- a/ui/server/routes/messages.js +++ b/ui/server/routes/messages.js @@ -37,6 +37,15 @@ router.get('/:sessionId/messages', async (req, res) => { projectKey: projectPath, limit: limit ?? undefined, cursor: offset > 0 ? String(offset) : undefined, + ...(typeof req.query.sessionKind === 'string' && req.query.sessionKind + ? { sessionKind: req.query.sessionKind } + : {}), + ...(typeof req.query.parentSessionId === 'string' && req.query.parentSessionId + ? { parentSessionId: req.query.parentSessionId } + : {}), + ...(typeof req.query.relativeTranscriptPath === 'string' && req.query.relativeTranscriptPath + ? { relativeTranscriptPath: req.query.relativeTranscriptPath } + : {}), }); const messages = result.messages.map((message) => mapWebMessageToNormalized(message, sessionId)); @@ -56,6 +65,43 @@ router.get('/:sessionId/messages', async (req, res) => { } }); +router.post('/:sessionId/fork', async (req, res) => { + try { + const { sessionId } = req.params; + const projectPath = String(req.body?.projectPath || req.body?.projectName || req.query.projectPath || REPO_ROOT); + const fromEntryId = String(req.body?.fromEntryId || ''); + if (!fromEntryId) { + return res.status(400).json({ error: 'fromEntryId is required' }); + } + + const gateway = await getPilotDeckGateway(); + const result = await gateway.forkSession({ + sessionKey: sessionId, + projectKey: projectPath, + fromEntryId, + }); + + return res.json({ + newSessionId: result.newSessionKey, + prefillText: result.prefillText, + carriedMessageCount: result.carriedMessageCount, + ...(result.mode ? { mode: result.mode } : {}), + }); + } catch (error) { + console.error('[messages] fork_session failed:', error); + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + if (code.startsWith('fork_')) { + return res.status(400).json({ + error: error instanceof Error ? error.message : 'Fork failed', + code, + }); + } + return res.status(500).json({ + error: error instanceof Error ? error.message : 'Fork failed', + }); + } +}); + router.get('/:sessionId/subagent/:subagentId/messages', async (req, res) => { try { const { sessionId, subagentId } = req.params; @@ -66,6 +112,15 @@ router.get('/:sessionId/subagent/:subagentId/messages', async (req, res) => { sessionKey: sessionId, subagentId, projectKey: projectPath, + ...(typeof req.query.sessionKind === 'string' && req.query.sessionKind + ? { sessionKind: req.query.sessionKind } + : {}), + ...(typeof req.query.parentSessionId === 'string' && req.query.parentSessionId + ? { parentSessionId: req.query.parentSessionId } + : {}), + ...(typeof req.query.relativeTranscriptPath === 'string' && req.query.relativeTranscriptPath + ? { relativeTranscriptPath: req.query.relativeTranscriptPath } + : {}), }); const messages = result.messages.map((message) => @@ -89,9 +144,13 @@ function mapWebMessageToNormalized(message, sessionId) { sessionId, timestamp: message.createdAt, provider: message.provider || 'pilotdeck', + ...(message.entryId ? { entryId: message.entryId } : {}), }; switch (message.kind) { - case 'text': + case 'text': { + const payload = message.payload && typeof message.payload === 'object' + ? message.payload + : {}; return createNormalizedMessage({ ...base, kind: 'text', @@ -100,7 +159,16 @@ function mapWebMessageToNormalized(message, sessionId) { ...(Array.isArray(message.images) && message.images.length > 0 ? { images: message.images.map((image) => image?.data).filter(Boolean) } : {}), + ...(payload.forkUnsupportedContent === true + ? { + forkUnsupportedContent: true, + forkUnsupportedReason: typeof payload.forkUnsupportedReason === 'string' + ? payload.forkUnsupportedReason + : undefined, + } + : {}), }); + } case 'thinking': return createNormalizedMessage({ ...base, kind: 'thinking', content: message.text || '' }); case 'tool_use': diff --git a/ui/src/components/app-shell/AppShellV2.tsx b/ui/src/components/app-shell/AppShellV2.tsx index 653022b5..64dc5539 100644 --- a/ui/src/components/app-shell/AppShellV2.tsx +++ b/ui/src/components/app-shell/AppShellV2.tsx @@ -486,7 +486,7 @@ export default function AppShellV2() { } const target = (project.sessions ?? []).find((s) => s.id === sessId); if (target) { - handleSessionSelect(target); + handleSessionSelect(fallbackSession ? { ...target, ...fallbackSession } : target); } else if (fallbackSession) { handleSessionSelect(fallbackSession); } else { diff --git a/ui/src/components/app-shell/SidebarV2.tsx b/ui/src/components/app-shell/SidebarV2.tsx index 7cbe6910..063aff81 100644 --- a/ui/src/components/app-shell/SidebarV2.tsx +++ b/ui/src/components/app-shell/SidebarV2.tsx @@ -6,6 +6,7 @@ import { useState, type KeyboardEvent, type MouseEvent, + type ReactNode, } from 'react'; import { useNavigate } from 'react-router-dom'; import { useTranslation } from 'react-i18next'; @@ -18,6 +19,7 @@ import { PanelLeftClose, Pencil, Plus, + GitBranch, Settings as SettingsIcon, Trash2, } from 'lucide-react'; @@ -113,6 +115,46 @@ type FlatSession = { lastActivity: number; }; +type SessionTreeNode = { + flat: FlatSession; + children: SessionTreeNode[]; +}; + +const isForkChildSession = (session: ProjectSession, knownSessionIds: Set): boolean => + Boolean( + session.parentSessionId && + session.sessionKind !== 'background_task' && + knownSessionIds.has(session.parentSessionId), + ); + +const buildSessionTree = (flatSessions: FlatSession[]): SessionTreeNode[] => { + const knownSessionIds = new Set(flatSessions.map((item) => item.sessionId)); + const childrenByParent = new Map(); + const roots: FlatSession[] = []; + + for (const item of flatSessions) { + if (isForkChildSession(item.session, knownSessionIds)) { + const parentId = item.session.parentSessionId as string; + const list = childrenByParent.get(parentId) ?? []; + list.push(item); + childrenByParent.set(parentId, list); + } else { + roots.push(item); + } + } + + const toNode = (flat: FlatSession): SessionTreeNode => ({ + flat, + children: (childrenByParent.get(flat.sessionId) ?? []) + .sort((left, right) => right.lastActivity - left.lastActivity) + .map(toNode), + }); + + return roots + .sort((left, right) => right.lastActivity - left.lastActivity) + .map(toNode); +}; + const collectSessionsForProject = (project: Project): FlatSession[] => { const sessions = Array.isArray(project.sessions) ? project.sessions : []; return sessions @@ -676,6 +718,121 @@ export default function SidebarV2({ // top-level list (no folder ancestor), so the usual ml-6 indent would // leave a weird empty gutter on the left. const containerClass = options.flat ? 'space-y-0.5' : 'ml-6 space-y-0.5'; + const sessionTree = buildSessionTree(sessions); + const sessionTitleById = new Map( + allSessions.map(({ sessionId, session }) => [sessionId, sessionDisplayTitle(session)]), + ); + + const renderSessionTreeNode = ( + node: SessionTreeNode, + depth: number, + isForkChild: boolean, + ): ReactNode => { + const { session, sessionId, lastActivity } = node.flat; + const isSessionActive = + selectedProject?.name === project.name && + selectedSession?.id === sessionId && + activeTab === 'chat'; + const isSessionRenaming = renamingSession === sessionId; + const isOptimisticRow = + typeof sessionId === 'string' && sessionId.startsWith('new-session-'); + const indicatorStatus: SessionIndicatorStatus = isOptimisticRow + ? 'processing' + : processingSessions?.has(sessionId) + ? 'processing' + : unreadSessionIds?.has(sessionId) + ? 'unread' + : 'idle'; + const indicatorLabel = + indicatorStatus === 'processing' + ? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' }) + : indicatorStatus === 'unread' + ? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' }) + : t('sidebar:sessions.idle', { defaultValue: 'No unread messages' }); + const parentTitle = session.parentSessionId + ? sessionTitleById.get(session.parentSessionId) + : undefined; + + return ( +
0 ? 'ml-4 border-l border-neutral-200 pl-2 dark:border-neutral-800' : undefined}> +
+ isOptimisticRow ? undefined : openSessionContextMenu(event, project, session) + } + className={cn( + 'group/session relative w-full rounded-md transition-colors', + isSessionActive + ? 'bg-neutral-200/70 dark:bg-neutral-800' + : 'hover:bg-neutral-100 dark:hover:bg-neutral-800', + )} + > + {isSessionRenaming ? ( +
+ setRenameDraft(event.target.value)} + onBlur={commitSessionRename} + onKeyDown={(event) => handleRenameKey(event, 'session')} + onClick={(event) => event.stopPropagation()} + placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string} + className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100" + /> +
+ ) : ( + + )} +
+ {node.children.length > 0 ? ( +
+ {node.children.map((child) => renderSessionTreeNode(child, depth + 1, true))} +
+ ) : null} +
+ ); + }; return (
@@ -694,102 +851,8 @@ export default function SidebarV2({ ) : null} - {sessions.length > 0 ? ( - sessions.map(({ session, sessionId, lastActivity }) => { - const isSessionActive = - selectedProject?.name === project.name && - selectedSession?.id === sessionId && - activeTab === 'chat'; - const isSessionRenaming = renamingSession === sessionId; - // Optimistic placeholder rows are not yet backed by a real - // session id on the server, so clicking / renaming / deleting - // them is meaningless until the server's `projects_updated` - // swaps in the real id (typically within ~300ms). - const isOptimisticRow = - typeof sessionId === 'string' && sessionId.startsWith('new-session-'); - // Optimistic rows always appear "processing" — the user just - // submitted; the agent is always running for them. - const indicatorStatus: SessionIndicatorStatus = isOptimisticRow - ? 'processing' - : processingSessions?.has(sessionId) - ? 'processing' - : unreadSessionIds?.has(sessionId) - ? 'unread' - : 'idle'; - const indicatorLabel = - indicatorStatus === 'processing' - ? t('sidebar:sessions.processing', { defaultValue: 'Agent is running' }) - : indicatorStatus === 'unread' - ? t('sidebar:sessions.unread', { defaultValue: 'Unread messages' }) - : t('sidebar:sessions.idle', { defaultValue: 'No unread messages' }); - - return ( -
- isOptimisticRow ? undefined : openSessionContextMenu(event, project, session) - } - className={cn( - 'group/session relative w-full rounded-md transition-colors', - isSessionActive - ? 'bg-neutral-200/70 dark:bg-neutral-800' - : 'hover:bg-neutral-100 dark:hover:bg-neutral-800', - )} - > - {isSessionRenaming ? ( -
- setRenameDraft(event.target.value)} - onBlur={commitSessionRename} - onKeyDown={(event) => handleRenameKey(event, 'session')} - onClick={(event) => event.stopPropagation()} - placeholder={t('sidebar:renamePlaceholder', { defaultValue: 'Rename - empty to reset' }) as string} - className="w-full rounded-sm border border-neutral-300 bg-white px-1.5 py-0.5 text-[12.5px] text-neutral-900 outline-none focus:border-neutral-500 dark:border-neutral-600 dark:bg-neutral-900 dark:text-neutral-100" - /> -
- ) : ( - - )} - -
- ); - }) + {sessionTree.length > 0 ? ( + sessionTree.map((node) => renderSessionTreeNode(node, 0, false)) ) : (
{t('sidebar:sessions.noSessions', { defaultValue: 'No sessions yet' })} diff --git a/ui/src/components/chat-v2/ChatInterfaceV2.tsx b/ui/src/components/chat-v2/ChatInterfaceV2.tsx index ace99fd1..cc3ba412 100644 --- a/ui/src/components/chat-v2/ChatInterfaceV2.tsx +++ b/ui/src/components/chat-v2/ChatInterfaceV2.tsx @@ -1,16 +1,19 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useTasksSettings } from '../../contexts/TasksSettingsContext'; -import type { ChatInterfaceProps, ChatRunMode, Provider } from '../chat/types/types'; +import { useToast } from '../../contexts/ToastContext'; +import { api } from '../../utils/api'; +import type { ChatInterfaceProps, ChatMessage, ChatRunMode, Provider } from '../chat/types/types'; import { getSessionRequestParams, - isBackgroundTaskSession, + isReadOnlySession, } from '../../types/app'; import { useChatProviderState } from '../chat/hooks/useChatProviderState'; import { useChatSessionState } from '../chat/hooks/useChatSessionState'; import { useChatRealtimeHandlers } from '../chat/hooks/useChatRealtimeHandlers'; import { useChatComposerState } from '../chat/hooks/useChatComposerState'; import { useSessionStore } from '../../stores/useSessionStore'; +import { safeLocalStorage } from '../chat/utils/chatStorage'; import MessagesPaneV2 from './MessagesPaneV2'; import ComposerV2 from './ComposerV2'; @@ -58,7 +61,7 @@ function ChatInterfaceV2({ const { t } = useTranslation('chat'); const { tasksEnabled: _tasksEnabled, isTaskMasterInstalled: _isTaskMasterInstalled } = useTasksSettings(); - const isReadOnlyBackgroundSession = isBackgroundTaskSession(selectedSession); + const sessionIsReadOnly = isReadOnlySession(selectedSession); const sessionRequestParams = React.useMemo( () => getSessionRequestParams(selectedSession), [selectedSession], @@ -71,6 +74,8 @@ function ChatInterfaceV2({ const pendingViewSessionRef = useRef(null); const [isAbortPending, setIsAbortPending] = useState(false); const [runMode, setRunMode] = useState('agent'); + const [isForkPending, setIsForkPending] = useState(false); + const { addToast } = useToast(); const resetStreamingState = useCallback(() => { if (streamTimerRef.current) { @@ -311,6 +316,85 @@ function ChatInterfaceV2({ setIsAbortPending(true); }, [canAbortSession, handleAbortSession, isAbortPending, isLoading]); + const handleFork = useCallback(async (message: ChatMessage, _carriedPreview: number) => { + if (isForkPending || isLoading || sessionIsReadOnly) return; + const sessionId = currentSessionId || selectedSession?.id; + const fromEntryId = message.entryId; + if (!sessionId || !fromEntryId || !selectedProject) { + addToast('error', t('fork.missingTarget', { defaultValue: 'Cannot fork this message.' })); + return; + } + + const projectPath = selectedProject.fullPath || selectedProject.path || ''; + setIsForkPending(true); + try { + const response = await api.forkSession(sessionId, { projectPath, fromEntryId }); + let result: { newSessionId?: string; prefillText?: string; mode?: string; error?: string } = {}; + try { + result = await response.json(); + } catch { + result = {}; + } + if (!response.ok) { + throw new Error(result?.error || `Fork failed (${response.status})`); + } + const newSessionId = result?.newSessionId; + if (!newSessionId) { + throw new Error('Fork did not return a new session id'); + } + setRunMode(result.mode === 'plan' ? 'plan' : 'agent'); + + if (typeof window.refreshProjects === 'function') { + try { + await window.refreshProjects(); + } catch { + // Keep the fork usable even if the sidebar refresh races/fails. + } + } + + const forkDraft = result.prefillText || message.content || ''; + if (forkDraft) { + safeLocalStorage.setItem(`draft_input_${selectedProject.name}`, forkDraft); + } else { + safeLocalStorage.removeItem(`draft_input_${selectedProject.name}`); + } + + onNavigateToSession?.(newSessionId); + setInput(forkDraft); + requestAnimationFrame(() => { + textareaRef.current?.focus(); + scrollToBottom?.(); + }); + // Messages load asynchronously after the session switch; scroll again + // once the carried history has had a chance to render. + setTimeout(() => scrollToBottom?.(), 400); + addToast( + 'success', + t('fork.ready', { + defaultValue: 'Fork created — edit the prompt and send when ready.', + }), + ); + } catch (error) { + const messageText = error instanceof Error ? error.message : String(error); + addToast('error', messageText || t('fork.failed', { defaultValue: 'Fork failed.' })); + } finally { + setIsForkPending(false); + } + }, [ + addToast, + currentSessionId, + isForkPending, + isLoading, + sessionIsReadOnly, + onNavigateToSession, + scrollToBottom, + selectedProject, + selectedSession?.id, + setInput, + t, + textareaRef, + ]); + useEffect(() => { if (!isLoading || !canAbortSession) return; const handleGlobalEscape = (event: KeyboardEvent) => { @@ -353,11 +437,11 @@ function ChatInterfaceV2({ // The composer is identical in welcome / normal mode — just rendered in a // different parent container. Pulled out so we don't drift between the two. - const composer = isReadOnlyBackgroundSession ? ( + const composer = sessionIsReadOnly ? (
- {t('session.readonlyBackground', { - defaultValue: 'This background task transcript is read-only.', + {t('session.readonlyTranscript', { + defaultValue: 'This transcript is read-only.', })}
@@ -481,6 +565,8 @@ function ChatInterfaceV2({ workingStatus={claudeStatus || pilotDeckStatus} runMode={runMode} sessionStore={sessionStore} + onFork={sessionIsReadOnly ? undefined : handleFork} + forkDisabled={isForkPending} /> {composer}
diff --git a/ui/src/components/chat-v2/MessageRowV2.tsx b/ui/src/components/chat-v2/MessageRowV2.tsx index 27376bcb..4f8a7519 100644 --- a/ui/src/components/chat-v2/MessageRowV2.tsx +++ b/ui/src/components/chat-v2/MessageRowV2.tsx @@ -1,8 +1,9 @@ import { memo, useMemo, useRef, useState, type ReactNode } from 'react'; import { useTranslation } from 'react-i18next'; import type { TFunction } from 'i18next'; -import { AlertTriangle, Check, ChevronRight, Copy, FileText, Loader2 } from 'lucide-react'; +import { AlertTriangle, Check, ChevronRight, Copy, FileText, GitBranch, Loader2 } from 'lucide-react'; import { copyTextToClipboard } from '../../utils/clipboard'; +import { cn } from '../../lib/utils.js'; import { useTypewriter } from './useTypewriter'; import type { Project, SessionProvider } from '../../types/app'; import type { @@ -82,6 +83,9 @@ type MessageRowV2Props = { subagentActivityById?: Map; subagentThinkingById?: Map; isSessionRunning?: boolean; + onFork?: (message: ChatMessage, carriedMessageCount: number) => void; + forkCarriedMessageCount?: number; + forkDisabled?: boolean; }; // Fall back to the heavy legacy renderer for anything that isn't a vanilla @@ -120,6 +124,9 @@ function MessageRowV2({ subagentActivityById, subagentThinkingById, isSessionRunning, + onFork, + forkCarriedMessageCount = 0, + forkDisabled = false, }: MessageRowV2Props) { const { t } = useTranslation('chat'); const delegate = useMemo(() => shouldDelegate(message), [message]); @@ -144,6 +151,11 @@ function MessageRowV2({ : [], [message.attachments], ); + const [userImageLightbox, setUserImageLightbox] = useState(null); + const hasForkUnsupportedContent = + Boolean(message.forkUnsupportedContent) || + messageImages.length > 0 || + messageAttachments.length > 0; if (message.isAgentActivitySummary) { return ( @@ -233,7 +245,6 @@ function MessageRowV2({ const isUser = message.type === 'user'; const isError = message.type === 'error'; - const [userImageLightbox, setUserImageLightbox] = useState(null); // User: right-aligned grey bubble. if (isUser) { @@ -243,7 +254,22 @@ function MessageRowV2({ mimeType: image.mimeType, })); return withProcessRows( -
+
+ {onFork ? ( + { + if (message.entryId && !hasForkUnsupportedContent) onFork(message, forkCarriedMessageCount); + }} + t={t} + /> + ) : null}
{message.isStreaming && !formattedContent ? ( @@ -421,6 +447,44 @@ function CopyMarkdownButton({ content }: { content: string }) { ); } +function ForkMessageButton({ + carriedMessageCount, + disabled, + disabledReason, + onFork, + t, +}: { + carriedMessageCount: number; + disabled?: boolean; + disabledReason?: string; + onFork: () => void; + t: TFunction; +}) { + const title = disabledReason ?? t('fork.fromHere', { + count: carriedMessageCount, + defaultValue: `Fork from here · carries ${carriedMessageCount} messages`, + }); + + return ( + + ); +} + export default memo(MessageRowV2); function ProcessSummaryRow({ diff --git a/ui/src/components/chat-v2/MessagesPaneV2.tsx b/ui/src/components/chat-v2/MessagesPaneV2.tsx index 5dbe35ca..941e1064 100644 --- a/ui/src/components/chat-v2/MessagesPaneV2.tsx +++ b/ui/src/components/chat-v2/MessagesPaneV2.tsx @@ -1,7 +1,7 @@ import { Fragment, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import type { Dispatch, ReactNode, RefObject, SetStateAction } from 'react'; import { useTranslation } from 'react-i18next'; -import { XCircle } from 'lucide-react'; +import { XCircle, GitBranch } from 'lucide-react'; import type { ChatMessage, ChatRunMode, @@ -11,7 +11,7 @@ import type { PermissionGrantResult, } from '../chat/types/types'; import type { SessionStore } from '../../stores/useSessionStore'; -import { isBackgroundTaskSession, type Project, type ProjectSession, type SessionProvider } from '../../types/app'; +import { getSessionRequestParams, isReadOnlySession, type Project, type ProjectSession, type SessionProvider } from '../../types/app'; import { getIntrinsicMessageKey } from '../chat/utils/messageKeys'; import MessageRowV2 from './MessageRowV2'; import SubagentDetailModal from './SubagentDetailModal'; @@ -66,6 +66,9 @@ type MessagesPaneV2Props = { workingStatus?: ClaudeWorkStatus | PilotDeckWorkStatus | null; runMode?: ChatRunMode; sessionStore?: SessionStore; + onFork?: (message: ChatMessage, carriedMessageCount: number) => void; + forkDisabled?: boolean; + forkParentSessionTitle?: string | null; }; type KeyedRenderableMessageItem = RenderableMessageItem & { @@ -232,6 +235,23 @@ function MeasuredMessageItem({ ); } +function countCarriedMessagesBefore( + messages: ChatMessage[], + originalIndex: number, +): number { + return messages + .slice(0, originalIndex) + .filter((message) => message.type === 'user' || message.type === 'assistant' || message.isToolUse) + .length; +} + +function isForkedChatSession(session: ProjectSession | null): boolean { + return Boolean( + session?.parentSessionId && + !isReadOnlySession(session), + ); +} + export default function MessagesPaneV2({ scrollContainerRef, onWheel, @@ -266,6 +286,9 @@ export default function MessagesPaneV2({ workingStatus, runMode = 'agent', sessionStore, + onFork, + forkDisabled = false, + forkParentSessionTitle = null, }: MessagesPaneV2Props) { const { t } = useTranslation('chat'); const messageKeyMapRef = useRef>(new WeakMap()); @@ -282,7 +305,7 @@ export default function MessagesPaneV2({ }, []); const sessionId = selectedSession?.id ?? null; - const projectPath = selectedProject?.fullPath ?? undefined; + const projectPath = selectedProject?.fullPath || selectedProject?.path || undefined; const getMessageKey = useCallback((message: ChatMessage, index: number) => { const existingKey = messageKeyMapRef.current.get(message); @@ -331,7 +354,7 @@ export default function MessagesPaneV2({ const hasSessionLoadError = Boolean(!isLoadingSessionMessages && sessionLoadError && chatMessages.length === 0); const isNewConversationEmpty = isEmpty && !selectedSession; const isExistingConversationEmpty = isEmpty && Boolean(selectedSession) && !hasSessionLoadError; - const isReadOnlyBackgroundSession = isBackgroundTaskSession(selectedSession); + const sessionIsReadOnly = isReadOnlySession(selectedSession); const liveActivities = useMemo( () => activityMessages.filter((message) => message.isAgentActivity), [activityMessages], @@ -391,12 +414,17 @@ export default function MessagesPaneV2({ openSubagentActivity && !['completed', 'failed', 'cancelled'].includes(String(openSubagentActivity.state || '')), ); + const sessionRequestParams = useMemo( + () => getSessionRequestParams(selectedSession), + [selectedSession], + ); const subagentDetail = useSubagentMessages( openSubagentId ? sessionId : null, openSubagentId, projectPath, sessionStore, openSubagentActivity?.state, + sessionRequestParams, ); const renderableMessages = useMemo( () => { @@ -711,6 +739,9 @@ export default function MessagesPaneV2({ ? keyedMessageItems[item.renderIndex + 1].message : null; const isLast = !isAssistantWorking && item.renderIndex === keyedMessageItems.length - 1; + const forkCarriedMessageCount = item.message.type === 'user' + ? countCarriedMessagesBefore(renderableMessages, item.originalIndex) + : 0; const anchoredLiveGroups = liveProcessGroupsByAnchor.get(item.originalIndex) || []; const rendersLiveHeaderAfterItem = item.renderIndex === liveProcessHeaderIndex - 1; @@ -758,6 +789,9 @@ export default function MessagesPaneV2({ subagentActivityById={subagentActivityById} subagentThinkingById={subagentThinkingById} isSessionRunning={isAssistantWorking} + onFork={onFork} + forkCarriedMessageCount={forkCarriedMessageCount} + forkDisabled={forkDisabled} /> {rendersLiveHeaderAfterItem ? ( ) : null}
+ ) : isExistingConversationEmpty && isForkedChatSession(selectedSession) ? ( +
+
+ +
+
+ {t('fork.emptyTitle', { defaultValue: 'New branch ready' })} +
+
+ {t('fork.emptyDescription', { + parent: forkParentSessionTitle || selectedSession?.parentSessionId || '', + defaultValue: + 'This branch starts from the beginning of the original conversation. The forked prompt is waiting in the composer — edit it and send to continue here.', + })} +
+
) : isExistingConversationEmpty ? (
- {isReadOnlyBackgroundSession - ? t('emptyChat.readonlyBackgroundTitle', { - defaultValue: 'No displayable messages in this task transcript', + {sessionIsReadOnly + ? t('emptyChat.readonlyTranscriptTitle', { + defaultValue: 'No displayable messages in this read-only transcript', }) : t('emptyChat.emptySessionTitle', { defaultValue: 'No displayable messages in this conversation', })}
- {isReadOnlyBackgroundSession - ? t('emptyChat.readonlyBackgroundDescription', { + {sessionIsReadOnly + ? t('emptyChat.readonlyTranscriptDescription', { defaultValue: - 'This read-only background task transcript only contains records the chat view cannot display.', + 'This read-only transcript only contains records the chat view cannot display.', }) : t('emptyChat.emptySessionDescription', { defaultValue: @@ -935,6 +989,18 @@ export default function MessagesPaneV2({
) : null} + {isForkedChatSession(selectedSession) ? ( +
+ + + {t('fork.banner', { + parent: forkParentSessionTitle || selectedSession?.parentSessionId || '', + defaultValue: `Forked from ${forkParentSessionTitle || selectedSession?.parentSessionId || 'parent session'}`, + })} + +
+ ) : null} + {shouldVirtualizeMessages && virtualWindow.topPadding > 0 ? (