shraga 0.1.96 → 0.1.98

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.
@@ -27,7 +27,8 @@ import { registerSpaCatchAll } from './spa-catchall.ts';
27
27
  import { slackFeature } from './slack/feature.ts';
28
28
  import { dataPath } from './paths.ts';
29
29
  import { getAllSessions, getSession, getSessionHistory, upsertSession, appendMessage, saveConversation, loadConversation, setSessionDirectives, getAutoApprove, setAutoApprove, getSessionsByScheduleId, getSessionsVisibleTo, isSessionVisibleTo, setRunStatus, incrementRetryCount, getRunningSessions, getActiveLockCount, updateScheduledSessionStatus, setShuttingDown, backfillSessionVisibility, writePartial, readPartial, clearPartial, registerLivePartial, unregisterLivePartial, readLivePartial, acquireSessionLock, releaseSessionLock, replaceSessionLock, isSessionLocked, getSessionAbortController, forkSession, generateSessionTitle, toListItem, pageSessions, isOwnSession, type ConvBlock, type ConvMessage, type SessionMeta } from './sessions.ts';
30
- import { setBroadcaster } from './session-bus.ts';
30
+ import { setBroadcaster, emitToSession } from './session-bus.ts';
31
+ import { TURN_CONTROL_EVENTS } from './turn-stream.ts';
31
32
  import * as scheduler from './scheduler/index.ts';
32
33
  import { initPolls } from './polls.ts';
33
34
  import { initBackgroundJobs } from './background-jobs.ts';
@@ -609,6 +610,8 @@ app.get('/api/schedules/:id/runs', requireAuth, (req, res) => {
609
610
  res.json(getSessionsByScheduleId(id));
610
611
  });
611
612
 
613
+ // Cap on a tool_result pushed to viewers; the transcript keeps the full output.
614
+ const STREAMED_RESULT_MAX = 2000;
612
615
  // ── REST chat endpoint (for automation / CLI triggers / agent-to-agent) ──────
613
616
  /**
614
617
  * Run a single chat turn with all its side effects (session lock, message
@@ -666,7 +669,13 @@ async function runChatTurn(
666
669
  abortController,
667
670
  context: opts.context ?? { source: 'api', user: userEmail },
668
671
  onPermissionRequest: async () => ({ allow: true }),
669
- }), onEvent);
672
+ }), onEvent, {
673
+ // Same reason as the wake lane: an /api/chat or MCP-driven turn used to surface nothing to a
674
+ // web viewer until it ended, and dropped an add-on engine's subagent events entirely.
675
+ maxResultChars: STREAMED_RESULT_MAX,
676
+ onDelta: (event) => broadcast({ type: 'session_stream', sessionId: sid, event }),
677
+ onPassthrough: (event) => emitToSession(sid, event),
678
+ });
670
679
  if (blocks.length) {
671
680
  appendMessage(sid, { id: crypto.randomUUID(), role: 'assistant', blocks });
672
681
  }
@@ -1218,7 +1227,18 @@ initPolls({
1218
1227
  await new Promise((r) => setTimeout(r, 2_000));
1219
1228
  }
1220
1229
  try {
1221
- return await consumeStream(streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }));
1230
+ // Stream it. A woken turn (a background job reporting back, a poll firing) used to surface
1231
+ // NOTHING until it ended — and an add-on engine's subagent events were dropped outright — so a
1232
+ // turn that dispatched a worker was indistinguishable from one that only claimed to.
1233
+ return await consumeStream(
1234
+ streamChat({ prompt, sessionId, uid, userEmail, mcpServers: getMcpConfig(uid), abortController, onPermissionRequest: async () => ({ allow: true }) }),
1235
+ undefined,
1236
+ {
1237
+ maxResultChars: STREAMED_RESULT_MAX,
1238
+ onDelta: (event) => broadcast({ type: 'session_stream', sessionId, event }),
1239
+ onPassthrough: (event) => emitToSession(sessionId, event),
1240
+ },
1241
+ );
1222
1242
  } finally {
1223
1243
  if (releaseSessionLock(sessionId, abortController)) setRunStatus(sessionId, 'idle');
1224
1244
  }
@@ -1550,6 +1570,11 @@ async function runStream(ws: WebSocket, session: WsSession, sid: string, promptT
1550
1570
  send(ws, { type: 'error', message: event.message, sessionId: sid });
1551
1571
  }
1552
1572
  break;
1573
+ } else if (!TURN_CONTROL_EVENTS.has(event.type)) {
1574
+ // Not core-owned — an add-on engine's event (subagent pills, worker cards). The owning
1575
+ // socket already got it verbatim above; mirror it to the session's OTHER viewers too, so a
1576
+ // second device watching the same turn sees the same thing.
1577
+ broadcast({ ...event, sessionId: sid }, ws);
1553
1578
  }
1554
1579
  }
1555
1580
  if (eventCount === 0) {
@@ -3,6 +3,7 @@ import { summarizeText } from './summarize.ts';
3
3
  import { dataSync } from './data-sync.ts';
4
4
  import type { McpConfig } from './mcp.ts';
5
5
  import { loadConversation, saveConversation, appendMessage, getSession, setSessionDirectives, addTriggeredSkills, upsertSession, type ConvMessage, type ConvBlock } from './sessions.ts';
6
+ import { createTurnAccumulator, type TurnStreamHooks } from './turn-stream.ts';
6
7
  import {
7
8
  resolveDefaultSkillsContent,
8
9
  expandMentionedSkills,
@@ -71,6 +72,8 @@ export function saveAgentConfig(config: AgentConfig): void {
71
72
 
72
73
  // ── WS events ───────────────────────────────────────────────────────────────
73
74
 
75
+ export type { TurnStreamHooks };
76
+
74
77
  export type WsEvent =
75
78
  | { type: 'text_delta'; text: string }
76
79
  | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
@@ -410,38 +413,16 @@ export async function* streamChat(opts: {
410
413
  * thing — the MCP path said NOTHING at all, so a truncated turn arrived looking finished. */
