Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions src/main/app-controls/mcp/tools/threads.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
DEFAULT_TERMINAL_SIZE,
resolveMcpLaunchSnapshot,
} from "@/shared/contracts";
import { isUnknownThreadSessionError } from "@/shared/threadRelaunch";
import { buildWorktreeLocation, normalizeWorktreePathForComparison } from "@/shared/worktree";
import { dbGetThreadRuntimeItemsPage } from "../../../db";
import {
Expand Down Expand Up @@ -418,7 +419,7 @@ export const threadTools: ToolDomain = {
await ctx.supervisor.sendThreadInput({ threadId, prompt: message, config: thread.config });
return { threadId, delivered: true, interruptedFirst: interruptFirst === true };
} catch (error) {
if (!isUnknownSessionError(error)) throw error;
if (!isUnknownThreadSessionError(error)) throw error;
}
// No live session — resume the thread the same way the app revives an
// inactive thread (startThread with the persisted config + sessionRef),
Expand Down Expand Up @@ -615,11 +616,6 @@ export const threadTools: ToolDomain = {
},
};

/** True when the supervisor rejected a call because the thread has no live session. */
function isUnknownSessionError(error: unknown): boolean {
return error instanceof Error && /unknown thread session/i.test(error.message);
}

/**
* Build the `startThread` payload that resumes an inactive thread, mirroring the
* app's own resume path (`performInitialThreadLaunch` / `createAppThread`): the
Expand Down
210 changes: 210 additions & 0 deletions src/renderer/actions/threadRuntimeActions.resume.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { Project, PromptSegment, SendThreadInputPayload, Thread } from "@/shared/contracts";

const mocks = vi.hoisted(() => ({
appState: {
threads: [] as Thread[],
projects: [] as Project[],
applyRuntimeEvent: vi.fn<(threadId: string, event: unknown) => void>(),
updateThreadRuntime: vi.fn<(threadId: string, input: { status: string }) => void>(),
touchThread: vi.fn<(threadId: string) => void>(),
},
bridge: {
sendThreadInput: vi.fn<(payload: SendThreadInputPayload) => Promise<void>>(),
},
performInitialThreadLaunch: vi.fn<(input: unknown) => Promise<void>>(),
}));

vi.mock("@/renderer/state/appStore", () => ({
useAppStore: { getState: () => mocks.appState },
}));
vi.mock("@/renderer/bridge", () => ({
readBridge: () => mocks.bridge,
}));
vi.mock("@/renderer/state/remoteProjection", () => ({
remoteOwner: () => undefined,
}));
vi.mock("@/renderer/state/fileCheckpointActions", () => ({
captureFileCheckpoint: vi.fn<(input: unknown) => Promise<void>>(),
}));
vi.mock("@/renderer/analytics/posthog", () => ({
captureThreadPromptSubmitted: vi.fn<(...args: unknown[]) => void>(),
threadProductProperties: () => ({}),
}));
vi.mock("@/renderer/analytics/productAnalytics", () => ({
captureProductEvent: vi.fn<(...args: unknown[]) => void>(),
}));
vi.mock("./threadLaunchActions", () => ({
performInitialThreadLaunch: mocks.performInitialThreadLaunch,
}));

import { performThreadInputSubmit, submitThreadInput } from "./threadRuntimeActions";

const project: Project = {
id: "project-1",
name: "Repo",
location: { kind: "posix", path: "/repo" },
scripts: { actions: [] },
createdAt: "2026-01-01T00:00:00.000Z",
};

function createThread(overrides: Partial<Thread> = {}): Thread {
return {
id: "thread-1",
projectId: project.id,
title: "Thread",
agentKind: "codex",
config: { model: "codex/model" },
status: "idle",
attention: "none",
canResumeWithConfig: true,
sessionRef: { providerSessionId: "ses_1", discoveredAt: "2026-01-01T00:00:00.000Z" },
archived: false,
done: false,
starred: false,
presentationMode: "gui",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
...overrides,
} as Thread;
}

const segments: PromptSegment[] = [{ kind: "text", content: "hello" }];

function rejectingTransport(message: string) {
return {
sendThreadInput: vi.fn<() => Promise<void>>(() => Promise.reject(new Error(message))),
};
}

/** The rollback write restores the pre-submit status; the optimistic one sets "working". */
function rollbackCalls(): unknown[] {
return mocks.appState.updateThreadRuntime.mock.calls.filter(
([, input]) => input.status !== "working",
);
}

describe("performThreadInputSubmit unknown-session resume", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.bridge.sendThreadInput.mockResolvedValue(undefined);
mocks.performInitialThreadLaunch.mockResolvedValue(undefined);
mocks.appState.threads = [];
mocks.appState.projects = [project];
});

