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).
This commit is contained in:
Ryder Sun committed 2026-10-16 11:51:00 +08:00
1 parent 4b8ddd917f
commit 5641969445
2 files changed
+58 -1

No files matched your search

+4 -1
View File
@@ -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')
@@ -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')
})