@yeaft/webchat-agent 1.0.384 → 1.0.385

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.
@@ -1,7 +1,9 @@
1
1
  import { createInterface } from 'node:readline';
2
2
  import { randomUUID } from 'node:crypto';
3
3
 
4
- const TERMINAL_STOP_REASONS = new Set(['end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error']);
4
+ const TERMINAL_STOP_REASONS = new Set([
5
+ 'end_turn', 'max_tokens', 'stop_sequence', 'aborted', 'error', 'tool_handoff', 'plan_recorded',
6
+ ]);
5
7
 
6
8
  function snakeUsage(usage) {
7
9
  const inputTokens = usage.inputTokens || 0;
@@ -32,10 +34,52 @@ export function extractPrompt(message) {
32
34
  .join('');
33
35
  }
34
36
 
37
+ /**
38
+ * Parse per-turn VP selectors without reading or mutating formal Session
39
+ * metadata. Membership is validated later by the Session runner against its
40
+ * canonical persisted roster.
41
+ */
42
+ export function normalizeStreamRoutingIntent(message) {
43
+ if (!message || typeof message !== 'object') return null;
44
+ const hasSelector = Object.hasOwn(message, 'targetVps')
45
+ || Object.hasOwn(message, 'targets')
46
+ || Object.hasOwn(message, 'targetVpId')
47
+ || Object.hasOwn(message, 'broadcast');
48
+ if (!hasSelector) return null;
49
+
50
+ const rawTargets = [];
51
+ for (const key of ['targetVps', 'targets']) {
52
+ if (!Object.hasOwn(message, key)) continue;
53
+ if (!Array.isArray(message[key])) {
54
+ throw new Error(`stream-json ${key} must be an array`);
55
+ }
56
+ rawTargets.push(...message[key]);
57
+ }
58
+ if (Object.hasOwn(message, 'targetVpId')) rawTargets.push(message.targetVpId);
59
+
60
+ const targetVpIds = [];
61
+ let broadcast = message.broadcast === true;
62
+ for (const rawTarget of rawTargets) {
63
+ if (typeof rawTarget !== 'string' || !rawTarget.trim()) {
64
+ throw new Error('stream-json target VP must be a non-empty string');
65
+ }
66
+ const target = rawTarget.trim();
67
+ if (target === 'all' || target === 'everyone') {
68
+ broadcast = true;
69
+ } else if (!targetVpIds.includes(target)) {
70
+ targetVpIds.push(target);
71
+ }
72
+ }
73
+
74
+ return Object.freeze({
75
+ targetVpIds: Object.freeze(targetVpIds),
76
+ broadcast,
77
+ explicit: true,
78
+ });
79
+ }
80
+
35
81
  export function createJsonlWriter(output = process.stdout) {
36
- return (event) => {
37
- output.write(`${JSON.stringify(event)}\n`);
38
- };
82
+ return event => { output.write(`${JSON.stringify(event)}\n`); };
39
83
  }
40
84
 
41
85
  export class JsonlInput {
@@ -126,335 +170,414 @@ export class JsonlInput {
126
170
  if (this.#answerRequestIds.has(requestId)) {
127
171
  throw new Error(`Duplicate AskUser request_id: ${requestId}`);
128
172
  }
129
- if (this.#error) throw this.#error;
130
173
  if (this.#closed) throw new Error('stdin closed before AskUser response');
131
174
  this.#answerRequestIds.add(requestId);
132
175
  return new Promise((resolve, reject) => this.#answerWaiters.set(requestId, { resolve, reject }));
133
176
  }
134
177
 
135
- close() {
136
- this.#rl.close();
137
- }
178
+ close() { this.#rl.close(); }
138
179
  }
139
180
 
140
- export async function runStreamTurn({
141
- engine,
142
- prompt,
143
- messages = [],
144
- sessionId = null,
145
- workDir = process.cwd(),
146
- model = null,
147
- modelEffort = null,
148
- input = null,
149
- write,
150
- getCurrentTodos = null,
151
- setCurrentTodos = null,
152
- }) {
153
- const clientTurnId = randomUUID();
154
- let engineTurnId = null;
155
- let resultText = '';
156
- let stopReason = 'end_turn';
157
- let failed = null;
158
- const usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 };
159
-
160
- const askUser = async ({ question, options }) => {
161
- const requestId = randomUUID();
162
- write({
163
- type: 'ask_user',
164
- subtype: 'request',
165
- request_id: requestId,
166
- session_id: sessionId,
167
- turn_id: engineTurnId || clientTurnId,
168
- question,
169
- options: Array.isArray(options) ? options : [],
170
- });
171
- if (!input) throw new Error('AskUser requires --input-format stream-json');
172
- const answers = await input.waitForAnswer(requestId);
173
- write({
174
- type: 'ask_user',
175
- subtype: 'response',
176
- request_id: requestId,
177
- session_id: sessionId,
178
- turn_id: engineTurnId || clientTurnId,
179
- answers,
180
- });
181
- return answers;
181
+ function makeUsageState() {
182
+ return {
183
+ inputTokens: 0,
184
+ outputTokens: 0,
185
+ cacheReadTokens: 0,
186
+ cacheWriteTokens: 0,
187
+ cacheInputDeltaTokens: 0,
182
188
  };
189
+ }
183
190
 
184
- try {
185
- for await (const event of engine.query({
186
- prompt,
187
- messages,
188
- sessionId,
189
- workDir,
190
- userEffort: modelEffort,
191
- askUser,
192
- getCurrentTodos,
193
- setCurrentTodos,
194
- })) {
195
- if (event.type === 'turn_open') engineTurnId = event.turnId;
196
- const turnId = engineTurnId || clientTurnId;
197
- switch (event.type) {
198
- case 'turn_open':
199
- write({ ...event, type: 'turn', subtype: 'start', session_id: sessionId, turn_id: event.turnId, model, model_effort: modelEffort, cwd: workDir });
200
- break;
201
- case 'text_delta':
202
- resultText += event.text || '';
203
- write({ type: 'assistant', subtype: 'text_delta', session_id: sessionId, turn_id: turnId, delta: { type: 'text_delta', text: event.text || '' } });
204
- break;
205
- case 'thinking_delta':
206
- write({ type: 'assistant', subtype: 'thinking_delta', session_id: sessionId, turn_id: turnId, delta: { type: 'thinking_delta', thinking: event.text || '' } });
207
- break;
208
- case 'skill_loaded':
209
- write({ type: 'skill', subtype: 'loaded', session_id: sessionId, turn_id: turnId, skill: event.skill });
210
- break;
211
- case 'skill_error':
212
- write({ type: 'skill', subtype: 'error', session_id: sessionId, turn_id: turnId, skill_name: event.skillName, error: event.message });
213
- break;
214
- case 'tool_call':
215
- write({ type: 'assistant', subtype: 'tool_use', session_id: sessionId, turn_id: turnId, content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }] });
216
- if (event.name === 'TodoWrite') {
217
- write({ type: 'todo', subtype: 'update', session_id: sessionId, turn_id: turnId, tool_use_id: event.id, todos: event.input?.todos || [] });
218
- }
219
- break;
220
- case 'tool_start':
221
- write({ type: 'tool', subtype: 'start', session_id: sessionId, turn_id: turnId, tool_use_id: event.id, name: event.name, input: event.input });
222
- break;
223
- case 'tool_end':
224
- write({
225
- type: 'tool',
226
- subtype: 'result',
227
- session_id: sessionId,
228
- turn_id: turnId,
229
- tool_use_id: event.id,
230
- name: event.name,
231
- content: event.output,
232
- is_error: !!event.isError,
233
- ...(Array.isArray(event.displayImages) && event.displayImages.length > 0
234
- ? { display_images: event.displayImages }
235
- : {}),
236
- });
237
- break;
238
- case 'usage': {
239
- const cacheDelta = event.cacheTokensAreIncludedInInput ? 0 : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0);
240
- usage.inputTokens += event.inputTokens || 0;
241
- usage.outputTokens += event.outputTokens || 0;
242
- usage.cacheReadTokens += event.cacheReadTokens || 0;
243
- usage.cacheWriteTokens += event.cacheWriteTokens || 0;
244
- usage.cacheInputDeltaTokens += cacheDelta;
245
- write({ type: 'usage', session_id: sessionId, turn_id: turnId, usage: snakeUsage(event) });
246
- break;
247
- }
248
- case 'stop':
249
- if (event.stopReason) stopReason = event.stopReason;
250
- break;
251
- case 'turn_end':
252
- if (event.stopReason && TERMINAL_STOP_REASONS.has(event.stopReason)) stopReason = event.stopReason;
253
- break;
254
- case 'turn_close':
255
- write({ type: 'turn', subtype: 'stop', session_id: sessionId, turn_id: turnId, duration_ms: event.totalMs, loop_count: event.loopCount, total_tokens: event.totalTokens });
256
- break;
257
- case 'error':
258
- failed = event.error instanceof Error ? event.error : new Error(event.error?.message || String(event.error || 'Unknown error'));
259
- write({ type: 'error', session_id: sessionId, turn_id: turnId, error: { name: failed.name, message: failed.message }, retryable: !!event.retryable });
260
- break;
261
- case 'fallback':
262
- case 'llm_retry':
263
- case 'memory_used':
264
- case 'recall':
265
- case 'consolidate':
266
- case 'reflection':
267
- write({ ...event, session_id: sessionId, turn_id: turnId });
268
- break;
269
- }
270
- }
271
- } catch (err) {
272
- failed = err;
273
- write({ type: 'error', session_id: sessionId, turn_id: engineTurnId || clientTurnId, error: { name: err.name || 'Error', message: err.message || String(err) } });
274
- }
275
-
276
- const finalUsage = {
277
- input_tokens: usage.inputTokens,
278
- output_tokens: usage.outputTokens,
279
- cache_read_input_tokens: usage.cacheReadTokens,
280
- cache_creation_input_tokens: usage.cacheWriteTokens,
281
- total_input_tokens: usage.inputTokens + usage.cacheInputDeltaTokens,
282
- total_tokens: usage.inputTokens + usage.cacheInputDeltaTokens + usage.outputTokens,
283
- };
284
- const result = {
285
- type: 'result',
286
- subtype: failed ? 'error' : 'success',
287
- session_id: sessionId,
288
- turn_id: engineTurnId || clientTurnId,
289
- model,
290
- model_effort: modelEffort,
291
- stop_reason: failed ? 'error' : stopReason,
292
- is_error: !!failed,
293
- result: resultText,
294
- usage: finalUsage,
295
- ...(failed ? { error: failed.message || String(failed) } : {}),
191
+ function finalUsage(state) {
192
+ return {
193
+ input_tokens: state.inputTokens,
194
+ output_tokens: state.outputTokens,
195
+ cache_read_input_tokens: state.cacheReadTokens,
196
+ cache_creation_input_tokens: state.cacheWriteTokens,
197
+ total_input_tokens: state.inputTokens + state.cacheInputDeltaTokens,
198
+ total_tokens: state.inputTokens + state.cacheInputDeltaTokens + state.outputTokens,
296
199
  };
297
- write(result);
298
- return result;
299
200
  }
300
201
 
301
202
  /**
302
- * Run one prompt through the multi-VP CLI Session runner. Engine events keep
303
- * the stream-json shape used by runStreamTurn and add `vp_id`, allowing
304
- * concurrent VP streams to be demultiplexed without imposing output order.
203
+ * One Engine-event projector shared by single- and multi-VP stream-json paths.
305
204
  */
306
- export async function runStreamSessionTurn({
307
- runner,
308
- prompt,
205
+ export function createStreamProjector({
309
206
  sessionId,
207
+ write,
310
208
  workDir = process.cwd(),
311
209
  model = null,
312
210
  modelEffort = null,
313
- input = null,
314
- write,
315
- }) {
316
- const clientTurnId = randomUUID();
211
+ defaultVpId = null,
212
+ defaultThreadId = 'main',
213
+ clientTurnId = randomUUID(),
214
+ } = {}) {
317
215
  const states = new Map();
318
216
  let failed = null;
319
-
320
- const stateFor = (vpId) => {
321
- let state = states.get(vpId);
217
+ const keyFor = vpId => vpId || '__single__';
218
+ const stateFor = (vpId = null) => {
219
+ const key = keyFor(vpId);
220
+ let state = states.get(key);
322
221
  if (!state) {
323
222
  state = {
223
+ vpId: vpId || defaultVpId || null,
224
+ threadId: defaultThreadId || 'main',
324
225
  turnId: clientTurnId,
325
226
  resultText: '',
326
227
  stopReason: 'end_turn',
327
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0, cacheInputDeltaTokens: 0 },
228
+ usage: makeUsageState(),
229
+ asyncWaitPendingByLoop: new Map(),
328
230
  };
329
- states.set(vpId, state);
231
+ states.set(key, state);
232
+ } else if (vpId && !state.vpId) {
233
+ state.vpId = vpId;
330
234
  }
331
235
  return state;
332
236
  };
237
+ const scopeFor = state => ({
238
+ session_id: sessionId,
239
+ turn_id: state.turnId || clientTurnId,
240
+ ...(state.vpId ? { vp_id: state.vpId, vpId: state.vpId } : {}),
241
+ thread_id: state.threadId || 'main',
242
+ threadId: state.threadId || 'main',
243
+ });
333
244
 
334
- const askUser = async ({ question, options }, vpId = null, turnId = clientTurnId) => {
335
- const requestId = randomUUID();
336
- write({
337
- type: 'ask_user', subtype: 'request', request_id: requestId,
338
- session_id: sessionId, turn_id: turnId, vp_id: vpId,
339
- question, options: Array.isArray(options) ? options : [],
340
- });
341
- if (!input) throw new Error('AskUser requires --input-format stream-json');
342
- const answers = await input.waitForAnswer(requestId);
343
- write({
344
- type: 'ask_user', subtype: 'response', request_id: requestId,
345
- session_id: sessionId, turn_id: turnId, vp_id: vpId, answers,
346
- });
347
- return answers;
348
- };
349
-
350
- const onEvent = async ({ vpId, event, turnId: fallbackTurnId }) => {
245
+ const project = ({ vpId = null, event, turnId: fallbackTurnId = null } = {}) => {
246
+ if (!event || typeof event !== 'object') return;
351
247
  const state = stateFor(vpId);
352
- if (event.type === 'turn_open' && event.turnId) state.turnId = event.turnId;
353
- const turnId = state.turnId || fallbackTurnId || clientTurnId;
354
- const base = { session_id: sessionId, turn_id: turnId, vp_id: vpId };
248
+ if (event.type === 'turn_open') {
249
+ if (event.turnId) state.turnId = event.turnId;
250
+ if (event.vpId) state.vpId = event.vpId;
251
+ if (event.threadId) state.threadId = event.threadId;
252
+ } else if (event.threadId) {
253
+ state.threadId = event.threadId;
254
+ }
255
+ if (!state.turnId && fallbackTurnId) state.turnId = fallbackTurnId;
256
+ const base = scopeFor(state);
355
257
  switch (event.type) {
356
258
  case 'turn_open':
357
- write({ ...event, type: 'turn', subtype: 'start', ...base, model, model_effort: modelEffort, cwd: workDir });
259
+ write({ ...event, ...base, type: 'turn', subtype: 'start', model, model_effort: modelEffort, cwd: workDir });
358
260
  break;
359
261
  case 'text_delta':
360
262
  state.resultText += event.text || '';
361
- write({ type: 'assistant', subtype: 'text_delta', ...base, delta: { type: 'text_delta', text: event.text || '' } });
263
+ write({ ...base, type: 'assistant', subtype: 'text_delta', delta: { type: 'text_delta', text: event.text || '' } });
362
264
  break;
363
265
  case 'thinking_delta':
364
- write({ type: 'assistant', subtype: 'thinking_delta', ...base, delta: { type: 'thinking_delta', thinking: event.text || '' } });
266
+ write({ ...base, type: 'assistant', subtype: 'thinking_delta', delta: { type: 'thinking_delta', thinking: event.text || '' } });
365
267
  break;
366
268
  case 'skill_loaded':
367
- write({ type: 'skill', subtype: 'loaded', ...base, skill: event.skill });
269
+ write({ ...base, type: 'skill', subtype: 'loaded', skill: event.skill });
368
270
  break;
369
271
  case 'skill_error':
370
- write({ type: 'skill', subtype: 'error', ...base, skill_name: event.skillName, error: event.message });
272
+ write({ ...base, type: 'skill', subtype: 'error', skill_name: event.skillName, error: event.message });
371
273
  break;
372
274
  case 'tool_call':
373
- write({ type: 'assistant', subtype: 'tool_use', ...base, content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }] });
275
+ write({ ...base, type: 'assistant', subtype: 'tool_use', content: [{ type: 'tool_use', id: event.id, name: event.name, input: event.input }] });
374
276
  if (event.name === 'TodoWrite') {
375
- write({ type: 'todo', subtype: 'update', ...base, tool_use_id: event.id, todos: event.input?.todos || [] });
277
+ write({ ...base, type: 'todo', subtype: 'update', tool_use_id: event.id, todos: event.input?.todos || [] });
376
278
  }
377
279
  break;
378
280
  case 'tool_start':
379
- write({ type: 'tool', subtype: 'start', ...base, tool_use_id: event.id, name: event.name, input: event.input });
281
+ write({ ...base, type: 'tool', subtype: 'start', tool_use_id: event.id, name: event.name, input: event.input });
380
282
  break;
381
283
  case 'tool_end':
382
- write({ type: 'tool', subtype: 'result', ...base, tool_use_id: event.id, name: event.name, content: event.output, is_error: !!event.isError });
284
+ write({
285
+ ...base,
286
+ type: 'tool',
287
+ subtype: 'result',
288
+ tool_use_id: event.id,
289
+ name: event.name,
290
+ content: event.output,
291
+ is_error: !!event.isError,
292
+ ...(Array.isArray(event.displayImages) && event.displayImages.length > 0
293
+ ? { display_images: event.displayImages }
294
+ : {}),
295
+ });
383
296
  break;
384
297
  case 'usage': {
385
- const cacheDelta = event.cacheTokensAreIncludedInInput ? 0 : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0);
298
+ const cacheDelta = event.cacheTokensAreIncludedInInput
299
+ ? 0
300
+ : (event.cacheReadTokens || 0) + (event.cacheWriteTokens || 0);
386
301
  state.usage.inputTokens += event.inputTokens || 0;
387
302
  state.usage.outputTokens += event.outputTokens || 0;
388
303
  state.usage.cacheReadTokens += event.cacheReadTokens || 0;
389
304
  state.usage.cacheWriteTokens += event.cacheWriteTokens || 0;
390
305
  state.usage.cacheInputDeltaTokens += cacheDelta;
391
- write({ type: 'usage', ...base, usage: snakeUsage(event) });
306
+ write({ ...base, type: 'usage', usage: snakeUsage(event) });
392
307
  break;
393
308
  }
309
+ case 'async_task_wait_start': {
310
+ const loopKey = event.loopNumber ?? state.turnId;
311
+ const pendingTaskIds = Array.isArray(event.pendingTaskIds) ? event.pendingTaskIds.slice() : [];
312
+ state.asyncWaitPendingByLoop.set(loopKey, pendingTaskIds);
313
+ write({
314
+ ...base,
315
+ type: 'async_task',
316
+ subtype: 'wait_start',
317
+ loop_number: event.loopNumber,
318
+ pending_task_ids: pendingTaskIds,
319
+ });
320
+ break;
321
+ }
322
+ case 'async_task_wait_end': {
323
+ const loopKey = event.loopNumber ?? state.turnId;
324
+ const pendingTaskIds = state.asyncWaitPendingByLoop.get(loopKey) || [];
325
+ write({
326
+ ...base,
327
+ type: 'async_task',
328
+ subtype: 'wait_end',
329
+ loop_number: event.loopNumber,
330
+ aborted: !!event.aborted,
331
+ timed_out: !!event.timedOut,
332
+ pending_task_ids: pendingTaskIds,
333
+ remaining_task_ids: Array.isArray(event.remainingTaskIds) ? event.remainingTaskIds.slice() : [],
334
+ deferred_task_ids: Array.isArray(event.deferredTaskIds) ? event.deferredTaskIds.slice() : [],
335
+ });
336
+ state.asyncWaitPendingByLoop.delete(loopKey);
337
+ break;
338
+ }
339
+ case 'aborted':
340
+ state.stopReason = 'aborted';
341
+ write({ ...base, type: 'aborted', reason: event.reason || 'external' });
342
+ break;
394
343
  case 'stop':
344
+ if (event.stopReason) state.stopReason = event.stopReason;
345
+ break;
395
346
  case 'turn_end':
396
- if (event.stopReason && TERMINAL_STOP_REASONS.has(event.stopReason)) state.stopReason = event.stopReason;
347
+ if (event.stopReason && (event.terminal || TERMINAL_STOP_REASONS.has(event.stopReason))) {
348
+ state.stopReason = event.stopReason;
349
+ }
397
350
  break;
398
351
  case 'turn_close':
399
- write({ type: 'turn', subtype: 'stop', ...base, duration_ms: event.totalMs, loop_count: event.loopCount, total_tokens: event.totalTokens });
352
+ write({ ...base, type: 'turn', subtype: 'stop', duration_ms: event.totalMs, loop_count: event.loopCount, total_tokens: event.totalTokens });
400
353
  break;
401
- case 'error':
402
- failed ||= event.error instanceof Error ? event.error : new Error(event.error?.message || String(event.error || 'Unknown error'));
403
- write({ type: 'error', ...base, error: { name: failed.name, message: failed.message }, retryable: !!event.retryable });
354
+ case 'error': {
355
+ const error = event.error instanceof Error
356
+ ? event.error
357
+ : new Error(event.error?.message || String(event.error || 'Unknown error'));
358
+ failed ||= error;
359
+ state.stopReason = 'error';
360
+ write({ ...base, type: 'error', error: { name: error.name, message: error.message }, retryable: !!event.retryable });
404
361
  break;
362
+ }
405
363
  case 'fallback':
406
364
  case 'llm_retry':
407
365
  case 'memory_used':
408
366
  case 'recall':
409
367
  case 'consolidate':
410
368
  case 'reflection':
369
+ case 'tool_result_update':
370
+ case 'user_append':
411
371
  write({ ...event, ...base });
412
372
  break;
413
373
  }
414
374
  };
415
375
 
416
- let outcome;
376
+ const recordFailure = (error, { vpId = null, threadId = null, turnId = null, writeError = true } = {}) => {
377
+ const normalized = error instanceof Error ? error : new Error(String(error || 'Unknown error'));
378
+ failed ||= normalized;
379
+ const state = stateFor(vpId);
380
+ if (threadId) state.threadId = threadId;
381
+ if (turnId) state.turnId = turnId;
382
+ state.stopReason = 'error';
383
+ if (writeError) {
384
+ write({
385
+ ...scopeFor(state),
386
+ type: 'error',
387
+ error: { name: normalized.name || 'Error', message: normalized.message || String(normalized) },
388
+ retryable: false,
389
+ });
390
+ }
391
+ return normalized;
392
+ };
393
+
394
+ const perVpResults = () => Array.from(states.values()).map(state => ({
395
+ ...(state.vpId ? { vp_id: state.vpId } : {}),
396
+ turn_id: state.turnId,
397
+ thread_id: state.threadId || 'main',
398
+ stop_reason: state.stopReason,
399
+ result: state.resultText,
400
+ usage: finalUsage(state.usage),
401
+ }));
402
+
403
+ return {
404
+ clientTurnId,
405
+ project,
406
+ recordFailure,
407
+ stateFor,
408
+ scopeFor,
409
+ get failure() { return failed; },
410
+ perVpResults,
411
+ };
412
+ }
413
+
414
+ export async function runStreamTurn({
415
+ engine,
416
+ prompt,
417
+ messages = [],
418
+ sessionId = null,
419
+ workDir = process.cwd(),
420
+ model = null,
421
+ modelEffort = null,
422
+ input = null,
423
+ write,
424
+ getCurrentTodos = null,
425
+ setCurrentTodos = null,
426
+ vpPersona = null,
427
+ sessionMembers = null,
428
+ router = null,
429
+ inboundEnvelope = null,
430
+ taskId = null,
431
+ taskMembers = null,
432
+ threadId = 'main',
433
+ vpTurnId = null,
434
+ userAlreadyPersisted = false,
435
+ }) {
436
+ const vpId = typeof vpPersona?.vpId === 'string' ? vpPersona.vpId : null;
437
+ const projector = createStreamProjector({
438
+ sessionId, write, workDir, model, modelEffort, defaultVpId: vpId, defaultThreadId: threadId,
439
+ });
440
+ const askUser = async ({ question, options }) => {
441
+ const requestId = randomUUID();
442
+ const state = projector.stateFor(vpId);
443
+ const scope = projector.scopeFor(state);
444
+ write({ ...scope, type: 'ask_user', subtype: 'request', request_id: requestId, question, options: Array.isArray(options) ? options : [] });
445
+ if (!input) throw new Error('AskUser requires --input-format stream-json');
446
+ const answers = await input.waitForAnswer(requestId);
447
+ write({ ...scope, type: 'ask_user', subtype: 'response', request_id: requestId, answers });
448
+ return answers;
449
+ };
450
+ try {
451
+ for await (const event of engine.query({
452
+ prompt,
453
+ messages,
454
+ sessionId,
455
+ workDir,
456
+ userEffort: modelEffort,
457
+ askUser,
458
+ getCurrentTodos,
459
+ setCurrentTodos,
460
+ vpPersona,
461
+ senderVpId: vpId,
462
+ sessionMembers,
463
+ router,
464
+ inboundEnvelope,
465
+ taskId,
466
+ taskMembers,
467
+ threadId,
468
+ vpTurnId,
469
+ userAlreadyPersisted,
470
+ })) {
471
+ projector.project({ vpId, event, turnId: vpTurnId });
472
+ }
473
+ } catch (error) {
474
+ projector.recordFailure(error, { vpId, threadId });
475
+ }
476
+ const perVp = projector.perVpResults();
477
+ const state = perVp[0] || { turn_id: projector.clientTurnId, thread_id: threadId, stop_reason: 'end_turn', result: '', usage: finalUsage(makeUsageState()) };
478
+ const failed = projector.failure;
479
+ const result = {
480
+ type: 'result',
481
+ subtype: failed ? 'error' : 'success',
482
+ session_id: sessionId,
483
+ turn_id: state.turn_id,
484
+ ...(vpId ? { vp_id: vpId, vpId } : {}),
485
+ thread_id: state.thread_id || 'main',
486
+ threadId: state.thread_id || 'main',
487
+ model,
488
+ model_effort: modelEffort,
489
+ stop_reason: failed ? 'error' : state.stop_reason,
490
+ is_error: !!failed,
491
+ result: state.result,
492
+ usage: state.usage,
493
+ ...(failed ? { error: failed.message || String(failed) } : {}),
494
+ };
495
+ write(result);
496
+ return result;
497
+ }
498
+
499
+ export async function runStreamSessionTurn({
500
+ runner,
501
+ prompt,
502
+ sessionId,
503
+ workDir = process.cwd(),
504
+ model = null,
505
+ modelEffort = null,
506
+ input = null,
507
+ write,
508
+ routingIntent = null,
509
+ internal = false,
510
+ taskId = null,
511
+ meta = null,
512
+ }) {
513
+ const projector = createStreamProjector({ sessionId, write, workDir, model, modelEffort });
514
+ const askUser = async ({ question, options }, vpId = null, turnId = projector.clientTurnId, threadId = 'main') => {
515
+ const requestId = randomUUID();
516
+ const state = projector.stateFor(vpId);
517
+ state.turnId = turnId || state.turnId;
518
+ state.threadId = threadId || state.threadId;
519
+ const scope = projector.scopeFor(state);
520
+ write({ ...scope, type: 'ask_user', subtype: 'request', request_id: requestId, question, options: Array.isArray(options) ? options : [] });
521
+ if (!input) throw new Error('AskUser requires --input-format stream-json');
522
+ const answers = await input.waitForAnswer(requestId);
523
+ write({ ...scope, type: 'ask_user', subtype: 'response', request_id: requestId, answers });
524
+ return answers;
525
+ };
526
+
527
+ let outcome = { report: { dispatched: [] }, results: [] };
417
528
  try {
418
529
  outcome = await runner.run(prompt, {
419
530
  modelEffort,
420
- onEvent,
531
+ routingIntent,
532
+ internal,
533
+ taskId,
534
+ meta,
535
+ onEvent: event => projector.project(event),
421
536
  askUser,
422
537
  });
423
538
  } catch (error) {
424
- failed = error;
425
- write({ type: 'error', session_id: sessionId, turn_id: clientTurnId, error: { name: error.name || 'Error', message: error.message || String(error) } });
426
- outcome = { report: { dispatched: [] }, results: [] };
539
+ projector.recordFailure(error);
540
+ }
541
+ for (const row of outcome?.results || []) {
542
+ if (row?.error) projector.recordFailure(row.error, { vpId: row.vpId, writeError: false });
543
+ }
544
+ const dispatchedVpIds = Array.isArray(outcome?.report?.dispatched)
545
+ ? outcome.report.dispatched
546
+ : [];
547
+ if (dispatchedVpIds.length === 0 && !projector.failure) {
548
+ const reportErrors = Array.isArray(outcome?.report?.errors)
549
+ ? outcome.report.errors
550
+ .map(entry => entry?.error || entry?.message)
551
+ .filter(Boolean)
552
+ : [];
553
+ const detail = reportErrors.join(', ') || outcome?.report?.reason || 'no_vp_dispatched';
554
+ projector.recordFailure(new Error(`stream-json Session turn dispatched no VP: ${detail}`));
427
555
  }
428
556
 
429
- const perVp = Array.from(states, ([vpId, state]) => ({
430
- vp_id: vpId,
431
- turn_id: state.turnId,
432
- stop_reason: state.stopReason,
433
- result: state.resultText,
434
- usage: {
435
- input_tokens: state.usage.inputTokens,
436
- output_tokens: state.usage.outputTokens,
437
- cache_read_input_tokens: state.usage.cacheReadTokens,
438
- cache_creation_input_tokens: state.usage.cacheWriteTokens,
439
- total_input_tokens: state.usage.inputTokens + state.usage.cacheInputDeltaTokens,
440
- total_tokens: state.usage.inputTokens + state.usage.cacheInputDeltaTokens + state.usage.outputTokens,
441
- },
442
- }));
557
+ const perVp = projector.perVpResults();
443
558
  const stopReasons = perVp.map(item => item.stop_reason);
444
- const aggregateStopReason = failed || stopReasons.includes('error')
559
+ if (stopReasons.includes('error') && !projector.failure) {
560
+ projector.recordFailure(new Error('stream-json VP turn ended with stop reason error'), { writeError: false });
561
+ }
562
+ const failed = projector.failure;
563
+ const aggregateStopReason = failed
445
564
  ? 'error'
446
565
  : stopReasons.length > 0 && stopReasons.every(reason => reason === 'aborted')
447
566
  ? 'aborted'
448
- : 'end_turn';
449
- const isError = !!failed || aggregateStopReason === 'error';
567
+ : stopReasons.length > 0 && stopReasons.every(reason => reason === 'tool_handoff')
568
+ ? 'tool_handoff'
569
+ : 'end_turn';
450
570
  const result = {
451
- type: 'result', subtype: isError ? 'error' : 'success',
452
- session_id: sessionId, turn_id: clientTurnId,
453
- model, model_effort: modelEffort,
571
+ type: 'result',
572
+ subtype: failed ? 'error' : 'success',
573
+ session_id: sessionId,
574
+ turn_id: projector.clientTurnId,
575
+ model,
576
+ model_effort: modelEffort,
454
577
  stop_reason: aggregateStopReason,
455
- is_error: isError,
578
+ is_error: !!failed,
456
579
  result: perVp.map(item => item.result).filter(Boolean).join('\n'),
457
- dispatched_vp_ids: outcome?.report?.dispatched || [],
580
+ dispatched_vp_ids: dispatchedVpIds,
458
581
  vp_results: perVp,
459
582
  ...(failed ? { error: failed.message || String(failed) } : {}),
460
583
  };