Fail Codex resume requests explicitly (#947)

This commit is contained in:
Mohamed Boudra
2026-05-12 17:50:31 +08:00
committed by GitHub
parent 751a07124f
commit d198c68b9e
4 changed files with 215 additions and 7 deletions

View File

@@ -35,6 +35,7 @@ interface CollaborationModeRecord {
}
interface CodexSessionTestAccess {
ensureThreadLoaded(): Promise<void>;
handleToolApprovalRequest(params: unknown): Promise<unknown>;
handleNotification(method: string, params: unknown): void;
loadPersistedHistory(): Promise<void>;
@@ -378,6 +379,80 @@ describe("Codex app-server provider", () => {
});
});
test("resumeSession does not replace a persisted Codex thread when app-server resume fails", async () => {
const threadRequests: string[] = [];
const appServer = createFakeCodexAppServer({
"thread/loaded/list": () => {
threadRequests.push("thread/loaded/list");
return { data: [] };
},
"thread/resume": () => {
threadRequests.push("thread/resume");
return Promise.reject(new Error("no rollout found for thread id archived-thread-id"));
},
"thread/start": () => {
threadRequests.push("thread/start");
return { thread: { id: "replacement-empty-thread-id" } };
},
"thread/read": () => {
threadRequests.push("thread/read");
return { thread: { turns: [] } };
},
getUserSavedConfig: () => {
threadRequests.push("getUserSavedConfig");
return { config: {} };
},
"config/read": () => {
threadRequests.push("config/read");
return { config: {} };
},
"model/list": () => {
threadRequests.push("model/list");
return {
data: [{ id: "gpt-5.4", isDefault: true, defaultReasoningEffort: "medium" }],
};
},
});
const provider = new CodexAppServerAgentClient(createTestLogger());
castInternals<{ goalsEnabledPromise: Promise<boolean> | null }>(provider).goalsEnabledPromise =
Promise.resolve(false);
castInternals<{ spawnAppServer: () => Promise<ChildProcessWithoutNullStreams> }>(
provider,
).spawnAppServer = async () => appServer.child;
const outcome = await Promise.race([
provider
.resumeSession({
sessionId: "archived-thread-id",
metadata: {
cwd: "/tmp/codex-question-test",
modeId: "auto",
model: "gpt-5.4",
},
})
.then(
() => "resolved" as const,
(error) => {
expect(error).toBeInstanceOf(Error);
expect((error as Error).message).toContain(
"no rollout found for thread id archived-thread-id",
);
return "rejected" as const;
},
),
new Promise<"timed_out">((resolve) => setTimeout(() => resolve("timed_out"), 500)),
]);
if (outcome === "timed_out") {
appServer.child.kill("SIGTERM");
throw new Error(`resumeSession timed out; thread requests: ${threadRequests.join(", ")}`);
}
expect(threadRequests).toEqual(["thread/loaded/list", "thread/resume"]);
expect(outcome).toBe("rejected");
appServer.assertNoErrors();
});
test("lists repo skills using WorkspaceGitService repo-root resolution", async () => {
const tempDir = await mkdtemp(path.join(tmpdir(), "codex-skills-"));
const cwd = path.join(tempDir, "repo", "packages", "app");
@@ -1134,6 +1209,37 @@ describe("Codex app-server provider", () => {
]);
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
const requests: Array<{ method: string; params: unknown }> = [];
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: [] };
}
if (method === "thread/resume") {
throw new Error("no rollout found for thread id archived-thread-id");
}
if (method === "thread/start") {
return { thread: { id: "replacement-empty-thread-id" } };
}
return {};
}),
};
await expect(asInternals(session).ensureThreadLoaded()).rejects.toThrow(
"no rollout found for thread id archived-thread-id",
);
expect(session.currentThreadId).toBe("archived-thread-id");
expect(requests).toEqual([
{ method: "thread/loaded/list", params: {} },
{ method: "thread/resume", params: { threadId: "archived-thread-id" } },
]);
});
test("appends blank-line spacing to /goal status messages", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession({}, { goalsEnabled: true });

View File

@@ -2987,9 +2987,10 @@ class CodexAppServerAgentSession implements AgentSession {
}
await this.client.request("thread/resume", params);
} catch (error) {
this.logger.warn({ error }, "Failed to resume Codex thread, starting new thread");
this.currentThreadId = null;
await this.ensureThread();
const threadId = this.currentThreadId;
const message = error instanceof Error ? error.message : String(error);
this.logger.warn({ error, threadId }, "Failed to resume persisted Codex thread");
throw new Error(`Failed to resume Codex thread ${threadId}: ${message}`, { cause: error });
}
}