411
414
  export const MAX_TURNS_NOTICE = '\n\n---\n⚠️ Reached the maximum number of steps for this turn. Send "continue" to pick up where I left off.';
412
415
 
413
- export async function consumeStream(stream: AsyncGenerator<WsEvent>, onEvent?: (ev: WsEvent) => void): Promise<ConvBlock[]> {
414
- let text = '';
415
- let thinking = '';
416
- const blocks: ConvBlock[] = [];
416
+ export async function consumeStream(
417
+ stream: AsyncGenerator<WsEvent>,
418
+ onEvent?: (ev: WsEvent) => void,
419
+ hooks?: TurnStreamHooks,
420
+ ): Promise<ConvBlock[]> {
421
+ const acc = createTurnAccumulator(hooks ?? {});
417
422
  for await (const ev of stream) {
418
423
  onEvent?.(ev);
419
- if (ev.type === 'thinking_delta') {
420
- thinking += ev.text;
421
- } else if (ev.type === 'text_delta') {
422
- if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
423
- text += ev.text;
424
- } else if (ev.type === 'tool_use') {
425
- if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; }
426
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
427
- blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
428
- } else if (ev.type === 'tool_use_input') {
429
- const existing = blocks.find((b) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as any;
430
- if (existing) existing.input = ev.input;
431
- } else if (ev.type === 'tool_result') {
432
- blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
433
- } else if (ev.type === 'tool_result_image') {
434
- blocks.push({ type: 'image', src: ev.dataUrl });
435
- } else if (ev.type === 'done') {
436
- break;
437
- } else if (ev.type === 'error') {
438
- if (text) { blocks.push({ type: 'text', text }); text = ''; }
439
- blocks.push({ type: 'error', text: ev.message });
440
- break;
441
- }
424
+ if (acc.push(ev)) break;
442
425
  }
443
- if (thinking) blocks.push({ type: 'thinking', text: thinking });
444
- if (text) blocks.push({ type: 'text', text });
445
- return blocks;
426
+ return acc.finish();
446
427
  }
447
428
 
@@ -12,6 +12,7 @@ import { getMcpConfig } from '../mcp.ts';
12
12
  import { dataPath } from '../paths.ts';
13
13
  import { appendMessage, setSlackContext, setRunStatus, setVisibleTo, writePartial, clearPartial, registerLivePartial, unregisterLivePartial, acquireSessionLock, releaseSessionLock, getSession, recordSeenSlackTs, type ConvBlock, type SessionMeta } from '../sessions.ts';
14
14
  import { injectFile } from '../file-inject.ts';
15
+ import { createTurnAccumulator } from '../turn-stream.ts';
15
16
  import {
16
17
  postMessage, addReaction, removeReaction, getBotUserId, getAgentUserId, getThreadMessages, getMessage,
17
18
  getChannelName, getUserName, getUserProfile, resolveUserMentions, isSupportedFile, SUPPORTED_FILE_MIMES,
@@ -72,48 +73,34 @@ async function* pumpStream(
72
73
  sessionId: string,
73
74
  opts: { partial?: boolean; artifacts?: boolean } = {},
74
75
  ): AsyncGenerator<AgentEvent> {
75
- let assistantText = '';
76
- const assistantBlocks: ConvBlock[] = [];
77
- const collect = () => [...assistantBlocks, ...(assistantText ? [{ type: 'text' as const, text: assistantText }] : [])];
76
+ // Transcript + web-viewer streaming is the shared reducer's job (turn-stream.ts); this function
77
+ // only adds what is Slack's: which events become Slack text, and the artifact hook.
78
+ const acc = createTurnAccumulator({
79
+ maxResultChars: 2000,
80
+ onDelta: (event) => broadcastFn({ type: 'session_stream', sessionId, event }),
81
+ onPassthrough: (event) => broadcastFn({ ...event, sessionId }),
82
+ });
78
83
  let partialInterval: ReturnType<typeof setInterval> | undefined;
79
84
  if (opts.partial) {
80
- registerLivePartial(sessionId, collect);
81
- partialInterval = setInterval(() => { const b = collect(); if (b.length) writePartial(sessionId, b); }, 5_000);
85
+ registerLivePartial(sessionId, () => acc.snapshot());
86
+ partialInterval = setInterval(() => { const b = acc.snapshot(); if (b.length) writePartial(sessionId, b); }, 5_000);
82
87
  }
83
- let stopReason = '';
84
88
  try {
85
89
  for await (const ev of gen) {
86
- if (ev.type === 'text_delta') {
87
- assistantText += ev.text;
88
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'text_delta', text: ev.text } });
89
- yield { type: 'text_delta', text: ev.text };
90
- } else if (ev.type === 'tool_use') {
91
- if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
92
- assistantBlocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
93
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input } });
90
+ const terminal = acc.push(ev);
91
+ if (ev.type === 'text_delta') yield { type: 'text_delta', text: ev.text };
92
+ else if (ev.type === 'tool_use') {
94
93
  if (opts.artifacts) { const e = handleArtifactToolUse(sessionId, ev.tool, ev.input); if (e) broadcastFn(e); }
95
94
  yield { type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input };
96
- } else if (ev.type === 'tool_result') {
97
- assistantBlocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
98
- const trimmed = ev.output.length > 2000 ? ev.output.slice(0, 2000) + '…' : ev.output;
99
- broadcastFn({ type: 'session_stream', sessionId, event: { type: 'tool_result', toolUseId: ev.toolUseId, output: trimmed } });
100
- } else if (ev.type === 'tool_result_image') {
101
- assistantBlocks.push({ type: 'image', src: ev.dataUrl });
102
- } else if (ev.type === 'done') {
103
- stopReason = ev.stopReason ?? 'end_turn';
104
- break;
105
95
  } else if (ev.type === 'error') {
106
96
  // Slack gets it as text (it has no block renderer); the transcript gets a real error block.
107
- const t = `\n⚠️ ${ev.message}`;
108
- if (assistantText) { assistantBlocks.push({ type: 'text', text: assistantText }); assistantText = ''; }
109
- assistantBlocks.push({ type: 'error', text: ev.message });
110
- yield { type: 'text_delta', text: t };
111
- break;
97
+ yield { type: 'text_delta', text: `\n⚠️ ${ev.message}` };
112
98
  }
