From 7f9f09bc79b73700776de2830ba9aefb99e60211 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 26 Oct 2026 07:43:31 +0000 Subject: [PATCH] Drain stdout before the runner exits, so a large result arrives whole --- .claude-plugin/plugin.json | 2 +- runtime/runner.mjs | 15 ++++++++++---- tests/runner.integration.mjs | 38 ++++++++++++++++++++++++++++++++++-- 3 files changed, 48 insertions(+), 7 deletions(-) diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 61dfa45..c5fe149 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "code-mode", - "version": "0.12.13", + "version": "0.12.14", "description": "Code mode for MCP: the model writes one JavaScript program that calls MCP tools in a sandbox, instead of one tool call per step.", "types": "./types/index.d.ts", "author": { diff --git a/runtime/runner.mjs b/runtime/runner.mjs index 07f727b..7a4d7d0 100644 --- a/runtime/runner.mjs +++ b/runtime/runner.mjs @@ -32,14 +32,21 @@ const xdir = process.argv[2] const input = JSON.parse(fs.readFileSync(0, 'utf8')) const timeoutMs = Number(input.timeoutMs) || 60_000 -const emit = msg => process.stdout.write(MARK + JSON.stringify(msg) + '\n') - let finished = false +const lineOf = msg => MARK + JSON.stringify(msg) + '\n' +// A message after finish (a late call) is dropped. The result line is the last +// line this process writes, so a later write cannot cut it. +const emit = msg => { + if (finished) return + process.stdout.write(lineOf(msg)) +} + +// A pipe takes the line in the background. process.exit drops a write that is +// still queued, so a line larger than the pipe buffer never reaches the host. const finish = msg => { if (finished) return finished = true - emit(msg) - process.exit(0) + process.stdout.write(lineOf(msg), () => process.exit(0)) } // Host side of the bridge: the context calls post(kind, a, b, c) with strings. diff --git a/tests/runner.integration.mjs b/tests/runner.integration.mjs index 663b33c..494df80 100644 --- a/tests/runner.integration.mjs +++ b/tests/runner.integration.mjs @@ -4,7 +4,7 @@ // // node tests/runner.integration.mjs -import { execFileSync, spawn } from 'node:child_process' +import { execFileSync, spawn, spawnSync } from 'node:child_process' import assert from 'node:assert/strict' import fs from 'node:fs' import net from 'node:net' @@ -35,6 +35,20 @@ const LAUNCH = [ const MKTEMP = ['mktemp', '-d', '-t', 'code-mode.XXXXXX'] const MARK = '\u0001cm ' +// The host reads stdout with takeMessages. Node 22.13 runs this file with no +// type stripping, so ask for it once and load the real helper. +let takeMessages +try { + ;({ takeMessages } = await import('../hooks/protocol.ts')) +} catch (err) { + if (process.env.CODE_MODE_STRIP === '1' || err?.code !== 'ERR_UNKNOWN_FILE_EXTENSION') throw err + const again = spawnSync(process.execPath, ['--experimental-strip-types', fileURLToPath(import.meta.url)], { + stdio: 'inherit', + env: { ...process.env, CODE_MODE_STRIP: '1' }, + }) + process.exit(again.status ?? 1) +} + const echo = m => m.t === 'resources' ? { ok: true, value: [{ uri: 'file:///a.md', server: m.server ?? 'any' }] } @@ -76,11 +90,13 @@ const run = ( child.stdin.on('error', () => {}) // a launch that ends early does not read stdin child.stdin.end(JSON.stringify({ code, timeoutMs, lib })) let buffer = '' + let stdout = '' let stderr = '' let calls = 0 let outcome child.stderr.on('data', d => (stderr += d)) child.stdout.on('data', d => { + stdout += d buffer += d let i while ((i = buffer.indexOf('\n')) >= 0) { @@ -99,7 +115,7 @@ const run = ( }) child.on('close', (exit, signal) => { fs.rmSync(xdir, { recursive: true, force: true }) - resolve({ exit, signal, calls, outcome, stderr }) + resolve({ exit, signal, calls, outcome, stderr, stdout }) }) }) } @@ -171,6 +187,20 @@ const read = file => fs.readFileSync(path.join(ROOT, file), 'utf8') const value = r => (r.outcome?.t === 'done' ? JSON.parse(r.outcome.value) : undefined) const failure = r => (r.outcome?.t === 'error' ? r.outcome.message : undefined) +// A return of n characters must come back as one whole done line. takeMessages +// keeps a line only when it ends in a newline, the same rule as the host. +const wholeThroughPipe = async n => { + const r = await run(`return ${JSON.stringify('x'.repeat(n))}`, { timeoutMs: 15_000 }) + const { messages, rest } = takeMessages(r.stdout) + const done = messages.filter(m => m.t === 'done') + assert.equal( + done.length, + 1, + `exit ${r.exit} stdout ${r.stdout.length} rest ${rest.length} newline ${r.stdout.endsWith('\n')}`, + ) + assert.equal(JSON.parse(done[0].value).length, n) +} + const cases = { 'the copies match the plugin': () => { const src = read('hooks/register.tsx') @@ -222,6 +252,10 @@ const cases = { 'returns a value': async () => assert.equal(value(await run('return 1 + 1')), 2), + 'make it fail: a 200000-char return arrives whole through a pipe': async () => wholeThroughPipe(200_000), + + 'make it fail: a 1000000-char return arrives whole through a pipe': async () => wholeThroughPipe(1_000_000), + 'parallel calls and console': async () => { const r = await run(` const [a, b] = await Promise.all([call("mcp__s__t", { n: 1 }), tools["a-b"].c({ n: 2 })])