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;