Skip to content

Commit 15b89d6

Browse files
DavertMikclaude
andcommitted
refactor(mcp): simplify pause_session — code in, result out
Drops the id-keyed message multiplexer and 7-action enum (run/snapshot/step/ resume/exit/status). The yield-mode subprocess now reads plain text lines from stdin (same shape as the TTY readline REPL) and emits one JSON line per input on stdout. The MCP server pause_session tool exposes only "start" and "run". A run takes a code string with the same conventions as the TTY pause REPL — "" steps, "resume" continues, "exit" aborts, otherwise treat as I.<expr> or =>raw_js. Each run returns the next protocol message. Net: 237 lines removed, 159 added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 41e9c1b commit 15b89d6

5 files changed

Lines changed: 159 additions & 396 deletions

File tree

bin/mcp-server.js

Lines changed: 56 additions & 181 deletions
Original file line numberDiff line numberDiff line change
@@ -236,40 +236,23 @@ function outputBaseDir() {
236236
}
237237

238238
let pauseChild = null
239-
let pausePending = new Map() // id -> { resolve, reject, timer }
240239
let pauseLogs = []
241240
let pauseStdoutBuf = ''
242-
let pauseStderrBuf = ''
243-
let pausePausedWaiters = []
241+
let pauseProtocolWaiters = []
244242
let pauseExitInfo = null
245243