it("resumes the thread instead of dropping the prompt when the session is gone", async () => {
const thread = createThread();
const resumeLaunch = vi.fn<(args: unknown) => Promise<void>>().mockResolvedValue(undefined);

await expect(
performThreadInputSubmit({
thread,
prompt: "hello",
segments,
transport: rejectingTransport("Unknown thread session: x"),
resumeLaunch,
}),
).resolves.toBeUndefined();

expect(resumeLaunch).toHaveBeenCalledExactlyOnceWith({
prompt: "hello",
segments,
userMessageItemId: expect.stringMatching(/^user-/),
});
expect(rollbackCalls()).toEqual([]);
});

it("rolls back and rejects when the resume launch itself fails", async () => {
const thread = createThread();

await expect(
performThreadInputSubmit({
thread,
prompt: "hello",
transport: rejectingTransport("Unknown thread session: x"),
resumeLaunch: () => Promise.reject(new Error("relaunch failed")),
}),
).rejects.toThrow("relaunch failed");

expect(rollbackCalls()).toHaveLength(1);
});

it("rolls back and rejects for any other transport error", async () => {
const thread = createThread();
const resumeLaunch = vi.fn<(args: unknown) => Promise<void>>().mockResolvedValue(undefined);

await expect(
performThreadInputSubmit({
thread,
prompt: "hello",
transport: rejectingTransport("boom"),
resumeLaunch,
}),
).rejects.toThrow("boom");

expect(resumeLaunch).not.toHaveBeenCalled();
expect(rollbackCalls()).toHaveLength(1);
});

it("keeps the old failure behavior without a resume hook or a resumable thread", async () => {
const thread = createThread();
await expect(
performThreadInputSubmit({
thread,
prompt: "hello",
transport: rejectingTransport("Unknown thread session: x"),
}),
).rejects.toThrow("Unknown thread session: x");
expect(rollbackCalls()).toHaveLength(1);

vi.clearAllMocks();
const resumeLaunch = vi.fn<(args: unknown) => Promise<void>>().mockResolvedValue(undefined);
await expect(
performThreadInputSubmit({
thread: createThread({ canResumeWithConfig: false, sessionRef: undefined }),
prompt: "hello",
transport: rejectingTransport("Unknown thread session: x"),
resumeLaunch,
}),
).rejects.toThrow("Unknown thread session: x");
expect(resumeLaunch).not.toHaveBeenCalled();
expect(rollbackCalls()).toHaveLength(1);
});
});

describe("submitThreadInput resume wiring", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.performInitialThreadLaunch.mockResolvedValue(undefined);
mocks.appState.projects = [project];
mocks.appState.threads = [createThread()];
});

it("relaunches with the freshest thread snapshot and the optimistic item id", async () => {
mocks.bridge.sendThreadInput.mockRejectedValueOnce(
new Error("Unknown thread session: thread-1"),
);
// The store snapshot moved on since the submit started; the relaunch must
// carry the newly discovered session ref, not the stale one.
mocks.appState.threads = [
createThread({
sessionRef: { providerSessionId: "ses_2", discoveredAt: "2026-01-02T00:00:00.000Z" },
}),
];

await expect(submitThreadInput("thread-1", "hello", segments)).resolves.toBeUndefined();

expect(mocks.performInitialThreadLaunch).toHaveBeenCalledExactlyOnceWith({
thread: expect.objectContaining({
sessionRef: expect.objectContaining({ providerSessionId: "ses_2" }),
}),
projectLocation: { kind: "posix", path: "/repo" },
prompt: "hello",
segments,
userMessageItemId: expect.stringMatching(/^user-/),
initialSize: { cols: 120, rows: 30 },
});
expect(rollbackCalls()).toEqual([]);
});
});
68 changes: 60 additions & 8 deletions src/renderer/actions/threadRuntimeActions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import type {
ThreadServerRequestId,
} from "@/shared/contracts";
import { toast } from "@heroui/react";
import { DEFAULT_TERMINAL_SIZE } from "@/shared/contracts";
import { isHomeProjectId } from "@/shared/homeScope";
import { friendlyError } from "@/shared/messages";
import { isUnknownThreadSessionError } from "@/shared/threadRelaunch";
import { resolveProjectLocation } from "@/shared/worktree";
import { buildPromptContentBlocks } from "@/shared/promptContent";
import { readBridge } from "@/renderer/bridge";
Expand All @@ -21,6 +23,7 @@ import { captureProductEvent } from "@/renderer/analytics/productAnalytics";
import { useAppStore } from "@/renderer/state/appStore";
import { captureFileCheckpoint } from "@/renderer/state/fileCheckpointActions";
import { remoteOwner } from "@/renderer/state/remoteProjection";
import { performInitialThreadLaunch } from "./threadLaunchActions";

