From 5a466209f2c9df1243812ce2e6ad1f2eb49065e1 Mon Sep 17 00:00:00 2001 From: Onur <2453968+osolmaz@users.noreply.github.com> Date: Tue, 7 Apr 2026 00:54:25 +0200 Subject: [PATCH 1/3] fix: close backend sessions on reset --- CHANGELOG.md | 1 + package-lock.json | 4 +- package.json | 2 +- src/acp/client.ts | 16 ++++++++ src/runtime.ts | 19 ++++++--- src/runtime/engine/manager.ts | 71 +++++++++++++++++++++++++++++++++- src/runtime/public/contract.ts | 6 ++- test/client.test.ts | 36 +++++++++++++++++ test/runtime-manager.test.ts | 57 +++++++++++++++++++++++++++ test/runtime.test.ts | 8 +++- 10 files changed, 208 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e8f8885..d260a39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,7 @@ Repo: https://github.com/openclaw/acpx ### Fixes +- Sessions/reset: close the live backend session when discarding persistent state so reset flows start a fresh ACP session instead of silently reopening the old one. - Agents/kiro: use `kiro-cli-chat acp` for the built-in Kiro adapter command to avoid orphan child processes. (#129) Thanks @vokako. - Agents/cursor: recognize Cursor's `Session \"...\" not found` `session/load` error format so reconnects fall back to `session/new` instead of failing. (#162) Thanks @log-li. - Output/thinking: preserve line breaks in text-mode `[thinking]` output instead of flattening multi-line thought chunks into one line. (#144) Thanks @Huarong. diff --git a/package-lock.json b/package-lock.json index 639cca0..5489d97 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "acpx", - "version": "0.5.1", + "version": "0.5.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "acpx", - "version": "0.5.1", + "version": "0.5.2", "license": "MIT", "dependencies": { "@agentclientprotocol/sdk": "^0.15.0", diff --git a/package.json b/package.json index ea23b05..83ae5a1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "acpx", - "version": "0.5.1", + "version": "0.5.2", "description": "Headless CLI client for the Agent Client Protocol (ACP) — talk to coding agents from the command line", "keywords": [ "acp", diff --git a/src/acp/client.ts b/src/acp/client.ts index 2505194..2e35003 100644 --- a/src/acp/client.ts +++ b/src/acp/client.ts @@ -331,6 +331,10 @@ export class AcpClient { return Boolean(this.initResult?.agentCapabilities?.loadSession); } + supportsCloseSession(): boolean { + return Boolean(this.initResult?.agentCapabilities?.sessionCapabilities?.close); + } + setEventHandlers( handlers: Pick< AcpClientOptions, @@ -827,6 +831,18 @@ export class AcpClient { ); } + async closeSession(sessionId: string): Promise { + const connection = this.getConnection(); + await this.runConnectionRequest(() => + connection.unstable_closeSession({ + sessionId, + }), + ); + if (this.loadedSessionId === sessionId) { + this.loadedSessionId = undefined; + } + } + async requestCancelActivePrompt(): Promise { const active = this.activePrompt; if (!active) { diff --git a/src/runtime.ts b/src/runtime.ts index de1b434..d12f1e4 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -222,13 +222,22 @@ export class AcpxRuntime implements AcpxRuntimeLike { }); } - async close(input: { handle: AcpRuntimeHandle; reason: string }): Promise { + async close(input: { + handle: AcpRuntimeHandle; + reason: string; + discardPersistentState?: boolean; + }): Promise { const state = this.resolveHandleState(input.handle); const manager = await this.getManager(); - await manager.close({ - ...input.handle, - acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey, - }); + await manager.close( + { + ...input.handle, + acpxRecordId: state.acpxRecordId ?? input.handle.acpxRecordId ?? input.handle.sessionKey, + }, + { + discardPersistentState: input.discardPersistentState, + }, + ); } private async getManager(): Promise { diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index a706a7d..ad13b13 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { AcpClient } from "../../acp/client.js"; import { normalizeOutputError } from "../../acp/error-normalization.js"; +import { extractAcpError } from "../../acp/error-shapes.js"; import { textPrompt, type PromptInput } from "../../prompt-content.js"; import { cloneSessionAcpxState, @@ -120,6 +121,21 @@ function isoNow(): string { return new Date().toISOString(); } +function isUnsupportedSessionCloseError(error: unknown): boolean { + const acp = extractAcpError(error); + if (!acp) { + return false; + } + if (acp.code === -32601 || acp.code === -32602) { + return true; + } + if (acp.code !== -32603 || !acp.data || typeof acp.data !== "object") { + return false; + } + const details = (acp.data as { details?: unknown }).details; + return typeof details === "string" && details.toLowerCase().includes("invalid params"); +} + function toPromptInput( text: string, attachments?: AcpRuntimeTurnAttachment[], @@ -611,14 +627,67 @@ export class AcpRuntimeManager { await controller?.requestCancelActivePrompt(); } - async close(handle: AcpRuntimeHandle): Promise { + async close( + handle: AcpRuntimeHandle, + options: { discardPersistentState?: boolean } = {}, + ): Promise { const record = await this.requireRecord(handle.acpxRecordId ?? handle.sessionKey); await this.cancel(handle); + if (options.discardPersistentState) { + await this.closeBackendSession(record); + } record.closed = true; record.closedAt = isoNow(); await this.options.sessionStore.save(record); } + private async closeBackendSession(record: SessionRecord): Promise { + const pendingClient = this.pendingPersistentClients.get(record.acpxRecordId); + if (pendingClient) { + this.pendingPersistentClients.delete(record.acpxRecordId); + } + const reusablePendingClient = + pendingClient?.hasReusableSession(record.acpSessionId) === true ? pendingClient : undefined; + if (pendingClient && !reusablePendingClient) { + await pendingClient.close().catch(() => {}); + } + + const client = + reusablePendingClient ?? + this.createClient({ + agentCommand: record.agentCommand, + cwd: record.cwd, + mcpServers: [...(this.options.mcpServers ?? [])], + permissionMode: this.options.permissionMode, + nonInteractivePermissions: this.options.nonInteractivePermissions, + verbose: this.options.verbose, + }); + + try { + if (!reusablePendingClient) { + await client.start(); + } + if (!client.supportsCloseSession()) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNSUPPORTED_CONTROL", + `Agent does not support session/close for ${record.acpxRecordId}.`, + ); + } + await client.closeSession(record.acpSessionId); + } catch (error) { + if (isUnsupportedSessionCloseError(error)) { + throw new AcpRuntimeError( + "ACP_BACKEND_UNSUPPORTED_CONTROL", + `Agent does not support session/close for ${record.acpxRecordId}.`, + { cause: error }, + ); + } + throw error; + } finally { + await client.close().catch(() => {}); + } + } + private async requireRecord(sessionId: string): Promise { const record = await this.options.sessionStore.load(sessionId); if (!record) { diff --git a/src/runtime/public/contract.ts b/src/runtime/public/contract.ts index 7a1163e..84f7f99 100644 --- a/src/runtime/public/contract.ts +++ b/src/runtime/public/contract.ts @@ -122,7 +122,11 @@ export interface AcpRuntime { setConfigOption?(input: { handle: AcpRuntimeHandle; key: string; value: string }): Promise; doctor?(): Promise; cancel(input: { handle: AcpRuntimeHandle; reason?: string }): Promise; - close(input: { handle: AcpRuntimeHandle; reason: string }): Promise; + close(input: { + handle: AcpRuntimeHandle; + reason: string; + discardPersistentState?: boolean; + }): Promise; } export type AcpSessionRecord = SessionRecord; diff --git a/test/client.test.ts b/test/client.test.ts index 435664a..376081a 100644 --- a/test/client.test.ts +++ b/test/client.test.ts @@ -104,6 +104,14 @@ type ClientInternals = { | undefined; cancellingSessionIds: Set; promptPermissionFailures: Map; + initResult?: { + agentCapabilities?: { + sessionCapabilities?: { + close?: Record; + }; + }; + }; + loadedSessionId?: string; lastKnownPid?: number; agentStartedAt?: string; closing: boolean; @@ -469,6 +477,34 @@ test("AcpClient setSessionModel uses session/set_model", async () => { }); }); +test("AcpClient closes sessions through session/close and clears the loaded session id", async () => { + const client = makeClient(); + const internals = asInternals(client); + let capturedCloseSessionParams: { sessionId: string } | undefined; + internals.initResult = { + agentCapabilities: { + sessionCapabilities: { + close: {}, + }, + }, + }; + internals.loadedSessionId = "session-close-1"; + internals.connection = { + unstable_closeSession: async (params: { sessionId: string }) => { + capturedCloseSessionParams = params; + return {}; + }, + }; + + assert.equal(client.supportsCloseSession(), true); + await client.closeSession("session-close-1"); + + assert.deepEqual(capturedCloseSessionParams, { + sessionId: "session-close-1", + }); + assert.equal(internals.loadedSessionId, undefined); +}); + test("AcpClient session update handling drains queued callbacks and swallows handler failures", async () => { const notifications: string[] = []; const client = makeClient({ diff --git a/test/runtime-manager.test.ts b/test/runtime-manager.test.ts index b7012d8..0c5631a 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -24,6 +24,7 @@ type FakeClient = { loadSession: (sessionId: string, cwd: string) => Promise<{ agentSessionId?: string }>; hasReusableSession: (sessionId: string) => boolean; supportsLoadSession: () => boolean; + supportsCloseSession?: () => boolean; loadSessionWithOptions: ( sessionId: string, cwd: string, @@ -46,6 +47,7 @@ type FakeClient = { ) => Promise<{ stopReason: string; }>; + closeSession?: (sessionId: string) => Promise; waitForSessionUpdatesIdle?: (options?: { idleMs?: number; timeoutMs?: number }) => Promise; requestCancelActivePrompt: () => Promise; hasActivePrompt: () => boolean; @@ -759,6 +761,61 @@ test("AcpRuntimeManager handles offline oneshot controls, status, close, and mis ); }); +test("AcpRuntimeManager closes the backend session when discarding persistent state", async () => { + const record = makeSessionRecord({ + acpxRecordId: "discard-session", + acpSessionId: "discard-sid", + agentCommand: "claude --acp", + cwd: "/workspace", + }); + const store = new InMemorySessionStore([record]); + let startCalls = 0; + let closeCalls = 0; + const closedSessionIds: string[] = []; + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/workspace", sessionStore: store }), + { + clientFactory: () => + ({ + start: async () => { + startCalls += 1; + }, + close: async () => { + closeCalls += 1; + }, + createSession: async () => ({ sessionId: "unused" }), + loadSession: async () => ({ agentSessionId: "unused" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + supportsCloseSession: () => true, + closeSession: async (sessionId: string) => { + closedSessionIds.push(sessionId); + }, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + await manager.close(createHandle("discard-session"), { + discardPersistentState: true, + }); + + assert.equal(startCalls, 1); + assert.equal(closeCalls, 1); + assert.deepEqual(closedSessionIds, ["discard-sid"]); + const closed = await store.load("discard-session"); + assert.equal(closed?.closed, true); + assert.equal(typeof closed?.closedAt, "string"); +}); + test("AcpRuntimeManager fails offline persistent controls clearly when session/load is unavailable", async () => { const record = makeSessionRecord({ acpxRecordId: "offline-persistent-session", diff --git a/test/runtime.test.ts b/test/runtime.test.ts index bb6cb2c..1c9dea5 100644 --- a/test/runtime.test.ts +++ b/test/runtime.test.ts @@ -71,6 +71,7 @@ test("AcpxRuntime delegates session lifecycle to the runtime manager", async () let turnMode: string | undefined; let turnSessionMode: string | undefined; let turnTimeoutMs: number | undefined; + let closeDiscardPersistentState: boolean | undefined; const manager = { ensureSession: async (input: { mode: string }) => { ensuredMode = input.mode; @@ -90,7 +91,9 @@ test("AcpxRuntime delegates session lifecycle to the runtime manager", async () setMode: async () => {}, setConfigOption: async () => {}, cancel: async () => {}, - close: async () => {}, + close: async (_handle: unknown, options?: { discardPersistentState?: boolean }) => { + closeDiscardPersistentState = options?.discardPersistentState; + }, }; const runtime = new AcpxRuntime( @@ -139,7 +142,8 @@ test("AcpxRuntime delegates session lifecycle to the runtime manager", async () await runtime.setMode({ handle, mode: "architect" }); await runtime.setConfigOption({ handle, key: "approval", value: "manual" }); await runtime.cancel({ handle }); - await runtime.close({ handle, reason: "test" }); + await runtime.close({ handle, reason: "test", discardPersistentState: true }); + assert.equal(closeDiscardPersistentState, true); }); test("createFileSessionStore persists records inside the provided state directory", async (t) => { From b205c1dd26cc229c8bce7b7e06511a2265b07536 Mon Sep 17 00:00:00 2001 From: Onur <2453968+osolmaz@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:16:38 +0200 Subject: [PATCH 2/3] Runtime: harden backend reset close --- src/runtime/engine/manager.ts | 14 ++- test/runtime-manager.test.ts | 160 ++++++++++++++++++++++++++++++++++ test/runtime-test-helpers.ts | 2 + 3 files changed, 173 insertions(+), 3 deletions(-) diff --git a/src/runtime/engine/manager.ts b/src/runtime/engine/manager.ts index ad13b13..ffb8ce0 100644 --- a/src/runtime/engine/manager.ts +++ b/src/runtime/engine/manager.ts @@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto"; import path from "node:path"; import { AcpClient } from "../../acp/client.js"; import { normalizeOutputError } from "../../acp/error-normalization.js"; -import { extractAcpError } from "../../acp/error-shapes.js"; +import { extractAcpError, isAcpResourceNotFoundError } from "../../acp/error-shapes.js"; +import { withTimeout } from "../../async-control.js"; import { textPrompt, type PromptInput } from "../../prompt-content.js"; import { cloneSessionAcpxState, @@ -635,6 +636,10 @@ export class AcpRuntimeManager { await this.cancel(handle); if (options.discardPersistentState) { await this.closeBackendSession(record); + record.acpx = { + ...record.acpx, + reset_on_next_ensure: true, + }; } record.closed = true; record.closedAt = isoNow(); @@ -665,7 +670,7 @@ export class AcpRuntimeManager { try { if (!reusablePendingClient) { - await client.start(); + await withTimeout(client.start(), this.options.timeoutMs); } if (!client.supportsCloseSession()) { throw new AcpRuntimeError( @@ -673,7 +678,7 @@ export class AcpRuntimeManager { `Agent does not support session/close for ${record.acpxRecordId}.`, ); } - await client.closeSession(record.acpSessionId); + await withTimeout(client.closeSession(record.acpSessionId), this.options.timeoutMs); } catch (error) { if (isUnsupportedSessionCloseError(error)) { throw new AcpRuntimeError( @@ -682,6 +687,9 @@ export class AcpRuntimeManager { { cause: error }, ); } + if (isAcpResourceNotFoundError(error)) { + return; + } throw error; } finally { await client.close().catch(() => {}); diff --git a/test/runtime-manager.test.ts b/test/runtime-manager.test.ts index 0c5631a..4e94f01 100644 --- a/test/runtime-manager.test.ts +++ b/test/runtime-manager.test.ts @@ -814,6 +814,166 @@ test("AcpRuntimeManager closes the backend session when discarding persistent st const closed = await store.load("discard-session"); assert.equal(closed?.closed, true); assert.equal(typeof closed?.closedAt, "string"); + assert.equal(closed?.acpx?.reset_on_next_ensure, true); + + let recreatedSessions = 0; + const restartedManager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/workspace", sessionStore: store }), + { + clientFactory: () => + ({ + start: async () => {}, + close: async () => {}, + createSession: async () => { + recreatedSessions += 1; + return { sessionId: "fresh-discard-sid", agentSessionId: "fresh-agent" }; + }, + loadSession: async () => ({ agentSessionId: "unused" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + supportsCloseSession: () => true, + closeSession: async () => {}, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + const recreated = await restartedManager.ensureSession({ + sessionKey: "discard-session", + agent: "claude", + mode: "persistent", + cwd: "/workspace", + }); + + assert.equal(recreatedSessions, 1); + assert.equal(recreated.acpSessionId, "fresh-discard-sid"); + assert.equal(recreated.agentSessionId, "fresh-agent"); + assert.equal(recreated.messages.length, 0); + assert.equal(recreated.acpx?.reset_on_next_ensure, undefined); +}); + +test("AcpRuntimeManager treats missing backend sessions as a successful discard reset", async () => { + const record = makeSessionRecord({ + acpxRecordId: "discard-missing-session", + acpSessionId: "missing-backend-session", + agentCommand: "claude --acp", + cwd: "/workspace", + }); + const store = new InMemorySessionStore([record]); + let startCalls = 0; + let closeCalls = 0; + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/workspace", sessionStore: store }), + { + clientFactory: () => + ({ + start: async () => { + startCalls += 1; + }, + close: async () => { + closeCalls += 1; + }, + createSession: async () => ({ sessionId: "unused" }), + loadSession: async () => ({ agentSessionId: "unused" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + supportsCloseSession: () => true, + closeSession: async () => { + throw { error: { code: -32002, message: "session not found" } }; + }, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + await manager.close(createHandle("discard-missing-session"), { + discardPersistentState: true, + }); + + assert.equal(startCalls, 1); + assert.equal(closeCalls, 1); + const closed = await store.load("discard-missing-session"); + assert.equal(closed?.closed, true); + assert.equal(typeof closed?.closedAt, "string"); + assert.equal(closed?.acpx?.reset_on_next_ensure, true); +}); + +test("AcpRuntimeManager applies timeoutMs to backend session shutdown during discard reset", async () => { + const record = makeSessionRecord({ + acpxRecordId: "discard-timeout-session", + acpSessionId: "slow-backend-session", + agentCommand: "claude --acp", + cwd: "/workspace", + }); + const store = new InMemorySessionStore([record]); + let startCalls = 0; + let closeCalls = 0; + let closeSessionCalls = 0; + const never = new Promise(() => {}); + const manager = new AcpRuntimeManager( + createRuntimeOptions({ cwd: "/workspace", sessionStore: store, timeoutMs: 5 }), + { + clientFactory: () => + ({ + start: async () => { + startCalls += 1; + }, + close: async () => { + closeCalls += 1; + }, + createSession: async () => ({ sessionId: "unused" }), + loadSession: async () => ({ agentSessionId: "unused" }), + hasReusableSession: () => false, + supportsLoadSession: () => true, + supportsCloseSession: () => true, + closeSession: async () => { + closeSessionCalls += 1; + await never; + }, + loadSessionWithOptions: async () => ({ agentSessionId: "unused" }), + getAgentLifecycleSnapshot: () => ({ running: true }), + prompt: async () => ({ stopReason: "end_turn" }), + requestCancelActivePrompt: async () => false, + hasActivePrompt: () => false, + setSessionMode: async () => {}, + setSessionConfigOption: async () => {}, + clearEventHandlers: () => {}, + setEventHandlers: () => {}, + }) as never, + }, + ); + + await assert.rejects( + async () => + await manager.close(createHandle("discard-timeout-session"), { + discardPersistentState: true, + }), + /Timed out after 5ms/, + ); + + assert.equal(startCalls, 1); + assert.equal(closeSessionCalls, 1); + assert.equal(closeCalls, 1); + const unchanged = await store.load("discard-timeout-session"); + assert.equal(unchanged?.closed, false); + assert.equal(unchanged?.closedAt, undefined); + assert.equal(unchanged?.acpx?.reset_on_next_ensure, undefined); }); test("AcpRuntimeManager fails offline persistent controls clearly when session/load is unavailable", async () => { diff --git a/test/runtime-test-helpers.ts b/test/runtime-test-helpers.ts index 4dd4f66..49fabd2 100644 --- a/test/runtime-test-helpers.ts +++ b/test/runtime-test-helpers.ts @@ -87,10 +87,12 @@ export function createRuntimeOptions(params: { cwd: string; sessionStore: AcpSessionStore; agentRegistry?: AcpAgentRegistry; + timeoutMs?: number; }): AcpRuntimeOptions { return { cwd: params.cwd, sessionStore: params.sessionStore, + timeoutMs: params.timeoutMs, agentRegistry: params.agentRegistry ?? { resolve(agentName: string) { return `${agentName} --acp`; From 45b4e1911676364d4f1214c72670a84ce15e933c Mon Sep 17 00:00:00 2001 From: Onur <2453968+osolmaz@users.noreply.github.com> Date: Tue, 7 Apr 2026 11:23:45 +0200 Subject: [PATCH 3/3] Runtime: persist reset-on-next-ensure state --- src/runtime/engine/reuse-policy.ts | 5 ++++- src/session/persistence/parse.ts | 4 ++++ src/types.ts | 1 + test/runtime-helpers.test.ts | 16 ++++++++++++++++ test/session-persistence.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/runtime/engine/reuse-policy.ts b/src/runtime/engine/reuse-policy.ts index 1beba1e..24e4462 100644 --- a/src/runtime/engine/reuse-policy.ts +++ b/src/runtime/engine/reuse-policy.ts @@ -2,13 +2,16 @@ import path from "node:path"; import type { SessionRecord } from "../../types.js"; export function shouldReuseExistingRecord( - record: Pick, + record: Pick, params: { cwd: string; agentCommand: string; resumeSessionId?: string; }, ): boolean { + if (record.acpx?.reset_on_next_ensure === true) { + return false; + } if (path.resolve(record.cwd) !== path.resolve(params.cwd)) { return false; } diff --git a/src/session/persistence/parse.ts b/src/session/persistence/parse.ts index 5577a63..2a28488 100644 --- a/src/session/persistence/parse.ts +++ b/src/session/persistence/parse.ts @@ -275,6 +275,10 @@ function parseAcpxState(raw: unknown): SessionAcpxState | undefined { const state: SessionAcpxState = {}; + if (record.reset_on_next_ensure === true) { + state.reset_on_next_ensure = true; + } + if (typeof record.current_mode_id === "string") { state.current_mode_id = record.current_mode_id; } diff --git a/src/types.ts b/src/types.ts index 19e9ee2..3f913f0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -281,6 +281,7 @@ export type SessionConversation = { }; export type SessionAcpxState = { + reset_on_next_ensure?: boolean; current_mode_id?: string; desired_mode_id?: string; current_model_id?: string; diff --git a/test/runtime-helpers.test.ts b/test/runtime-helpers.test.ts index cd16dd0..2c1e7e0 100644 --- a/test/runtime-helpers.test.ts +++ b/test/runtime-helpers.test.ts @@ -170,6 +170,7 @@ test("runtime reuse policy only keeps compatible records", () => { cwd: path.resolve("/workspace"), agentCommand: "codex --acp", acpSessionId: "sid-1", + acpx: {}, }; assert.equal( shouldReuseExistingRecord(base, { @@ -201,4 +202,19 @@ test("runtime reuse policy only keeps compatible records", () => { }), false, ); + assert.equal( + shouldReuseExistingRecord( + { + ...base, + acpx: { + reset_on_next_ensure: true, + }, + }, + { + cwd: "/workspace", + agentCommand: "codex --acp", + }, + ), + false, + ); }); diff --git a/test/session-persistence.test.ts b/test/session-persistence.test.ts index b80a292..5c08d42 100644 --- a/test/session-persistence.test.ts +++ b/test/session-persistence.test.ts @@ -49,6 +49,31 @@ test("listSessions preserves acpx desired_mode_id", async () => { }); }); +test("listSessions preserves acpx reset_on_next_ensure", async () => { + await withTempHome(async (homeDir) => { + const session = await loadSessionModule(); + const cwd = path.join(homeDir, "workspace"); + + await writeSessionRecord( + homeDir, + makeSessionRecord({ + acpxRecordId: "reset-on-next-ensure", + acpSessionId: "reset-on-next-ensure", + agentCommand: "agent-a", + cwd, + acpx: { + reset_on_next_ensure: true, + }, + }), + ); + + const sessions = await session.listSessions(); + const record = sessions.find((entry) => entry.acpxRecordId === "reset-on-next-ensure"); + assert.ok(record); + assert.equal(record.acpx?.reset_on_next_ensure, true); + }); +}); + test("listSessions preserves acpx session_options", async () => { await withTempHome(async (homeDir) => { const session = await loadSessionModule();