246-
function pauseProcessLine(line) {
247-
const trimmed = line.trim()
248-
if (!trimmed) return
249-
let msg = null
250-
if (trimmed.startsWith('{')) {
251-
try { msg = JSON.parse(trimmed) } catch {}
252-
}
253-
if (msg && msg.__mcpPause) {
254-
if (msg.event === 'paused') {
255-
const waiters = pausePausedWaiters
256-
pausePausedWaiters = []
257-
for (const w of waiters) w.resolve(msg)
258-
return
259-
}
260-
if (msg.id != null && pausePending.has(msg.id)) {
261-
const pending = pausePending.get(msg.id)
262-
pausePending.delete(msg.id)
263-
clearTimeout(pending.timer)
264-
pending.resolve(msg)
265-
return
266-
}
267-
if (msg.event === 'error') {
268-
pauseLogs.push({ stream: 'protocol-error', line: trimmed })
269-
return
270-
}
271-
pauseLogs.push({ stream: 'protocol', line: trimmed })
272-
return
244+
function pauseProcessStdoutLine(line) {
245+
if (!line) return
246+
if (line.trim().startsWith('{')) {
247+
try {
248+
const msg = JSON.parse(line.trim())
249+
if (msg && msg.__mcpPause) {
250+
const waiter = pauseProtocolWaiters.shift()
251+
if (waiter) waiter(msg)
252+
else pauseLogs.push({ stream: 'protocol-unwaited', line })
253+
return
254+
}
255+
} catch {}
273256
}
274257
pauseLogs.push({ stream: 'stdout', line })
275258
if (pauseLogs.length > 500) pauseLogs.splice(0, pauseLogs.length - 500)
@@ -281,7 +264,7 @@ function pauseProcessChunk(buf, chunk, stream) {
281264
while ((idx = buf.indexOf('\n')) !== -1) {
282265
const line = buf.slice(0, idx)
283266
buf = buf.slice(idx + 1)
284-
if (stream === 'stdout') pauseProcessLine(line)
267+
if (stream === 'stdout') pauseProcessStdoutLine(line)
285268
else {
286269
pauseLogs.push({ stream: 'stderr', line })
287270
if (pauseLogs.length > 500) pauseLogs.splice(0, pauseLogs.length - 500)
@@ -290,60 +273,42 @@ function pauseProcessChunk(buf, chunk, stream) {
290273
return buf
291274
}
292275

293-
function pauseSendCommand(payload, { timeout = 30000 } = {}) {
294-
if (!pauseChild) return Promise.reject(new Error('No active pause_session. Call action: "start" first.'))
295-
if (pauseChild.exitCode != null) return Promise.reject(new Error('pause_session subprocess has exited'))
296-
297-
let id = payload.id
298-
if (id == null) {
299-
id = `req-${Date.now()}-${Math.floor(Math.random() * 1e6)}`
300-
payload = { ...payload, id }
301-
}
302-
276+
function pauseAwaitProtocol({ timeout = 60000 } = {}) {
303277
return new Promise((resolve, reject) => {
278+
if (!pauseChild) return reject(new Error('No active pause_session. Call action: "start" first.'))
279+
let done = false
304280
const timer = setTimeout(() => {
305-
pausePending.delete(id)
306-
reject(new Error(`Timeout waiting for pause_session response (${payload.type}) after ${timeout}ms`))
281+
if (done) return
282+
done = true
283+
const i = pauseProtocolWaiters.indexOf(receiver)
284+
if (i >= 0) pauseProtocolWaiters.splice(i, 1)
285+
pauseChild?.removeListener('exit', onExit)
286+
reject(new Error(`Timeout waiting for pause_session response after ${timeout}ms`))
307287
}, timeout)
308-
pausePending.set(id, { resolve, reject, timer })
309-
try {
310-
pauseChild.stdin.write(JSON.stringify(payload) + '\n')
311-
} catch (e) {
288+
const cleanup = () => {
289+
done = true
312290
clearTimeout(timer)
313-
pausePending.delete(id)
314-
reject(e)
291+
pauseChild?.removeListener('exit', onExit)
315292
}
316-
})
317-
}
318-
319-
function pauseWaitForPaused({ timeout = 60000 } = {}) {
320-
if (!pauseChild) return Promise.reject(new Error('No active pause_session. Call action: "start" first.'))
321-
return new Promise((resolve, reject) => {
322-
const timer = setTimeout(() => {
323-
const idx = pausePausedWaiters.findIndex(w => w.resolve === wrapped)
324-
if (idx >= 0) pausePausedWaiters.splice(idx, 1)
325-
reject(new Error(`Timeout waiting for paused event after ${timeout}ms`))
326-
}, timeout)
327-
const wrapped = msg => {
328-
clearTimeout(timer)
293+
const receiver = msg => {
294+
if (done) return
295+
cleanup()
329296
resolve(msg)
330297
}
331-
pausePausedWaiters.push({ resolve: wrapped, reject })
298+
const onExit = () => {
299+
if (done) return
300+
const i = pauseProtocolWaiters.indexOf(receiver)
301+
if (i >= 0) pauseProtocolWaiters.splice(i, 1)
302+
cleanup()
303+
resolve({ event: 'exited', exitInfo: pauseExitInfo })
304+
}
305+
pauseProtocolWaiters.push(receiver)
306+
pauseChild.once('exit', onExit)
332307
})
333308
}
334309

335-
function pauseTeardown(reason) {
336-
for (const [id, p] of pausePending.entries()) {
337-
clearTimeout(p.timer)
338-
p.reject(new Error(reason || 'pause_session ended'))
339-
}
340-
pausePending.clear()
341-
for (const w of pausePausedWaiters) {
342-
if (typeof w.reject === 'function') {
343-
try { w.reject(new Error(reason || 'pause_session ended')) } catch {}
344-
}
345-
}
346-
pausePausedWaiters = []
310+
function pauseTeardown() {
311+
pauseProtocolWaiters = []
347312
pauseChild = null
348313
}
349314

@@ -462,11 +427,11 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
462427
},
463428
{
464429
name: 'pause_session',
465-
description: 'Drive a paused test through pause(). Sub-actions: start (spawn test, wait for first paused event), run (execute CodeceptJS code in the paused session), snapshot (capture state without acting), step (let the test run one step then re-pause), resume (continue test to completion), exit (abort the paused test), status (return current state).',
430+
description: 'Run code inside a paused test, mirroring the human pause() REPL. Two actions: "start" spawns a test and waits for it to hit pause(); "run" sends a code line (same syntax as the TTY pause REPL — empty string steps to the next test step, "resume" continues the test, "exit" aborts; any other input is treated as I.<expr> unless prefixed with "=>"). Each run returns the value plus an artifact bundle (URL, ARIA, HTML, screenshot, console, storage), like run_code.',
466431
inputSchema: {
467432
type: 'object',
468433
properties: {
469-
action: { type: 'string', enum: ['start', 'run', 'snapshot', 'step', 'resume', 'exit', 'status'] },
434+
action: { type: 'string', enum: ['start', 'run'] },
470435
test: { type: 'string' },
471436
code: { type: 'string' },
472437
config: { type: 'string' },
@@ -593,7 +558,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
593558

594559
if (action === 'start') {
595560
if (pauseChild && pauseChild.exitCode == null) {
596-
throw new Error('pause_session already running. Call action: "exit" or "resume" first.')
561+
throw new Error('pause_session already running. Send code: "exit" via action: "run" first.')
597562
}
598563
const { test, config: configPathArg, timeout = 60000 } = args
599564
if (!test) throw new Error('pause_session start requires "test" parameter')
@@ -610,8 +575,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
610575

611576
pauseLogs = []
612577
pauseStdoutBuf = ''
613-
pauseStderrBuf = ''
614578
pauseExitInfo = null
579+
pauseProtocolWaiters = []
615580

616581
const env = {
617582
...process.env,
@@ -623,29 +588,18 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
623588
const cmd = isNodeScript ? process.execPath : cli
624589
const cmdArgs = isNodeScript ? [cli, ...runArgs] : runArgs
625590

626-
pauseChild = spawn(cmd, cmdArgs, {
627-
cwd: root,
628-
env,
629-
stdio: ['pipe', 'pipe', 'pipe'],
630-
})
631-
632-
pauseChild.stdout.on('data', d => {
633-
pauseStdoutBuf = pauseProcessChunk(pauseStdoutBuf, d, 'stdout')
634-
})
635-
pauseChild.stderr.on('data', d => {
636-
pauseStderrBuf = pauseProcessChunk(pauseStderrBuf, d, 'stderr')
637-
})
591+
pauseChild = spawn(cmd, cmdArgs, { cwd: root, env, stdio: ['pipe', 'pipe', 'pipe'] })
592+
let stderrBuf = ''
593+
pauseChild.stdout.on('data', d => { pauseStdoutBuf = pauseProcessChunk(pauseStdoutBuf, d, 'stdout') })
594+
pauseChild.stderr.on('data', d => { stderrBuf = pauseProcessChunk(stderrBuf, d, 'stderr') })
638595
pauseChild.on('exit', (code, signal) => {
639596
pauseExitInfo = { code, signal }
640-
pauseTeardown(`subprocess exited (code=${code}, signal=${signal})`)
641-
})
642-
pauseChild.on('error', err => {
643-
pauseTeardown(`subprocess error: ${err.message}`)
597+
pauseTeardown()
644598
})
645599

646600
let pausedMsg
647601
try {
648-
pausedMsg = await pauseWaitForPaused({ timeout })
602+
pausedMsg = await pauseAwaitProtocol({ timeout })
649603
} catch (err) {
650604
try { pauseChild?.kill('SIGKILL') } catch {}
651605
const stderr = pauseLogs.filter(l => l.stream === 'stderr').map(l => l.line).join('\n')
@@ -655,99 +609,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
655609
return {
656610
content: [{
657611
type: 'text',
658-
text: JSON.stringify({
659-
status: 'paused',
660-
resolvedFile: resolvedFile || null,
661-
paused: pausedMsg,
662-
}, null, 2),
663-
}],
664-
}
665-
}
666-
667-
if (action === 'status') {
668-
return {
669-
content: [{
670-
type: 'text',
671-
text: JSON.stringify({
672-
running: !!(pauseChild && pauseChild.exitCode == null),
673-
exitInfo: pauseExitInfo,
674-
logs: pauseLogs.slice(-50),
675-
}, null, 2),
612+
text: JSON.stringify({ status: 'paused', resolvedFile: resolvedFile || null, paused: pausedMsg }, null, 2),
676613
}],
677614
}
678615
}
679616

680617
if (action === 'run') {
681-
const { code, timeout = 60000 } = args
682-
if (!code) throw new Error('pause_session run requires "code"')
683-
const resp = await pauseSendCommand({ type: 'run', code }, { timeout })
618+
if (!pauseChild) throw new Error('No active pause_session. Call action: "start" first.')
619+
if (pauseChild.exitCode != null) throw new Error('pause_session subprocess has exited')
620+
const { code = '', timeout = 60000 } = args
621+
pauseChild.stdin.write(code + '\n')
622+
const resp = await pauseAwaitProtocol({ timeout })
684623
return { content: [{ type: 'text', text: JSON.stringify(resp, null, 2) }] }
685624
}
686625

687-
if (action === 'snapshot') {
688-
const { timeout = 30000 } = args
689-
const resp = await pauseSendCommand({ type: 'snapshot' }, { timeout })
690-
return { content: [{ type: 'text', text: JSON.stringify(resp, null, 2) }] }
691-
}
692-
693-
if (action === 'step') {
694-
const { timeout = 60000 } = args
695-
const resumed = await pauseSendCommand({ type: 'step' }, { timeout })
696-
let pausedAgain = null
697-
try {
698-
pausedAgain = await pauseWaitForPaused({ timeout })
699-
} catch {
700-
// test may have ended after the step — that's fine
701-
}
702-
return {
703-
content: [{
704-
type: 'text',
705-
text: JSON.stringify({ resumed, paused: pausedAgain, exitInfo: pauseExitInfo }, null, 2),
706-
}],
707-
}
708-
}
709-
710-
if (action === 'resume') {
711-
const { timeout = 60000 } = args
712-
const resumed = await pauseSendCommand({ type: 'resume' }, { timeout })
713-
await new Promise(resolve => {
714-
if (!pauseChild || pauseChild.exitCode != null) return resolve()
715-
pauseChild.once('exit', resolve)
716-
setTimeout(resolve, timeout)
717-
})
718-
return {
719-
content: [{
720-
type: 'text',
721-
text: JSON.stringify({ resumed, exitInfo: pauseExitInfo }, null, 2),
722-
}],
723-
}
724-
}
725-
726-
if (action === 'exit') {
727-
if (!pauseChild) {
728-
return { content: [{ type: 'text', text: JSON.stringify({ status: 'no-active-session' }, null, 2) }] }
729-
}
730-
const { timeout = 30000 } = args
731-
let resp = null
732-
try {
733-
resp = await pauseSendCommand({ type: 'exit' }, { timeout: Math.min(timeout, 5000) })
734-
} catch {}
735-
await new Promise(resolve => {
736-
if (!pauseChild || pauseChild.exitCode != null) return resolve()
737-
const t = setTimeout(() => {
738-
try { pauseChild?.kill('SIGKILL') } catch {}
739-
resolve()
740-
}, timeout)
741-
pauseChild.once('exit', () => { clearTimeout(t); resolve() })
742-
})
743-
return {
744-
content: [{
745-
type: 'text',
746-
text: JSON.stringify({ exited: resp, exitInfo: pauseExitInfo }, null, 2),
747-
}],
748-
}
749-
}
750-
751626
throw new Error(`pause_session unknown action: ${action}`)
752627
}
753628

docs/mcp.md

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -237,43 +237,38 @@ Capture the current state of the browser without performing any action. Useful f
237237

238238
### pause_session
239239

240-
Drive a paused test through `pause()` over MCP. Replaces the human-only readline REPL with a JSON-line protocol the agent can speak. Useful when a test hits `pause()` or you want to pause-on-failure without a TTY.
240+
Mirrors the human `pause()` REPL for an AI agent: send a code string, get a result with artifacts (same shape as `run_code`).
241241

242-
The subprocess is spawned with `CODECEPTJS_MCP=1` and `CODECEPTJS_MCP_PAUSE=1` so any `pause()` calls in the test land in yield mode (instead of the default skip-on-MCP behaviour).
242+
Two actions:
243243

244-
**Sub-actions** (selected via `action`):
244+
| Action | Params | Effect |
245+
|---|---|---|
246+
| `start` | `test`, `config?`, `timeout?` | Spawn the test subprocess in pause yield mode. Resolves when the test hits `pause()` and emits `{event:"paused"}`. |
247+
| `run` | `code`, `timeout?` | Send one line of input — same syntax as the TTY REPL. Returns the next protocol message from the subprocess. |
245248

246-
| Action | Effect |
247-
|---|---|
248-
| `start` | Spawn the test subprocess in pause yield mode. Resolves when the first `paused` event arrives. |
249-
| `run` | Execute a CodeceptJS expression in the paused session (`I.click('Save')` or `=> myVar`). Returns artifacts + return value. |
250-
| `snapshot` | Capture browser state without acting. Returns the same artifact bundle as the `snapshot` tool. |
251-
| `step` | Let the test run one step, then re-pause. Returns the `resumed` ack and the next `paused` event (or `exitInfo` if the test ended). |
252-
| `resume` | Continue the test to completion. Returns when the subprocess exits. |
253-
| `exit` | Abort the paused test and tear down the subprocess. |
254-
| `status` | Inspect the current session — running flag, exit info, last stdout/stderr lines. |
249+
`code` follows the TTY pause REPL conventions:
250+
- An expression like `click('Save')` runs as `I.click('Save')` and returns `{event:"result", ok, value, artifacts, error}`.
251+
- Prefix `=>` to evaluate raw JS: `=> myVar.id`.
252+
- `""` (empty) → step to the next test step. The subprocess re-pauses; response is `{event:"step"}` followed by `{event:"paused"}` on the next `run` call.
253+
- `"resume"` → continue the test to completion. Response is `{event:"resumed"}`; the subprocess will exit on its own.
254+
- `"exit"` → abort the paused test. Same `{event:"resumed"}` response, then exit.
255255

256-
**Parameters:**
257-
- `action` (required): one of the values above
258-
- `test` (`start` only): test name or file path
259-
- `code` (`run` only): expression to evaluate (defaults to `I.<expr>`; prefix with `=>` for raw JS)
260-
- `config` (`start` only): path to codecept.conf.js
261-
- `timeout` (optional): per-action timeout in ms
256+
Each result includes the artifact bundle (URL, ARIA, HTML, screenshot, console, storage), like `run_code`. If the subprocess exits during a `run`, the response is `{event:"exited", exitInfo:{code, signal}}`.
262257

263258
**Lifecycle example:**
264259

265260
```json
266261
{ "name": "pause_session", "arguments": { "action": "start", "test": "checkout_test" } }
267262
{ "name": "pause_session", "arguments": { "action": "run", "code": "grabCurrentUrl()" } }
268-
{ "name": "pause_session", "arguments": { "action": "snapshot" } }
269-
{ "name": "pause_session", "arguments": { "action": "step" } }
270-
{ "name": "pause_session", "arguments": { "action": "resume" } }
263+
{ "name": "pause_session", "arguments": { "action": "run", "code": "click('Save')" } }
264+
{ "name": "pause_session", "arguments": { "action": "run", "code": "resume" } }
271265
```
272266

273-
A single `pause_session` instance owns one subprocess. Concurrent `start` calls are rejected — `exit` (or `resume`) the running session first.
267+
A single `pause_session` instance owns one subprocess. Concurrent `start` calls are rejected — send `code: "exit"` (or `"resume"`) first.
274268

275269
**Notes:**
276-
- `pause()` calls in tests run through MCP without yield mode (env `CODECEPTJS_MCP=1` only) print a notice and return immediately so leftover `pause()` calls don't deadlock CI runs.
270+
- The subprocess is spawned with `CODECEPTJS_MCP=1` and `CODECEPTJS_MCP_PAUSE=1` so `pause()` calls in the test land in yield mode.
271+
- `pause()` calls running under `CODECEPTJS_MCP=1` *without* `CODECEPTJS_MCP_PAUSE=1` print a notice and return immediately so leftover `pause()` calls don't deadlock CI runs invoked through MCP.
277272
- TTY behaviour (`npx codeceptjs run --debug` at a terminal) is unchanged — the readline REPL is used whenever `process.stdin.isTTY` is true.
278273

279274
### run_test

0 commit comments

Comments
 (0)