test(core): pin abort + hub-restart durability, and title seeded sessions (#13097)

* test(core): cover abort + host restart + seeded recovery durability

Adds an e2e regression guard for the reported "cancel a turn, lose the
conversation" failure: a cancelled turn, a daemon restart, a
client-side recovery seeded from disk, and a second restart before that
replacement ever runs a turn. Reverting the eager seeded-history
persistence makes the final read come back empty.

Materializing a seeded session at start also left its history row with
no prompt and no title, since there is no first prompt to derive one
from. Seed the title from the inherited transcript using the same
inference listSessionHistory hydration applies, so forks and recoveries
stay identifiable in unhydrated surfaces too.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* fix(core): retitle seeded sessions from their first user prompt

Eagerly-materialized seeded sessions kept the interim transcript-
inferred title forever, a behavior change from pre-eager persistence
where a fork's history row was titled by the first post-fork prompt.

The interim title now only covers the window where no turn has run
(previously those rows were simply absent), and the first user prompt
after the seed backfills the row's prompt and retitles it — unless the
user renamed the session in the meantime, in which case only the prompt
column is backfilled. The resident manifest and session metadata are
updated in step so the end-of-turn usage-metadata merge cannot clobber
the title back through a stale in-memory fallback.

The e2e mock's updateSession now mirrors the real persistence-service
contract (row + manifest file), and the durability e2e covers both the
retitle and the rename guard; removing the retitle call fails the
'now add tests' assertion.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

* refactor(core): collapse seeded-session titling to the old mechanism

The interim transcript-derived title, retitle flags, rename comparison,
and resident-manifest syncing existed only to title forks that never
run a turn - a new nicety, not parity. Dropping it collapses the whole
design back to what rows did before eager persistence: the persistence
service derives the title from the prompt when a row gains one, so the
host only needs to backfill the promptless row with the first user
prompt via updateSession. Renames win automatically because the service
preserves an existing title when no explicit title is passed.

Net production change vs main is a single 20-line backfill block in
executeTurn. The e2e mock's updateSession now models the service's
title semantics (explicit title wins, existing title preserved,
untitled rows derive from prompt), and the durability e2e asserts the
raw row stays untitled until first prompt while history hydration
infers a display title from the transcript.

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>

---------

Co-authored-by: Saoud Rizwan <saoudrizwan@users.noreply.github.com>
This commit is contained in:
Saoud Rizwan
2026-08-08 12:30:30 -07:00
committed by GitHub
co-authored by Saoud Rizwan
parent 62a6b5a0b2
commit ff2f860941
2 changed files with 291 additions and 0 deletions
@@ -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<string, unknown>;
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<string, unknown> | 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<string, unknown>;
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<void>((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<AgentResult>((_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");
});
});
@@ -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);