shraga 0.1.96 → 0.1.97
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.
- package/dist/client/assets/{index-CJDfTuzn.css → index-DlgCLI89.css} +1 -1
- package/dist/client/assets/{index-519evGGj.js → index-QkJewVCL.js} +237 -237
- package/dist/client/index.html +2 -2
- package/package.json +1 -1
- package/src/client/components/ChatView.tsx +2 -5
- package/src/client/components/Sidebar.tsx +20 -1
- package/src/client/hooks/useConversation.ts +2 -0
- package/src/client/lib/build-version.ts +7 -0
- package/src/client/lib/tool-preview.ts +28 -0
- package/src/client/lib/ws.ts +1 -1
- package/src/client/vite-env.d.ts +9 -0
- package/src/server/boot.ts +28 -3
- package/src/server/claude.ts +11 -30
- package/src/server/slack/bot.ts +18 -31
- package/src/server/turn-stream.ts +128 -0
package/dist/client/index.html
CHANGED
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
14
14
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
15
15
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
|
|
16
|
-
<script type="module" crossorigin src="/assets/index-
|
|
17
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
16
|
+
<script type="module" crossorigin src="/assets/index-QkJewVCL.js"></script>
|
|
17
|
+
<link rel="stylesheet" crossorigin href="/assets/index-DlgCLI89.css">
|
|
18
18
|
</head>
|
|
19
19
|
<body>
|
|
20
20
|
<div id="root"></div>
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "shraga",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.97",
|
|
4
4
|
"description": "The teammate you delegate coding to — a self-hostable, multi-user AI coding agent web UI (Claude Code, with a pluggable engine seam).",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./src/index.ts",
|
|
@@ -23,6 +23,7 @@ const rehypeBidi: Plugin<[], Root> = () => (tree) => {
|
|
|
23
23
|
import 'highlight.js/styles/github.css';
|
|
24
24
|
import { ChevronRight, Wrench, User, Bot, Copy, Check, RotateCcw, Pencil, X, SendHorizontal, ShieldQuestion, CheckCircle2, XCircle, Eye, EyeOff, Info, Loader2, BrainCircuit, GitFork, Minimize2 } from 'lucide-react';
|
|
25
25
|
import { cn } from '@/lib/utils';
|
|
26
|
+
import { toolPreview } from '@/lib/tool-preview';
|
|
26
27
|
import { annotateLimitReset } from '@/lib/limit-reset';
|
|
27
28
|
import type { ChatMessage, MessageBlock, Attachment } from '@/hooks/useConversation';
|
|
28
29
|
import type { AskQuestion, QuestionAnswers } from '@/lib/ws';
|
|
@@ -622,11 +623,7 @@ function ToolUseBlock({ block, tool, input, result, busy, screenMap }: { block:
|
|
|
622
623
|
const [expanded, setExpanded] = useState(false);
|
|
623
624
|
const inputStr = typeof input === 'string' ? input : JSON.stringify(input, null, 2);
|
|
624
625
|
const isEmpty = typeof input === 'object' && input !== null && Object.keys(input as object).length === 0;
|
|
625
|
-
const preview = isEmpty
|
|
626
|
-
? ''
|
|
627
|
-
: typeof input === 'object' && input !== null
|
|
628
|
-
? Object.keys(input as object).join(', ')
|
|
629
|
-
: String(input).slice(0, 60);
|
|
626
|
+
const preview = isEmpty ? '' : toolPreview(input);
|
|
630
627
|
|
|
631
628
|
const resultTrimmed = result != null ? stripLineNumbers(result.trim().replace(/\[Image #\d+\]\s*/g, '').trim()) : undefined;
|
|
632
629
|
const hasResult = result != null;
|
|
@@ -9,6 +9,7 @@ import { MachineStats } from './MachineStats';
|
|
|
9
9
|
import type { UnreadSession } from '@/hooks/useUnread';
|
|
10
10
|
import type { AgentSocket } from '@/lib/ws';
|
|
11
11
|
import { useSessionList, type SessionRow as Session, type ChatsFilter } from '@/hooks/useSessionList';
|
|
12
|
+
import { CLIENT_BUILD_VERSION } from '@/lib/build-version';
|
|
12
13
|
|
|
13
14
|
interface Props {
|
|
14
15
|
getToken: () => Promise<string | null>;
|
|
@@ -123,6 +124,14 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
123
124
|
getToken().then(t => t ? fetch('/api/version', { headers: { Authorization: `Bearer ${t}` } }) : null).then(r => r?.json()).then(d => d && setVersion(d.version)).catch(() => {});
|
|
124
125
|
}, []);
|
|
125
126
|
|
|
127
|
+
// Version skew is SILENT otherwise: a `dist/client` left stale by a deploy keeps serving a client
|
|
128
|
+
// written against an older API shape, which discards the new response and renders an empty list —
|
|
129
|
+
// no error, no log (the list routes are quiet on 200). Surface it instead of debugging a ghost.
|
|
130
|
+
const stale = !!version && version !== 'unknown' && CLIENT_BUILD_VERSION !== 'dev' && version !== CLIENT_BUILD_VERSION;
|
|
131
|
+
useEffect(() => {
|
|
132
|
+
if (stale) console.warn(`[shraga] stale client bundle: built from v${CLIENT_BUILD_VERSION}, server runs v${version} — rebuild dist/client`);
|
|
133
|
+
}, [stale, version]);
|
|
134
|
+
|
|
126
135
|
function renderRow(s: Session) {
|
|
127
136
|
const unread = unreads[s.sessionId];
|
|
128
137
|
const isBusy = busySessions.has(s.sessionId) || s.runStatus === 'running' || s.scheduleRunStatus === 'running';
|
|
@@ -218,7 +227,17 @@ export function Sidebar({ getToken, activeSessionId, onSelect, onNew, refreshKey
|
|
|
218
227
|
<MachineStats socket={socket ?? null} getToken={getToken} />
|
|
219
228
|
{slots.sidebarExtras?.()}
|
|
220
229
|
{version && (
|
|
221
|
-
|
|
230
|
+
stale ? (
|
|
231
|
+
<button
|
|
232
|
+
onClick={() => location.reload()}
|
|
233
|
+
title={`This page was built from v${CLIENT_BUILD_VERSION}, the server runs v${version} — reload to update`}
|
|
234
|
+
className="text-[10px] text-amber-400 hover:text-amber-300 text-center underline decoration-dotted"
|
|
235
|
+
>
|
|
236
|
+
⚠️ stale page (v{CLIENT_BUILD_VERSION} → v{version}) · reload
|
|
237
|
+
</button>
|
|
238
|
+
) : (
|
|
239
|
+
<div className="text-[10px] text-muted-foreground/50 text-center">v{version}</div>
|
|
240
|
+
)
|
|
222
241
|
)}
|
|
223
242
|
</div>
|
|
224
243
|
</div>
|
|
@@ -391,6 +391,8 @@ function applyStream(setMessages: Dispatch<SetStateAction<ChatMessage[]>>, event
|
|
|
391
391
|
return setMessages((prev) =>
|
|
392
392
|
appendToAssistant(prev, { type: 'tool_result', toolUseId: event.toolUseId, output: event.output }),
|
|
393
393
|
);
|
|
394
|
+
case 'tool_result_image':
|
|
395
|
+
return setMessages((prev) => appendToAssistant(prev, { type: 'image', src: event.dataUrl }));
|
|
394
396
|
}
|
|
395
397
|
}
|
|
396
398
|
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The shraga version this client bundle was built from (stamped by vite's `define`).
|
|
3
|
+
* Falls back to 'dev' wherever the define is absent (tests, an embedder's own vite config), which
|
|
4
|
+
* disables the skew check rather than reporting a false mismatch.
|
|
5
|
+
*/
|
|
6
|
+
export const CLIENT_BUILD_VERSION: string =
|
|
7
|
+
typeof __SHRAGA_CLIENT_VERSION__ === 'string' ? __SHRAGA_CLIENT_VERSION__ : 'dev';
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one-line preview on a collapsed tool pill. Shows the ARGUMENT that identifies the call — the
|
|
3
|
+
* command, the path, the pattern — not the argument NAMES. Listing keys (`command, background,
|
|
4
|
+
* description`) made every Bash pill identical, so a real operation (dispatching a worker, running a
|
|
5
|
+
* build) was indistinguishable from any other and read as "no tool calls shown at all".
|
|
6
|
+
* Order is by how well a field identifies the call; anything unrecognised falls back to the first
|
|
7
|
+
* usable string value, then to the key list.
|
|
8
|
+
*/
|
|
9
|
+
const PREVIEW_KEYS = ['command', 'pattern', 'glob_pattern', 'file_path', 'path', 'url', 'query', 'prompt', 'id', 'description', 'name', 'text'];
|
|
10
|
+
|
|
11
|
+
export function toolPreview(input: unknown, max = 120): string {
|
|
12
|
+
if (typeof input === 'string') return oneLine(input, max);
|
|
13
|
+
if (typeof input !== 'object' || input === null) return String(input).slice(0, max);
|
|
14
|
+
const obj = input as Record<string, unknown>;
|
|
15
|
+
for (const k of PREVIEW_KEYS) {
|
|
16
|
+
const v = obj[k];
|
|
17
|
+
if (typeof v === 'string' && v.trim()) return oneLine(v, max);
|
|
18
|
+
}
|
|
19
|
+
const first = Object.values(obj).find((v) => typeof v === 'string' && v.trim());
|
|
20
|
+
if (typeof first === 'string') return oneLine(first, max);
|
|
21
|
+
return Object.keys(obj).join(', ');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function oneLine(s: string, max: number): string {
|
|
25
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
26
|
+
return flat.length > max ? flat.slice(0, max) + '…' : flat;
|
|
27
|
+
}
|
|
28
|
+
|
package/src/client/lib/ws.ts
CHANGED
|
@@ -40,7 +40,7 @@ export type ServerEvent =
|
|
|
40
40
|
| { type: 'session_title_updated'; sessionId: string; title: string }
|
|
41
41
|
| { type: 'directives'; directives: { model?: string; turns?: number; thinking?: string; engine?: string } }
|
|
42
42
|
| { type: 'session_busy'; sessionId: string; busy: boolean }
|
|
43
|
-
| { type: 'session_stream'; sessionId: string; event: { type: 'thinking_delta'; text: string } | { type: 'text_delta'; text: string } | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown } | { type: 'tool_use_input'; toolUseId: string; input: unknown } | { type: 'tool_result'; toolUseId: string; output: string } }
|
|
43
|
+
| { type: 'session_stream'; sessionId: string; event: { type: 'thinking_delta'; text: string } | { type: 'text_delta'; text: string } | { type: 'tool_use'; tool: string; toolUseId: string; input: unknown } | { type: 'tool_use_input'; toolUseId: string; input: unknown } | { type: 'tool_result'; toolUseId: string; output: string } | { type: 'tool_result_image'; toolUseId: string; dataUrl: string } }
|
|
44
44
|
| { type: 'artifact'; id: string; sessionId: string; title: string; dimensions: [number, number]; version: number }
|
|
45
45
|
| { type: 'server_restarting' }
|
|
46
46
|
| { type: 'unread'; sessionId: string; count: number; preview: string; source: 'response' | 'proactive' | 'schedule'; title?: string }
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/// <reference types="vite/client" />
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The shraga version the CLIENT BUNDLE was built from, stamped by vite at build time.
|
|
5
|
+
* Compared against the server's `/api/version` at runtime — a mismatch means `dist/client` is
|
|
6
|
+
* stale relative to the running server, which fails SILENTLY (a client written against an older
|
|
7
|
+
* API shape just discards the response). See the version-skew banner in Sidebar.
|
|
8
|
+
*/
|
|
9
|
+
declare const __SHRAGA_CLIENT_VERSION__: string;
|
package/src/server/boot.ts
CHANGED
|
@@ -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
|
-
|
|
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) {
|
package/src/server/claude.ts
CHANGED
|
@@ -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(
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
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
|
|
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
|
-
|
|
444
|
-
if (text) blocks.push({ type: 'text', text });
|
|
445
|
-
return blocks;
|
|
426
|
+
return acc.finish();
|
|
446
427
|
}
|
|
447
428
|
|
package/src/server/slack/bot.ts
CHANGED
|
@@ -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
|
-
|
|
76
|
-
|
|
77
|
-
const
|
|
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,
|
|
81
|
-
partialInterval = setInterval(() => { const b =
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|