diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.e2e.test.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.e2e.test.ts index 9bb82259cf..628ac9dfec 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.e2e.test.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.e2e.test.ts @@ -14,10 +14,12 @@ import type { AgentResult } from "@cline/shared"; import { setClineDir, setHomeDir } from "@cline/shared/storage"; import { nanoid } from "nanoid"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { deriveTitleFromPrompt } from "../../services/session-data"; import type { SessionManifest } from "../../session/models/session-manifest"; import type { SessionRow } from "../../session/models/session-row"; import type { RootSessionArtifacts } from "../../session/services/session-service"; import type { SessionSource, SessionStatus } from "../../types/common"; +import { listSessionHistory } from "./history"; import { LocalRuntimeHost as RuntimeHostUnderTest } from "./local-runtime-host"; import { splitCoreSessionConfig } from "./runtime-host"; @@ -72,6 +74,7 @@ class LocalFileSessionService { enableSpawn: boolean; enableTeams: boolean; prompt?: string; + metadata?: Record; startedAt?: string; }): RootSessionArtifacts { const startedAt = input.startedAt ?? nowIso(); @@ -99,6 +102,7 @@ class LocalFileSessionService { enable_spawn: input.enableSpawn, enable_teams: input.enableTeams, prompt, + metadata: input.metadata, messages_path: messagesPath, }; writeFileSync( @@ -136,6 +140,7 @@ class LocalFileSessionService { conversationId: null, isSubagent: false, prompt: prompt ?? null, + metadata: input.metadata ?? null, hookPath: "", messagesPath, updatedAt: startedAt, @@ -173,6 +178,59 @@ class LocalFileSessionService { ); } + readSessionManifest(sessionId: string): SessionManifest | undefined { + const manifestPath = join(this.sessionsDir, sessionId, `${sessionId}.json`); + try { + return JSON.parse(readFileSync(manifestPath, "utf8")) as SessionManifest; + } catch { + return undefined; + } + } + + // Mirrors the real persistence-service contract: updates land in both the + // session row and the on-disk manifest, an explicit title wins, an + // existing title is preserved, and an untitled row is titled from the + // prompt exactly like the create path. + updateSession(input: { + sessionId: string; + prompt?: string | null; + metadata?: Record | null; + title?: string | null; + }): { updated: boolean } { + const row = this.rows.get(input.sessionId); + if (!row) return { updated: false }; + if (input.prompt !== undefined) { + row.prompt = input.prompt ?? null; + } + const existingTitle = + typeof row.metadata?.title === "string" ? row.metadata.title : undefined; + const metadata = { + ...((input.metadata !== undefined ? input.metadata : row.metadata) ?? {}), + } as Record; + const nextTitle = + input.title !== undefined + ? (input.title ?? undefined) + : (existingTitle ?? deriveTitleFromPrompt(input.prompt)); + if (nextTitle) { + metadata.title = nextTitle; + } else { + delete metadata.title; + } + row.metadata = Object.keys(metadata).length > 0 ? metadata : null; + const manifest = this.readSessionManifest(input.sessionId); + if (manifest) { + if (input.prompt !== undefined) { + manifest.prompt = input.prompt ?? undefined; + } + manifest.metadata = row.metadata ?? undefined; + this.writeSessionManifest( + join(this.sessionsDir, input.sessionId, `${input.sessionId}.json`), + manifest, + ); + } + return { updated: true }; + } + updateSessionStatus( sessionId: string, status: SessionStatus, @@ -573,4 +631,217 @@ describe("LocalRuntimeHost e2e", () => { cost: 0.13, }); }); + + // Regression guard for the "cancel a turn, lose the whole conversation" + // report: the daemon dies while a cancelled turn is only in memory, the + // client recovers by seeding a replacement session from disk, and that + // replacement itself dies before its first turn. Every hop has to keep the + // transcript, so this walks the whole chain against real artifact files. + it("keeps the transcript across an abort, a host restart, and a seeded recovery that never runs a turn", async () => { + const sessionsDir = mkdtempSync(join(tmpdir(), "core-e2e-durability-")); + tempDirs.push(sessionsDir); + + // One service instance across every host: a daemon restart loses the + // resident sessions, not the session database or the artifact files. + const sessionService = new LocalFileSessionService(sessionsDir); + const runtimeBuilder = { + build: vi.fn().mockReturnValue({ tools: [], shutdown: vi.fn() }), + }; + const config = splitCoreSessionConfig({ + providerId: "anthropic", + modelId: "claude-sonnet-4-6", + apiKey: "test-key", + cwd: sessionsDir, + systemPrompt: "You are a test agent", + mode: "act", + enableTools: false, + enableSpawnAgent: false, + enableAgentTeams: false, + }); + + let messages: LlmsProviders.Message[] = []; + let running = false; + let rejectRun: ((error: Error) => void) | undefined; + let markRunStarted: (() => void) | undefined; + const runStarted = new Promise((resolve) => { + markRunStarted = resolve; + }); + const appendUser = (prompt: string) => { + messages = [ + ...messages, + { + role: "user", + content: [{ type: "text", text: prompt }], + }, + ] as LlmsProviders.Message[]; + }; + const run = vi.fn(async (prompt: string) => { + appendUser(prompt); + messages = [ + ...messages, + { + role: "assistant", + content: [{ type: "text", text: "the auth flow works like this" }], + }, + ] as LlmsProviders.Message[]; + return createResult({ text: "the auth flow works like this", messages }); + }); + // The real agent appends the user turn before the provider stream + // starts, so a cancelled turn leaves the prompt in the transcript. + // The first continue hangs until aborted; later continues complete + // normally so the recovered session can run a real turn. + let continueCalls = 0; + const continueFn = vi.fn((prompt: string) => { + continueCalls += 1; + if (continueCalls === 1) { + return new Promise((_resolve, reject) => { + appendUser(prompt); + running = true; + rejectRun = (error) => { + running = false; + reject(error); + }; + markRunStarted?.(); + }); + } + appendUser(prompt); + messages = [ + ...messages, + { + role: "assistant", + content: [{ type: "text", text: "tests added" }], + }, + ] as LlmsProviders.Message[]; + return Promise.resolve(createResult({ text: "tests added", messages })); + }); + const abort = vi.fn(() => rejectRun?.(new Error("user cancelled"))); + const createAgent = vi.fn( + () => + ({ + run, + continue: continueFn, + abort, + canStartRun: vi.fn(() => !running), + getAgentId: vi.fn().mockReturnValue("agent-durability"), + getConversationId: vi.fn().mockReturnValue("conv-durability"), + restore: vi.fn(), + subscribeEvents: vi.fn().mockReturnValue(() => {}), + updateConnection: vi.fn(), + shutdown: vi.fn().mockResolvedValue(undefined), + getMessages: vi.fn(() => [...messages]), + }) as never, + ); + + const firstHost = new RuntimeHostUnderTest({ + distinctId: `test-${nanoid(5)}`, + sessionService: sessionService as never, + runtimeBuilder: runtimeBuilder as never, + createAgent, + }); + const started = await firstHost.startSession({ + interactive: true, + ...config, + }); + await firstHost.runTurn({ + sessionId: started.sessionId, + prompt: "explain the auth flow", + }); + + const cancelledTurn = firstHost.runTurn({ + sessionId: started.sessionId, + prompt: "now walk me through token refresh", + }); + await runStarted; + await firstHost.abort(started.sessionId, new Error("user cancelled")); + await expect(cancelledTurn).resolves.toMatchObject({ + finishReason: "aborted", + }); + + // The daemon dies here: no dispose, no graceful shutdown. Everything the + // replacement host knows has to come off disk. + const secondHost = new RuntimeHostUnderTest({ + distinctId: `test-${nanoid(5)}`, + sessionService: sessionService as never, + runtimeBuilder: runtimeBuilder as never, + createAgent, + }); + const recoveredMessages = await secondHost.readSessionMessages( + started.sessionId, + ); + expect( + recoveredMessages.map((message) => [ + message.role, + JSON.stringify(message.content), + ]), + ).toEqual([ + ["user", expect.stringContaining("explain the auth flow")], + ["assistant", expect.stringContaining("the auth flow works like this")], + ["user", expect.stringContaining("now walk me through token refresh")], + ]); + + // Client-side recovery: a brand-new session id seeded with the rescued + // transcript, exactly like the interactive CLI does on session_not_found. + messages = [...recoveredMessages]; + const recovered = await secondHost.startSession({ + interactive: true, + initialMessages: recoveredMessages as never, + ...config, + }); + expect(recovered.sessionId).not.toBe(started.sessionId); + + // ...and that replacement dies too, before the user sends anything. The + // seed has to already be on disk or the conversation is gone for good. + const thirdHost = new RuntimeHostUnderTest({ + distinctId: `test-${nanoid(5)}`, + sessionService: sessionService as never, + runtimeBuilder: runtimeBuilder as never, + createAgent, + }); + await expect( + thirdHost.readSessionMessages(recovered.sessionId), + ).resolves.toHaveLength(3); + + // Materializing at start means the row exists before any prompt, so it + // is promptless and untitled at the raw level (before eager + // persistence it did not exist at all yet); history hydration infers a + // display title from the inherited transcript. + const recoveredRow = await thirdHost.getSession(recovered.sessionId); + expect(recoveredRow?.metadata?.title ?? undefined).toBeUndefined(); + expect(recoveredRow?.prompt ?? undefined).toBeUndefined(); + const hydrated = await listSessionHistory(thirdHost); + expect( + hydrated.find((row) => row.sessionId === recovered.sessionId)?.metadata + ?.title, + ).toBe("explain the auth flow"); + + // The first prompt after the seed retitles the row — pre-eager- + // persistence parity, where a fork was named after what the user did + // with it rather than the conversation it inherited. + await secondHost.runTurn({ + sessionId: recovered.sessionId, + prompt: "now add tests", + }); + const retitledRow = await thirdHost.getSession(recovered.sessionId); + expect(retitledRow?.metadata?.title).toBe("now add tests"); + expect(retitledRow?.prompt).toContain("now add tests"); + + // A rename between materialization and the first prompt wins over the + // automatic retitle; the prompt column is still backfilled. + messages = [...recoveredMessages]; + const renamedFork = await secondHost.startSession({ + interactive: true, + initialMessages: recoveredMessages as never, + ...config, + }); + await secondHost.updateSession(renamedFork.sessionId, { + title: "my renamed fork", + }); + await secondHost.runTurn({ + sessionId: renamedFork.sessionId, + prompt: "refactor the parser", + }); + const renamedRow = await thirdHost.getSession(renamedFork.sessionId); + expect(renamedRow?.metadata?.title).toBe("my renamed fork"); + expect(renamedRow?.prompt).toContain("refactor the parser"); + }); }); diff --git a/sdk/packages/core/src/runtime/host/local-runtime-host.ts b/sdk/packages/core/src/runtime/host/local-runtime-host.ts index 2854d7c5d2..87e2aaa797 100644 --- a/sdk/packages/core/src/runtime/host/local-runtime-host.ts +++ b/sdk/packages/core/src/runtime/host/local-runtime-host.ts @@ -1616,6 +1616,26 @@ export class LocalRuntimeHost implements RuntimeHost { session.pendingPrompt = prompt; } await this.ensureSessionPersisted(session); + // A seeded session (fork, checkpoint restore, missing-session + // recovery) materializes at start, before any prompt exists, so its + // row is created promptless. Backfill it with the first user prompt, + // which restores the behavior rows had when materialization happened + // here: the persistence service derives the title from this prompt + // when the session is untitled, and leaves any user-set title alone. + if (!session.pendingPrompt) { + session.pendingPrompt = prompt; + try { + await this.invokeOptionalValue("updateSession", { + sessionId: session.sessionId, + prompt, + }); + } catch (error) { + session.config.logger?.log?.( + "Failed to backfill seeded session prompt", + { severity: "warn", sessionId: session.sessionId, error }, + ); + } + } await this.refreshActiveSessionGitMetadata(session); await this.syncOAuthCredentials(session); await this.markTurnRunning(session);