cortena-ui 1.4.2 → 1.6.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/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,1234 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The chat state, and the hook that drives an `AgentChatClient` from React.
|
|
5
|
+
*
|
|
6
|
+
* The accumulation rules are ported from `cortena-shared/src/stores/use-chat.ts`
|
|
7
|
+
* (Cortena monorepo) so a run leaves this state in the same place it leaves
|
|
8
|
+
* cortenaweb's. The ones that are not obvious, and that a rewrite gets wrong:
|
|
9
|
+
*
|
|
10
|
+
* - A `delta` carries a snapshot, so text and A2UI blocks are **replaced**, not
|
|
11
|
+
* appended. Appending doubles every character.
|
|
12
|
+
* - Thinking is never replaced by something shorter. The upstream emitter can
|
|
13
|
+
* fall back to increments when a snapshot is unavailable, and treating one of
|
|
14
|
+
* those as a snapshot truncates the accumulated chain of thought.
|
|
15
|
+
* - On `final`, a second assistant message in the same turn folds the previous
|
|
16
|
+
* visible text into the chain of thought rather than adding a second bubble,
|
|
17
|
+
* and keeps the A2UI blocks of the message it folded away — the surface is
|
|
18
|
+
* still on screen and still interactive.
|
|
19
|
+
* - A reply that is only a drawn surface has no text, so `text || a2ui.length`
|
|
20
|
+
* is the test for "there is something to show". Testing text alone silently
|
|
21
|
+
* dropped everything the agent drew.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import * as React from "react";
|
|
25
|
+
import { type A2UIChatBlock, extractA2UIBlocks, foldA2UIBlocks } from "./a2ui-block";
|
|
26
|
+
import {
|
|
27
|
+
isProvisionalSessionKey,
|
|
28
|
+
isSessionOfAgent,
|
|
29
|
+
mintSessionKey,
|
|
30
|
+
parseHistoryMessages,
|
|
31
|
+
readStoredSessionKey,
|
|
32
|
+
readStoredSteps,
|
|
33
|
+
separateThinking,
|
|
34
|
+
STEP_LIMIT,
|
|
35
|
+
wouldShrinkHistory,
|
|
36
|
+
writeStoredSessionKey,
|
|
37
|
+
writeStoredSteps,
|
|
38
|
+
} from "./session";
|
|
39
|
+
import { confirmationRequiredLabel, isGenericLabel, stepLabel, truncate } from "./step-label";
|
|
40
|
+
import type {
|
|
41
|
+
AgentApprovalDecision,
|
|
42
|
+
AgentApprovalRequest,
|
|
43
|
+
AgentChatClient,
|
|
44
|
+
AgentChatEvent,
|
|
45
|
+
AgentChatHistoryResponse,
|
|
46
|
+
AgentChatMessage,
|
|
47
|
+
AgentChatStep,
|
|
48
|
+
AgentSession,
|
|
49
|
+
AgentSessionsListResponse,
|
|
50
|
+
AgentToolEvent,
|
|
51
|
+
AgentToolResultMeta,
|
|
52
|
+
AgentToolResultMetadata,
|
|
53
|
+
} from "./types";
|
|
54
|
+
|
|
55
|
+
export interface AgentToolEntry {
|
|
56
|
+
id: string;
|
|
57
|
+
name: string;
|
|
58
|
+
status: "running" | "complete" | "error";
|
|
59
|
+
input?: unknown;
|
|
60
|
+
output?: unknown;
|
|
61
|
+
startedAt: number;
|
|
62
|
+
completedAt?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface AgentChatState {
|
|
66
|
+
sessionKey: string | null;
|
|
67
|
+
messages: AgentChatMessage[];
|
|
68
|
+
streaming: boolean;
|
|
69
|
+
streamText: string;
|
|
70
|
+
streamThinkingText: string;
|
|
71
|
+
streamA2ui: A2UIChatBlock[];
|
|
72
|
+
runId: string | null;
|
|
73
|
+
hasMoreHistory: boolean;
|
|
74
|
+
loadingHistory: boolean;
|
|
75
|
+
toolOrder: string[];
|
|
76
|
+
tools: Record<string, AgentToolEntry>;
|
|
77
|
+
/**
|
|
78
|
+
* The turn's tool calls in plain words, in arrival order (SKILLS-9).
|
|
79
|
+
*
|
|
80
|
+
* The same calls as `tools`, worded rather than dumped, and rendered under
|
|
81
|
+
* the assistant turn instead of in the strip at the foot. Cleared when the
|
|
82
|
+
* next run starts, so the list is always "what is happening now" or "what
|
|
83
|
+
* just happened", never a transcript.
|
|
84
|
+
*/
|
|
85
|
+
steps: AgentChatStep[];
|
|
86
|
+
/**
|
|
87
|
+
* Ids of steps the `STEP_LIMIT` cap has already dropped.
|
|
88
|
+
*
|
|
89
|
+
* A runaway turn pushes the first calls off the front of the list, and their
|
|
90
|
+
* results arrive afterwards. Without this the store saw an id it did not
|
|
91
|
+
* know, took it for a call nobody announced, and appended a fresh
|
|
92
|
+
* "Working: tool" line at the BOTTOM of the strip — the oldest call in the
|
|
93
|
+
* turn, drawn as the newest, with no words on it. Bounded, because it exists
|
|
94
|
+
* to recognise a handful of stragglers, not to keep a transcript.
|
|
95
|
+
*/
|
|
96
|
+
retiredSteps: string[];
|
|
97
|
+
approvals: AgentApprovalRequest[];
|
|
98
|
+
/**
|
|
99
|
+
* Transcript RECORDS loaded, which is not `messages.length`.
|
|
100
|
+
*
|
|
101
|
+
* `parseHistoryMessages` folds a turn — an assistant preamble, the tool call,
|
|
102
|
+
* the tool result, the answer — into one visible message, so the two numbers
|
|
103
|
+
* diverge on the very first agent turn. `offset` is an offset into the
|
|
104
|
+
* server's records, and paging with the folded count asked for records the
|
|
105
|
+
* screen already had and skipped the ones it did not: "load older" silently
|
|
106
|
+
* lost messages the further back a user read.
|
|
107
|
+
*/
|
|
108
|
+
historyCount: number;
|
|
109
|
+
sessions: AgentSession[];
|
|
110
|
+
loadingSessions: boolean;
|
|
111
|
+
/** A failure the user has to be told about, e.g. no template for the slug. */
|
|
112
|
+
error: string | null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const EMPTY: AgentChatState = {
|
|
116
|
+
sessionKey: null,
|
|
117
|
+
messages: [],
|
|
118
|
+
streaming: false,
|
|
119
|
+
streamText: "",
|
|
120
|
+
streamThinkingText: "",
|
|
121
|
+
streamA2ui: [],
|
|
122
|
+
runId: null,
|
|
123
|
+
hasMoreHistory: false,
|
|
124
|
+
loadingHistory: false,
|
|
125
|
+
toolOrder: [],
|
|
126
|
+
tools: {},
|
|
127
|
+
steps: [],
|
|
128
|
+
retiredSteps: [],
|
|
129
|
+
approvals: [],
|
|
130
|
+
historyCount: 0,
|
|
131
|
+
sessions: [],
|
|
132
|
+
loadingSessions: false,
|
|
133
|
+
error: null,
|
|
134
|
+
};
|
|
135
|
+
|
|
136
|
+
type Action =
|
|
137
|
+
| { kind: "chat"; event: AgentChatEvent }
|
|
138
|
+
| { kind: "tool"; event: AgentToolEvent }
|
|
139
|
+
| { kind: "approval/request"; request: AgentApprovalRequest }
|
|
140
|
+
| { kind: "approval/resolved"; id: string }
|
|
141
|
+
| { kind: "approval/denied"; id: string }
|
|
142
|
+
| { kind: "approval/failed"; request: AgentApprovalRequest; message: string }
|
|
143
|
+
| { kind: "session"; key: string | null }
|
|
144
|
+
| { kind: "send"; message: AgentChatMessage | null; runId: string; sessionKey: string }
|
|
145
|
+
| { kind: "send/failed"; message: string }
|
|
146
|
+
| { kind: "abort" }
|
|
147
|
+
| { kind: "history/loading" }
|
|
148
|
+
| {
|
|
149
|
+
kind: "history/loaded";
|
|
150
|
+
sessionKey: string;
|
|
151
|
+
messages: AgentChatMessage[];
|
|
152
|
+
hasMore: boolean;
|
|
153
|
+
records: number;
|
|
154
|
+
}
|
|
155
|
+
| { kind: "history/older"; messages: AgentChatMessage[]; hasMore: boolean; records: number }
|
|
156
|
+
| { kind: "history/failed" }
|
|
157
|
+
| { kind: "sessions/loading" }
|
|
158
|
+
| { kind: "sessions/loaded"; sessions: AgentSession[] }
|
|
159
|
+
| { kind: "steps/restored"; steps: AgentChatStep[] }
|
|
160
|
+
| { kind: "error"; message: string | null };
|
|
161
|
+
|
|
162
|
+
function generateId(): string {
|
|
163
|
+
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function extractText(message?: AgentChatEvent["message"]): string {
|
|
167
|
+
return (message?.content ?? [])
|
|
168
|
+
.filter((c) => c.type === "text" && c.text)
|
|
169
|
+
.map((c) => c.text!)
|
|
170
|
+
.join("");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function extractThinking(message?: AgentChatEvent["message"]): string {
|
|
174
|
+
const blocks = (message?.content ?? [])
|
|
175
|
+
.filter((c) => c.type === "thinking")
|
|
176
|
+
.map((c) => c.thinking ?? c.text ?? "")
|
|
177
|
+
.filter(Boolean);
|
|
178
|
+
return blocks.join("\n\n");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function reduce(state: AgentChatState, action: Action): AgentChatState {
|
|
182
|
+
switch (action.kind) {
|
|
183
|
+
case "chat":
|
|
184
|
+
return reduceChat(state, action.event);
|
|
185
|
+
case "tool":
|
|
186
|
+
return reduceTool(state, action.event);
|
|
187
|
+
case "approval/request":
|
|
188
|
+
return state.approvals.some((r) => r.id === action.request.id)
|
|
189
|
+
? state
|
|
190
|
+
: { ...state, approvals: [...state.approvals, action.request] };
|
|
191
|
+
case "approval/resolved":
|
|
192
|
+
return { ...state, approvals: state.approvals.filter((r) => r.id !== action.id) };
|
|
193
|
+
case "approval/denied":
|
|
194
|
+
// The user said no. The call the run paused on is never going to happen,
|
|
195
|
+
// so it stops waiting — `stopped`, not `error`, for the same reason the
|
|
196
|
+
// Stop button is: nothing failed. Steps still RUNNING are untouched; a
|
|
197
|
+
// deny answers one question, it does not end the rest of the turn.
|
|
198
|
+
return {
|
|
199
|
+
...state,
|
|
200
|
+
approvals: state.approvals.filter((r) => r.id !== action.id),
|
|
201
|
+
steps: endRunningSteps(state.steps, { kind: "denied" }),
|
|
202
|
+
};
|
|
203
|
+
case "approval/failed":
|
|
204
|
+
// The decision did not land, so the card comes back — with the reason on
|
|
205
|
+
// it. Removing it optimistically and then swallowing the failure left
|
|
206
|
+
// the user certain they had answered and the run paused for ever.
|
|
207
|
+
return state.approvals.some((r) => r.id === action.request.id)
|
|
208
|
+
? state
|
|
209
|
+
: {
|
|
210
|
+
...state,
|
|
211
|
+
approvals: [...state.approvals, { ...action.request, error: action.message }],
|
|
212
|
+
};
|
|
213
|
+
case "session":
|
|
214
|
+
return action.key === state.sessionKey
|
|
215
|
+
? state
|
|
216
|
+
: { ...EMPTY, sessions: state.sessions, sessionKey: action.key };
|
|
217
|
+
case "send":
|
|
218
|
+
return {
|
|
219
|
+
...state,
|
|
220
|
+
sessionKey: action.sessionKey,
|
|
221
|
+
messages: action.message ? [...state.messages, action.message] : state.messages,
|
|
222
|
+
streaming: true,
|
|
223
|
+
streamText: "",
|
|
224
|
+
streamThinkingText: "",
|
|
225
|
+
streamA2ui: [],
|
|
226
|
+
runId: action.runId,
|
|
227
|
+
toolOrder: [],
|
|
228
|
+
tools: {},
|
|
229
|
+
// The steps of the previous turn describe work that is over. Leaving
|
|
230
|
+
// them under the next answer reads as though the agent were still
|
|
231
|
+
// doing them.
|
|
232
|
+
steps: [],
|
|
233
|
+
retiredSteps: [],
|
|
234
|
+
// A new run is a new set of pending decisions. An approval card left
|
|
235
|
+
// over from the previous run points at a run that is over, and
|
|
236
|
+
// answering it sends a decision nothing is waiting for.
|
|
237
|
+
approvals: [],
|
|
238
|
+
error: null,
|
|
239
|
+
};
|
|
240
|
+
case "send/failed":
|
|
241
|
+
return {
|
|
242
|
+
...state,
|
|
243
|
+
streaming: false,
|
|
244
|
+
runId: null,
|
|
245
|
+
messages: [
|
|
246
|
+
...state.messages,
|
|
247
|
+
{
|
|
248
|
+
id: generateId(),
|
|
249
|
+
role: "assistant",
|
|
250
|
+
text: action.message,
|
|
251
|
+
timestamp: Date.now(),
|
|
252
|
+
isError: true,
|
|
253
|
+
},
|
|
254
|
+
],
|
|
255
|
+
};
|
|
256
|
+
case "abort":
|
|
257
|
+
// Reset immediately so the composer flips Stop -> Send without waiting on
|
|
258
|
+
// a server event an aborted run does not reliably send.
|
|
259
|
+
return {
|
|
260
|
+
...state,
|
|
261
|
+
streaming: false,
|
|
262
|
+
streamText: "",
|
|
263
|
+
streamThinkingText: "",
|
|
264
|
+
streamA2ui: [],
|
|
265
|
+
runId: null,
|
|
266
|
+
// The run they belonged to is gone; the decisions go with it.
|
|
267
|
+
approvals: [],
|
|
268
|
+
// A stopped run sends no end event for the call in flight, so a step
|
|
269
|
+
// left running spins for ever under an answer that will never come.
|
|
270
|
+
// Stopped, not failed: the user asked for it.
|
|
271
|
+
steps: endRunningSteps(state.steps, { kind: "stopped" }),
|
|
272
|
+
};
|
|
273
|
+
case "history/loading":
|
|
274
|
+
return { ...state, loadingHistory: true };
|
|
275
|
+
case "history/loaded":
|
|
276
|
+
if (
|
|
277
|
+
wouldShrinkHistory({
|
|
278
|
+
currentSessionKey: state.sessionKey,
|
|
279
|
+
loadedSessionKey: action.sessionKey,
|
|
280
|
+
currentCount: state.messages.length,
|
|
281
|
+
loadedCount: action.messages.length,
|
|
282
|
+
})
|
|
283
|
+
) {
|
|
284
|
+
return {
|
|
285
|
+
...state,
|
|
286
|
+
sessionKey: action.sessionKey,
|
|
287
|
+
hasMoreHistory: action.hasMore,
|
|
288
|
+
loadingHistory: false,
|
|
289
|
+
historyCount: action.records,
|
|
290
|
+
};
|
|
291
|
+
}
|
|
292
|
+
return {
|
|
293
|
+
...state,
|
|
294
|
+
sessionKey: action.sessionKey,
|
|
295
|
+
messages: action.messages,
|
|
296
|
+
hasMoreHistory: action.hasMore,
|
|
297
|
+
loadingHistory: false,
|
|
298
|
+
historyCount: action.records,
|
|
299
|
+
// Avoids an orphan streaming bubble beside reloaded history.
|
|
300
|
+
streaming: false,
|
|
301
|
+
streamText: "",
|
|
302
|
+
streamThinkingText: "",
|
|
303
|
+
streamA2ui: [],
|
|
304
|
+
};
|
|
305
|
+
case "history/older":
|
|
306
|
+
return {
|
|
307
|
+
...state,
|
|
308
|
+
messages: [...action.messages, ...state.messages],
|
|
309
|
+
hasMoreHistory: action.hasMore,
|
|
310
|
+
loadingHistory: false,
|
|
311
|
+
historyCount: state.historyCount + action.records,
|
|
312
|
+
};
|
|
313
|
+
case "history/failed":
|
|
314
|
+
return { ...state, loadingHistory: false };
|
|
315
|
+
case "sessions/loading":
|
|
316
|
+
return { ...state, loadingSessions: true };
|
|
317
|
+
case "sessions/loaded":
|
|
318
|
+
return { ...state, sessions: action.sessions, loadingSessions: false };
|
|
319
|
+
case "steps/restored":
|
|
320
|
+
// Only ever into an empty strip. A restore that arrives after the user
|
|
321
|
+
// has already started a new run would otherwise resurrect the last one.
|
|
322
|
+
return state.steps.length > 0 ? state : { ...state, steps: action.steps.slice(-STEP_LIMIT) };
|
|
323
|
+
case "error":
|
|
324
|
+
return { ...state, error: action.message };
|
|
325
|
+
default:
|
|
326
|
+
return state;
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
function reduceChat(state: AgentChatState, event: AgentChatEvent): AgentChatState {
|
|
331
|
+
// Events from another session are dropped, or one window's run leaks into
|
|
332
|
+
// another window's transcript. A provisional key is the exception: the server
|
|
333
|
+
// echoes it back today, but if it ever canonicalised the key instead, dropping
|
|
334
|
+
// the event would silently kill the whole first turn.
|
|
335
|
+
if (event.sessionKey && state.sessionKey && event.sessionKey !== state.sessionKey) {
|
|
336
|
+
if (!isProvisionalSessionKey(state.sessionKey)) {
|
|
337
|
+
return state;
|
|
338
|
+
}
|
|
339
|
+
state = { ...state, sessionKey: event.sessionKey };
|
|
340
|
+
} else if (event.sessionKey && !state.sessionKey) {
|
|
341
|
+
state = { ...state, sessionKey: event.sessionKey };
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
switch (event.state) {
|
|
345
|
+
case "delta": {
|
|
346
|
+
const text = extractText(event.message);
|
|
347
|
+
const thinking = extractThinking(event.message);
|
|
348
|
+
const a2ui = extractA2UIBlocks(event.message);
|
|
349
|
+
const next: AgentChatState = { ...state, streaming: true };
|
|
350
|
+
if (text) {
|
|
351
|
+
next.streamText = text;
|
|
352
|
+
}
|
|
353
|
+
if (a2ui.length > 0) {
|
|
354
|
+
next.streamA2ui = a2ui;
|
|
355
|
+
}
|
|
356
|
+
// Never replace accumulated thinking with something shorter.
|
|
357
|
+
const acceptThinking = !!thinking && thinking.length >= state.streamThinkingText.length;
|
|
358
|
+
if (acceptThinking) {
|
|
359
|
+
next.streamThinkingText = thinking;
|
|
360
|
+
}
|
|
361
|
+
return text || acceptThinking || a2ui.length > 0 ? next : state;
|
|
362
|
+
}
|
|
363
|
+
case "final": {
|
|
364
|
+
const finalText = extractText(event.message);
|
|
365
|
+
const finalThinking = extractThinking(event.message);
|
|
366
|
+
const finalA2ui = extractA2UIBlocks(event.message);
|
|
367
|
+
const text = finalText || state.streamText;
|
|
368
|
+
const thinkingFromBlocks = finalThinking || state.streamThinkingText;
|
|
369
|
+
const a2ui = foldA2UIBlocks(finalA2ui.length > 0 ? finalA2ui : state.streamA2ui);
|
|
370
|
+
// The run id deliberately SURVIVES a final. A run can end more than
|
|
371
|
+
// once — an interrupt for an approval, then the continuation — and
|
|
372
|
+
// clearing it here meant the second final saw `runId: null`, could not
|
|
373
|
+
// fold, and pushed a second bubble: one answer to one question, drawn
|
|
374
|
+
// as two. `send` sets the next run's id, and `abort`, `error` and
|
|
375
|
+
// `send/failed` each clear it, so nothing outlives the turn it belongs
|
|
376
|
+
// to.
|
|
377
|
+
const cleared = {
|
|
378
|
+
streaming: false,
|
|
379
|
+
streamText: "",
|
|
380
|
+
streamThinkingText: "",
|
|
381
|
+
streamA2ui: [] as A2UIChatBlock[],
|
|
382
|
+
// The run is over, so nothing is still running. A result event can go
|
|
383
|
+
// missing — a reconnect drops the tail — and the spinner then outlived
|
|
384
|
+
// the answer it was drawn beside. The label is untouched: a plain
|
|
385
|
+
// RUN_FINISHED means the run succeeded, so the step did too.
|
|
386
|
+
//
|
|
387
|
+
// An INTERRUPT is not that. cortenacore ends the run to ask for an
|
|
388
|
+
// approval or to hand a frontend tool to the client, and the tool call
|
|
389
|
+
// is still open: the continuation is a second run that ends it. Ticking
|
|
390
|
+
// it here claimed a write had gone through while the card asking
|
|
391
|
+
// permission for it was still on screen.
|
|
392
|
+
steps: endRunningSteps(
|
|
393
|
+
state.steps,
|
|
394
|
+
event.interrupted ? { kind: "paused" } : { kind: "finished" },
|
|
395
|
+
),
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (!text && a2ui.length === 0) {
|
|
399
|
+
// No text and nothing drawn: fold whatever thinking accumulated into
|
|
400
|
+
// the previous turn rather than dropping it.
|
|
401
|
+
const accumulated = state.streamThinkingText;
|
|
402
|
+
const last = state.messages[state.messages.length - 1];
|
|
403
|
+
if (!accumulated || !foldsInto(last, state.runId)) {
|
|
404
|
+
return { ...state, ...cleared };
|
|
405
|
+
}
|
|
406
|
+
const messages = [...state.messages];
|
|
407
|
+
messages[messages.length - 1] = {
|
|
408
|
+
...last,
|
|
409
|
+
timestamp: Date.now(),
|
|
410
|
+
thinkingText: [last.thinkingText ?? "", accumulated].filter(Boolean).join("\n\n"),
|
|
411
|
+
};
|
|
412
|
+
return { ...state, ...cleared, messages };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const { thinkingText: tagThinking, mainText } = separateThinking(text);
|
|
416
|
+
const thinkingText = thinkingFromBlocks || tagThinking;
|
|
417
|
+
const displayText = thinkingFromBlocks ? text : mainText;
|
|
418
|
+
const messages = [...state.messages];
|
|
419
|
+
const last = messages[messages.length - 1];
|
|
420
|
+
|
|
421
|
+
if (foldsInto(last, state.runId)) {
|
|
422
|
+
const combined = [last.thinkingText ?? "", last.text.trim()].filter(Boolean).join("\n\n");
|
|
423
|
+
const updated = [combined, thinkingText].filter(Boolean).join("\n\n");
|
|
424
|
+
messages[messages.length - 1] = {
|
|
425
|
+
...last,
|
|
426
|
+
text: displayText,
|
|
427
|
+
timestamp: Date.now(),
|
|
428
|
+
...(updated ? { thinkingText: updated } : { thinkingText: undefined }),
|
|
429
|
+
...(a2ui.length > 0 || last.a2ui?.length
|
|
430
|
+
? { a2ui: foldA2UIBlocks([...(last.a2ui ?? []), ...a2ui]) }
|
|
431
|
+
: {}),
|
|
432
|
+
};
|
|
433
|
+
} else {
|
|
434
|
+
messages.push({
|
|
435
|
+
id: generateId(),
|
|
436
|
+
role: "assistant",
|
|
437
|
+
text: displayText,
|
|
438
|
+
timestamp: Date.now(),
|
|
439
|
+
// The run this bubble belongs to, so the NEXT run's `final` cannot
|
|
440
|
+
// fold its answer into it.
|
|
441
|
+
...(state.runId ? { runId: state.runId } : {}),
|
|
442
|
+
...(thinkingText ? { thinkingText } : {}),
|
|
443
|
+
...(a2ui.length > 0 ? { a2ui } : {}),
|
|
444
|
+
});
|
|
445
|
+
}
|
|
446
|
+
return { ...state, ...cleared, messages };
|
|
447
|
+
}
|
|
448
|
+
case "error":
|
|
449
|
+
return {
|
|
450
|
+
...state,
|
|
451
|
+
streaming: false,
|
|
452
|
+
streamText: "",
|
|
453
|
+
streamThinkingText: "",
|
|
454
|
+
streamA2ui: [],
|
|
455
|
+
runId: null,
|
|
456
|
+
// A RUN_ERROR is the end of every call still in flight, whether or not
|
|
457
|
+
// the transport got round to sending a result for it.
|
|
458
|
+
steps: endRunningSteps(state.steps, {
|
|
459
|
+
kind: "failed",
|
|
460
|
+
message: truncate(event.errorMessage ?? "The run failed.", ERROR_LIMIT),
|
|
461
|
+
}),
|
|
462
|
+
messages: [
|
|
463
|
+
...state.messages,
|
|
464
|
+
{
|
|
465
|
+
id: generateId(),
|
|
466
|
+
role: "assistant",
|
|
467
|
+
text: event.errorMessage ?? "An error occurred",
|
|
468
|
+
timestamp: Date.now(),
|
|
469
|
+
isError: true,
|
|
470
|
+
},
|
|
471
|
+
],
|
|
472
|
+
};
|
|
473
|
+
default:
|
|
474
|
+
return state;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* May a `final` fold into this message?
|
|
480
|
+
*
|
|
481
|
+
* Only when it is an assistant bubble from the SAME run. Two assistant
|
|
482
|
+
* messages in one turn are one answer with an interruption in the middle, and
|
|
483
|
+
* folding them is right. Two assistant messages from different runs are two
|
|
484
|
+
* answers to two questions, and folding them rewrote the earlier answer with
|
|
485
|
+
* the later one — visibly, in a transcript the user had already read.
|
|
486
|
+
*
|
|
487
|
+
* A message with no `runId` is history, which never folds either.
|
|
488
|
+
*/
|
|
489
|
+
function foldsInto(
|
|
490
|
+
last: AgentChatMessage | undefined,
|
|
491
|
+
runId: string | null,
|
|
492
|
+
): last is AgentChatMessage {
|
|
493
|
+
return (
|
|
494
|
+
last !== undefined &&
|
|
495
|
+
last.role === "assistant" &&
|
|
496
|
+
!last.isError &&
|
|
497
|
+
runId !== null &&
|
|
498
|
+
last.runId === runId
|
|
499
|
+
);
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function reduceTool(state: AgentChatState, event: AgentToolEvent): AgentChatState {
|
|
503
|
+
const id = event.data.id;
|
|
504
|
+
if (!id) {
|
|
505
|
+
return state;
|
|
506
|
+
}
|
|
507
|
+
const existing = state.tools[id];
|
|
508
|
+
const base: AgentToolEntry = existing ?? {
|
|
509
|
+
id,
|
|
510
|
+
name: event.data.name ?? "tool",
|
|
511
|
+
status: "running",
|
|
512
|
+
startedAt: event.data.startedAt ?? Date.now(),
|
|
513
|
+
};
|
|
514
|
+
let entry: AgentToolEntry;
|
|
515
|
+
switch (event.event) {
|
|
516
|
+
case "tool.start":
|
|
517
|
+
entry = { ...base, name: event.data.name ?? base.name, status: "running" };
|
|
518
|
+
break;
|
|
519
|
+
case "tool.args":
|
|
520
|
+
entry = { ...base, input: event.data.input };
|
|
521
|
+
break;
|
|
522
|
+
case "tool.progress":
|
|
523
|
+
entry = { ...base, output: event.data.output };
|
|
524
|
+
break;
|
|
525
|
+
case "tool.complete":
|
|
526
|
+
entry = {
|
|
527
|
+
...base,
|
|
528
|
+
status: "complete",
|
|
529
|
+
output: event.data.output,
|
|
530
|
+
completedAt: event.data.completedAt ?? Date.now(),
|
|
531
|
+
};
|
|
532
|
+
break;
|
|
533
|
+
case "tool.error":
|
|
534
|
+
entry = {
|
|
535
|
+
...base,
|
|
536
|
+
status: "error",
|
|
537
|
+
output: event.data.output,
|
|
538
|
+
completedAt: event.data.completedAt ?? Date.now(),
|
|
539
|
+
};
|
|
540
|
+
break;
|
|
541
|
+
default:
|
|
542
|
+
return state;
|
|
543
|
+
}
|
|
544
|
+
const stepped = reduceStep(state, event, id);
|
|
545
|
+
return {
|
|
546
|
+
...state,
|
|
547
|
+
tools: { ...state.tools, [id]: entry },
|
|
548
|
+
toolOrder: state.toolOrder.includes(id) ? state.toolOrder : [...state.toolOrder, id],
|
|
549
|
+
steps: stepped.steps,
|
|
550
|
+
retiredSteps: stepped.retiredSteps,
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* ── steps ───────────────────────────────────────────────────────────────── */
|
|
555
|
+
|
|
556
|
+
/** The step list and the retired ids, which only ever move together. */
|
|
557
|
+
interface StepUpdate {
|
|
558
|
+
steps: AgentChatStep[];
|
|
559
|
+
retiredSteps: string[];
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* `TOOL_CALL_RESULT.metadata`, read defensively.
|
|
564
|
+
*
|
|
565
|
+
* `null` when nothing usable arrived, which a transport that predates the
|
|
566
|
+
* contract will produce. A step with no metadata at all is treated as a
|
|
567
|
+
* success by `tool.complete` and as a failure by `tool.error`, which is the
|
|
568
|
+
* most either event can honestly claim on its own.
|
|
569
|
+
*
|
|
570
|
+
* Note what is NOT here: any reading of `content`. The result text is the
|
|
571
|
+
* model's copy of the reply, in whatever shape the tool returned, and the
|
|
572
|
+
* client's previous attempt to find `ok: false` in it matched nothing real —
|
|
573
|
+
* a Cortena tool result is `{ content: [...], details }`, so the envelope it
|
|
574
|
+
* was looking for was two levels down when it existed at all.
|
|
575
|
+
*/
|
|
576
|
+
export function readResultMetadata(raw: unknown): AgentToolResultMetadata | null {
|
|
577
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
const record = raw as Record<string, unknown>;
|
|
581
|
+
const meta = readMeta(record.meta);
|
|
582
|
+
return {
|
|
583
|
+
...(typeof record.toolName === "string" ? { toolName: record.toolName } : {}),
|
|
584
|
+
isError: record.isError === true,
|
|
585
|
+
...(meta ? { meta } : {}),
|
|
586
|
+
};
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function readMeta(raw: unknown): AgentToolResultMeta | null {
|
|
590
|
+
if (typeof raw !== "object" || raw === null || Array.isArray(raw)) {
|
|
591
|
+
return null;
|
|
592
|
+
}
|
|
593
|
+
const record = raw as Record<string, unknown>;
|
|
594
|
+
const meta: AgentToolResultMeta = {
|
|
595
|
+
...(typeof record.status === "number" ? { status: record.status } : {}),
|
|
596
|
+
...(typeof record.code === "string" ? { code: record.code } : {}),
|
|
597
|
+
...(typeof record.message === "string" ? { message: record.message } : {}),
|
|
598
|
+
};
|
|
599
|
+
return Object.keys(meta).length > 0 ? meta : null;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* Is this the broker asking for a yes rather than reporting a failure?
|
|
604
|
+
*
|
|
605
|
+
* The broker refuses a catalogued write with `409 confirmation_required` until
|
|
606
|
+
* the call carries `confirmed: true`. It is the safety rail working, and the
|
|
607
|
+
* step says so instead of showing the user a red cross for it.
|
|
608
|
+
*
|
|
609
|
+
* Keyed on the CODE, never on the status. `409` is also an ordinary conflict —
|
|
610
|
+
* a task already claimed, a version that moved under a write — and wording one
|
|
611
|
+
* of those as "Needs your confirmation" invites the user to retry something
|
|
612
|
+
* that will never work.
|
|
613
|
+
*/
|
|
614
|
+
function isConfirmationRequired(meta: AgentToolResultMeta | undefined): boolean {
|
|
615
|
+
return meta?.code === "confirmation_required";
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
/** Longest failure message put on a step. */
|
|
619
|
+
const ERROR_LIMIT = 200;
|
|
620
|
+
|
|
621
|
+
/**
|
|
622
|
+
* The human part of a tool result, for a failure that carried no `meta`.
|
|
623
|
+
*
|
|
624
|
+
* A Cortena tool returns `{ content: [{ type: "text", text }], details }`,
|
|
625
|
+
* JSON-stringified by the mapper, so the text blocks are the message and the
|
|
626
|
+
* rest is machinery. Anything else — a plain string, a shape this does not
|
|
627
|
+
* recognise — is used as it stands. Bounded and sanitised either way.
|
|
628
|
+
*/
|
|
629
|
+
function resultText(output: unknown): string {
|
|
630
|
+
const direct = typeof output === "string" ? output : "";
|
|
631
|
+
let value: unknown = output;
|
|
632
|
+
if (direct) {
|
|
633
|
+
try {
|
|
634
|
+
value = JSON.parse(direct);
|
|
635
|
+
} catch {
|
|
636
|
+
return truncate(direct, ERROR_LIMIT);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
if (typeof value === "object" && value !== null) {
|
|
640
|
+
const content = (value as { content?: unknown }).content;
|
|
641
|
+
if (Array.isArray(content)) {
|
|
642
|
+
const text = content
|
|
643
|
+
.map((part) =>
|
|
644
|
+
typeof part === "object" &&
|
|
645
|
+
part !== null &&
|
|
646
|
+
typeof (part as { text?: unknown }).text === "string"
|
|
647
|
+
? (part as { text: string }).text
|
|
648
|
+
: "",
|
|
649
|
+
)
|
|
650
|
+
.filter(Boolean)
|
|
651
|
+
.join(" ");
|
|
652
|
+
if (text) {
|
|
653
|
+
return truncate(text, ERROR_LIMIT);
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
return truncate(direct, ERROR_LIMIT);
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
/** What a failed step says: the server's message, or the result text. */
|
|
661
|
+
function errorMessageOf(meta: AgentToolResultMeta | undefined, output: unknown): string {
|
|
662
|
+
const fromMeta = meta?.message ? truncate(meta.message, ERROR_LIMIT) : "";
|
|
663
|
+
return fromMeta || resultText(output) || "The call failed.";
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/** The call's input as an object, or nothing when it never parsed as one. */
|
|
667
|
+
function readArgs(input: unknown): Record<string, unknown> | undefined {
|
|
668
|
+
return typeof input === "object" && input !== null && !Array.isArray(input)
|
|
669
|
+
? (input as Record<string, unknown>)
|
|
670
|
+
: undefined;
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
/**
|
|
674
|
+
* Close every step still running, however the run ended.
|
|
675
|
+
*
|
|
676
|
+
* A step's spinner is a promise that something is happening, and the transport
|
|
677
|
+
* does not keep it: a run that errors, is stopped, or simply finishes with a
|
|
678
|
+
* result event lost on the way leaves a line spinning for ever under an answer
|
|
679
|
+
* that has already arrived. Three outcomes, because they are three different
|
|
680
|
+
* facts about the user's work:
|
|
681
|
+
*
|
|
682
|
+
* - `failed` — the run itself broke. Red, with the reason.
|
|
683
|
+
* - `stopped` — the user pressed Stop. Neutral: nothing went wrong.
|
|
684
|
+
* - `finished` — a plain RUN_FINISHED. The run SUCCEEDED, so the calls it made
|
|
685
|
+
* succeeded too; the words they finished with are still true, and only the
|
|
686
|
+
* spinner changes.
|
|
687
|
+
* - `paused` — RUN_FINISHED with `outcome.type === "interrupt"`. The call is
|
|
688
|
+
* still open and the run will resume, so the step neither ticks nor spins:
|
|
689
|
+
* it waits, visibly, and the result closes it later.
|
|
690
|
+
* - `denied` — the user refused the approval the run paused on. Only PAUSED
|
|
691
|
+
* steps end, and they end `stopped`.
|
|
692
|
+
*
|
|
693
|
+
* `running` and `paused` are both OPEN, which is what the first three outcomes
|
|
694
|
+
* close: an abort or a run error while a step waits for an approval must not
|
|
695
|
+
* leave it waiting for ever.
|
|
696
|
+
*/
|
|
697
|
+
type StepOutcome =
|
|
698
|
+
| { kind: "failed"; message: string }
|
|
699
|
+
| { kind: "stopped" }
|
|
700
|
+
| { kind: "finished" }
|
|
701
|
+
| { kind: "paused" }
|
|
702
|
+
| { kind: "denied" };
|
|
703
|
+
|
|
704
|
+
/** Is this step still open — spinning, or waiting on the user? */
|
|
705
|
+
function isOpenStep(step: AgentChatStep): boolean {
|
|
706
|
+
return step.status === "running" || step.status === "paused";
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
function endRunningSteps(steps: AgentChatStep[], outcome: StepOutcome): AgentChatStep[] {
|
|
710
|
+
const closes = (step: AgentChatStep) =>
|
|
711
|
+
outcome.kind === "paused"
|
|
712
|
+
? step.status === "running"
|
|
713
|
+
: outcome.kind === "denied"
|
|
714
|
+
? step.status === "paused"
|
|
715
|
+
: isOpenStep(step);
|
|
716
|
+
if (!steps.some(closes)) {
|
|
717
|
+
return steps;
|
|
718
|
+
}
|
|
719
|
+
const endedAt = Date.now();
|
|
720
|
+
return steps.map((step) => {
|
|
721
|
+
if (!closes(step)) {
|
|
722
|
+
return step;
|
|
723
|
+
}
|
|
724
|
+
if (outcome.kind === "paused") {
|
|
725
|
+
// No `endedAt`: nothing has ended. It is the absence of `running` that
|
|
726
|
+
// stops the strip's ticking counter.
|
|
727
|
+
return { ...step, status: "paused" as const };
|
|
728
|
+
}
|
|
729
|
+
if (outcome.kind === "failed") {
|
|
730
|
+
return { ...step, status: "error" as const, endedAt, errorMessage: outcome.message };
|
|
731
|
+
}
|
|
732
|
+
if (outcome.kind === "stopped" || outcome.kind === "denied") {
|
|
733
|
+
return { ...step, status: "stopped" as const, endedAt };
|
|
734
|
+
}
|
|
735
|
+
return { ...step, status: "done" as const, endedAt };
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
/**
|
|
740
|
+
* A step appended, with the list capped at `STEP_LIMIT`.
|
|
741
|
+
*
|
|
742
|
+
* A turn with more calls than this is a loop, not a plan, and an uncapped list
|
|
743
|
+
* grows without bound in memory and re-renders the whole strip on every event
|
|
744
|
+
* of it. The most recent are kept: they are what the user is waiting on, and
|
|
745
|
+
* the ids of the ones dropped are kept so a late result for one is recognised
|
|
746
|
+
* rather than mistaken for a call nobody announced.
|
|
747
|
+
*/
|
|
748
|
+
function append(
|
|
749
|
+
steps: AgentChatStep[],
|
|
750
|
+
retiredSteps: string[],
|
|
751
|
+
step: AgentChatStep,
|
|
752
|
+
): StepUpdate {
|
|
753
|
+
const next = [...steps, step];
|
|
754
|
+
if (next.length <= STEP_LIMIT) {
|
|
755
|
+
return { steps: next, retiredSteps };
|
|
756
|
+
}
|
|
757
|
+
const dropped = next.slice(0, next.length - STEP_LIMIT).map((s) => s.id);
|
|
758
|
+
return {
|
|
759
|
+
steps: next.slice(-STEP_LIMIT),
|
|
760
|
+
retiredSteps: [...retiredSteps, ...dropped].slice(-RETIRED_LIMIT),
|
|
761
|
+
};
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/** Most dropped ids remembered. Enough for stragglers, not a transcript. */
|
|
765
|
+
const RETIRED_LIMIT = 100;
|
|
766
|
+
|
|
767
|
+
function replaceStep(
|
|
768
|
+
steps: AgentChatStep[],
|
|
769
|
+
index: number,
|
|
770
|
+
next: AgentChatStep,
|
|
771
|
+
): AgentChatStep[] {
|
|
772
|
+
const copy = [...steps];
|
|
773
|
+
copy[index] = next;
|
|
774
|
+
return copy;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* One tool event, folded into the step list.
|
|
779
|
+
*
|
|
780
|
+
* Start and end are paired by the AG-UI `toolCallId`, not by position: calls
|
|
781
|
+
* overlap, and a run that issues two searches at once ended the wrong one when
|
|
782
|
+
* the list was treated as a stack.
|
|
783
|
+
*
|
|
784
|
+
* The label is recomputed when the arguments land, because they land in a
|
|
785
|
+
* SECOND event. `TOOL_CALL_START` carries the name only, so a label fixed at
|
|
786
|
+
* start time is "Working: engram_search" for the whole of the call — the one
|
|
787
|
+
* line the wording map exists to avoid.
|
|
788
|
+
*/
|
|
789
|
+
function reduceStep(
|
|
790
|
+
state: AgentChatState,
|
|
791
|
+
event: AgentToolEvent,
|
|
792
|
+
id: string,
|
|
793
|
+
): StepUpdate {
|
|
794
|
+
const { steps, retiredSteps } = state;
|
|
795
|
+
const keep: StepUpdate = { steps, retiredSteps };
|
|
796
|
+
const index = steps.findIndex((step) => step.id === id);
|
|
797
|
+
const existing = index === -1 ? undefined : steps[index];
|
|
798
|
+
|
|
799
|
+
// A call the cap already dropped. Its result is arriving after fifty other
|
|
800
|
+
// calls pushed it off the front, and there is nothing left to update: the
|
|
801
|
+
// only alternatives are a stale line back at the bottom of the strip or
|
|
802
|
+
// silence, and silence about the fiftieth-oldest call of a runaway turn is
|
|
803
|
+
// the right answer.
|
|
804
|
+
if (!existing && retiredSteps.includes(id)) {
|
|
805
|
+
return keep;
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
if (event.event === "tool.start") {
|
|
809
|
+
// A start for a call that has already ENDED is a replay, not a restart.
|
|
810
|
+
// A reconnect re-streams the run from the top, so every call of it starts
|
|
811
|
+
// again; taking that at face value put a finished run's steps back into
|
|
812
|
+
// spinners, with no second result coming to take them out again.
|
|
813
|
+
if (existing && !isOpenStep(existing)) {
|
|
814
|
+
return keep;
|
|
815
|
+
}
|
|
816
|
+
const toolName = event.data.name ?? existing?.toolName ?? "tool";
|
|
817
|
+
const step: AgentChatStep = {
|
|
818
|
+
id,
|
|
819
|
+
toolName,
|
|
820
|
+
...(existing?.args ? { args: existing.args } : {}),
|
|
821
|
+
label: stepLabel(toolName, existing?.args, "running"),
|
|
822
|
+
startedAt: event.data.startedAt ?? existing?.startedAt ?? Date.now(),
|
|
823
|
+
status: "running",
|
|
824
|
+
};
|
|
825
|
+
return existing && index !== -1
|
|
826
|
+
? { steps: replaceStep(steps, index, step), retiredSteps }
|
|
827
|
+
: append(steps, retiredSteps, step);
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
// Anything else about a call nobody announced. The strip still shows it —
|
|
831
|
+
// a result with no start is a call that happened, and silence about it is
|
|
832
|
+
// worse than a line with a generic label.
|
|
833
|
+
const base: AgentChatStep = existing ?? {
|
|
834
|
+
id,
|
|
835
|
+
toolName: event.data.name ?? "tool",
|
|
836
|
+
label: stepLabel(event.data.name ?? "tool", undefined, "running"),
|
|
837
|
+
startedAt: event.data.startedAt ?? Date.now(),
|
|
838
|
+
status: "running",
|
|
839
|
+
};
|
|
840
|
+
const at = index === -1 ? steps.length : index;
|
|
841
|
+
const put = (next: AgentChatStep): StepUpdate =>
|
|
842
|
+
index === -1
|
|
843
|
+
? append(steps, retiredSteps, next)
|
|
844
|
+
: { steps: replaceStep(steps, at, next), retiredSteps };
|
|
845
|
+
|
|
846
|
+
switch (event.event) {
|
|
847
|
+
case "tool.args": {
|
|
848
|
+
const args = readArgs(event.data.input);
|
|
849
|
+
if (!args) {
|
|
850
|
+
return keep;
|
|
851
|
+
}
|
|
852
|
+
// The broker asked for a confirmation before the arguments landed, so
|
|
853
|
+
// the question it asked is "that action". It has a name now, and the
|
|
854
|
+
// card the user is reading is the one place the name matters.
|
|
855
|
+
if (base.label === CONFIRMATION_FALLBACK) {
|
|
856
|
+
return put({ ...base, args, label: confirmationRequiredLabel(args) });
|
|
857
|
+
}
|
|
858
|
+
// A finished step keeps the words it finished with — unless those words
|
|
859
|
+
// are the fallback. Arguments can land after the result on a short call,
|
|
860
|
+
// and "Working: broker_invoke", ticked, is the one line the wording map
|
|
861
|
+
// exists to avoid; a real sentence is better late than never. A label
|
|
862
|
+
// the arguments already produced is never rewritten.
|
|
863
|
+
const open = isOpenStep(base);
|
|
864
|
+
const relabel = open || isGenericLabel(base.toolName, base.label);
|
|
865
|
+
return put({
|
|
866
|
+
...base,
|
|
867
|
+
args,
|
|
868
|
+
label: relabel ? stepLabel(base.toolName, args, open ? "running" : "done") : base.label,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
case "tool.progress":
|
|
872
|
+
return keep;
|
|
873
|
+
case "tool.complete":
|
|
874
|
+
case "tool.error":
|
|
875
|
+
return put(endStep(base, event));
|
|
876
|
+
default:
|
|
877
|
+
return keep;
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
/** The confirmation label with no operation on it; see `tool.args` above. */
|
|
882
|
+
const CONFIRMATION_FALLBACK = confirmationRequiredLabel(undefined);
|
|
883
|
+
|
|
884
|
+
/**
|
|
885
|
+
* A start event's step, ended by its result.
|
|
886
|
+
*
|
|
887
|
+
* The whole outcome decision, in one place, against
|
|
888
|
+
* `TOOL_CALL_RESULT.metadata` and nothing else:
|
|
889
|
+
*
|
|
890
|
+
* - `isError` with `meta.code === "confirmation_required"` — not a failure.
|
|
891
|
+
* The broker is asking a question, and the step is relabelled to ask it.
|
|
892
|
+
* - `isError` otherwise — a failure, worded from `meta.message` when there is
|
|
893
|
+
* one and from the result text when there is not.
|
|
894
|
+
* - neither — done.
|
|
895
|
+
*/
|
|
896
|
+
function endStep(base: AgentChatStep, event: AgentToolEvent): AgentChatStep {
|
|
897
|
+
const metadata: AgentToolResultMetadata | null = readResultMetadata(event.data.metadata);
|
|
898
|
+
// Either witness is enough. `tool.error` is what the bridge emits when the
|
|
899
|
+
// metadata says `isError`, but it is also what a transport that predates the
|
|
900
|
+
// contract emits with no metadata at all — and a metadata object that
|
|
901
|
+
// arrived without an `isError` field then made `isError` false and drew a
|
|
902
|
+
// failed call as a success.
|
|
903
|
+
const isError = metadata?.isError === true || event.event === "tool.error";
|
|
904
|
+
const meta: AgentToolResultMeta | undefined = metadata?.meta;
|
|
905
|
+
const endedAt = event.data.completedAt ?? Date.now();
|
|
906
|
+
|
|
907
|
+
if (isError && isConfirmationRequired(meta)) {
|
|
908
|
+
return { ...base, status: "done", endedAt, label: confirmationRequiredLabel(base.args) };
|
|
909
|
+
}
|
|
910
|
+
if (isError) {
|
|
911
|
+
return { ...base, status: "error", endedAt, errorMessage: errorMessageOf(meta, event.data.output) };
|
|
912
|
+
}
|
|
913
|
+
return { ...base, status: "done", endedAt, label: stepLabel(base.toolName, base.args, "done") };
|
|
914
|
+
}
|
|
915
|
+
|
|
916
|
+
/* ── the hook ────────────────────────────────────────────────────────────── */
|
|
917
|
+
|
|
918
|
+
/** How long a burst of step changes is collapsed for before it is written. */
|
|
919
|
+
const STEP_WRITE_DEBOUNCE_MS = 250;
|
|
920
|
+
|
|
921
|
+
export interface UseAgentChatOptions {
|
|
922
|
+
client: AgentChatClient;
|
|
923
|
+
/**
|
|
924
|
+
* Names the `sessionStorage` slot the current session key lives in. Two
|
|
925
|
+
* pop-ups with different scopes never share a key, which is what keeps one
|
|
926
|
+
* extension's chat out of another's.
|
|
927
|
+
*/
|
|
928
|
+
storageScope: string;
|
|
929
|
+
/** Skip reading and writing `sessionStorage`; for a transient mount. */
|
|
930
|
+
ephemeral?: boolean;
|
|
931
|
+
thinking?: "low" | "medium" | "high";
|
|
932
|
+
}
|
|
933
|
+
|
|
934
|
+
export interface UseAgentChatResult extends AgentChatState {
|
|
935
|
+
send: (text: string, attachments?: unknown[]) => void;
|
|
936
|
+
sendAction: (action: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => void;
|
|
937
|
+
abort: () => void;
|
|
938
|
+
resolveApproval: (id: string, decision: AgentApprovalDecision) => void;
|
|
939
|
+
newSession: () => void;
|
|
940
|
+
resumeSession: (key: string) => void;
|
|
941
|
+
loadOlder: () => void;
|
|
942
|
+
refreshSessions: () => void;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export function useAgentChat({
|
|
946
|
+
client,
|
|
947
|
+
storageScope,
|
|
948
|
+
ephemeral,
|
|
949
|
+
thinking,
|
|
950
|
+
}: UseAgentChatOptions): UseAgentChatResult {
|
|
951
|
+
const [state, dispatch] = React.useReducer(reduce, EMPTY);
|
|
952
|
+
const stateRef = React.useRef(state);
|
|
953
|
+
stateRef.current = state;
|
|
954
|
+
|
|
955
|
+
// Subscribe once per client. A collapsed pop-up keeps this mounted — the
|
|
956
|
+
// panel is hidden, not unmounted — so a run started before the collapse
|
|
957
|
+
// keeps streaming into the state the pill's unread dot is read from.
|
|
958
|
+
React.useEffect(() => {
|
|
959
|
+
const offChat = client.onChat((event) => dispatch({ kind: "chat", event }));
|
|
960
|
+
const offTool = client.onTool((event) => dispatch({ kind: "tool", event }));
|
|
961
|
+
const offApproval = client.onApproval((event) => {
|
|
962
|
+
if (event.type === "request") {
|
|
963
|
+
dispatch({ kind: "approval/request", request: event.request });
|
|
964
|
+
} else {
|
|
965
|
+
dispatch({ kind: "approval/resolved", id: event.id });
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
return () => {
|
|
969
|
+
offChat();
|
|
970
|
+
offTool();
|
|
971
|
+
offApproval();
|
|
972
|
+
};
|
|
973
|
+
}, [client]);
|
|
974
|
+
|
|
975
|
+
const refreshSessions = React.useCallback(() => {
|
|
976
|
+
dispatch({ kind: "sessions/loading" });
|
|
977
|
+
void client
|
|
978
|
+
.request<AgentSessionsListResponse>("sessions.list", { includeDerivedTitles: true })
|
|
979
|
+
.then((response) => {
|
|
980
|
+
// Filtered to this agent: a shared control plane lists every session on
|
|
981
|
+
// the pod, and an extension must never show another agent's chats.
|
|
982
|
+
const sessions = (response?.sessions ?? []).filter((s) =>
|
|
983
|
+
isSessionOfAgent(s.key, client.agentId),
|
|
984
|
+
);
|
|
985
|
+
dispatch({ kind: "sessions/loaded", sessions });
|
|
986
|
+
})
|
|
987
|
+
.catch((err: unknown) => {
|
|
988
|
+
dispatch({ kind: "sessions/loaded", sessions: [] });
|
|
989
|
+
dispatch({ kind: "error", message: errorText(err) });
|
|
990
|
+
});
|
|
991
|
+
}, [client]);
|
|
992
|
+
|
|
993
|
+
const loadHistory = React.useCallback(
|
|
994
|
+
(sessionKey: string) => {
|
|
995
|
+
dispatch({ kind: "history/loading" });
|
|
996
|
+
void client
|
|
997
|
+
.request<AgentChatHistoryResponse>("chat.history", { sessionKey, limit: 100 })
|
|
998
|
+
.then((response) => {
|
|
999
|
+
const records = response?.messages ?? [];
|
|
1000
|
+
dispatch({
|
|
1001
|
+
kind: "history/loaded",
|
|
1002
|
+
sessionKey,
|
|
1003
|
+
messages: parseHistoryMessages(records),
|
|
1004
|
+
hasMore: response?.hasMore ?? false,
|
|
1005
|
+
records: records.length,
|
|
1006
|
+
});
|
|
1007
|
+
})
|
|
1008
|
+
.catch(() => dispatch({ kind: "history/failed" }));
|
|
1009
|
+
},
|
|
1010
|
+
[client],
|
|
1011
|
+
);
|
|
1012
|
+
|
|
1013
|
+
// Restore this window's session, or mint one. `sessionStorage`, not
|
|
1014
|
+
// `localStorage`: a second window must not adopt the first window's session.
|
|
1015
|
+
const restored = React.useRef(false);
|
|
1016
|
+
React.useEffect(() => {
|
|
1017
|
+
if (restored.current) {
|
|
1018
|
+
return;
|
|
1019
|
+
}
|
|
1020
|
+
restored.current = true;
|
|
1021
|
+
const stored = ephemeral ? null : readStoredSessionKey(storageScope);
|
|
1022
|
+
const key = stored && isSessionOfAgent(stored, client.agentId) ? stored : null;
|
|
1023
|
+
if (key) {
|
|
1024
|
+
dispatch({ kind: "session", key });
|
|
1025
|
+
// Before the history load, and after the `session` reset it would
|
|
1026
|
+
// otherwise be cleared by. `chat.history` has no steps in it — the
|
|
1027
|
+
// server never sees them — so this slot is the only way back.
|
|
1028
|
+
const steps = ephemeral ? [] : readStoredSteps(storageScope, key);
|
|
1029
|
+
if (steps.length > 0) {
|
|
1030
|
+
dispatch({ kind: "steps/restored", steps });
|
|
1031
|
+
}
|
|
1032
|
+
loadHistory(key);
|
|
1033
|
+
}
|
|
1034
|
+
refreshSessions();
|
|
1035
|
+
}, [client, ephemeral, loadHistory, refreshSessions, storageScope]);
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Keep the per-window store in step with the strip, on a debounce.
|
|
1039
|
+
*
|
|
1040
|
+
* Skipped while there is no session key, which is also the first commit —
|
|
1041
|
+
* writing then would empty the slot the effect above is about to read.
|
|
1042
|
+
*
|
|
1043
|
+
* Debounced because a busy run changes `steps` several times per call —
|
|
1044
|
+
* start, arguments, result — and each write serialises the whole list and
|
|
1045
|
+
* hands it to a SYNCHRONOUS storage API on the main thread. A quarter of a
|
|
1046
|
+
* second collapses a call's worth of edits into one write and is far below
|
|
1047
|
+
* anything a reload could notice.
|
|
1048
|
+
*/
|
|
1049
|
+
const pendingSteps = React.useRef<{ sessionKey: string; steps: AgentChatStep[] } | null>(null);
|
|
1050
|
+
React.useEffect(() => {
|
|
1051
|
+
if (ephemeral || !state.sessionKey) {
|
|
1052
|
+
return;
|
|
1053
|
+
}
|
|
1054
|
+
const sessionKey = state.sessionKey;
|
|
1055
|
+
const steps = state.steps;
|
|
1056
|
+
pendingSteps.current = { sessionKey, steps };
|
|
1057
|
+
const id = setTimeout(() => {
|
|
1058
|
+
pendingSteps.current = null;
|
|
1059
|
+
writeStoredSteps(storageScope, sessionKey, steps);
|
|
1060
|
+
}, STEP_WRITE_DEBOUNCE_MS);
|
|
1061
|
+
return () => clearTimeout(id);
|
|
1062
|
+
}, [ephemeral, state.sessionKey, state.steps, storageScope]);
|
|
1063
|
+
|
|
1064
|
+
// Flushed on the way out. The unmount is the case the slot exists for — a
|
|
1065
|
+
// pop-up collapsing, a host remounting the surface mid-run — and a debounce
|
|
1066
|
+
// that dropped its last write on unmount would lose exactly the strip it was
|
|
1067
|
+
// meant to preserve.
|
|
1068
|
+
React.useEffect(() => {
|
|
1069
|
+
return () => {
|
|
1070
|
+
const pending = pendingSteps.current;
|
|
1071
|
+
if (!ephemeral && pending) {
|
|
1072
|
+
pendingSteps.current = null;
|
|
1073
|
+
writeStoredSteps(storageScope, pending.sessionKey, pending.steps);
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
1076
|
+
}, [ephemeral, storageScope]);
|
|
1077
|
+
|
|
1078
|
+
const setSession = React.useCallback(
|
|
1079
|
+
(key: string) => {
|
|
1080
|
+
dispatch({ kind: "session", key });
|
|
1081
|
+
if (!ephemeral) {
|
|
1082
|
+
writeStoredSessionKey(storageScope, key);
|
|
1083
|
+
}
|
|
1084
|
+
},
|
|
1085
|
+
[ephemeral, storageScope],
|
|
1086
|
+
);
|
|
1087
|
+
|
|
1088
|
+
const run = React.useCallback(
|
|
1089
|
+
(text: string, extra: Partial<Parameters<AgentChatClient["send"]>[0]> = {}) => {
|
|
1090
|
+
const current = stateRef.current;
|
|
1091
|
+
// One run at a time. A second `send` on a streaming session opens a
|
|
1092
|
+
// second stream into one store: two sets of deltas overwrite each
|
|
1093
|
+
// other's snapshot, `final` folds one run's answer into the other's
|
|
1094
|
+
// bubble, and `abort` can only reach whichever the client thinks is
|
|
1095
|
+
// active. The composer disables Send while a run is live; this is the
|
|
1096
|
+
// same rule for Enter, for a2ui actions and for a caller of the hook.
|
|
1097
|
+
if (current.streaming) {
|
|
1098
|
+
return;
|
|
1099
|
+
}
|
|
1100
|
+
const sessionKey = current.sessionKey ?? mintSessionKey(client.agentId);
|
|
1101
|
+
if (!current.sessionKey && !ephemeral) {
|
|
1102
|
+
writeStoredSessionKey(storageScope, sessionKey);
|
|
1103
|
+
}
|
|
1104
|
+
const runId = generateId();
|
|
1105
|
+
// An action with no typed text is a click, not a sentence: a user bubble
|
|
1106
|
+
// for it would put `{"name":"createTask"}` in the transcript as though
|
|
1107
|
+
// the user had said it.
|
|
1108
|
+
const message: AgentChatMessage | null =
|
|
1109
|
+
text || (!extra.a2uiAction && !extra.mcpAppAction)
|
|
1110
|
+
? { id: runId, role: "user", text, timestamp: Date.now(), runId }
|
|
1111
|
+
: null;
|
|
1112
|
+
dispatch({ kind: "send", message, runId, sessionKey });
|
|
1113
|
+
void client
|
|
1114
|
+
.send({
|
|
1115
|
+
sessionKey,
|
|
1116
|
+
message: text,
|
|
1117
|
+
runId,
|
|
1118
|
+
...(thinking ? { thinking } : {}),
|
|
1119
|
+
...extra,
|
|
1120
|
+
})
|
|
1121
|
+
.then(() => refreshSessions())
|
|
1122
|
+
.catch((err: unknown) => dispatch({ kind: "send/failed", message: errorText(err) }));
|
|
1123
|
+
},
|
|
1124
|
+
[client, ephemeral, refreshSessions, storageScope, thinking],
|
|
1125
|
+
);
|
|
1126
|
+
|
|
1127
|
+
const send = React.useCallback(
|
|
1128
|
+
(text: string, attachments?: unknown[]) => {
|
|
1129
|
+
run(text, attachments?.length ? { attachments } : {});
|
|
1130
|
+
},
|
|
1131
|
+
[run],
|
|
1132
|
+
);
|
|
1133
|
+
|
|
1134
|
+
const sendAction = React.useCallback(
|
|
1135
|
+
(a2uiAction: NonNullable<Parameters<AgentChatClient["send"]>[0]["a2uiAction"]>) => {
|
|
1136
|
+
run("", { a2uiAction });
|
|
1137
|
+
},
|
|
1138
|
+
[run],
|
|
1139
|
+
);
|
|
1140
|
+
|
|
1141
|
+
const abort = React.useCallback(() => {
|
|
1142
|
+
const { runId, sessionKey } = stateRef.current;
|
|
1143
|
+
dispatch({ kind: "abort" });
|
|
1144
|
+
void client
|
|
1145
|
+
.abort({
|
|
1146
|
+
...(runId ? { runId } : {}),
|
|
1147
|
+
...(sessionKey ? { sessionKey } : {}),
|
|
1148
|
+
})
|
|
1149
|
+
.catch(() => {
|
|
1150
|
+
// Best-effort: local state is already reset, and dropping the stream
|
|
1151
|
+
// aborts the run server-side anyway.
|
|
1152
|
+
});
|
|
1153
|
+
}, [client]);
|
|
1154
|
+
|
|
1155
|
+
const resolveApproval = React.useCallback(
|
|
1156
|
+
(id: string, decision: AgentApprovalDecision) => {
|
|
1157
|
+
const request = stateRef.current.approvals.find((r) => r.id === id);
|
|
1158
|
+
if (!request) {
|
|
1159
|
+
return;
|
|
1160
|
+
}
|
|
1161
|
+
// A deny also ends the step the run paused on: the call is not going to
|
|
1162
|
+
// happen, and a line left waiting for an approval the user has already
|
|
1163
|
+
// refused is the state this whole path exists to avoid.
|
|
1164
|
+
dispatch(
|
|
1165
|
+
decision === "deny" ? { kind: "approval/denied", id } : { kind: "approval/resolved", id },
|
|
1166
|
+
);
|
|
1167
|
+
// Bound to the run and the session the REQUEST arrived on, never to
|
|
1168
|
+
// whatever is in flight now. The two come apart the moment a user opens
|
|
1169
|
+
// another session while a command waits for an answer.
|
|
1170
|
+
void client
|
|
1171
|
+
.resolveApproval(id, decision, {
|
|
1172
|
+
runId: request.runId,
|
|
1173
|
+
sessionKey: request.sessionKey,
|
|
1174
|
+
})
|
|
1175
|
+
.catch((err: unknown) => {
|
|
1176
|
+
dispatch({ kind: "approval/failed", request, message: errorText(err) });
|
|
1177
|
+
});
|
|
1178
|
+
},
|
|
1179
|
+
[client],
|
|
1180
|
+
);
|
|
1181
|
+
|
|
1182
|
+
const newSession = React.useCallback(() => {
|
|
1183
|
+
setSession(mintSessionKey(client.agentId));
|
|
1184
|
+
}, [client, setSession]);
|
|
1185
|
+
|
|
1186
|
+
const resumeSession = React.useCallback(
|
|
1187
|
+
(key: string) => {
|
|
1188
|
+
setSession(key);
|
|
1189
|
+
loadHistory(key);
|
|
1190
|
+
},
|
|
1191
|
+
[loadHistory, setSession],
|
|
1192
|
+
);
|
|
1193
|
+
|
|
1194
|
+
const loadOlder = React.useCallback(() => {
|
|
1195
|
+
const current = stateRef.current;
|
|
1196
|
+
if (!current.sessionKey || current.loadingHistory || !current.hasMoreHistory) {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
dispatch({ kind: "history/loading" });
|
|
1200
|
+
void client
|
|
1201
|
+
.request<AgentChatHistoryResponse>("chat.history", {
|
|
1202
|
+
sessionKey: current.sessionKey,
|
|
1203
|
+
limit: 100,
|
|
1204
|
+
// Records, not bubbles. See `historyCount`.
|
|
1205
|
+
offset: current.historyCount,
|
|
1206
|
+
})
|
|
1207
|
+
.then((response) => {
|
|
1208
|
+
const records = response?.messages ?? [];
|
|
1209
|
+
dispatch({
|
|
1210
|
+
kind: "history/older",
|
|
1211
|
+
messages: parseHistoryMessages(records),
|
|
1212
|
+
hasMore: response?.hasMore ?? false,
|
|
1213
|
+
records: records.length,
|
|
1214
|
+
});
|
|
1215
|
+
})
|
|
1216
|
+
.catch(() => dispatch({ kind: "history/failed" }));
|
|
1217
|
+
}, [client]);
|
|
1218
|
+
|
|
1219
|
+
return {
|
|
1220
|
+
...state,
|
|
1221
|
+
send,
|
|
1222
|
+
sendAction,
|
|
1223
|
+
abort,
|
|
1224
|
+
resolveApproval,
|
|
1225
|
+
newSession,
|
|
1226
|
+
resumeSession,
|
|
1227
|
+
loadOlder,
|
|
1228
|
+
refreshSessions,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function errorText(err: unknown): string {
|
|
1233
|
+
return err instanceof Error ? err.message : "The agent could not be reached";
|
|
1234
|
+
}
|