@wjarka/cezarion 0.14.9-dev.944 → 0.14.9-dev.958

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -4318,19 +4318,6 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
4318
4318
  } & {
4319
4319
  "/repo/commit/:sha": {
4320
4320
  $get: {
4321
- output: string;
4322
- outputFormat: "text";
4323
- status: import("hono/utils/http-status").ContentfulStatusCode;
4324
- input: {
4325
- query: {
4326
- structured?: string | undefined;
4327
- };
4328
- } & {
4329
- param: {
4330
- sha: string;
4331
- };
4332
- };
4333
- } | {
4334
4321
  output: {
4335
4322
  error: string;
4336
4323
  code?: "invalid_input" | undefined;
@@ -4394,6 +4381,19 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
4394
4381
  sha: string;
4395
4382
  };
4396
4383
  };
4384
+ } | {
4385
+ output: string;
4386
+ outputFormat: "text";
4387
+ status: import("hono/utils/http-status").ContentfulStatusCode;
4388
+ input: {
4389
+ query: {
4390
+ structured?: string | undefined;
4391
+ };
4392
+ } & {
4393
+ param: {
4394
+ sha: string;
4395
+ };
4396
+ };
4397
4397
  };
4398
4398
  };
4399
4399
  } & {
@@ -12306,20 +12306,20 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
12306
12306
  } & {
12307
12307
  "/runs/:id/handoff": {
12308
12308
  $get: {
12309
- output: string;
12310
- outputFormat: "text";
12311
- status: 200;
12309
+ output: {
12310
+ error: string;
12311
+ };
12312
+ outputFormat: "json";
12313
+ status: 404;
12312
12314
  input: {
12313
12315
  param: {
12314
12316
  id: string;
12315
12317
  };
12316
12318
  };
12317
12319
  } | {
12318
- output: {
12319
- error: string;
12320
- };
12321
- outputFormat: "json";
12322
- status: 404;
12320
+ output: string;
12321
+ outputFormat: "text";
12322
+ status: 200;
12323
12323
  input: {
12324
12324
  param: {
12325
12325
  id: string;
@@ -12357,20 +12357,20 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
12357
12357
  } & {
12358
12358
  "/runs/:id/diff": {
12359
12359
  $get: {
12360
- output: string;
12361
- outputFormat: "text";
12362
- status: import("hono/utils/http-status").ContentfulStatusCode;
12360
+ output: {
12361
+ error: string;
12362
+ };
12363
+ outputFormat: "json";
12364
+ status: 404;
12363
12365
  input: {
12364
12366
  param: {
12365
12367
  id: string;
12366
12368
  };
12367
12369
  };
12368
12370
  } | {
12369
- output: {
12370
- error: string;
12371
- };
12372
- outputFormat: "json";
12373
- status: 404;
12371
+ output: string;
12372
+ outputFormat: "text";
12373
+ status: import("hono/utils/http-status").ContentfulStatusCode;
12374
12374
  input: {
12375
12375
  param: {
12376
12376
  id: string;
@@ -15239,19 +15239,6 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
15239
15239
  } & {
15240
15240
  "/repo/commit/:sha": {
15241
15241
  $get: {
15242
- output: string;
15243
- outputFormat: "text";
15244
- status: import("hono/utils/http-status").ContentfulStatusCode;
15245
- input: {
15246
- query: {
15247
- structured?: string | undefined;
15248
- };
15249
- } & {
15250
- param: {
15251
- sha: string;
15252
- };
15253
- };
15254
- } | {
15255
15242
  output: {
15256
15243
  error: string;
15257
15244
  code?: "invalid_input" | undefined;
@@ -15315,6 +15302,19 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
15315
15302
  sha: string;
15316
15303
  };
15317
15304
  };
15305
+ } | {
15306
+ output: string;
15307
+ outputFormat: "text";
15308
+ status: import("hono/utils/http-status").ContentfulStatusCode;
15309
+ input: {
15310
+ query: {
15311
+ structured?: string | undefined;
15312
+ };
15313
+ } & {
15314
+ param: {
15315
+ sha: string;
15316
+ };
15317
+ };
15318
15318
  };
15319
15319
  };
15320
15320
  } & {
@@ -23227,20 +23227,20 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
23227
23227
  } & {
23228
23228
  "/runs/:id/handoff": {
23229
23229
  $get: {
23230
- output: string;
23231
- outputFormat: "text";
23232
- status: 200;
23230
+ output: {
23231
+ error: string;
23232
+ };
23233
+ outputFormat: "json";
23234
+ status: 404;
23233
23235
  input: {
23234
23236
  param: {
23235
23237
  id: string;
23236
23238
  };
23237
23239
  };
23238
23240
  } | {
23239
- output: {
23240
- error: string;
23241
- };
23242
- outputFormat: "json";
23243
- status: 404;
23241
+ output: string;
23242
+ outputFormat: "text";
23243
+ status: 200;
23244
23244
  input: {
23245
23245
  param: {
23246
23246
  id: string;
@@ -23278,20 +23278,20 @@ export declare function createApp(deps: ServerDeps): import("hono/hono-base").Ho
23278
23278
  } & {
23279
23279
  "/runs/:id/diff": {
23280
23280
  $get: {
23281
- output: string;
23282
- outputFormat: "text";
23283
- status: import("hono/utils/http-status").ContentfulStatusCode;
23281
+ output: {
23282
+ error: string;
23283
+ };
23284
+ outputFormat: "json";
23285
+ status: 404;
23284
23286
  input: {
23285
23287
  param: {
23286
23288
  id: string;
23287
23289
  };
23288
23290
  };
23289
23291
  } | {
23290
- output: {
23291
- error: string;
23292
- };
23293
- outputFormat: "json";
23294
- status: 404;
23292
+ output: string;
23293
+ outputFormat: "text";
23294
+ status: import("hono/utils/http-status").ContentfulStatusCode;
23295
23295
  input: {
23296
23296
  param: {
23297
23297
  id: string;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wjarka/cezarion",
3
- "version": "0.14.9-dev.944",
3
+ "version": "0.14.9-dev.958",
4
4
  "description": "Local cockpit for running and tracking AI agent tasks in your repo. Uses your logged-in `claude` CLI and `gh` — zero config, zero database.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -35,8 +35,8 @@
35
35
  "server-uninstall": "tsx src/index.ts server-uninstall",
36
36
  "test": "vitest run",
37
37
  "test:watch": "vitest",
38
- "test:unit": "node --import tsx --test test/unit/*.test.ts",
39
- "test:package": "node --import tsx --test test/e2e/*.test.ts",
38
+ "test:unit": "node --import ../../scripts/test-git-env.mjs --import tsx --test test/unit/*.test.ts",
39
+ "test:package": "node --import ../../scripts/test-git-env.mjs --import tsx --test test/e2e/*.test.ts",
40
40
  "typecheck": "tsc --noEmit -p tsconfig.test.json",
41
41
  "postbuild": "node scripts/inline-contract.mjs"
42
42
  },
@@ -51,8 +51,8 @@
51
51
  "zod": "^4.4.3"
52
52
  },
53
53
  "devDependencies": {
54
- "@open-mercato/cezar-api-client": "0.14.9-dev.944",
55
- "@open-mercato/cezar-contract": "0.14.9-dev.944",
54
+ "@open-mercato/cezar-api-client": "0.14.9-dev.958",
55
+ "@open-mercato/cezar-contract": "0.14.9-dev.958",
56
56
  "@types/node": "^20.14.0",
57
57
  "@types/ws": "^8.18.1",
58
58
  "esbuild": "^0.28.1",
@@ -8,6 +8,15 @@
8
8
  import { createInterface } from 'node:readline';
9
9
  import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
10
10
 
11
+ import * as parityGateFs from 'node:fs';
12
+
13
+ // #401: let the test observe the actual monitoring park before releasing late wire frames.
14
+ async function afterParityPark(prompt) {
15
+ const gate = /parity-release=([^\s"\\]+)/.exec(prompt)?.[1];
16
+ if (!gate) throw new Error('post-park scenario requires a release path');
17
+ while (!parityGateFs.existsSync(gate)) await new Promise(resolve => setTimeout(resolve, 10));
18
+ }
19
+
11
20
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
12
21
  const emit = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
13
22
 
@@ -105,6 +114,23 @@ async function respond(userText, imageCount, uuid) {
105
114
  emit({ type: 'result', subtype: 'success', result: text, user_message_uuids: [uuid, ...steered.map(s => s.uuid)].filter(Boolean), usage: { input_tokens: 20, output_tokens: 10 } });
106
115
  return;
107
116
  }
117
+ // Native child attribution from __fixtures__/claude/subagent-task.ndjson.
118
+ if (!userText.includes('[cez-namer]') && userText.includes('mock:subagent-after-park')) {
119
+ const task = `toolu_park_${turn}`;
120
+ emit({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', id: task, name: 'Task', input: { description: 'Review' } }] } });
121
+ emit({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Watching the child.\nCEZ:MONITORING' }] } });
122
+ emit({ type: 'result', subtype: 'success', result: 'Watching the child.\nCEZ:MONITORING', usage: { input_tokens: 10, output_tokens: 5 } });
123
+ await afterParityPark(userText);
124
+ emit({ type: 'assistant', parent_tool_use_id: task, message: { role: 'assistant', content: [{ type: 'tool_use', id: 'late-read', name: 'Read', input: { file_path: 'README.md' } }] } });
125
+ emit({ type: 'user', parent_tool_use_id: task, message: { role: 'user', content: [{ type: 'tool_result', tool_use_id: 'late-read', content: 'Read the file.' }] } });
126
+ emit({ type: 'assistant', parent_tool_use_id: task, message: { role: 'assistant', content: [{ type: 'text', text: 'Post-park child update processed.' }] } });
127
+ return;
128
+ }
129
+ // #401: result-only full text; mapResult and v1 use the same fallback.
130
+ if (!userText.includes('[cez-namer]') && userText.includes('mock:ask-snapshot')) {
131
+ emit({ type: 'result', subtype: 'success', result: 'Choose a test library.\nCEZ:ASK {"questions":[{"header":"Library","question":"Which test library?","options":[{"label":"Vitest"},{"label":"Node test"}]}]}', usage: { input_tokens: 10, output_tokens: 5 } });
132
+ return;
133
+ }
108
134
  if (userText.includes('mock:ci-wait')) {
109
135
  const { ciPrompt } = await import('./mock-ci-tool.mjs');
110
136
  const text = await ciPrompt('claude', process.argv.slice(2), userText);
@@ -11,6 +11,15 @@
11
11
  import { createInterface } from 'node:readline';
12
12
  import { appendFileSync } from 'node:fs';
13
13
 
14
+ import * as parityGateFs from 'node:fs';
15
+
16
+ // #401: let the test observe the actual monitoring park before releasing late wire frames.
17
+ async function afterParityPark(prompt) {
18
+ const gate = /parity-release=([^\s"\\]+)/.exec(prompt)?.[1];
19
+ if (!gate) throw new Error('post-park scenario requires a release path');
20
+ while (!parityGateFs.existsSync(gate)) await new Promise(resolve => setTimeout(resolve, 10));
21
+ }
22
+
14
23
  const write = (obj) => process.stdout.write(`${JSON.stringify(obj)}\n`);
15
24
  // #505: like the real app-server, any active main-thread turn accepts `turn/steer`.
16
25
  // Steers accepted by an ordinary scripted turn are read just before it completes:
@@ -141,6 +150,23 @@ rl.on('line', async (line) => {
141
150
  emit({ method: 'item/started', params: { threadId: 'th_mock_1', turnId: 'turn_mock_1', item: opening } });
142
151
  emit({ method: 'item/completed', params: { threadId: 'th_mock_1', turnId: 'turn_mock_1', item: opening } });
143
152
  }
153
+ // Native thread attribution from collab-agent-tool-call.ndjson (#121/#401).
154
+ if (turnText.includes('mock:subagent-after-park')) {
155
+ emit({ method: 'item/started', params: { threadId: 'th_mock_1', item: { type: 'collabAgentToolCall', id: 'park-spawn', tool: 'spawnAgent', status: 'inProgress', receiverThreadIds: ['th_park_child'] } } });
156
+ emit({ method: 'item/completed', params: { threadId: 'th_mock_1', item: { type: 'agentMessage', id: 'park-parent', text: 'Watching the child.\nCEZ:MONITORING' } } });
157
+ emit({ method: 'turn/completed', params: { threadId: 'th_mock_1', turn: { id: 'turn_mock_1', status: 'completed' } } });
158
+ await afterParityPark(turnText);
159
+ emit({ method: 'item/started', params: { threadId: 'th_park_child', item: { type: 'commandExecution', id: 'late-read', command: 'cat README.md', status: 'inProgress' } } });
160
+ emit({ method: 'item/completed', params: { threadId: 'th_park_child', item: { type: 'commandExecution', id: 'late-read', command: 'cat README.md', status: 'completed', exitCode: 0 } } });
161
+ emit({ method: 'item/completed', params: { threadId: 'th_park_child', item: { type: 'agentMessage', id: 'late-child', text: 'Post-park child update processed.' } } });
162
+ return;
163
+ }
164
+ // #401: agentMessage completion without deltas (same item envelope as baseline).
165
+ if (turnText.includes('mock:ask-snapshot')) {
166
+ emit({ method: 'item/completed', params: { threadId: 'th_mock_1', turnId: 'turn_mock_1', item: { type: 'agentMessage', id: 'ask-snapshot', text: 'Choose a test library.\nCEZ:ASK {"questions":[{"header":"Library","question":"Which test library?","options":[{"label":"Vitest"},{"label":"Node test"}]}]}' } } });
167
+ emit({ method: 'turn/completed', params: { threadId: 'th_mock_1', turn: { id: 'turn_mock_1', status: 'completed' } } });
168
+ return;
169
+ }
144
170
  if (turnText.includes('mock:ci-wait')) {
145
171
  const { ciPrompt } = await import('./mock-ci-tool.mjs');
146
172
  const text = await ciPrompt('codex', ciWire, turnText);
@@ -2,6 +2,15 @@
2
2
  // Offline Cursor ACP wire. Shapes: cursor.com/docs/cli/acp; ACP v1 schema.
3
3
  import { createInterface } from 'node:readline';
4
4
  import { appendFileSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import * as parityGateFs from 'node:fs';
6
+
7
+ // #401: let the test observe the actual monitoring park before releasing late wire frames.
8
+ async function afterParityPark(prompt) {
9
+ const gate = /parity-release=([^\s"\\]+)/.exec(prompt)?.[1];
10
+ if (!gate) throw new Error('post-park scenario requires a release path');
11
+ while (!parityGateFs.existsSync(gate)) await new Promise(resolve => setTimeout(resolve, 10));
12
+ }
13
+
5
14
  const record = (file, value) => { if (file) appendFileSync(file, `${JSON.stringify(value, (key, value) => key === 'env' && Array.isArray(value) ? value.map(item => item.name?.startsWith('CEZ_TOOL_') ? { ...item, value: '[redacted]' } : item) : value)}\n`); };
6
15
  record(process.env.CEZ_MOCK_ARGS_FILE, process.argv.slice(2));
7
16
  /** #529: remaining-count file. Each session/new or session/load decrements; at >0 the process writes stderr and exits 1 before the reply. */
@@ -62,11 +71,31 @@ async function prompt(id, content) {
62
71
  if (input.includes('mock:provider-error-instant-far')) { text(`\n\nError: usage limit reached, try again at ${new Date(Date.now() + 6 * 3600000).toISOString()}.`); complete(id); return; }
63
72
  if (input.includes('mock:provider-error-verbose')) { text(`\n\nError: usage limit reached. The request ${'x'.repeat(600)} could not be completed, try again at ${new Date(Date.now() + 6 * 3600000).toISOString()}.`); complete(id); return; }
64
73
  if (input.includes('mock:provider-error') && !input.includes('mock:provider-error-')) { text('\n\nError: [unauthenticated] Backend rejected authentication.'); complete(id); return; }
74
+ // #401: ACP has chunks only, so completion must flush the same text to v1/v2.
75
+ if (input.includes('mock:ask-snapshot')) {
76
+ text('Choose a test library.\nCEZ:ASK {"questions":[{"header":"Library","question":"Which test library?","options":[{"label":"Vitest"},{"label":"Node test"}]}]}'); complete(id); return;
77
+ }
65
78
  if (input.includes('mock:ask-bad')) { pendingAsk = { id }; emit({ id: 'bad-question', method: 'cursor/ask_question', params: { questions: [] } }); return; }
66
79
  if (input.includes('mock:plan')) { pendingAsk = { id, input }; emit({ id: 'plan-1', method: 'cursor/create_plan', params: { name: 'Test plan', overview: 'Approve the changes?', plan: 'Implement the change and run tests.' } }); return; }
67
80
  if (input.includes('mock:multi-ask')) { pendingAsk = { id }; emit({ id: 'multi-question', method: 'cursor/ask_question', params: { title: 'Choices', questions: [question, { ...question, id: 'build', prompt: 'Which build tool?', options: [{ id: 'vite', label: 'Vite' }, { id: 'webpack', label: 'Webpack' }] }] } }); return; }
68
81
  if (input.includes('mock:ask')) { pendingAsk = { id, input }; emit({ id: 'question-1', method: 'cursor/ask_question', params: { toolCallId: 'ask-tool', title: 'Tests', questions: [question] } }); return; }
69
82
  if (input.includes('mock:permission')) { pendingAsk = { id }; emit({ id: 'permission-1', method: 'session/request_permission', params: { sessionId, toolCall: { toolCallId: 'shell-1', title: 'echo hello', kind: 'execute' }, options: [{ optionId: 'allow-once', name: 'Allow once', kind: 'allow_once' }, { optionId: 'reject-once', name: 'Reject', kind: 'reject_once' }] } }); return; }
83
+ // ACP subagent attribution from __fixtures__/cursor/acp-subagents.ndjson.
84
+ if (input.includes('mock:subagent-after-park')) {
85
+ update({ sessionUpdate: 'subagent_spawned', subagentSessionId: 'park-child', name: 'Reviewer', task: 'Review', capabilities: {}, _meta: { cursor: { toolCallId: 'park-task', agentId: 'park-agent' } } });
86
+ update({ sessionUpdate: 'tool_call', toolCallId: 'late-read', kind: 'other', title: 'Inspect README', status: 'in_progress' }, 'park-child');
87
+ update({ sessionUpdate: 'tool_call_update', toolCallId: 'late-read', status: 'completed' }, 'park-child');
88
+ text('Watching the child.\nCEZ:MONITORING');
89
+ complete(id);
90
+ await afterParityPark(input);
91
+ update({ sessionUpdate: 'tool_call_update', toolCallId: 'late-read', status: 'completed', rawOutput: 'Read the file.' }, 'park-child');
92
+ update({ sessionUpdate: 'agent_message_chunk', content: { type: 'text', text: 'Late child text.' } }, 'park-child');
93
+ // Cursor task metadata can follow the regular completion (acp-subagents.ndjson).
94
+ // Sessionless extensions are attributed by their known child toolCallId even
95
+ // after prompt completion; this is the ordered barrier and a nested item update.
96
+ emit({ method: 'cursor/task', params: { toolCallId: 'late-read', description: 'Post-park child update processed.' } });
97
+ return;
98
+ }
70
99
  if (input.includes('mock:subagent')) {
71
100
  update({ sessionUpdate: 'tool_call', toolCallId: 'task-1', title: 'Review', kind: 'other', rawInput: { description: 'Review' }, status: 'in_progress' });
72
101
  update({ sessionUpdate: 'subagent_spawned', subagentSessionId: 'child', name: 'Reviewer', task: 'Review', capabilities: {}, _meta: { cursor: { toolCallId: 'task-1', agentId: 'child-agent' } } });
@@ -11,6 +11,15 @@ import { createServer } from 'node:http';
11
11
 
12
12
  if (process.env.CEZ_MOCK_ARGS_FILE && process.env.OPENCODE_CONFIG_CONTENT) appendFileSync(process.env.CEZ_MOCK_ARGS_FILE, JSON.stringify({ type: 'runtime-config', config: JSON.parse(process.env.OPENCODE_CONFIG_CONTENT) }) + '\n');
13
13
  if (process.env.CEZ_MOCK_CI_PR) { const { probeCiTool } = await import('./mock-ci-tool.mjs'); await probeCiTool('opencode', JSON.parse(process.env.OPENCODE_CONFIG_CONTENT ?? '{}')); }
14
+ import * as parityGateFs from 'node:fs';
15
+
16
+ // #401: let the test observe the actual monitoring park before releasing late wire frames.
17
+ async function afterParityPark(prompt) {
18
+ const gate = /parity-release=([^\s"\\]+)/.exec(prompt)?.[1];
19
+ if (!gate) throw new Error('post-park scenario requires a release path');
20
+ while (!parityGateFs.existsSync(gate)) await new Promise(resolve => setTimeout(resolve, 10));
21
+ }
22
+
14
23
  const args = process.argv.slice(2);
15
24
  const arg = (flag, fallback) => {
16
25
  const i = args.indexOf(flag);
@@ -194,6 +203,23 @@ const server = createServer((req, res) => {
194
203
  send({ type: 'session.idle', properties: { sessionID: SESSION_ID } });
195
204
  return;
196
205
  }
206
+ // SSE child attribution from __fixtures__/opencode/subtask-nested.ndjson.
207
+ if (body.includes('mock:subagent-after-park')) {
208
+ send({ type: 'message.updated', properties: { info: info({}) } });
209
+ send({ type: 'message.part.updated', properties: { part: { id: 'park-task', messageID: MESSAGE_ID, sessionID: SESSION_ID, type: 'subtask', prompt: 'Review', description: 'Review', agent: 'general' } } });
210
+ send({ type: 'message.updated', properties: { info: { ...info({}), id: 'park-child-msg', sessionID: 'park-child', parentID: SESSION_ID, mode: 'subagent' } } });
211
+ send({ type: 'message.part.updated', properties: { part: { id: 'early-child', messageID: 'park-child-msg', sessionID: 'park-child', type: 'text', text: 'Child review started.', time: { start: 1, end: 2 } } } });
212
+ send({ type: 'message.part.updated', properties: { part: { id: 'park-parent', messageID: MESSAGE_ID, sessionID: SESSION_ID, type: 'text', text: 'Watching the child.\nCEZ:MONITORING', time: { start: 1, end: 2 } } } });
213
+ send({ type: 'session.idle', properties: { sessionID: SESSION_ID } });
214
+ await afterParityPark(body);
215
+ send({ type: 'message.part.updated', properties: { part: { id: 'late-read', messageID: 'park-child-msg', sessionID: 'park-child', type: 'tool', tool: 'read', state: { status: 'running', input: { path: 'README.md' } } } } });
216
+ send({ type: 'message.part.updated', properties: { part: { id: 'late-read', messageID: 'park-child-msg', sessionID: 'park-child', type: 'tool', tool: 'read', state: { status: 'completed', input: { path: 'README.md' }, output: 'Read the file.' } } } });
217
+ send({ type: 'message.part.updated', properties: { part: { id: 'late-child-text', messageID: 'park-child-msg', sessionID: 'park-child', type: 'text', text: 'Late child text.', time: { start: 3, end: 4 } } } });
218
+ // Closed child scopes are ignored. This passive usage snapshot proves the
219
+ // preceding SSE frames have reached the runner before the test observes it.
220
+ send({ type: 'message.updated', properties: { info: info({ tokens: { input: 424240, output: 2, reasoning: 0, cache: { read: 0, write: 0 } } }) } });
221
+ return;
222
+ }
197
223
  if (body.includes('mock:subagent')) {
198
224
  // Wire shape from `__fixtures__/opencode/subtask-nested.ndjson`: a
199
225
  // `subtask` part on the parent message, then a child message whose info
@@ -287,6 +313,13 @@ const server = createServer((req, res) => {
287
313
  setTimeout(() => send({ type: 'session.idle', properties: { sessionID: SESSION_ID } }), 30);
288
314
  return;
289
315
  }
316
+ // #401: one completed text snapshot, without an earlier streaming part.
317
+ if (body.includes('mock:ask-snapshot')) {
318
+ send({ type: 'message.updated', properties: { info: info({}) } });
319
+ send({ type: 'message.part.updated', properties: { part: { id: 'ask-snapshot', messageID: MESSAGE_ID, sessionID: SESSION_ID, type: 'text', text: 'Choose a test library.\nCEZ:ASK {"questions":[{"header":"Library","question":"Which test library?","options":[{"label":"Vitest"},{"label":"Node test"}]}]}', time: { start: 1, end: 2 } } } });
320
+ send({ type: 'session.idle', properties: { sessionID: SESSION_ID } });
321
+ return;
322
+ }
290
323
  if (body.includes('mock:ask-bad')) {
291
324
  // Malformed: `questions` is not an array, so the runner must REJECT the
292
325
  // native question and finish the turn rather than wait for an answer to
@@ -181,6 +181,13 @@ async function handle(command) {
181
181
  send({ type: 'turn_start' });
182
182
  sendText(['parity done: the task is complete\n\nCEZ:DONE']);
183
183
  sendTurnEnd();
184
+ } else if (command.type === 'prompt' && command.message.includes('mock:ask-snapshot')) {
185
+ // #401: authoritative text_end without deltas, using rpc-lifecycle frames.
186
+ send({ id: command.id, type: 'response', command: 'prompt', success: true });
187
+ send({ type: 'agent_start' });
188
+ send({ type: 'turn_start' });
189
+ send({ type: 'message_update', message: {}, assistantMessageEvent: { type: 'text_end', contentIndex: 0, content: 'Choose a test library.\nCEZ:ASK {"questions":[{"header":"Library","question":"Which test library?","options":[{"label":"Vitest"},{"label":"Node test"}]}]}', partial: {} } });
190
+ sendTurnEnd();
184
191
  } else if (command.type === 'prompt' && command.message.includes('mock:ask-bad')) {
185
192
  // A marker whose JSON body is invalid. `parseAskMarker` must render no card
186
193
  // and the turn must still end (harness parity R4) — pi has no native ask