Bug Report: qodercli ACP session/new 创建的 Session 无法跨进程恢复
Issue Description
通过 ACP 协议 session/new 创建的 session,进程被杀后重新启动,session/load 无法恢复该 session。原因是 ACP session/new 只写入了 state.json,没有生成 {sessionId}-session.json 元数据文件,导致 session/load 和 session/list 无法发现和加载该 session。
Steps to Reproduce
- 启动 qodercli ACP 模式:
qodercli --acp - 发送
initialize请求,确认agentCapabilities.loadSession: true - 发送
session/new创建新 session,记录返回的sessionId - 发送
session/prompt发送一条消息,确认正常回复 - 尝试发送
session/close→ 返回-32601 Method not found - 杀掉进程
- 重新启动
qodercli --acp - 发送
initialize - 发送
session/list→ 列表中不包含步骤 3 创建的 session - 发送
session/load使用步骤 3 的 sessionId → 返回-32603 Internal error: Invalid session identifier
Expected Behavior
session/close应被实现,用于通知 agent 持久化 session 状态session/new创建的 session 应在进程重启后仍然可通过session/list发现、通过session/load恢复- 或在 ACP 实现完整之前,
agentCapabilities.loadSession不应宣告为true
Actual Behavior
session/close返回-32601 Method not found(未实现)session/new仅在~/.qoder/projects/{project}/下创建{sessionId}/state.json,不生成{sessionId}-session.json元数据文件- 非 ACP 模式(CLI 直接使用)的 session 有
{id}-session.json+{id}.jsonl+{id}/state.json,可正常恢复 - 进程重启后
session/list只列出非 ACP 模式创建的旧 session,ACP 创建的不可见 session/load始终失败,提示 “Searched for sessions in ~/.qoder/projects/{project}” 但找不到
磁盘对比:
| 创建方式 | 磁盘文件 |
|---|---|
| CLI 直接使用 | {id}-session.json + {id}.jsonl + {id}/state.json |
ACP session/new |
{id}/state.json only |
Operating System
macOS 15.x (Darwin 25.2.0, arm64)
Current Qoder Version
0.2.5 和 0.2.8 均存在此问题。0.2.8 未修复。
附:独立 ACP 协议测试脚本,可直接运行验证:
node /tmp/test-qodercli-resume.mjs
// Standalone ACP protocol test for qodercli session resume.
// Tests whether qodercli supports session persistence across process restarts.
//
// Usage: node /tmp/test-qodercli-resume.mjs [path-to-qodercli] [working-dir]import { spawn } from ‘node:child_process’;
import { createInterface } from ‘node:readline’;
import * as path from ‘node:path’;
import * as os from ‘node:os’;const QODERCLI = process.argv[2] || ‘/Users/yang/.npm-global/bin/qodercli’;
const CWD = process.argv[3] || os.homedir();let requestId = 0;
const pending = new Map();// ── Spawn + NDJSON I/O ──────────────────────────────────────────
function spawnQoder() {
const child = spawn(QODERCLI, [‘–acp’], {
stdio: [‘pipe’, ‘pipe’, ‘pipe’],
cwd: CWD,
env: { …process.env },
});const rl = createInterface({ input: child.stdout, crlfDelay: Infinity });
child.stderr.on(‘data’, (d) => process.stderr.write(
[STDERR] ${d}));
child.on(‘error’, (e) => console.error(‘SPAWN ERROR:’, e.message));
child.on(‘close’, (code) => console.error([PROCESS EXIT] code=${code}));rl.on(‘line’, (line) => {
try {
const msg = JSON.parse(line);
if (msg.id !== undefined && pending.has(msg.id)) {
const { resolve, reject, timer } = pending.get(msg.id);
clearTimeout(timer);
pending.delete(msg.id);
if (msg.error) reject(new Error(${msg.error.code}: ${msg.error.message}));
else resolve(msg.result);
} else if (msg.method) {
console.log(\n[NOTIFICATION] method=${msg.method} params=${JSON.stringify(msg.params).slice(0, 200)});
}
} catch {
/* ignore parse errors */
}
});return { child, rl };
}function send(child, method, params = {}) {
return new Promise((resolve, reject) => {
const id = ++requestId;
const req = JSON.stringify({ jsonrpc: ‘2.0’, method, params, id }) + ‘\n’;
const timer = setTimeout(() => {
pending.delete(id);
reject(new Error(TIMEOUT: ${method} (10s)));
}, 10_000);
pending.set(id, { resolve, reject, timer });
child.stdin.write(req);
});
}// ── Wait for notification ────────────────────────────────────────
function waitForNotification(rl, method, timeoutMs = 10_000) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
rl.off(‘line’, handler);
reject(new Error(TIMEOUT waiting for notification: ${method}));
}, timeoutMs);
const handler = (line) => {
try {
const msg = JSON.parse(line);
if (msg.method === method) {
clearTimeout(timer);
rl.off(‘line’, handler);
resolve(msg.params);
}
} catch { /* skip */ }
};
rl.on(‘line’, handler);
});
}function sleep(ms) {
return new Promise((r) => setTimeout(r, ms));
}// ── Main test flow ───────────────────────────────────────────────
async function main() {
console.log(‘═══════════════════════════════════════════════════════’);
console.log(’ qodercli ACP Session Resume Test’);
console.log(binary: ${QODERCLI});
console.log(cwd: ${CWD});
console.log(‘═══════════════════════════════════════════════════════\n’);// ── Phase 1: Create a session ────────────────────────────────
console.log(‘── Phase 1: Spawn first process ──’);
const { child: c1, rl: rl1 } = spawnQoder();
await sleep(500); // let process startconsole.log(‘\n1.1 initialize…’);
const init1 = await send(c1, ‘initialize’, {
clientInfo: { name: ‘TestScript’, version: ‘1.0.0’ },
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
console.log(✅ protocolVersion=${init1.protocolVersion});
console.log(capabilities: ${JSON.stringify(init1.agentCapabilities)});
console.log(loadSession: ${init1.agentCapabilities?.loadSession ?? 'NOT ADVERTISED'});const supportsList = init1.agentCapabilities?.sessionCapabilities?.list !== undefined;
console.log(listSessions: ${supportsList ? 'advertised' : 'NOT ADVERTISED'});console.log(‘\n1.2 session/new…’);
const session1 = await send(c1, ‘session/new’, {
cwd: CWD,
mcpServers: ,
additionalDirectories: ,
});
const sessionId = session1.sessionId;
console.log(✅ sessionId = ${sessionId});console.log(‘\n1.3 session/prompt (simple message)…’);
const promptPromise = send(c1, ‘session/prompt’, {
sessionId,
prompt: [{ type: ‘text’, text: ‘Hello, reply with exactly: ACK’ }],
});
// Wait for session/update notifications (agent response)
let responseText = ‘’;
try {
while (true) {
const update = await waitForNotification(rl1, ‘session/update’, 15_000);
if (update?.sessionUpdate === ‘agent_message_chunk’) {
responseText += update.content?.text || ‘’;
console.log(chunk: "${update.content?.text?.slice(0, 100)}");
} else if (update?.sessionUpdate === ‘agent_thought_chunk’) {
// skip thoughts
} else {
console.log(update: ${update?.sessionUpdate});
}
}
} catch (e) {
// Timeout means response is done
console.log(full response: "${responseText.slice(0, 200)}");
}
// Resolve the prompt promise
try {
await promptPromise;
console.log(’prompt completed’);
} catch (e) {
console.log(prompt result: ${e.message});
}// ── Phase 2: Suspend (closeSession + kill process) ───────────
console.log(‘\n── Phase 2: Suspend ──’);console.log(‘2.1 session/close…’);
try {
const closeResult = await send(c1, ‘session/close’, { sessionId });
console.log(✅ closeSession succeeded: ${JSON.stringify(closeResult)});
} catch (e) {
console.log(⚠️ closeSession error: ${e.message});
}console.log(‘2.2 Kill process…’);
c1.kill();
rl1.close();
await sleep(1000);
console.log(’process killed’);
// ── Phase 3: Resume (new process, loadSession) ───────────────
console.log(‘\n── Phase 3: Resume ──’);
const { child: c2, rl: rl2 } = spawnQoder();
await sleep(500);console.log(‘3.1 initialize…’);
const init2 = await send(c2, ‘initialize’, {
clientInfo: { name: ‘TestScript’, version: ‘1.0.0’ },
protocolVersion: 1,
clientCapabilities: { fs: { readTextFile: true, writeTextFile: true } },
});
console.log(loadSession: ${init2.agentCapabilities?.loadSession ?? 'NOT ADVERTISED'});console.log(‘\n3.2 session/list (check available sessions)…’);
if (supportsList) {
try {
const listResult = await send(c2, ‘session/list’, {
cwd: CWD,
additionalDirectories: ,
cursor: null,
});
const sessions = listResult.sessions || ;
console.log(found ${sessions.length} session(s):);
for (const s of sessions) {
console.log(- id=${s.sessionId} cwd=${s.cwd} updatedAt=${s.updatedAt || 'N/A'});
}
const found = sessions.some((s) => s.sessionId === sessionId);
console.log(old session found in list: ${found ? 'YES ✅' : 'NO ❌'});
} catch (e) {
console.log(❌ session/list error: ${e.message});
}
} else {
console.log(’skipped (agent does not advertise listSessions)');
}console.log(‘\n3.3 session/load…’);
if (init2.agentCapabilities?.loadSession) {
try {
const loaded = await send(c2, ‘session/load’, {
sessionId,
cwd: CWD,
mcpServers: ,
additionalDirectories: ,
});
console.log(✅ loadSession SUCCEEDED!);
console.log(loaded sessionId: ${loaded.sessionId});
console.log(loaded model: ${loaded.models?.currentModelId || 'N/A'});// Verify: send a prompt to the loaded session console.log('\n3.4 Verify loaded session (send prompt)...'); const verifyPrompt = send(c2, 'session/prompt', { sessionId: loaded.sessionId, prompt: [{ type: 'text', text: 'What was my first message? Reply briefly.' }], }); try { let verifyText = ''; while (true) { const update = await waitForNotification(rl2, 'session/update', 10_000); if (update?.sessionUpdate === 'agent_message_chunk') { verifyText += update.content?.text || ''; console.log(` chunk: "${update.content?.text?.slice(0, 100)}"`); } } } catch { console.log(` verify response: "${verifyText.slice(0, 200)}"`); } try { await verifyPrompt; } catch {} } catch (e) { console.log(` ❌ loadSession FAILED: ${e.message}`); }} else {
console.log(’qodercli does NOT advertise loadSession capability’);
console.log(’ → session resume is NOT supported by qodercli itself’);
}// ── Cleanup ──────────────────────────────────────────────────
console.log(‘\n── Cleanup ──’);
c2.kill();
rl2.close();
await sleep(500);console.log(‘\n═══════════════════════════════════════════════════’);
console.log(’ Test complete’);
console.log(‘═══════════════════════════════════════════════════’);
}main().catch((e) => {
console.error(‘\nFATAL:’, e.message);
process.exit(1);
});