99
+ if (terminal) break;
113
100
  }
114
- if (stopReason === 'max_turns_reached') {
101
+ if (acc.stopReason === 'max_turns_reached') {
115
102
  const notice = '\n\n---\n⚠️ _Reached the maximum number of steps for this turn. Reply "continue" to pick up where I left off._';
116
- assistantText += notice;
103
+ acc.push({ type: 'text_delta', text: notice });
117
104
  yield { type: 'text_delta', text: notice };
118
105
  }
119
106
  } finally {
@@ -121,7 +108,7 @@ async function* pumpStream(
121
108
  if (opts.partial) unregisterLivePartial(sessionId);
122
109
  }
123
110
 
124
- if (assistantText) assistantBlocks.push({ type: 'text', text: assistantText });
111
+ const assistantBlocks = acc.finish();
125
112
  for (const b of assistantBlocks) if (mdText(b)) (b as any).text = await resolveUserMentions(b.text);
126
113
  if (assistantBlocks.length) {
127
114
  if (opts.partial) clearPartial(sessionId);
@@ -0,0 +1,128 @@
1
+ /**
2
+ * The single event→(transcript blocks, client events) reducer for an agent turn.
3
+ *
4
+ * Every lane that runs a turn has to do the same two things with the engine's event stream: build
5
+ * the assistant message's `ConvBlock[]`, and push the live deltas to whoever is watching the session
6
+ * on the web. Each surface (websocket, Slack, webhook-driven, background-job/wake, the REST chat
7
+ * endpoint) grew its own copy, and most were lossy in a different way — a Slack turn rendered tool
8
+ * pills with empty inputs; a job-triggered turn streamed nothing at all and dropped an add-on
9
+ * engine's subagent events on the floor. This is that logic, once.
10
+ *
11
+ * Two output channels, deliberately distinct:
12
+ * - `onDelta` gets the core display events, to be wrapped as `{ type: 'session_stream', event }`.
13
+ * - `onPassthrough` gets everything the CORE DOESN'T OWN (an add-on engine's `duplex_*` subagent
14
+ * pills — see the note on the WsEvent union in claude.ts), forwarded VERBATIM and top-level,
15
+ * which is the shape those clients already listen for. Unknown = forward, never drop: dropping is
16
+ * what made a dispatched worker invisible.
17
+ */
18
+ import type { ConvBlock } from './sessions.ts';
19
+
20
+ /** Events a lane handles itself (transport, control flow) — never a display delta, never forwarded. */
21
+ export const TURN_CONTROL_EVENTS = new Set([
22
+ 'done', 'error', 'permission_request', 'question_request',
23
+ 'model_resolved', 'stats', 'session_id', 'session_busy', 'forked',
24
+ ]);
25
+
26
+ /** The inner payload of a `session_stream` event — the core's display deltas. */
27
+ export type StreamDelta =
28
+ | { type: 'thinking_delta'; text: string }
29
+ | { type: 'text_delta'; text: string }
30
+ | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown }
31
+ | { type: 'tool_use_input'; toolUseId: string; input: unknown }
32
+ | { type: 'tool_result'; toolUseId: string; output: string }
33
+ | { type: 'tool_result_image'; toolUseId: string; dataUrl: string };
34
+
35
+ export interface TurnStreamHooks {
36
+ onDelta?: (ev: StreamDelta) => void;
37
+ onPassthrough?: (ev: object) => void;
38
+ /** Cap on a tool_result's streamed output. The transcript keeps the full text either way. */
39
+ maxResultChars?: number;
40
+ }
41
+
42
+ export interface TurnAccumulator {
43
+ /** The assistant blocks so far — safe to read mid-turn (a live "partial" snapshot). */
44
+ readonly blocks: ConvBlock[];
45
+ /** Feed one engine event. Returns true for a terminal event (`done`/`error`), so lanes can break. */
46
+ push(ev: { type: string } & Record<string, any>): boolean;
47
+ /** Append arbitrary blocks (a lane's own notices, e.g. the max-turns hint). */
48
+ add(block: ConvBlock): void;
49
+ /** Flush trailing thinking/text and return the finished block list. */
50
+ finish(): ConvBlock[];
51
+ /** Blocks as they stand, including a trailing in-progress thinking/text — for live partials. */
52
+ snapshot(): ConvBlock[];
53
+ /** Set once a `done` event arrives. */
54
+ stopReason: string;
55
+ }
56
+
57
+ export function createTurnAccumulator(hooks: TurnStreamHooks = {}): TurnAccumulator {
58
+ const blocks: ConvBlock[] = [];
59
+ const max = hooks.maxResultChars ?? Infinity;
60
+ let text = '';
61
+ let thinking = '';
62
+
63
+ const flushThinking = () => { if (thinking) { blocks.push({ type: 'thinking', text: thinking }); thinking = ''; } };
64
+ const flushText = () => { if (text) { blocks.push({ type: 'text', text }); text = ''; } };
65
+ const delta = (ev: StreamDelta) => hooks.onDelta?.(ev);
66
+
67
+ const acc: TurnAccumulator = {
68
+ blocks,
69
+ stopReason: '',
70
+ add(block) { flushThinking(); flushText(); blocks.push(block); },
71
+ push(ev) {
72
+ switch (ev.type) {
73
+ case 'thinking_delta':
74
+ thinking += ev.text;
75
+ delta({ type: 'thinking_delta', text: ev.text });
76
+ return false;
77
+ case 'text_delta':
78
+ flushThinking();
79
+ text += ev.text;
80
+ delta({ type: 'text_delta', text: ev.text });
81
+ return false;
82
+ case 'tool_use':
83
+ flushThinking(); flushText();
84
+ blocks.push({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
85
+ delta({ type: 'tool_use', tool: ev.tool, toolUseId: ev.toolUseId, input: ev.input });
86
+ return false;
87
+ case 'tool_use_input': {
88
+ // The claude-code engine opens a tool_use with an EMPTY input and fills it here. A lane
89
+ // that drops this renders pills labelled with nothing.
90
+ const open = blocks.find((b) => b.type === 'tool_use' && b.toolUseId === ev.toolUseId) as { input?: unknown } | undefined;
91
+ if (open) open.input = ev.input;
92
+ delta({ type: 'tool_use_input', toolUseId: ev.toolUseId, input: ev.input });
93
+ return false;
94
+ }
95
+ case 'tool_result':
96
+ blocks.push({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output });
97
+ delta({ type: 'tool_result', toolUseId: ev.toolUseId, output: ev.output.length > max ? ev.output.slice(0, max) + '…' : ev.output });
98
+ return false;
99
+ case 'tool_result_image':
100
+ blocks.push({ type: 'image', src: ev.dataUrl });
101
+ delta({ type: 'tool_result_image', toolUseId: ev.toolUseId, dataUrl: ev.dataUrl });
102
+ return false;
103
+ case 'done':
104
+ acc.stopReason = ev.stopReason ?? 'end_turn';
105
+ return true;
106
+ case 'error':
107
+ flushThinking(); flushText();
108
+ blocks.push({ type: 'error', text: ev.message });
109
+ acc.stopReason = 'error';
110
+ return true;
111
+ default:
112
+ // Not core-owned. An add-on engine's event (subagent pills, worker cards) — forward it
113
+ // verbatim rather than discarding it, and leave the transcript to whoever owns it.
114
+ if (!TURN_CONTROL_EVENTS.has(ev.type)) hooks.onPassthrough?.(ev);
115
+ return false;
116
+ }
117
+ },
118
+ finish() { flushThinking(); flushText(); return blocks; },
119
+ snapshot() {
120
+ return [
121
+ ...blocks,
122
+ ...(thinking ? [{ type: 'thinking' as const, text: thinking }] : []),
123
+ ...(text ? [{ type: 'text' as const, text }] : []),
124
+ ];
125
+ },
126
+ };
127
+ return acc;
128
+ }