View File

@@ -151,7 +151,7 @@ function waitForSignal<T>(
class NonPersistentReloadSession implements AgentSession {
readonly provider = "claude" as const;
readonly id = null;
readonly id: string | null;
readonly capabilities = {
supportsStreaming: false,
supportsSessionPersistence: true,
@@ -161,7 +161,12 @@ class NonPersistentReloadSession implements AgentSession {
supportsToolInvocations: false,
} as const;
constructor(private readonly onClose: () => void) {}
constructor(
private readonly onClose: () => void,
id: string | null = null,
) {
this.id = id;
}
async run(): Promise<AgentRunResult> {
return {
@@ -259,6 +264,37 @@ class NonPersistentReloadClient implements AgentClient {
}
}
class FailingResumeSession extends NonPersistentReloadSession {
constructor(onClose: () => void) {
super(onClose, "failing-resume-session");
}
describePersistence(): AgentPersistenceHandle | null {
return {
provider: "claude",
sessionId: this.id,
metadata: { cwd: process.cwd() },
};
}
}
class FailingResumeClient extends NonPersistentReloadClient {
async createSession(_config: AgentSessionConfig): Promise<AgentSession> {
this.createSessionCalls += 1;
return new FailingResumeSession(() => {
this.closeCalls += 1;
});
}
async resumeSession(
_handle: AgentPersistenceHandle,
_overrides?: Partial<AgentSessionConfig>,
): Promise<AgentSession> {
this.resumeSessionCalls += 1;
throw new Error("resume exploded");
}
}
function resolveSpeechConfig() {
if (hasLocalSpeech) {
return {
@@ -482,6 +518,36 @@ test("refresh_agent rebuilds a live agent even when it has no persistence handle
}
});
test("refresh_agent rejects when persisted session resume fails", async () => {
const cwd = tmpCwd();
const client = new FailingResumeClient();
const localCtx = await createDaemonTestContext({
agentClients: {
claude: client,
},
});
try {
const created = await localCtx.client.createAgent({
config: {
provider: "claude",
cwd,
},
});
await localCtx.client.archiveAgent(created.id);
await expect(localCtx.client.refreshAgent(created.id)).rejects.toMatchObject({
name: "DaemonRpcError",
code: "agent_refresh_failed",
requestType: "refresh_agent_request",
});
expect(client.resumeSessionCalls).toBe(1);
} finally {
await localCtx.cleanup();
rmSync(cwd, { recursive: true, force: true });
}
});
test("resume_agent auto-unarchives archived agents", async () => {
const cwd = tmpCwd();
try {

View File

@@ -3085,6 +3085,17 @@ export class Session {
const { handle, overrides, requestId } = msg;
if (!handle) {
this.sessionLogger.warn("Resume request missing persistence handle");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: "Unable to resume agent: missing persistence handle",
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
@@ -3121,14 +3132,26 @@ export class Session {
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error }, "Failed to resume agent");
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_resume_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to resume agent: ${getErrorMessage(error)}`,
content: `Failed to resume agent: ${message}`,
},
});
}
@@ -3249,14 +3272,26 @@ export class Session {
});
}
} catch (error) {
const message = getErrorMessage(error);
this.sessionLogger.error({ err: error, agentId }, `Failed to refresh agent ${agentId}`);
if (requestId) {
this.emit({
type: "rpc_error",
payload: {
requestId,
requestType: msg.type,
error: message,
code: "agent_refresh_failed",
},
});
}
this.emit({
type: "activity_log",
payload: {
id: uuidv4(),
timestamp: new Date(),
type: "error",
content: `Failed to refresh agent: ${getErrorMessage(error)}`,
content: `Failed to refresh agent: ${message}`,
},
});
}