From 56419694451b310427471e6100f7234cfa8967ab Mon Sep 17 00:00:00 2001 From: Ryder Sun Date: Fri, 16 Oct 2026 11:51:00 +0800 Subject: [PATCH] fix: switching between read-only and write no longer restarts the ACP process The ACP runtime was identified by a signature that included the session mode (plan/build for MiMo and OpenCode, default/accept_edits for Hermes). Group discussions alternate between a read-only proposal and a write-enabled turn, so the signature changed, the process was closed and the stored native session was reported invalid, losing the conversation's context on every switch. The mode is applied on every turn with session/set_mode, so it does not identify a process. What genuinely needs another process (Hermes' and OpenCode's permission policy is passed through the environment) is already part of the signature through the environment, so those still start separately. MiMo, whose arguments and environment are identical in both modes, now keeps one process and one session across a read-only turn, a write turn and another read-only turn. The new test fails on the previous commit with LOCAL_AGENT_SESSION_INVALID. Checked with: cli-native-acp-lifecycle (16 pass). --- desktop/src/agents/cli/cli-acp-runner.cjs | 5 +- .../cli/cli-native-acp-lifecycle.test.cjs | 54 +++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/desktop/src/agents/cli/cli-acp-runner.cjs b/desktop/src/agents/cli/cli-acp-runner.cjs index c5f679a..af89528 100644 --- a/desktop/src/agents/cli/cli-acp-runner.cjs +++ b/desktop/src/agents/cli/cli-acp-runner.cjs @@ -621,7 +621,10 @@ function runtimeSignature(agent, workdir, spec, profile, childEnv) { workdir, command: spec.command, args: spec.args, - acpMode: spec.acpMode || '', + // The session mode (read-only plan versus write) is applied on every turn + // with session/set_mode, so it does not identify a process. Anything that + // really needs a separate process (a permission policy in the environment) + // is already part of the environment below. profileId: profile.profileId, environment, })).digest('hex') diff --git a/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs b/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs index 599e2d9..61bc181 100644 --- a/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs +++ b/desktop/test/agents/cli/cli-native-acp-lifecycle.test.cjs @@ -1172,3 +1172,57 @@ input.on('line', (line) => { error => error?.message === 'LOCAL_AGENT_EXECUTION_STOPPED', ) }) + +test('switching between read-only and write keeps one ACP process when only the session mode differs', async (t) => { + const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'meldwork-acp-mode-switch-')) + const spawnCountFile = path.join(directory, 'spawn-count.txt') + const modesFile = path.join(directory, 'modes.jsonl') + t.after(async () => { + await adapters.shutdownAcpSessionRuntime?.() + fs.rmSync(directory, { recursive: true, force: true }) + }) + const cli = executable(directory, 'mimo-mode-switch.cjs', ` +const fs = require('node:fs') +const readline = require('node:readline') +if (process.argv[2] !== 'acp') process.exit(2) +const countFile = process.env.MELDWORK_TEST_SPAWN_COUNT_FILE +fs.writeFileSync(countFile, String((fs.existsSync(countFile) ? Number(fs.readFileSync(countFile, 'utf8')) : 0) + 1)) +const input = readline.createInterface({ input: process.stdin }) +const send = value => process.stdout.write(JSON.stringify(value) + '\\n') +input.on('line', (line) => { + const message = JSON.parse(line) + const sessionId = message.params?.sessionId || 'mimo-mode-session' + if (message.method === 'initialize') send({ jsonrpc: '2.0', id: message.id, result: { protocolVersion: 1 } }) + else if (message.method === 'session/new') send({ jsonrpc: '2.0', id: message.id, result: { sessionId } }) + else if (message.method === 'session/set_mode') { + fs.appendFileSync(process.env.MELDWORK_TEST_MODES_FILE, message.params.modeId + '\\n') + send({ jsonrpc: '2.0', id: message.id, result: {} }) + } else if (message.method === 'session/prompt') { + send({ jsonrpc: '2.0', method: 'session/update', params: { sessionId, update: { + sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'mimo reply' }, + } } }) + send({ jsonrpc: '2.0', id: message.id, result: { stopReason: 'end_turn' } }) + } +}) +`) + const agent = { kind: 'mimo', executable: cli, name: 'MiMo' } + const env = { MELDWORK_TEST_SPAWN_COUNT_FILE: spawnCountFile, MELDWORK_TEST_MODES_FILE: modesFile } + const sessionRefs = [] + const turn = (sandbox, sessionRef) => within(adapters.runAgent(agent, `turn in ${sandbox}`, directory, { + acpPersistenceKey: 'workspace:mimo:mode-switch', + sandbox, + sessionRef, + sessionTransport: sessionRef ? 'acp' : '', + env, + onSessionRef: ref => sessionRefs.push(ref), + }), 10000) + + const proposal = await turn('read-only', '') + const discussion = await turn('workspace-write', sessionRefs.at(-1)) + const review = await turn('read-only', sessionRefs.at(-1)) + + assert.equal([proposal, discussion, review].every(result => result.text === 'mimo reply'), true) + assert.equal(fs.readFileSync(spawnCountFile, 'utf8'), '1', 'one process served all three turns') + assert.deepEqual(fs.readFileSync(modesFile, 'utf8').trim().split('\n'), ['plan', 'build', 'plan']) + assert.equal(new Set(sessionRefs).size, 1, 'the native session was kept') +})