opencandle 0.7.0 → 0.9.0
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/README.md +34 -21
- package/dist/cli-main.d.ts +1 -0
- package/dist/cli-main.js +224 -0
- package/dist/cli-main.js.map +1 -0
- package/dist/cli.d.ts +1 -1
- package/dist/cli.js +13 -229
- package/dist/cli.js.map +1 -1
- package/dist/doctor/cli-command.d.ts +1 -0
- package/dist/doctor/cli-command.js +69 -0
- package/dist/doctor/cli-command.js.map +1 -0
- package/dist/doctor/render.d.ts +2 -0
- package/dist/doctor/render.js +32 -0
- package/dist/doctor/render.js.map +1 -0
- package/dist/doctor/report.d.ts +63 -0
- package/dist/doctor/report.js +500 -0
- package/dist/doctor/report.js.map +1 -0
- package/dist/infra/native-dependencies.d.ts +8 -0
- package/dist/infra/native-dependencies.js +49 -0
- package/dist/infra/native-dependencies.js.map +1 -1
- package/dist/infra/node-version.js +2 -5
- package/dist/infra/node-version.js.map +1 -1
- package/dist/monitor.d.ts +1 -1
- package/dist/monitor.js +2 -1
- package/dist/monitor.js.map +1 -1
- package/dist/onboarding/provider-status.d.ts +3 -5
- package/dist/onboarding/provider-status.js +5 -34
- package/dist/onboarding/provider-status.js.map +1 -1
- package/dist/pi/session-writer-lock.d.ts +30 -0
- package/dist/pi/session-writer-lock.js +111 -0
- package/dist/pi/session-writer-lock.js.map +1 -0
- package/dist/pi/session.d.ts +0 -1
- package/dist/pi/session.js +2 -1
- package/dist/pi/session.js.map +1 -1
- package/dist/prompts/policy-cards.js +8 -4
- package/dist/prompts/policy-cards.js.map +1 -1
- package/dist/prompts/workflow-prompts.js +25 -6
- package/dist/prompts/workflow-prompts.js.map +1 -1
- package/dist/providers/external-tool-command.d.ts +17 -0
- package/dist/providers/external-tool-command.js +114 -0
- package/dist/providers/external-tool-command.js.map +1 -0
- package/dist/providers/reddit-cli.d.ts +2 -6
- package/dist/providers/reddit-cli.js +4 -33
- package/dist/providers/reddit-cli.js.map +1 -1
- package/dist/providers/twitter-cli.d.ts +2 -6
- package/dist/providers/twitter-cli.js +4 -33
- package/dist/providers/twitter-cli.js.map +1 -1
- package/dist/routing/classify-intent.js +15 -12
- package/dist/routing/classify-intent.js.map +1 -1
- package/dist/routing/entity-extractor.js +1 -1
- package/dist/routing/entity-extractor.js.map +1 -1
- package/dist/routing/planning.js +9 -6
- package/dist/routing/planning.js.map +1 -1
- package/dist/routing/router-llm-client.d.ts +1 -1
- package/dist/routing/router-llm-client.js +1 -1
- package/dist/routing/router-llm-client.js.map +1 -1
- package/dist/routing/router.js +44 -3
- package/dist/routing/router.js.map +1 -1
- package/dist/workflows/compare-assets.js +4 -1
- package/dist/workflows/compare-assets.js.map +1 -1
- package/gui/server/ask-user-bridge.ts +26 -19
- package/gui/server/chat-event-adapter.ts +49 -3
- package/gui/server/http-routes.ts +577 -0
- package/gui/server/invoke-tool.ts +102 -7
- package/gui/server/live-chat-event-adapter.ts +16 -4
- package/gui/server/server.ts +70 -296
- package/gui/server/session-actions.ts +7 -1
- package/gui/server/session-entry-wait.ts +79 -0
- package/gui/server/writer-lock.ts +1 -122
- package/gui/server/ws-hub.ts +25 -0
- package/gui/shared/chat-events.ts +42 -19
- package/gui/shared/event-reducer.ts +41 -26
- package/gui/web/dist/assets/CatalogOverlay-DcTXLc7u.js +1 -0
- package/gui/web/dist/assets/index-DdMnIvZ7.css +2 -0
- package/gui/web/dist/assets/index-YlMaahAS.js +65 -0
- package/gui/web/dist/index.html +2 -2
- package/package.json +23 -19
- package/src/cli-main.ts +270 -0
- package/src/cli.ts +14 -267
- package/src/doctor/cli-command.ts +83 -0
- package/src/doctor/render.ts +37 -0
- package/src/doctor/report.ts +638 -0
- package/src/infra/native-dependencies.ts +67 -0
- package/src/infra/node-version.ts +2 -5
- package/src/monitor.ts +3 -1
- package/src/onboarding/provider-status.ts +10 -38
- package/src/pi/session-writer-lock.ts +156 -0
- package/src/pi/session.ts +2 -1
- package/src/prompts/policy-cards.ts +12 -4
- package/src/prompts/workflow-prompts.ts +28 -6
- package/src/providers/external-tool-command.ts +164 -0
- package/src/providers/reddit-cli.ts +10 -41
- package/src/providers/twitter-cli.ts +10 -41
- package/src/routing/classify-intent.ts +22 -17
- package/src/routing/entity-extractor.ts +1 -1
- package/src/routing/planning.ts +15 -10
- package/src/routing/router-llm-client.ts +2 -1
- package/src/routing/router.ts +57 -6
- package/src/workflows/compare-assets.ts +4 -2
- package/gui/web/dist/assets/CatalogOverlay-CgeY5Pkp.js +0 -1
- package/gui/web/dist/assets/index-C6W_2eAn.js +0 -69
- package/gui/web/dist/assets/index-hwbx24a5.css +0 -1
|
@@ -2,7 +2,11 @@ import { unlink } from "node:fs/promises";
|
|
|
2
2
|
import { type AgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
|
|
3
3
|
import type { ModelSetupState } from "./model-setup.js";
|
|
4
4
|
import { type PromptObservation, selectReplayPrompt } from "./prompt-observation.js";
|
|
5
|
-
import {
|
|
5
|
+
import {
|
|
6
|
+
waitForNewEntryId,
|
|
7
|
+
waitForResolvedToolCalls,
|
|
8
|
+
waitForSessionTurnSettlement,
|
|
9
|
+
} from "./session-entry-wait.js";
|
|
6
10
|
|
|
7
11
|
interface AskUserBridge {
|
|
8
12
|
answer(id: string, answer: string): boolean;
|
|
@@ -164,6 +168,7 @@ export async function promptAndSettle(
|
|
|
164
168
|
() => runSession.sessionManager.getEntries().map((entry) => entry.id),
|
|
165
169
|
beforeIds,
|
|
166
170
|
);
|
|
171
|
+
await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
|
|
167
172
|
await replayObservedWorkflowPromptIfNeeded(runSession, prompt, observation);
|
|
168
173
|
}
|
|
169
174
|
|
|
@@ -184,6 +189,7 @@ export async function replayObservedWorkflowPromptIfNeeded(
|
|
|
184
189
|
isStreaming: runSession.isStreaming,
|
|
185
190
|
pendingMessageCount: runSession.pendingMessageCount,
|
|
186
191
|
}));
|
|
192
|
+
await waitForResolvedToolCalls(() => runSession.sessionManager.getEntries());
|
|
187
193
|
}
|
|
188
194
|
|
|
189
195
|
export async function renameSessionFile(
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { SessionEntry } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
1
3
|
export interface WaitForEntryCountOptions {
|
|
2
4
|
timeoutMs?: number;
|
|
3
5
|
intervalMs?: number;
|
|
@@ -12,6 +14,11 @@ export interface SessionRunStatus {
|
|
|
12
14
|
pendingMessageCount: number;
|
|
13
15
|
}
|
|
14
16
|
|
|
17
|
+
export interface UnresolvedToolCall {
|
|
18
|
+
id: string;
|
|
19
|
+
name: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
15
22
|
export async function waitForEntryCount(
|
|
16
23
|
getCount: () => number,
|
|
17
24
|
previousCount: number,
|
|
@@ -76,6 +83,78 @@ export async function waitForSessionTurnSettlement(
|
|
|
76
83
|
throw new Error("Timed out waiting for the session turn to settle");
|
|
77
84
|
}
|
|
78
85
|
|
|
86
|
+
export function findUnresolvedToolCalls(entries: SessionEntry[]): UnresolvedToolCall[] {
|
|
87
|
+
const latestAssistantIndex = latestAssistantToolUseIndex(entries);
|
|
88
|
+
if (latestAssistantIndex === -1) return [];
|
|
89
|
+
|
|
90
|
+
const assistant = asMessage(entries[latestAssistantIndex]);
|
|
91
|
+
const calls = toolCallsFromContent(assistant?.content);
|
|
92
|
+
if (calls.length === 0) return [];
|
|
93
|
+
|
|
94
|
+
const resolved = new Set<string>();
|
|
95
|
+
for (const entry of entries.slice(latestAssistantIndex + 1)) {
|
|
96
|
+
const message = asMessage(entry);
|
|
97
|
+
if (message?.role !== "toolResult") continue;
|
|
98
|
+
const toolCallId = typeof message.toolCallId === "string" ? message.toolCallId : "";
|
|
99
|
+
if (toolCallId) resolved.add(toolCallId);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return calls.filter((call) => !resolved.has(call.id));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function waitForResolvedToolCalls(
|
|
106
|
+
getEntries: () => SessionEntry[],
|
|
107
|
+
options: WaitForEntryCountOptions = {},
|
|
108
|
+
): Promise<void> {
|
|
109
|
+
const timeoutMs = options.timeoutMs ?? 120_000;
|
|
110
|
+
const intervalMs = options.intervalMs ?? 25;
|
|
111
|
+
const deadline = Date.now() + timeoutMs;
|
|
112
|
+
|
|
113
|
+
while (Date.now() < deadline) {
|
|
114
|
+
if (findUnresolvedToolCalls(getEntries()).length === 0) return;
|
|
115
|
+
await delay(intervalMs);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const unresolved = findUnresolvedToolCalls(getEntries());
|
|
119
|
+
if (unresolved.length > 0) {
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Timed out waiting for tool results: ${unresolved.map((call) => call.name).join(", ")}`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
79
126
|
function delay(ms: number): Promise<void> {
|
|
80
127
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
81
128
|
}
|
|
129
|
+
|
|
130
|
+
function latestAssistantToolUseIndex(entries: SessionEntry[]): number {
|
|
131
|
+
for (let index = entries.length - 1; index >= 0; index--) {
|
|
132
|
+
const message = asMessage(entries[index]);
|
|
133
|
+
if (message?.role === "assistant") {
|
|
134
|
+
if (toolCallsFromContent(message.content).length > 0) return index;
|
|
135
|
+
if (message.stopReason === "stop") return -1;
|
|
136
|
+
}
|
|
137
|
+
if (message?.role === "user") return -1;
|
|
138
|
+
}
|
|
139
|
+
return -1;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function asMessage(entry: SessionEntry | undefined): Record<string, unknown> | undefined {
|
|
143
|
+
if (entry?.type !== "message") return undefined;
|
|
144
|
+
const message = (entry as { message?: unknown }).message;
|
|
145
|
+
return typeof message === "object" && message !== null && !Array.isArray(message)
|
|
146
|
+
? (message as Record<string, unknown>)
|
|
147
|
+
: undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function toolCallsFromContent(content: unknown): UnresolvedToolCall[] {
|
|
151
|
+
if (!Array.isArray(content)) return [];
|
|
152
|
+
return content.flatMap((part): UnresolvedToolCall[] => {
|
|
153
|
+
if (typeof part !== "object" || part === null || Array.isArray(part)) return [];
|
|
154
|
+
const record = part as Record<string, unknown>;
|
|
155
|
+
if (record.type !== "toolCall") return [];
|
|
156
|
+
const id = typeof record.id === "string" ? record.id : "";
|
|
157
|
+
const name = typeof record.name === "string" ? record.name : "tool";
|
|
158
|
+
return id ? [{ id, name }] : [];
|
|
159
|
+
});
|
|
160
|
+
}
|
|
@@ -1,122 +1 @@
|
|
|
1
|
-
|
|
2
|
-
import { join } from "node:path";
|
|
3
|
-
|
|
4
|
-
export type ProcessKind = "tui" | "gui";
|
|
5
|
-
|
|
6
|
-
export interface WriterLock {
|
|
7
|
-
pid: number;
|
|
8
|
-
processKind: ProcessKind;
|
|
9
|
-
acquiredAt: string;
|
|
10
|
-
lastHeartbeat: string;
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
export interface AcquireOptions {
|
|
14
|
-
pid?: number;
|
|
15
|
-
staleGraceMs?: number;
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export type AcquireResult =
|
|
19
|
-
| { role: "writer"; lock: WriterLock }
|
|
20
|
-
| { role: "follower"; lock: WriterLock };
|
|
21
|
-
|
|
22
|
-
const DEFAULT_STALE_GRACE_MS = 15_000;
|
|
23
|
-
|
|
24
|
-
export async function acquireWriterLock(
|
|
25
|
-
sessionDir: string,
|
|
26
|
-
processKind: ProcessKind,
|
|
27
|
-
options: AcquireOptions = {},
|
|
28
|
-
): Promise<AcquireResult> {
|
|
29
|
-
mkdirSync(sessionDir, { recursive: true });
|
|
30
|
-
const pid = options.pid ?? process.pid;
|
|
31
|
-
const staleGraceMs = options.staleGraceMs ?? DEFAULT_STALE_GRACE_MS;
|
|
32
|
-
|
|
33
|
-
const created = tryCreate(sessionDir, processKind, pid);
|
|
34
|
-
if (created) return { role: "writer", lock: created };
|
|
35
|
-
|
|
36
|
-
const existing = readWriterLock(sessionDir);
|
|
37
|
-
if (existing && isLockCurrent(existing, staleGraceMs)) {
|
|
38
|
-
return { role: "follower", lock: existing };
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
await sleep(staleGraceMs);
|
|
42
|
-
const afterGrace = readWriterLock(sessionDir);
|
|
43
|
-
if (afterGrace && isLockCurrent(afterGrace, staleGraceMs)) {
|
|
44
|
-
return { role: "follower", lock: afterGrace };
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
try {
|
|
48
|
-
unlinkSync(lockPath(sessionDir));
|
|
49
|
-
} catch {
|
|
50
|
-
// Missing or concurrently removed is fine; the next create decides ownership.
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
const recovered = tryCreate(sessionDir, processKind, pid);
|
|
54
|
-
if (recovered) return { role: "writer", lock: recovered };
|
|
55
|
-
|
|
56
|
-
const current = readWriterLock(sessionDir) ?? afterGrace ?? existing;
|
|
57
|
-
if (!current) throw new Error("Unable to determine active writer lock");
|
|
58
|
-
return { role: "follower", lock: current };
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export function readWriterLock(sessionDir: string): WriterLock | null {
|
|
62
|
-
try {
|
|
63
|
-
return JSON.parse(readFileSync(lockPath(sessionDir), "utf8")) as WriterLock;
|
|
64
|
-
} catch {
|
|
65
|
-
return null;
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function refreshWriterLock(sessionDir: string, pid = process.pid): void {
|
|
70
|
-
const lock = readWriterLock(sessionDir);
|
|
71
|
-
if (!lock || lock.pid !== pid) return;
|
|
72
|
-
writeFileSync(
|
|
73
|
-
lockPath(sessionDir),
|
|
74
|
-
JSON.stringify({ ...lock, lastHeartbeat: new Date().toISOString() }, null, 2),
|
|
75
|
-
);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export function releaseWriterLock(sessionDir: string, pid = process.pid): void {
|
|
79
|
-
const lock = readWriterLock(sessionDir);
|
|
80
|
-
if (!lock || lock.pid !== pid) return;
|
|
81
|
-
try {
|
|
82
|
-
unlinkSync(lockPath(sessionDir));
|
|
83
|
-
} catch {
|
|
84
|
-
// Best effort shutdown cleanup.
|
|
85
|
-
}
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
function tryCreate(sessionDir: string, processKind: ProcessKind, pid: number): WriterLock | null {
|
|
89
|
-
const now = new Date().toISOString();
|
|
90
|
-
const lock: WriterLock = { pid, processKind, acquiredAt: now, lastHeartbeat: now };
|
|
91
|
-
try {
|
|
92
|
-
const fd = openSync(lockPath(sessionDir), "wx");
|
|
93
|
-
writeFileSync(fd, JSON.stringify(lock, null, 2));
|
|
94
|
-
return lock;
|
|
95
|
-
} catch {
|
|
96
|
-
return null;
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
function isPidAlive(pid: number): boolean {
|
|
101
|
-
try {
|
|
102
|
-
process.kill(pid, 0);
|
|
103
|
-
return true;
|
|
104
|
-
} catch {
|
|
105
|
-
return false;
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function isLockCurrent(lock: WriterLock, staleGraceMs: number): boolean {
|
|
110
|
-
const heartbeat = Date.parse(lock.lastHeartbeat);
|
|
111
|
-
return (
|
|
112
|
-
isPidAlive(lock.pid) && Number.isFinite(heartbeat) && Date.now() - heartbeat <= staleGraceMs
|
|
113
|
-
);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function lockPath(sessionDir: string): string {
|
|
117
|
-
return join(sessionDir, "writer.lock");
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
function sleep(ms: number): Promise<void> {
|
|
121
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
122
|
-
}
|
|
1
|
+
export * from "../../src/pi/session-writer-lock.js";
|
package/gui/server/ws-hub.ts
CHANGED
|
@@ -35,6 +35,7 @@ export interface WsHub {
|
|
|
35
35
|
broadcast(message: unknown): void;
|
|
36
36
|
broadcastModelSetup(): void;
|
|
37
37
|
broadcastState(): void;
|
|
38
|
+
broadcastSessionSnapshot(sessionManager: SessionManager): void;
|
|
38
39
|
broadcastSessions(): void;
|
|
39
40
|
buildStateSnapshot(): {
|
|
40
41
|
sessionId: string;
|
|
@@ -251,6 +252,13 @@ export function createWsHub({
|
|
|
251
252
|
});
|
|
252
253
|
}
|
|
253
254
|
|
|
255
|
+
function broadcastSessionSnapshot(sessionManager: SessionManager): void {
|
|
256
|
+
broadcast({
|
|
257
|
+
type: "session.snapshot",
|
|
258
|
+
...buildSnapshotForSession(sessionManager),
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
|
|
254
262
|
function buildStateSnapshot() {
|
|
255
263
|
const sessionManager = getSessionManager();
|
|
256
264
|
const sessionId = sessionManager.getSessionId();
|
|
@@ -263,11 +271,27 @@ export function createWsHub({
|
|
|
263
271
|
};
|
|
264
272
|
}
|
|
265
273
|
|
|
274
|
+
function buildSnapshotForSession(sessionManager: SessionManager) {
|
|
275
|
+
const sessionId = sessionManager.getSessionId();
|
|
276
|
+
const entries = sessionManager.getEntries();
|
|
277
|
+
return {
|
|
278
|
+
sessionId,
|
|
279
|
+
state: projectDashboard(backgroundQuoteRefreshes.withEntries(entries), sessionId),
|
|
280
|
+
entries,
|
|
281
|
+
events: sessionEntriesToChatEvents(entries, {
|
|
282
|
+
sessionId,
|
|
283
|
+
title: sessionManager.getSessionName(),
|
|
284
|
+
}),
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
266
288
|
function currentChatEvents(entries = getSessionManager().getEntries()): ChatEvent[] {
|
|
267
289
|
const sessionManager = getSessionManager();
|
|
290
|
+
const session = getSession();
|
|
268
291
|
return sessionEntriesToChatEvents(entries, {
|
|
269
292
|
sessionId: sessionManager.getSessionId(),
|
|
270
293
|
title: sessionManager.getSessionName(),
|
|
294
|
+
markUnresolvedToolCalls: !(session.isStreaming || session.pendingMessageCount > 0),
|
|
271
295
|
});
|
|
272
296
|
}
|
|
273
297
|
|
|
@@ -297,6 +321,7 @@ export function createWsHub({
|
|
|
297
321
|
broadcast,
|
|
298
322
|
broadcastModelSetup,
|
|
299
323
|
broadcastState,
|
|
324
|
+
broadcastSessionSnapshot,
|
|
300
325
|
broadcastSessions,
|
|
301
326
|
buildStateSnapshot,
|
|
302
327
|
currentChatEvents,
|
|
@@ -44,37 +44,56 @@ export interface Usage {
|
|
|
44
44
|
cost?: unknown;
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
interface ChatEventBase {
|
|
48
|
+
sessionId: string;
|
|
49
|
+
seq: number;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
interface RunScopedChatEventBase extends ChatEventBase {
|
|
53
|
+
runId: string;
|
|
54
|
+
}
|
|
55
|
+
|
|
47
56
|
export type ChatEvent =
|
|
48
|
-
| { type: "run.started"
|
|
49
|
-
| { type: "thinking.delta";
|
|
50
|
-
| { type: "thinking.completed";
|
|
51
|
-
| { type: "message.created"; messageId: string; role: ChatRole;
|
|
52
|
-
| { type: "message.delta"; messageId: string; text: string;
|
|
53
|
-
| {
|
|
54
|
-
|
|
57
|
+
| ({ type: "run.started" } & RunScopedChatEventBase)
|
|
58
|
+
| ({ type: "thinking.delta"; text: string } & RunScopedChatEventBase)
|
|
59
|
+
| ({ type: "thinking.completed"; text?: string } & RunScopedChatEventBase)
|
|
60
|
+
| ({ type: "message.created"; messageId: string; role: ChatRole; runId?: string } & ChatEventBase)
|
|
61
|
+
| ({ type: "message.delta"; messageId: string; text: string; runId?: string } & ChatEventBase)
|
|
62
|
+
| ({
|
|
63
|
+
type: "message.completed";
|
|
64
|
+
messageId: string;
|
|
65
|
+
content: MessageContent[];
|
|
66
|
+
runId?: string;
|
|
67
|
+
} & ChatEventBase)
|
|
68
|
+
| ({
|
|
55
69
|
type: "custom.message";
|
|
56
70
|
messageId: string;
|
|
57
71
|
customType: string;
|
|
58
72
|
content: MessageContent[];
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
| {
|
|
73
|
+
} & ChatEventBase)
|
|
74
|
+
| ({
|
|
62
75
|
type: "tool.started";
|
|
63
76
|
toolCallId: string;
|
|
64
77
|
messageId: string;
|
|
65
78
|
name: string;
|
|
66
79
|
input: unknown;
|
|
67
|
-
|
|
68
|
-
}
|
|
69
|
-
| { type: "tool.delta"; toolCallId: string; chunk: unknown;
|
|
70
|
-
| {
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
80
|
+
runId?: string;
|
|
81
|
+
} & ChatEventBase)
|
|
82
|
+
| ({ type: "tool.delta"; toolCallId: string; chunk: unknown; runId?: string } & ChatEventBase)
|
|
83
|
+
| ({
|
|
84
|
+
type: "tool.completed";
|
|
85
|
+
toolCallId: string;
|
|
86
|
+
output: ToolOutput;
|
|
87
|
+
runId?: string;
|
|
88
|
+
} & ChatEventBase)
|
|
89
|
+
| ({ type: "tool.failed"; toolCallId: string; error: ToolError; runId?: string } & ChatEventBase)
|
|
90
|
+
| ({ type: "run.completed"; usage?: Usage } & RunScopedChatEventBase)
|
|
91
|
+
| ({ type: "run.failed"; error: RunError } & RunScopedChatEventBase)
|
|
92
|
+
| ({ type: "session.updated"; title?: string; updatedAt: string } & ChatEventBase);
|
|
75
93
|
|
|
76
94
|
export interface RenderMessage {
|
|
77
95
|
id: string;
|
|
96
|
+
sessionId?: string;
|
|
78
97
|
role: ChatRole;
|
|
79
98
|
status: "streaming" | "completed";
|
|
80
99
|
content: MessageContent[];
|
|
@@ -84,6 +103,7 @@ export interface RenderMessage {
|
|
|
84
103
|
|
|
85
104
|
export interface RenderToolCall {
|
|
86
105
|
id: string;
|
|
106
|
+
sessionId?: string;
|
|
87
107
|
messageId: string;
|
|
88
108
|
name: string;
|
|
89
109
|
input: unknown;
|
|
@@ -95,6 +115,7 @@ export interface RenderToolCall {
|
|
|
95
115
|
|
|
96
116
|
export interface RenderThinking {
|
|
97
117
|
runId: string;
|
|
118
|
+
sessionId?: string;
|
|
98
119
|
status: "streaming" | "completed";
|
|
99
120
|
text: string;
|
|
100
121
|
}
|
|
@@ -109,7 +130,8 @@ export interface ChatRunState {
|
|
|
109
130
|
|
|
110
131
|
export interface ChatRenderState {
|
|
111
132
|
lastSeq: number;
|
|
112
|
-
seenSeq: Set<
|
|
133
|
+
seenSeq: Set<string>;
|
|
134
|
+
lastSeqBySession: Map<string, number>;
|
|
113
135
|
messages: RenderMessage[];
|
|
114
136
|
messageById: Map<string, RenderMessage>;
|
|
115
137
|
tools: Map<string, RenderToolCall>;
|
|
@@ -123,6 +145,7 @@ export function createChatRenderState(): ChatRenderState {
|
|
|
123
145
|
return {
|
|
124
146
|
lastSeq: 0,
|
|
125
147
|
seenSeq: new Set(),
|
|
148
|
+
lastSeqBySession: new Map(),
|
|
126
149
|
messages: [],
|
|
127
150
|
messageById: new Map(),
|
|
128
151
|
tools: new Map(),
|
|
@@ -8,23 +8,26 @@ import {
|
|
|
8
8
|
|
|
9
9
|
export function reduceChatEvents(events: ChatEvent[]): ChatRenderState {
|
|
10
10
|
return [...events]
|
|
11
|
-
.sort((a, b) => a.seq - b.seq)
|
|
11
|
+
.sort((a, b) => (a.sessionId === b.sessionId ? a.seq - b.seq : 0))
|
|
12
12
|
.reduce((state, event) => applyChatEvent(state, event), createChatRenderState());
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRenderState {
|
|
16
|
-
|
|
16
|
+
const eventKey = `${event.sessionId}:${event.seq}`;
|
|
17
|
+
if (state.seenSeq.has(eventKey)) return state;
|
|
17
18
|
|
|
18
19
|
const next = cloneState(state);
|
|
19
|
-
|
|
20
|
-
|
|
20
|
+
const sessionLastSeq = next.lastSeqBySession.get(event.sessionId) ?? 0;
|
|
21
|
+
if (event.seq > sessionLastSeq + 1 && sessionLastSeq !== 0) {
|
|
22
|
+
next.gaps.push({ expected: sessionLastSeq + 1, received: event.seq });
|
|
21
23
|
}
|
|
22
|
-
next.seenSeq.add(
|
|
24
|
+
next.seenSeq.add(eventKey);
|
|
25
|
+
next.lastSeqBySession.set(event.sessionId, Math.max(sessionLastSeq, event.seq));
|
|
23
26
|
next.lastSeq = Math.max(next.lastSeq, event.seq);
|
|
24
27
|
|
|
25
28
|
switch (event.type) {
|
|
26
29
|
case "run.started":
|
|
27
|
-
next.runs.set(event.runId, {
|
|
30
|
+
next.runs.set(scopedId(event.sessionId, event.runId), {
|
|
28
31
|
id: event.runId,
|
|
29
32
|
sessionId: event.sessionId,
|
|
30
33
|
status: "running",
|
|
@@ -33,25 +36,25 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
33
36
|
break;
|
|
34
37
|
|
|
35
38
|
case "thinking.delta": {
|
|
36
|
-
const thinking = ensureThinking(next, event.runId);
|
|
39
|
+
const thinking = ensureThinking(next, event.sessionId, event.runId);
|
|
37
40
|
thinking.status = "streaming";
|
|
38
41
|
thinking.text += event.text;
|
|
39
42
|
break;
|
|
40
43
|
}
|
|
41
44
|
|
|
42
45
|
case "thinking.completed": {
|
|
43
|
-
const thinking = ensureThinking(next, event.runId);
|
|
46
|
+
const thinking = ensureThinking(next, event.sessionId, event.runId);
|
|
44
47
|
thinking.status = "completed";
|
|
45
48
|
if (event.text !== undefined) thinking.text = event.text;
|
|
46
49
|
break;
|
|
47
50
|
}
|
|
48
51
|
|
|
49
52
|
case "message.created":
|
|
50
|
-
ensureMessage(next, event.messageId, event.role);
|
|
53
|
+
ensureMessage(next, event.sessionId, event.messageId, event.role);
|
|
51
54
|
break;
|
|
52
55
|
|
|
53
56
|
case "message.delta": {
|
|
54
|
-
const message = ensureMessage(next, event.messageId, "assistant");
|
|
57
|
+
const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
|
|
55
58
|
message.status = "streaming";
|
|
56
59
|
message.text += event.text;
|
|
57
60
|
message.content = [{ type: "text", text: message.text }];
|
|
@@ -59,7 +62,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
59
62
|
}
|
|
60
63
|
|
|
61
64
|
case "message.completed": {
|
|
62
|
-
const message = ensureMessage(next, event.messageId, "assistant");
|
|
65
|
+
const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
|
|
63
66
|
message.status = "completed";
|
|
64
67
|
message.content = event.content;
|
|
65
68
|
message.text = contentText(event.content);
|
|
@@ -67,7 +70,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
67
70
|
}
|
|
68
71
|
|
|
69
72
|
case "custom.message": {
|
|
70
|
-
const message = ensureMessage(next, event.messageId, "assistant");
|
|
73
|
+
const message = ensureMessage(next, event.sessionId, event.messageId, "assistant");
|
|
71
74
|
message.status = "completed";
|
|
72
75
|
message.content = event.content;
|
|
73
76
|
message.text = contentText(event.content);
|
|
@@ -76,9 +79,10 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
76
79
|
}
|
|
77
80
|
|
|
78
81
|
case "tool.started":
|
|
79
|
-
ensureMessage(next, event.messageId, "assistant");
|
|
80
|
-
next.tools.set(event.toolCallId, {
|
|
82
|
+
ensureMessage(next, event.sessionId, event.messageId, "assistant");
|
|
83
|
+
next.tools.set(scopedId(event.sessionId, event.toolCallId), {
|
|
81
84
|
id: event.toolCallId,
|
|
85
|
+
sessionId: event.sessionId,
|
|
82
86
|
messageId: event.messageId,
|
|
83
87
|
name: event.name,
|
|
84
88
|
input: event.input,
|
|
@@ -88,13 +92,13 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
88
92
|
break;
|
|
89
93
|
|
|
90
94
|
case "tool.delta": {
|
|
91
|
-
const tool = next.tools.get(event.toolCallId);
|
|
95
|
+
const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
|
|
92
96
|
if (tool) tool.chunks.push(event.chunk);
|
|
93
97
|
break;
|
|
94
98
|
}
|
|
95
99
|
|
|
96
100
|
case "tool.completed": {
|
|
97
|
-
const tool = next.tools.get(event.toolCallId);
|
|
101
|
+
const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
|
|
98
102
|
if (tool) {
|
|
99
103
|
tool.status = event.output.isError ? "failed" : "completed";
|
|
100
104
|
tool.output = event.output;
|
|
@@ -103,7 +107,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
103
107
|
}
|
|
104
108
|
|
|
105
109
|
case "tool.failed": {
|
|
106
|
-
const tool = next.tools.get(event.toolCallId);
|
|
110
|
+
const tool = next.tools.get(scopedId(event.sessionId, event.toolCallId));
|
|
107
111
|
if (tool) {
|
|
108
112
|
tool.status = "failed";
|
|
109
113
|
tool.error = event.error;
|
|
@@ -112,7 +116,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
112
116
|
}
|
|
113
117
|
|
|
114
118
|
case "run.completed": {
|
|
115
|
-
const run = next.runs.get(event.runId);
|
|
119
|
+
const run = next.runs.get(scopedId(event.sessionId, event.runId));
|
|
116
120
|
if (run) {
|
|
117
121
|
run.status = "completed";
|
|
118
122
|
run.usage = event.usage;
|
|
@@ -121,7 +125,7 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
121
125
|
}
|
|
122
126
|
|
|
123
127
|
case "run.failed": {
|
|
124
|
-
const run = next.runs.get(event.runId);
|
|
128
|
+
const run = next.runs.get(scopedId(event.sessionId, event.runId));
|
|
125
129
|
if (run) {
|
|
126
130
|
run.status = "failed";
|
|
127
131
|
run.error = event.error;
|
|
@@ -143,20 +147,23 @@ export function applyChatEvent(state: ChatRenderState, event: ChatEvent): ChatRe
|
|
|
143
147
|
|
|
144
148
|
function ensureMessage(
|
|
145
149
|
state: ChatRenderState,
|
|
150
|
+
sessionId: string,
|
|
146
151
|
id: string,
|
|
147
152
|
role: RenderMessage["role"],
|
|
148
153
|
): RenderMessage {
|
|
149
|
-
const
|
|
154
|
+
const key = scopedId(sessionId, id);
|
|
155
|
+
const existing = state.messageById.get(key);
|
|
150
156
|
if (existing) return existing;
|
|
151
157
|
|
|
152
158
|
const message: RenderMessage = {
|
|
153
159
|
id,
|
|
160
|
+
sessionId,
|
|
154
161
|
role,
|
|
155
162
|
status: "streaming",
|
|
156
163
|
content: [],
|
|
157
164
|
text: "",
|
|
158
165
|
};
|
|
159
|
-
state.messageById.set(
|
|
166
|
+
state.messageById.set(key, message);
|
|
160
167
|
state.messages.push(message);
|
|
161
168
|
return message;
|
|
162
169
|
}
|
|
@@ -166,10 +173,13 @@ function cloneState(state: ChatRenderState): ChatRenderState {
|
|
|
166
173
|
...message,
|
|
167
174
|
content: [...message.content],
|
|
168
175
|
}));
|
|
169
|
-
const messageById = new Map(
|
|
176
|
+
const messageById = new Map(
|
|
177
|
+
messages.map((message) => [scopedId(message.sessionId ?? "", message.id), message]),
|
|
178
|
+
);
|
|
170
179
|
return {
|
|
171
180
|
...state,
|
|
172
181
|
seenSeq: new Set(state.seenSeq),
|
|
182
|
+
lastSeqBySession: new Map(state.lastSeqBySession),
|
|
173
183
|
messages,
|
|
174
184
|
messageById,
|
|
175
185
|
tools: new Map(
|
|
@@ -188,14 +198,19 @@ function cloneState(state: ChatRenderState): ChatRenderState {
|
|
|
188
198
|
};
|
|
189
199
|
}
|
|
190
200
|
|
|
191
|
-
function ensureThinking(state: ChatRenderState, runId: string) {
|
|
192
|
-
const
|
|
201
|
+
function ensureThinking(state: ChatRenderState, sessionId: string, runId: string) {
|
|
202
|
+
const key = scopedId(sessionId, runId);
|
|
203
|
+
const existing = state.thinking.get(key);
|
|
193
204
|
if (existing) return existing;
|
|
194
|
-
const thinking = { runId, status: "streaming" as const, text: "" };
|
|
195
|
-
state.thinking.set(
|
|
205
|
+
const thinking = { runId, sessionId, status: "streaming" as const, text: "" };
|
|
206
|
+
state.thinking.set(key, thinking);
|
|
196
207
|
return thinking;
|
|
197
208
|
}
|
|
198
209
|
|
|
210
|
+
function scopedId(sessionId: string, id: string): string {
|
|
211
|
+
return `${sessionId ?? ""}::${id}`;
|
|
212
|
+
}
|
|
213
|
+
|
|
199
214
|
function contentText(content: MessageContent[]): string {
|
|
200
215
|
return content
|
|
201
216
|
.filter((part): part is Extract<MessageContent, { type: "text" }> => part.type === "text")
|