/** Resolve a thread and its on-disk project location from the store. */
function resolveThreadProjectLocation(
Expand Down Expand Up @@ -58,6 +61,16 @@ export async function performThreadInputSubmit(input: {
transport: ThreadInputTransport;
/** Desktop-only: capture a file checkpoint keyed to the optimistic user message. */
captureCheckpoint?: (checkpointItemId: string) => Promise<void>;
/**
* Relaunch the thread and deliver this prompt as the resumed session's first
* input. Called only when the host has no session left for a thread that is
* still resumable, so the prompt is never dropped.
*/
resumeLaunch?: (args: {
prompt: string;
segments?: PromptSegment[];
userMessageItemId?: string;
}) => Promise<void>;
}): Promise<void> {
const { thread, prompt, segments, transport } = input;

Expand Down Expand Up @@ -95,6 +108,16 @@ export async function performThreadInputSubmit(input: {
await input.captureCheckpoint(optimisticUserMessageItemId);
}
}
const rollbackOptimisticWorking = (): void => {
if (!markedWorking) return;
store.updateThreadRuntime(thread.id, {
status: thread.status,
attention: thread.attention,
canResumeWithConfig: thread.canResumeWithConfig,
forceCloseActiveTurn: true,
...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}),
});
};
try {
await transport.sendThreadInput({
threadId: thread.id,
Expand All @@ -104,15 +127,31 @@ export async function performThreadInputSubmit(input: {
...(optimisticUserMessageItemId ? { userMessageItemId: optimisticUserMessageItemId } : {}),
});
} catch (error) {
if (markedWorking) {
store.updateThreadRuntime(thread.id, {
status: thread.status,
attention: thread.attention,
canResumeWithConfig: thread.canResumeWithConfig,
forceCloseActiveTurn: true,
...(thread.sessionRef ? { sessionRef: thread.sessionRef } : {}),
});
// The host session is gone (thread unloaded, supervisor restarted) but the
// thread can still be resumed: relaunch it with this prompt instead of
// dropping it. The optimistic paint stays — the relaunch reuses its item id.
if (
input.resumeLaunch &&
isUnknownThreadSessionError(error) &&
(thread.sessionRef || thread.canResumeWithConfig)
) {
try {
await input.resumeLaunch({
prompt,
...(segments ? { segments } : {}),
...(optimisticUserMessageItemId
? { userMessageItemId: optimisticUserMessageItemId }
: {}),
});
} catch (resumeError) {
rollbackOptimisticWorking();
throw resumeError;
}
// The relaunch captures its own prompt-submitted event.
store.touchThread(thread.id);
return;
}
rollbackOptimisticWorking();
throw error;
}
captureThreadPromptSubmitted(thread, prompt, segments);
Expand Down Expand Up @@ -140,6 +179,19 @@ export async function submitThreadInput(
prompt,
...(segments ? { segments } : {}),
transport: readBridge(),
resumeLaunch: async (resume) => {
// Re-resolve the thread: the pre-send snapshot can miss a sessionRef
// discovered since, and the resume payload must carry the latest one.
const latest = resolveThreadProjectLocation(threadId);
await performInitialThreadLaunch({
thread: latest?.thread ?? thread,
projectLocation: latest?.projectLocation ?? projectLocation,
prompt: resume.prompt,
...(resume.segments ? { segments: resume.segments } : {}),
...(resume.userMessageItemId ? { userMessageItemId: resume.userMessageItemId } : {}),
initialSize: DEFAULT_TERMINAL_SIZE,
});
},
...(!owner
? {
captureCheckpoint: async (checkpointItemId: string) => {
Expand Down
9 changes: 9 additions & 0 deletions src/shared/threadRelaunch.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,15 @@
import type { ProjectLocation, TerminalSize, Thread } from "./contracts";
import type { StartRemoteThreadInput } from "./remote/client";

/**
* True when a supervisor call was rejected because the thread has no live
* session. Every caller that can revive a thread (the renderer composer, the
* `send_to_thread` MCP tool) branches on this to resume instead of failing.
*/
export function isUnknownThreadSessionError(error: unknown): boolean {
return error instanceof Error && /unknown thread session/i.test(error.message);
}

/**
* Reopening a thread on its host relaunches it with an empty prompt. Only an
* INACTIVE thread qualifies: every other status means the host session is
Expand Down
Loading