From fc25d453fd0edf72af1a2e3bb2cfdec6ad535fa9 Mon Sep 17 00:00:00 2001 From: Bee <68532117+abeatrix@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:31:08 -0700 Subject: [PATCH] fix: preserve fork start config before stopping session (#278) Resolve the fork start configuration before stopping the active session so forks can be created from either the source session or current config. Reuse the resolved config when starting the fork and store it for the new session. --- sdk/apps/code/sidecar/chat-session.ts | 114 ++++++++++++++++++ sdk/apps/code/sidecar/types.ts | 1 + sdk/apps/vscode/src/extension.ts | 17 ++- .../core/src/transports/local.test.ts | 77 ++++++++++++ sdk/packages/core/src/transports/local.ts | 22 +++- sdk/packages/core/src/types/session.ts | 3 + 6 files changed, 227 insertions(+), 7 deletions(-) diff --git a/sdk/apps/code/sidecar/chat-session.ts b/sdk/apps/code/sidecar/chat-session.ts index 58e25769a5..927bc32197 100644 --- a/sdk/apps/code/sidecar/chat-session.ts +++ b/sdk/apps/code/sidecar/chat-session.ts @@ -57,6 +57,13 @@ function readSessionMetadataTitle(sessionId: string): string | undefined { return typeof title === "string" ? title.trim() || undefined : undefined; } +function readSessionMetadata(sessionId: string): JsonRecord | undefined { + const manifest = readSessionManifest(sessionId); + return manifest?.metadata && typeof manifest.metadata === "object" + ? (manifest.metadata as JsonRecord) + : undefined; +} + function derivePromptFromMessages(messages: unknown[]): string { for (const msg of messages) { if (!msg || typeof msg !== "object") continue; @@ -598,6 +605,112 @@ async function handleAbort( return { sessionId, ok: true }; } +async function handleFork( + ctx: SidecarContext, + request: ChatSessionCommandRequest, +): Promise { + const sourceSessionId = request.sessionId?.trim(); + if (!sourceSessionId) throw new Error("sessionId is required"); + const manager = getSessionManager(ctx); + const sourceMessages = + readPersistedChatMessages(sourceSessionId) ?? + ctx.liveSessions.get(sourceSessionId)?.messages; + if (!sourceMessages?.length) { + throw new Error(`No messages found for session ${sourceSessionId}`); + } + + const sourceSession = await manager.get(sourceSessionId); + const sourceMetadata = + (sourceSession?.metadata && typeof sourceSession.metadata === "object" + ? (sourceSession.metadata as JsonRecord) + : undefined) ?? readSessionMetadata(sourceSessionId); + const liveConfig = ctx.liveSessions.get(sourceSessionId)?.config; + const forkConfig: JsonRecord = { + ...(liveConfig ?? {}), + ...(request.config ?? {}), + sessionId: undefined, + provider: + sourceSession?.provider || + liveConfig?.provider || + request.config?.provider || + request.config?.providerId || + "", + model: + sourceSession?.model || + liveConfig?.model || + request.config?.model || + request.config?.modelId || + "", + cwd: + sourceSession?.cwd || + sourceSession?.workspaceRoot || + liveConfig?.cwd || + request.config?.cwd || + request.config?.workspaceRoot || + request.config?.workspace_root || + "", + workspaceRoot: + sourceSession?.workspaceRoot || + sourceSession?.cwd || + liveConfig?.workspaceRoot || + request.config?.workspaceRoot || + request.config?.workspace_root || + request.config?.cwd || + "", + }; + const checkpointMetadata = + sourceMetadata?.checkpoint !== undefined + ? { checkpoints: sourceMetadata.checkpoint } + : {}; + const forkMetadata: JsonRecord = { + ...(sourceMetadata ?? {}), + fork: { + forkedFromSessionId: sourceSessionId, + forkedAt: new Date().toISOString(), + source: sourceSession?.source ?? "desktop", + ...checkpointMetadata, + }, + }; + const systemPrompt = await resolveSystemPrompt(forkConfig); + const startResult = await manager.start({ + ...splitCoreSessionConfig( + buildCoreSessionConfig({ + ...forkConfig, + systemPrompt, + initialMessages: sourceMessages, + }) as any, + ), + source: SessionSource.DESKTOP, + interactive: true, + initialMessages: sourceMessages as any[], + sessionMetadata: forkMetadata, + toolPolicies: resolveToolPolicies(forkConfig), + }); + const newSessionId = startResult.sessionId; + ctx.liveSessions.delete(sourceSessionId); + ctx.liveSessions.set( + newSessionId, + createLiveSession(forkConfig, { + messages: sourceMessages, + prompt: derivePromptFromMessages(sourceMessages), + title: readSessionMetadataTitle(sourceSessionId), + status: "idle", + }), + ); + sendPromptsInQueueSnapshot(ctx, sourceSessionId); + sendPromptsInQueueSnapshot(ctx, newSessionId); + let messages: unknown[] = sourceMessages; + try { + const read = await manager.readMessages(newSessionId); + if (read?.length > 0) messages = read; + } catch {} + return { + sessionId: newSessionId, + forkedFromSessionId: sourceSessionId, + messages, + }; +} + async function handleReset( ctx: SidecarContext, request: ChatSessionCommandRequest, @@ -787,6 +900,7 @@ const ACTION_HANDLERS: Record< send: handleSend, stop: handleStop, abort: handleAbort, + fork: handleFork, reset: handleReset, restore_checkpoint: handleRestoreCheckpoint, pending_prompts: handlePendingPrompts, diff --git a/sdk/apps/code/sidecar/types.ts b/sdk/apps/code/sidecar/types.ts index c296e8916e..af37378d8f 100644 --- a/sdk/apps/code/sidecar/types.ts +++ b/sdk/apps/code/sidecar/types.ts @@ -14,6 +14,7 @@ export type ChatSessionCommandRequest = { | "send" | "stop" | "abort" + | "fork" | "reset" | "restore_checkpoint" | "pending_prompts" diff --git a/sdk/apps/vscode/src/extension.ts b/sdk/apps/vscode/src/extension.ts index 504558cf6b..7fdfb77f5c 100644 --- a/sdk/apps/vscode/src/extension.ts +++ b/sdk/apps/vscode/src/extension.ts @@ -1121,16 +1121,19 @@ class CoreChatWebviewController implements vscode.Disposable { } } } - // Stop the current session before spawning the fork. - await this.stopExistingSession(); - if (!this.startConfig) { + const forkStartConfig = sourceSession + ? await this.buildStartConfigFromSession(sourceSession) + : this.startConfig; + if (!forkStartConfig) { throw new Error("Could not resolve start config for fork."); } + // Stop the current session before spawning the fork. + await this.stopExistingSession(); const toolPolicies: Record = { "*": { autoApprove: true } }; const response = await host.start({ interactive: true, - config: this.startConfig, + config: forkStartConfig, toolPolicies, initialMessages: rawMessages as import("@clinebot/llms").Message[], sessionMetadata: forkMetadata, @@ -1139,7 +1142,9 @@ class CoreChatWebviewController implements vscode.Disposable { if (!newSessionId) { throw new Error("Fork did not return a session id."); } + const newSession = await host.get(newSessionId); this.sessionId = newSessionId; + this.startConfig = forkStartConfig; this.startEventStream(newSessionId); await this.post({ type: "session_started", sessionId: newSessionId }); const forkMessages = mapPersistedMessagesToWebviewMessages( @@ -1150,6 +1155,7 @@ class CoreChatWebviewController implements vscode.Disposable { await this.post({ type: "session_hydrated", sessionId: newSessionId, + status: newSession?.status, messages: forkMessages, }); await this.post({ @@ -1216,6 +1222,9 @@ class CoreChatWebviewController implements vscode.Disposable { } private async forwardSessionHubEvent(event: HubEventEnvelope): Promise { + if (!this.sessionId || event.sessionId !== this.sessionId) { + return; + } const payload = asRecord(event.payload); const shouldRefreshSessions = event.event === "session.created" || diff --git a/sdk/packages/core/src/transports/local.test.ts b/sdk/packages/core/src/transports/local.test.ts index 01edd1b66a..d5de9198c7 100644 --- a/sdk/packages/core/src/transports/local.test.ts +++ b/sdk/packages/core/src/transports/local.test.ts @@ -465,6 +465,83 @@ describe("LocalRuntimeHost", () => { expect(started.manifest.source).toBe("kanban"); }); + it("persists initial messages for idle resumed sessions", async () => { + const sessionId = "sess-fork-copy"; + const manifest = createManifest(sessionId); + const initialMessages = [ + { role: "user" as const, content: "build a thing" }, + { role: "assistant" as const, content: "done" }, + ]; + const sessionService = { + ensureSessionsDir: vi.fn().mockReturnValue("/tmp/sessions"), + createRootSessionWithArtifacts: vi.fn().mockResolvedValue({ + manifestPath: "/tmp/manifest.json", + messagesPath: "/tmp/messages.json", + manifest, + }), + persistSessionMessages: vi.fn(), + updateSessionStatus: vi.fn().mockResolvedValue({ + updated: true, + endedAt: "2026-01-01T00:00:05.000Z", + }), + writeSessionManifest: vi.fn(), + listSessions: vi.fn().mockResolvedValue([]), + deleteSession: vi.fn().mockResolvedValue({ deleted: true }), + }; + const runtimeBuilder = { + build: vi.fn().mockReturnValue({ + tools: [], + teamRuntime: undefined, + teamRestoredFromPersistence: false, + shutdown: vi.fn(), + }), + }; + const agent = { + run: vi.fn().mockResolvedValue(createResult()), + continue: vi.fn().mockResolvedValue(createResult()), + getMessages: vi.fn().mockReturnValue(initialMessages), + getAgentId: vi.fn().mockReturnValue("agent-root-1"), + getConversationId: vi.fn().mockReturnValue("conv-root-1"), + abort: vi.fn(), + subscribeEvents: vi.fn().mockReturnValue(() => {}), + canStartRun: vi.fn().mockReturnValue(true), + shutdown: vi.fn().mockResolvedValue(undefined), + }; + const manager = new RuntimeHostUnderTest({ + distinctId, + sessionService: sessionService as never, + runtimeBuilder: runtimeBuilder as never, + createAgent: () => agent as never, + }); + + await manager.start( + normalizeStartInput({ + config: createConfig({ sessionId }), + interactive: true, + initialMessages, + }), + ); + + expect(agent.run).not.toHaveBeenCalled(); + expect(sessionService.createRootSessionWithArtifacts).toHaveBeenCalledTimes( + 1, + ); + expect(sessionService.persistSessionMessages).toHaveBeenCalledWith( + sessionId, + initialMessages, + "You are a test agent", + ); + expect(sessionService.updateSessionStatus).toHaveBeenCalledWith( + sessionId, + "completed", + 0, + ); + await expect(manager.get(sessionId)).resolves.toMatchObject({ + sessionId, + status: "completed", + }); + }); + it("reuses the persisted team name when resuming a session", async () => { const sessionId = "sess-team-resume"; const manifest = createManifest(sessionId); diff --git a/sdk/packages/core/src/transports/local.ts b/sdk/packages/core/src/transports/local.ts index 55540baa47..767ff4b53b 100644 --- a/sdk/packages/core/src/transports/local.ts +++ b/sdk/packages/core/src/transports/local.ts @@ -145,9 +145,9 @@ function toActiveSessionRecord(session: ActiveSession): SessionRecord { source: session.source, pid: process.pid, startedAt: session.startedAt, - endedAt: null, - exitCode: null, - status: "running", + endedAt: session.endedAt ?? null, + exitCode: session.exitCode ?? null, + status: session.status, interactive: session.interactive, provider: session.config.providerId, model: session.config.modelId, @@ -436,6 +436,7 @@ export class LocalRuntimeHost implements RuntimeHost { runtime, agent, started: false, + status: "running", aborting: false, interactive: input.interactive === true, persistedMessages: startInput.initialMessages, @@ -448,6 +449,18 @@ export class LocalRuntimeHost implements RuntimeHost { }; this.sessions.set(sessionId, active); this.emitStatus(sessionId, "running"); + if ((startInput.initialMessages?.length ?? 0) > 0) { + await this.ensureSessionPersisted(active); + await this.invoke( + "persistSessionMessages", + active.sessionId, + startInput.initialMessages, + active.config.systemPrompt, + ); + if (!startInput.prompt?.trim()) { + await this.updateStatus(active, "completed", 0); + } + } let result: AgentResult | undefined; try { @@ -1114,6 +1127,9 @@ export class LocalRuntimeHost implements RuntimeHost { latestManifest.ended_at = result.endedAt ?? nowIso(); latestManifest.exit_code = typeof exitCode === "number" ? exitCode : null; session.artifacts.manifest = latestManifest; + session.status = status; + session.endedAt = latestManifest.ended_at; + session.exitCode = latestManifest.exit_code; await this.invoke( "writeSessionManifest", session.artifacts.manifestPath, diff --git a/sdk/packages/core/src/types/session.ts b/sdk/packages/core/src/types/session.ts index 4ac861a66c..08c1474bb8 100644 --- a/sdk/packages/core/src/types/session.ts +++ b/sdk/packages/core/src/types/session.ts @@ -14,6 +14,9 @@ export type ActiveSession = { artifacts?: RootSessionArtifacts; source: SessionSource; startedAt: string; + status: SessionStatus; + endedAt?: string | null; + exitCode?: number | null; pendingPrompt?: string; runtime: BuiltRuntime; agent: SessionRuntime;