@windowkit/agent-chat 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +147 -2
- package/dist/core/driver.d.ts +183 -0
- package/dist/core/driver.d.ts.map +1 -0
- package/dist/core/driver.js +12 -0
- package/dist/core/driver.js.map +1 -0
- package/dist/core/index.d.ts +8 -0
- package/dist/core/index.d.ts.map +1 -0
- package/dist/core/index.js +11 -0
- package/dist/core/index.js.map +1 -0
- package/dist/core/markdown.d.ts +7 -0
- package/dist/core/markdown.d.ts.map +1 -0
- package/dist/core/markdown.js +53 -0
- package/dist/core/markdown.js.map +1 -0
- package/dist/core/policy.d.ts +18 -0
- package/dist/core/policy.d.ts.map +1 -0
- package/dist/core/policy.js +67 -0
- package/dist/core/policy.js.map +1 -0
- package/dist/core/reducer.d.ts +61 -0
- package/dist/core/reducer.d.ts.map +1 -0
- package/dist/core/reducer.js +391 -0
- package/dist/core/reducer.js.map +1 -0
- package/dist/core/session.d.ts +105 -0
- package/dist/core/session.d.ts.map +1 -0
- package/dist/core/session.js +648 -0
- package/dist/core/session.js.map +1 -0
- package/dist/core/tools.d.ts +120 -0
- package/dist/core/tools.d.ts.map +1 -0
- package/dist/core/tools.js +120 -0
- package/dist/core/tools.js.map +1 -0
- package/dist/core/types.d.ts +295 -0
- package/dist/core/types.d.ts.map +1 -0
- package/dist/core/types.js +21 -0
- package/dist/core/types.js.map +1 -0
- package/dist/drivers/claude/index.d.ts +20 -0
- package/dist/drivers/claude/index.d.ts.map +1 -0
- package/dist/drivers/claude/index.js +727 -0
- package/dist/drivers/claude/index.js.map +1 -0
- package/dist/drivers/claude/input.d.ts +7 -0
- package/dist/drivers/claude/input.d.ts.map +1 -0
- package/dist/drivers/claude/input.js +46 -0
- package/dist/drivers/claude/input.js.map +1 -0
- package/dist/drivers/claude/peers.d.ts +16 -0
- package/dist/drivers/claude/peers.d.ts.map +1 -0
- package/dist/drivers/claude/peers.js +137 -0
- package/dist/drivers/claude/peers.js.map +1 -0
- package/dist/drivers/claude/schema.d.ts +5 -0
- package/dist/drivers/claude/schema.d.ts.map +1 -0
- package/dist/drivers/claude/schema.js +77 -0
- package/dist/drivers/claude/schema.js.map +1 -0
- package/dist/drivers/claude/sdk.d.ts +86 -0
- package/dist/drivers/claude/sdk.d.ts.map +1 -0
- package/dist/drivers/claude/sdk.js +47 -0
- package/dist/drivers/claude/sdk.js.map +1 -0
- package/dist/drivers/claude/tools.d.ts +18 -0
- package/dist/drivers/claude/tools.d.ts.map +1 -0
- package/dist/drivers/claude/tools.js +119 -0
- package/dist/drivers/claude/tools.js.map +1 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/testing/fake.d.ts +63 -0
- package/dist/testing/fake.d.ts.map +1 -0
- package/dist/testing/fake.js +270 -0
- package/dist/testing/fake.js.map +1 -0
- package/dist/testing/index.d.ts +2 -0
- package/dist/testing/index.d.ts.map +1 -0
- package/dist/testing/index.js +4 -0
- package/dist/testing/index.js.map +1 -0
- package/dist/view/AgentChat.d.ts +53 -0
- package/dist/view/AgentChat.d.ts.map +1 -0
- package/dist/view/AgentChat.js +87 -0
- package/dist/view/AgentChat.js.map +1 -0
- package/dist/view/CodeBlock.d.ts +9 -0
- package/dist/view/CodeBlock.d.ts.map +1 -0
- package/dist/view/CodeBlock.js +60 -0
- package/dist/view/CodeBlock.js.map +1 -0
- package/dist/view/Composer.d.ts +24 -0
- package/dist/view/Composer.d.ts.map +1 -0
- package/dist/view/Composer.js +144 -0
- package/dist/view/Composer.js.map +1 -0
- package/dist/view/Header.d.ts +3 -0
- package/dist/view/Header.d.ts.map +1 -0
- package/dist/view/Header.js +30 -0
- package/dist/view/Header.js.map +1 -0
- package/dist/view/MessageList.d.ts +9 -0
- package/dist/view/MessageList.d.ts.map +1 -0
- package/dist/view/MessageList.js +111 -0
- package/dist/view/MessageList.js.map +1 -0
- package/dist/view/QuestionCard.d.ts +6 -0
- package/dist/view/QuestionCard.d.ts.map +1 -0
- package/dist/view/QuestionCard.js +39 -0
- package/dist/view/QuestionCard.js.map +1 -0
- package/dist/view/TextBlock.d.ts +9 -0
- package/dist/view/TextBlock.d.ts.map +1 -0
- package/dist/view/TextBlock.js +22 -0
- package/dist/view/TextBlock.js.map +1 -0
- package/dist/view/ToolCard.d.ts +7 -0
- package/dist/view/ToolCard.d.ts.map +1 -0
- package/dist/view/ToolCard.js +155 -0
- package/dist/view/ToolCard.js.map +1 -0
- package/dist/view/Transcript.d.ts +5 -0
- package/dist/view/Transcript.d.ts.map +1 -0
- package/dist/view/Transcript.js +47 -0
- package/dist/view/Transcript.js.map +1 -0
- package/dist/view/context.d.ts +21 -0
- package/dist/view/context.d.ts.map +1 -0
- package/dist/view/context.js +37 -0
- package/dist/view/context.js.map +1 -0
- package/dist/view/fences.d.ts +13 -0
- package/dist/view/fences.d.ts.map +1 -0
- package/dist/view/fences.js +59 -0
- package/dist/view/fences.js.map +1 -0
- package/dist/view/files.d.ts +11 -0
- package/dist/view/files.d.ts.map +1 -0
- package/dist/view/files.js +109 -0
- package/dist/view/files.js.map +1 -0
- package/dist/view/look.d.ts +18 -0
- package/dist/view/look.d.ts.map +1 -0
- package/dist/view/look.js +37 -0
- package/dist/view/look.js.map +1 -0
- package/dist/view/types.d.ts +50 -0
- package/dist/view/types.d.ts.map +1 -0
- package/dist/view/types.js +14 -0
- package/dist/view/types.js.map +1 -0
- package/dist/view/use-agent.d.ts +11 -0
- package/dist/view/use-agent.d.ts.map +1 -0
- package/dist/view/use-agent.js +33 -0
- package/dist/view/use-agent.js.map +1 -0
- package/package.json +92 -4
- package/src/core/driver.ts +191 -0
- package/src/core/index.ts +54 -0
- package/src/core/markdown.ts +56 -0
- package/src/core/policy.ts +77 -0
- package/src/core/reducer.ts +490 -0
- package/src/core/session.ts +824 -0
- package/src/core/tools.ts +233 -0
- package/src/core/types.ts +321 -0
- package/src/drivers/claude/index.ts +825 -0
- package/src/drivers/claude/input.ts +47 -0
- package/src/drivers/claude/peers.ts +153 -0
- package/src/drivers/claude/schema.ts +99 -0
- package/src/drivers/claude/sdk.ts +138 -0
- package/src/drivers/claude/tools.ts +141 -0
- package/src/index.ts +51 -0
- package/src/testing/fake.ts +374 -0
- package/src/testing/index.ts +11 -0
- package/src/view/AgentChat.tsx +204 -0
- package/src/view/CodeBlock.tsx +108 -0
- package/src/view/Composer.tsx +230 -0
- package/src/view/Header.tsx +73 -0
- package/src/view/MessageList.tsx +290 -0
- package/src/view/QuestionCard.tsx +107 -0
- package/src/view/TextBlock.tsx +58 -0
- package/src/view/ToolCard.tsx +283 -0
- package/src/view/Transcript.tsx +81 -0
- package/src/view/context.ts +70 -0
- package/src/view/dom-globals.d.ts +10 -0
- package/src/view/fences.ts +73 -0
- package/src/view/files.ts +111 -0
- package/src/view/look.ts +53 -0
- package/src/view/types.ts +71 -0
- package/src/view/use-agent.ts +40 -0
|
@@ -0,0 +1,374 @@
|
|
|
1
|
+
// A driver that plays a script: no harness, no key, no network.
|
|
2
|
+
//
|
|
3
|
+
// It goes through the real session — the gate, the policy, the approval
|
|
4
|
+
// row, the app's tools — so a test of the UI tests what an app ships, and the
|
|
5
|
+
// demo example runs on a machine with nothing installed.
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
AgentDriver,
|
|
9
|
+
DriverEvent,
|
|
10
|
+
DriverHost,
|
|
11
|
+
DriverSession,
|
|
12
|
+
OutgoingMessage,
|
|
13
|
+
} from '../core/driver.js';
|
|
14
|
+
import {
|
|
15
|
+
NO_CAPABILITIES,
|
|
16
|
+
type Capabilities,
|
|
17
|
+
type MessageSource,
|
|
18
|
+
type Question,
|
|
19
|
+
type SessionInfo,
|
|
20
|
+
type ToolCall,
|
|
21
|
+
type ToolKind,
|
|
22
|
+
type ToolOutput,
|
|
23
|
+
} from '../core/types.js';
|
|
24
|
+
|
|
25
|
+
export type FakeStep =
|
|
26
|
+
| DriverEvent
|
|
27
|
+
/** Pause the script. */
|
|
28
|
+
| { type: 'wait'; ms: number }
|
|
29
|
+
/** Stream text in chunks of `chunk` characters, `delay` ms apart. */
|
|
30
|
+
| {
|
|
31
|
+
type: 'say';
|
|
32
|
+
text: string;
|
|
33
|
+
chunk?: number;
|
|
34
|
+
delay?: number;
|
|
35
|
+
reasoning?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* A tool call, through the gate: an app tool (one in `tools`) runs the
|
|
39
|
+
* app's function; any other ends with `result`.
|
|
40
|
+
*/
|
|
41
|
+
| {
|
|
42
|
+
type: 'call';
|
|
43
|
+
id?: string;
|
|
44
|
+
name: string;
|
|
45
|
+
input?: unknown;
|
|
46
|
+
kind?: ToolKind;
|
|
47
|
+
title?: string;
|
|
48
|
+
result?: string | readonly ToolOutput[];
|
|
49
|
+
error?: string;
|
|
50
|
+
}
|
|
51
|
+
/** A question for the user; the script goes on once it is answered. */
|
|
52
|
+
| { type: 'ask'; id?: string; questions: readonly Question[] };
|
|
53
|
+
|
|
54
|
+
export type FakeScript = (
|
|
55
|
+
message: OutgoingMessage,
|
|
56
|
+
turn: number,
|
|
57
|
+
) =>
|
|
58
|
+
readonly FakeStep[] | Promise<readonly FakeStep[]> | AsyncIterable<FakeStep>;
|
|
59
|
+
|
|
60
|
+
export interface FakeDriverOptions {
|
|
61
|
+
name?: string;
|
|
62
|
+
capabilities?: Partial<Capabilities>;
|
|
63
|
+
info?: Partial<SessionInfo>;
|
|
64
|
+
/** Throw this from `start`, as a harness that cannot run would. */
|
|
65
|
+
failStart?: Error;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface FakeDriver extends AgentDriver {
|
|
69
|
+
/** Every message the session sent. */
|
|
70
|
+
readonly sent: readonly OutgoingMessage[];
|
|
71
|
+
/** The host, once started. */
|
|
72
|
+
readonly host: DriverHost | null;
|
|
73
|
+
/** How many times `interrupt` was called. */
|
|
74
|
+
readonly interrupts: number;
|
|
75
|
+
/**
|
|
76
|
+
* A message from someone other than the user — another session, a
|
|
77
|
+
* channel — arriving as a harness reports one: the session draws it, and
|
|
78
|
+
* the script plays a turn for it after any turn under way, handed `from`
|
|
79
|
+
* where it is a peer.
|
|
80
|
+
*/
|
|
81
|
+
receive(text: string, from: MessageSource): void;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const FAKE_CAPABILITIES: Capabilities = Object.freeze({
|
|
85
|
+
...NO_CAPABILITIES,
|
|
86
|
+
tools: 'in-process',
|
|
87
|
+
systemPrompt: Object.freeze({ replace: true, append: true }),
|
|
88
|
+
coreTools: 'remove',
|
|
89
|
+
gate: 'every-call',
|
|
90
|
+
}) as Capabilities;
|
|
91
|
+
|
|
92
|
+
export function fakeDriver(
|
|
93
|
+
script: FakeScript,
|
|
94
|
+
options: FakeDriverOptions = {},
|
|
95
|
+
): FakeDriver {
|
|
96
|
+
const sent: OutgoingMessage[] = [];
|
|
97
|
+
let host: DriverHost | null = null;
|
|
98
|
+
let interrupts = 0;
|
|
99
|
+
let take: ((text: string, from: MessageSource) => void) | null = null;
|
|
100
|
+
let received = 0;
|
|
101
|
+
const capabilities: Capabilities = {
|
|
102
|
+
...FAKE_CAPABILITIES,
|
|
103
|
+
...options.capabilities,
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
const driver: FakeDriver = {
|
|
107
|
+
name: options.name ?? 'fake',
|
|
108
|
+
get sent() {
|
|
109
|
+
return sent;
|
|
110
|
+
},
|
|
111
|
+
get host() {
|
|
112
|
+
return host;
|
|
113
|
+
},
|
|
114
|
+
get interrupts() {
|
|
115
|
+
return interrupts;
|
|
116
|
+
},
|
|
117
|
+
receive(text, from) {
|
|
118
|
+
if (!take) {
|
|
119
|
+
throw new Error('fakeDriver: receive() before the session started');
|
|
120
|
+
}
|
|
121
|
+
take(text, from);
|
|
122
|
+
},
|
|
123
|
+
async start(h) {
|
|
124
|
+
if (options.failStart) throw options.failStart;
|
|
125
|
+
host = h;
|
|
126
|
+
h.emit({
|
|
127
|
+
type: 'info',
|
|
128
|
+
info: {
|
|
129
|
+
agent: driver.name,
|
|
130
|
+
model: 'fake',
|
|
131
|
+
models: [],
|
|
132
|
+
...options.info,
|
|
133
|
+
},
|
|
134
|
+
});
|
|
135
|
+
let turn = 0;
|
|
136
|
+
let running: AbortController | null = null;
|
|
137
|
+
let chain = Promise.resolve();
|
|
138
|
+
|
|
139
|
+
/** A turn for `message`, after the one under way. One that arrived
|
|
140
|
+
* from elsewhere is reported as its turn begins, as a harness reports
|
|
141
|
+
* a message when it takes it. */
|
|
142
|
+
const playTurn = (message: OutgoingMessage, arrived?: MessageSource) => {
|
|
143
|
+
const n = turn++;
|
|
144
|
+
chain = chain.then(async () => {
|
|
145
|
+
if (arrived) {
|
|
146
|
+
h.emit({
|
|
147
|
+
type: 'message',
|
|
148
|
+
id: message.id,
|
|
149
|
+
text: message.text,
|
|
150
|
+
from: arrived,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
const abort = new AbortController();
|
|
154
|
+
running = abort;
|
|
155
|
+
try {
|
|
156
|
+
await play(h, script(message, n), n, abort.signal);
|
|
157
|
+
if (!abort.signal.aborted)
|
|
158
|
+
h.emit({ type: 'turn-end', reason: 'done' });
|
|
159
|
+
} catch (err) {
|
|
160
|
+
if (!abort.signal.aborted) {
|
|
161
|
+
h.emit({
|
|
162
|
+
type: 'turn-end',
|
|
163
|
+
reason: 'error',
|
|
164
|
+
error: err instanceof Error ? err.message : String(err),
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
} finally {
|
|
168
|
+
if (running === abort) running = null;
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
};
|
|
172
|
+
take = (text, from) =>
|
|
173
|
+
playTurn(
|
|
174
|
+
{
|
|
175
|
+
id: `r${++received}`,
|
|
176
|
+
text,
|
|
177
|
+
attachments: [],
|
|
178
|
+
steer: false,
|
|
179
|
+
...(from.kind === 'peer' ? { from } : {}),
|
|
180
|
+
},
|
|
181
|
+
from,
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
const session: DriverSession = {
|
|
185
|
+
capabilities,
|
|
186
|
+
send(message) {
|
|
187
|
+
sent.push(message);
|
|
188
|
+
if (message.steer) return;
|
|
189
|
+
playTurn(message);
|
|
190
|
+
},
|
|
191
|
+
async interrupt() {
|
|
192
|
+
interrupts++;
|
|
193
|
+
running?.abort();
|
|
194
|
+
},
|
|
195
|
+
async setModel(id) {
|
|
196
|
+
h.emit({ type: 'info', info: { model: id } });
|
|
197
|
+
},
|
|
198
|
+
async setMode(id) {
|
|
199
|
+
h.emit({ type: 'info', info: { mode: id } });
|
|
200
|
+
},
|
|
201
|
+
async close() {
|
|
202
|
+
running?.abort();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
return session;
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
return driver;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** A script that answers turn `n` with `turns[n]`, and the last one after. */
|
|
212
|
+
export function scripted(
|
|
213
|
+
...turns: ReadonlyArray<readonly FakeStep[]>
|
|
214
|
+
): FakeScript {
|
|
215
|
+
return (_message, n) => turns[Math.min(n, turns.length - 1)] ?? [];
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
async function play(
|
|
219
|
+
host: DriverHost,
|
|
220
|
+
steps: ReturnType<FakeScript>,
|
|
221
|
+
turn: number,
|
|
222
|
+
signal: AbortSignal,
|
|
223
|
+
): Promise<void> {
|
|
224
|
+
const resolved = await steps;
|
|
225
|
+
let i = 0;
|
|
226
|
+
const iterable: Iterable<FakeStep> | AsyncIterable<FakeStep> =
|
|
227
|
+
Symbol.asyncIterator in (resolved as object)
|
|
228
|
+
? (resolved as AsyncIterable<FakeStep>)
|
|
229
|
+
: (resolved as readonly FakeStep[]);
|
|
230
|
+
for await (const step of iterable) {
|
|
231
|
+
if (signal.aborted) return;
|
|
232
|
+
await run(host, step, `f${turn}:${i++}`, signal);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async function run(
|
|
237
|
+
host: DriverHost,
|
|
238
|
+
step: FakeStep,
|
|
239
|
+
key: string,
|
|
240
|
+
signal: AbortSignal,
|
|
241
|
+
): Promise<void> {
|
|
242
|
+
switch (step.type) {
|
|
243
|
+
case 'wait':
|
|
244
|
+
await sleep(step.ms, signal);
|
|
245
|
+
return;
|
|
246
|
+
case 'say': {
|
|
247
|
+
const size = Math.max(1, step.chunk ?? step.text.length);
|
|
248
|
+
for (let at = 0; at < step.text.length; at += size) {
|
|
249
|
+
if (signal.aborted) return;
|
|
250
|
+
host.emit({
|
|
251
|
+
type: 'text',
|
|
252
|
+
id: key,
|
|
253
|
+
delta: step.text.slice(at, at + size),
|
|
254
|
+
...(step.reasoning ? { reasoning: true } : {}),
|
|
255
|
+
});
|
|
256
|
+
await sleep(step.delay ?? 0, signal);
|
|
257
|
+
}
|
|
258
|
+
host.emit({
|
|
259
|
+
type: 'text',
|
|
260
|
+
id: key,
|
|
261
|
+
text: step.text,
|
|
262
|
+
done: true,
|
|
263
|
+
...(step.reasoning ? { reasoning: true } : {}),
|
|
264
|
+
});
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
case 'call':
|
|
268
|
+
await call(host, step, step.id ?? key, signal);
|
|
269
|
+
return;
|
|
270
|
+
case 'ask': {
|
|
271
|
+
await host.ask(step.id ?? key, step.questions, signal);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
default:
|
|
275
|
+
host.emit(step);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function call(
|
|
280
|
+
host: DriverHost,
|
|
281
|
+
step: Extract<FakeStep, { type: 'call' }>,
|
|
282
|
+
id: string,
|
|
283
|
+
signal: AbortSignal,
|
|
284
|
+
): Promise<void> {
|
|
285
|
+
const app = host.config.tools.has(step.name);
|
|
286
|
+
const def = app ? host.config.tools.get(step.name) : undefined;
|
|
287
|
+
const kind = step.kind ?? def?.kind ?? 'other';
|
|
288
|
+
const title =
|
|
289
|
+
step.title ??
|
|
290
|
+
(def?.title && step.input !== undefined
|
|
291
|
+
? def.title(step.input)
|
|
292
|
+
: step.name);
|
|
293
|
+
host.emit({
|
|
294
|
+
type: 'tool-start',
|
|
295
|
+
id,
|
|
296
|
+
name: step.name,
|
|
297
|
+
label: step.name,
|
|
298
|
+
kind,
|
|
299
|
+
title,
|
|
300
|
+
input: step.input ?? {},
|
|
301
|
+
app,
|
|
302
|
+
});
|
|
303
|
+
const toolCall: ToolCall = {
|
|
304
|
+
id,
|
|
305
|
+
name: step.name,
|
|
306
|
+
label: step.name,
|
|
307
|
+
kind,
|
|
308
|
+
title,
|
|
309
|
+
input: step.input ?? {},
|
|
310
|
+
app,
|
|
311
|
+
};
|
|
312
|
+
const gate = await host.gate(toolCall);
|
|
313
|
+
let input: unknown = step.input ?? {};
|
|
314
|
+
if (gate.behavior === 'deny') {
|
|
315
|
+
host.emit({ type: 'tool-end', id, state: 'denied', error: gate.message });
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
if (gate.behavior === 'allow' && gate.input !== undefined) input = gate.input;
|
|
319
|
+
if (gate.behavior === 'ask') {
|
|
320
|
+
const approval = await host.approve(toolCall, signal);
|
|
321
|
+
if (approval.behavior === 'deny') {
|
|
322
|
+
if (!signal.aborted) {
|
|
323
|
+
host.emit({
|
|
324
|
+
type: 'tool-end',
|
|
325
|
+
id,
|
|
326
|
+
state: 'denied',
|
|
327
|
+
error: approval.message,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
return;
|
|
331
|
+
}
|
|
332
|
+
if (approval.input !== undefined) input = approval.input;
|
|
333
|
+
}
|
|
334
|
+
if (signal.aborted) return;
|
|
335
|
+
host.emit({ type: 'tool-update', id, state: 'running' });
|
|
336
|
+
if (app) {
|
|
337
|
+
const result = await host.runTool(step.name, input, { callId: id, signal });
|
|
338
|
+
if (signal.aborted) return;
|
|
339
|
+
const text = result.content
|
|
340
|
+
.map((c) => (c.type === 'text' ? c.text : ''))
|
|
341
|
+
.join('\n');
|
|
342
|
+
host.emit({
|
|
343
|
+
type: 'tool-end',
|
|
344
|
+
id,
|
|
345
|
+
state: result.isError ? 'error' : 'done',
|
|
346
|
+
...(result.isError ? { error: text } : {}),
|
|
347
|
+
});
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (step.error !== undefined) {
|
|
351
|
+
host.emit({ type: 'tool-end', id, state: 'error', error: step.error });
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
const output: readonly ToolOutput[] =
|
|
355
|
+
typeof step.result === 'string'
|
|
356
|
+
? [{ type: 'text', text: step.result }]
|
|
357
|
+
: (step.result ?? []);
|
|
358
|
+
host.emit({ type: 'tool-end', id, state: 'done', output });
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function sleep(ms: number, signal: AbortSignal): Promise<void> {
|
|
362
|
+
if (ms <= 0) return Promise.resolve();
|
|
363
|
+
return new Promise((resolve) => {
|
|
364
|
+
const timer = setTimeout(resolve, ms);
|
|
365
|
+
signal.addEventListener(
|
|
366
|
+
'abort',
|
|
367
|
+
() => {
|
|
368
|
+
clearTimeout(timer);
|
|
369
|
+
resolve();
|
|
370
|
+
},
|
|
371
|
+
{ once: true },
|
|
372
|
+
);
|
|
373
|
+
});
|
|
374
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// `@windowkit/agent-chat/testing`: a driver that plays a script, for tests
|
|
2
|
+
// and for demos with no harness installed.
|
|
3
|
+
|
|
4
|
+
export {
|
|
5
|
+
fakeDriver,
|
|
6
|
+
scripted,
|
|
7
|
+
type FakeDriver,
|
|
8
|
+
type FakeDriverOptions,
|
|
9
|
+
type FakeScript,
|
|
10
|
+
type FakeStep,
|
|
11
|
+
} from './fake.js';
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// `<AgentChat>`: a chat with a local agent harness.
|
|
2
|
+
//
|
|
3
|
+
// One element, one ladder. At the bottom, `<AgentChat agent="claude" />` is a
|
|
4
|
+
// working chat. Each rung up is a prop: context (`cwd`, `instructions`,
|
|
5
|
+
// `coreTools`), the app's own tools (`tools`), its renderers (`fences`,
|
|
6
|
+
// `components`, `renderTool`), its interception (`codeActions`,
|
|
7
|
+
// `onToolCall`). The top rung lifts the session out (`useAgent`, then
|
|
8
|
+
// `session`) with the same options, moved rather than rewritten.
|
|
9
|
+
|
|
10
|
+
import { useMemo, useRef, type ReactElement, type ReactNode } from 'react';
|
|
11
|
+
import type { StyleProp } from 'react-x11';
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
describeRenderers,
|
|
15
|
+
type AgentOptions,
|
|
16
|
+
type AgentSession,
|
|
17
|
+
type SessionSnapshot,
|
|
18
|
+
} from '../core/session.js';
|
|
19
|
+
import type { ToolCall } from '../core/types.js';
|
|
20
|
+
import { Composer, type ComposerOptions } from './Composer.js';
|
|
21
|
+
import {
|
|
22
|
+
ChatContext,
|
|
23
|
+
componentMap,
|
|
24
|
+
fenceMap,
|
|
25
|
+
type ChatContextValue,
|
|
26
|
+
} from './context.js';
|
|
27
|
+
import { Header } from './Header.js';
|
|
28
|
+
import { useLook } from './look.js';
|
|
29
|
+
import { Transcript } from './Transcript.js';
|
|
30
|
+
import {
|
|
31
|
+
copyCode,
|
|
32
|
+
type CodeAction,
|
|
33
|
+
type ComponentEntry,
|
|
34
|
+
type FenceEntry,
|
|
35
|
+
type ToolRenderer,
|
|
36
|
+
} from './types.js';
|
|
37
|
+
import { useAgent, useSessionSnapshot } from './use-agent.js';
|
|
38
|
+
|
|
39
|
+
/** What the view takes, whoever owns the session. */
|
|
40
|
+
export interface AgentChatViewProps {
|
|
41
|
+
/** Fenced blocks drawn by the app, by language. An entry with `describe`
|
|
42
|
+
* also tells the agent the format exists. */
|
|
43
|
+
fences?: Readonly<Record<string, FenceEntry>>;
|
|
44
|
+
/** Components a reply may name as a tag on its own line. */
|
|
45
|
+
components?: Readonly<Record<string, ComponentEntry>>;
|
|
46
|
+
/** Tool calls drawn by the app, by the tool's label; `'*'` for the rest.
|
|
47
|
+
* A tool's own `render` comes first. */
|
|
48
|
+
renderTool?: Readonly<Record<string, ToolRenderer>>;
|
|
49
|
+
/** Buttons on every fenced code block. Default `[copyCode]`. Nothing runs
|
|
50
|
+
* a block unless an action here does. */
|
|
51
|
+
codeActions?: readonly CodeAction[];
|
|
52
|
+
/** A link in a reply was pressed. Nothing navigates by itself. */
|
|
53
|
+
onLink?: (href: string) => void;
|
|
54
|
+
placeholder?: string;
|
|
55
|
+
/** How the message box behaves: what it draws as markdown while typing,
|
|
56
|
+
* the `@` file list, the `/` command list, pastes as attachments. */
|
|
57
|
+
composer?: ComposerOptions;
|
|
58
|
+
/** `true` for the session's line (agent, model, mode, cost, Copy), or a
|
|
59
|
+
* function of the session for the app's own. Default `true`. */
|
|
60
|
+
header?: boolean | ((session: AgentSession) => ReactNode);
|
|
61
|
+
/** Shown while the transcript is empty. */
|
|
62
|
+
empty?: ReactNode;
|
|
63
|
+
/** Shown instead of the chat when the harness cannot run here. Default: a
|
|
64
|
+
* line saying why. */
|
|
65
|
+
fallback?: ReactNode | ((snapshot: SessionSnapshot) => ReactNode);
|
|
66
|
+
/** Base text size. Default: the theme's. */
|
|
67
|
+
fontSize?: number;
|
|
68
|
+
autoFocus?: boolean;
|
|
69
|
+
style?: StyleProp;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
type NoOptions = { readonly [K in keyof AgentOptions]?: never };
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Either the session's options — the chat owns its session — or a
|
|
76
|
+
* `session` from `useAgent`, and none of the options: they are the
|
|
77
|
+
* session's, and a chat handed one does not change it.
|
|
78
|
+
*/
|
|
79
|
+
export type AgentChatProps = AgentChatViewProps &
|
|
80
|
+
(
|
|
81
|
+
| (AgentOptions & { session?: undefined })
|
|
82
|
+
| (NoOptions & { session: AgentSession })
|
|
83
|
+
);
|
|
84
|
+
|
|
85
|
+
export function AgentChat(props: AgentChatProps): ReactElement {
|
|
86
|
+
return props.session ? (
|
|
87
|
+
<ChatView session={props.session} view={props} />
|
|
88
|
+
) : (
|
|
89
|
+
<OwnSession {...(props as AgentOptions & AgentChatViewProps)} />
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function OwnSession(props: AgentOptions & AgentChatViewProps): ReactElement {
|
|
94
|
+
// Options are read once, but `onToolCall` is usually an inline arrow over
|
|
95
|
+
// the app's state: the session calls the latest one.
|
|
96
|
+
const latest = useRef(props);
|
|
97
|
+
latest.current = props;
|
|
98
|
+
const session = useAgent({
|
|
99
|
+
...props,
|
|
100
|
+
instructions: [
|
|
101
|
+
...(Array.isArray(props.instructions)
|
|
102
|
+
? props.instructions
|
|
103
|
+
: props.instructions
|
|
104
|
+
? [props.instructions as string]
|
|
105
|
+
: []),
|
|
106
|
+
...describeRenderers({
|
|
107
|
+
fences: props.fences,
|
|
108
|
+
components: props.components,
|
|
109
|
+
}),
|
|
110
|
+
],
|
|
111
|
+
...(props.onToolCall
|
|
112
|
+
? { onToolCall: (call: ToolCall) => latest.current.onToolCall?.(call) }
|
|
113
|
+
: {}),
|
|
114
|
+
});
|
|
115
|
+
return <ChatView session={session} view={props} />;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function ChatView({
|
|
119
|
+
session,
|
|
120
|
+
view,
|
|
121
|
+
}: {
|
|
122
|
+
session: AgentSession;
|
|
123
|
+
view: AgentChatViewProps;
|
|
124
|
+
}): ReactElement {
|
|
125
|
+
const snap = useSessionSnapshot(session);
|
|
126
|
+
const look = useLook();
|
|
127
|
+
const { fences, components, renderTool, codeActions, onLink, fontSize } =
|
|
128
|
+
view;
|
|
129
|
+
const context = useMemo<ChatContextValue>(
|
|
130
|
+
() => ({
|
|
131
|
+
session,
|
|
132
|
+
fences: fenceMap(fences),
|
|
133
|
+
components: componentMap(components),
|
|
134
|
+
renderTool: renderTool ?? {},
|
|
135
|
+
codeActions: codeActions ?? [copyCode],
|
|
136
|
+
onLink,
|
|
137
|
+
fontSize,
|
|
138
|
+
}),
|
|
139
|
+
[session, fences, components, renderTool, codeActions, onLink, fontSize],
|
|
140
|
+
);
|
|
141
|
+
|
|
142
|
+
if (snap.status === 'unavailable' && view.fallback !== undefined) {
|
|
143
|
+
return (
|
|
144
|
+
<box style={[{ flexDirection: 'column', flexGrow: 1 }, view.style]}>
|
|
145
|
+
{typeof view.fallback === 'function'
|
|
146
|
+
? view.fallback(snap)
|
|
147
|
+
: view.fallback}
|
|
148
|
+
</box>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const header = view.header ?? true;
|
|
153
|
+
return (
|
|
154
|
+
<ChatContext.Provider value={context}>
|
|
155
|
+
<box
|
|
156
|
+
style={[
|
|
157
|
+
{
|
|
158
|
+
// fills what it is put in: the one-line usage needs no style
|
|
159
|
+
flexDirection: 'column',
|
|
160
|
+
flexGrow: 1,
|
|
161
|
+
flexShrink: 1,
|
|
162
|
+
minHeight: 0,
|
|
163
|
+
backgroundColor: look.background,
|
|
164
|
+
},
|
|
165
|
+
view.style,
|
|
166
|
+
]}
|
|
167
|
+
data-testname="agent-chat"
|
|
168
|
+
>
|
|
169
|
+
{header === true ? (
|
|
170
|
+
<Header />
|
|
171
|
+
) : typeof header === 'function' ? (
|
|
172
|
+
header(session)
|
|
173
|
+
) : null}
|
|
174
|
+
{snap.status === 'unavailable' || snap.status === 'error' ? (
|
|
175
|
+
<box
|
|
176
|
+
style={{
|
|
177
|
+
padding: 10,
|
|
178
|
+
borderBottomWidth: 1,
|
|
179
|
+
borderBottomColor: look.border,
|
|
180
|
+
}}
|
|
181
|
+
>
|
|
182
|
+
<text
|
|
183
|
+
style={{
|
|
184
|
+
color: snap.status === 'error' ? look.danger : look.muted,
|
|
185
|
+
}}
|
|
186
|
+
>
|
|
187
|
+
{snap.error ?? 'The agent could not start.'}
|
|
188
|
+
</text>
|
|
189
|
+
</box>
|
|
190
|
+
) : null}
|
|
191
|
+
<Transcript empty={view.empty} />
|
|
192
|
+
<Composer
|
|
193
|
+
{...(view.placeholder !== undefined
|
|
194
|
+
? { placeholder: view.placeholder }
|
|
195
|
+
: {})}
|
|
196
|
+
{...(view.autoFocus !== undefined
|
|
197
|
+
? { autoFocus: view.autoFocus }
|
|
198
|
+
: {})}
|
|
199
|
+
{...(view.composer ? { options: view.composer } : {})}
|
|
200
|
+
/>
|
|
201
|
+
</box>
|
|
202
|
+
</ChatContext.Provider>
|
|
203
|
+
);
|
|
204
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
// A fenced code block with a header of actions: Copy by default, and
|
|
2
|
+
// whatever the app's `codeActions` add. Nothing here runs code; an action
|
|
3
|
+
// that does is the app's.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useRef, useState, type ReactElement } from 'react';
|
|
6
|
+
import { Button, useClipboard } from 'react-x11';
|
|
7
|
+
import { Code } from '@react-x11/components/code';
|
|
8
|
+
import type { FenceInfo } from '@react-x11/components/markdown';
|
|
9
|
+
|
|
10
|
+
import type { AgentMessage } from '../core/types.js';
|
|
11
|
+
import { useChat } from './context.js';
|
|
12
|
+
import { useLook } from './look.js';
|
|
13
|
+
import type { CodeAction, CodeActionContext } from './types.js';
|
|
14
|
+
|
|
15
|
+
export interface CodeBlockProps {
|
|
16
|
+
fence: FenceInfo;
|
|
17
|
+
message: AgentMessage;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function CodeBlock({ fence, message }: CodeBlockProps): ReactElement {
|
|
21
|
+
const { codeActions, session } = useChat();
|
|
22
|
+
const look = useLook();
|
|
23
|
+
const clipboard = useClipboard();
|
|
24
|
+
const [done, setDone] = useState<string | null>(null);
|
|
25
|
+
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
26
|
+
useEffect(
|
|
27
|
+
() => () => {
|
|
28
|
+
if (timer.current) clearTimeout(timer.current);
|
|
29
|
+
},
|
|
30
|
+
[],
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
// an action on code still streaming in would act on half of it
|
|
34
|
+
const actions = fence.partial
|
|
35
|
+
? []
|
|
36
|
+
: codeActions.filter((a) => !a.when || a.when(fence));
|
|
37
|
+
|
|
38
|
+
const run = (action: CodeAction) => {
|
|
39
|
+
const ctx: CodeActionContext = {
|
|
40
|
+
session,
|
|
41
|
+
message,
|
|
42
|
+
copy: (text) => clipboard.writeText(text),
|
|
43
|
+
};
|
|
44
|
+
Promise.resolve()
|
|
45
|
+
.then(() => action.run(fence, ctx))
|
|
46
|
+
.then(
|
|
47
|
+
() => {
|
|
48
|
+
if (!action.doneLabel) return;
|
|
49
|
+
setDone(action.id);
|
|
50
|
+
if (timer.current) clearTimeout(timer.current);
|
|
51
|
+
timer.current = setTimeout(() => setDone(null), 1500);
|
|
52
|
+
},
|
|
53
|
+
(err: unknown) =>
|
|
54
|
+
session.notify(
|
|
55
|
+
'error',
|
|
56
|
+
`${action.label} failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
57
|
+
),
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
return (
|
|
62
|
+
<box
|
|
63
|
+
style={{
|
|
64
|
+
flexDirection: 'column',
|
|
65
|
+
borderWidth: 1,
|
|
66
|
+
borderColor: look.border,
|
|
67
|
+
borderRadius: 6,
|
|
68
|
+
overflow: 'hidden',
|
|
69
|
+
}}
|
|
70
|
+
>
|
|
71
|
+
<box
|
|
72
|
+
style={{
|
|
73
|
+
flexDirection: 'row',
|
|
74
|
+
alignItems: 'center',
|
|
75
|
+
gap: 4,
|
|
76
|
+
paddingLeft: 10,
|
|
77
|
+
paddingRight: 4,
|
|
78
|
+
minHeight: 26,
|
|
79
|
+
borderBottomWidth: 1,
|
|
80
|
+
borderBottomColor: look.border,
|
|
81
|
+
}}
|
|
82
|
+
>
|
|
83
|
+
<text style={{ color: look.muted, fontSize: 11, flexGrow: 1 }}>
|
|
84
|
+
{fence.lang || 'text'}
|
|
85
|
+
</text>
|
|
86
|
+
{actions.map((action) => (
|
|
87
|
+
<Button
|
|
88
|
+
key={action.id}
|
|
89
|
+
size="small"
|
|
90
|
+
variant="ghost"
|
|
91
|
+
label={
|
|
92
|
+
done === action.id
|
|
93
|
+
? (action.doneLabel ?? action.label)
|
|
94
|
+
: action.label
|
|
95
|
+
}
|
|
96
|
+
onPress={() => run(action)}
|
|
97
|
+
/>
|
|
98
|
+
))}
|
|
99
|
+
</box>
|
|
100
|
+
<Code
|
|
101
|
+
source={fence.text}
|
|
102
|
+
{...(fence.lang ? { lang: fence.lang } : {})}
|
|
103
|
+
wrap
|
|
104
|
+
style={{ borderWidth: 0, borderRadius: 0 }}
|
|
105
|
+
/>
|
|
106
|
+
</box>
|
|
107
|
+
);
|
|
108
|
+
}
|