@adecore/agents 0.29.2 → 0.30.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.
Files changed (47) hide show
  1. package/dist/chat/acp-backend.d.ts +59 -0
  2. package/dist/chat/acp-backend.js +407 -0
  3. package/dist/chat/acp-protocol.d.ts +131 -0
  4. package/dist/chat/acp-protocol.js +605 -0
  5. package/dist/chat/chat-session.d.ts +2 -0
  6. package/dist/chat/chat-session.js +14 -10
  7. package/dist/chat/codex-transport.d.ts +4 -27
  8. package/dist/chat/codex-transport.js +4 -185
  9. package/dist/chat/fixtures/acp-v1-turn.json +240 -0
  10. package/dist/chat/index.d.ts +2 -0
  11. package/dist/chat/index.js +1 -0
  12. package/dist/chat/json-rpc-transport.d.ts +50 -0
  13. package/dist/chat/json-rpc-transport.js +203 -0
  14. package/dist/providers/accounts/accounts.d.ts +10 -6
  15. package/dist/providers/accounts/accounts.js +19 -19
  16. package/dist/providers/accounts/service.d.ts +3 -0
  17. package/dist/providers/accounts/service.js +20 -13
  18. package/dist/providers/accounts/store.d.ts +4 -5
  19. package/dist/providers/accounts/store.js +12 -12
  20. package/dist/providers/acp-provider.d.ts +22 -0
  21. package/dist/providers/acp-provider.js +45 -0
  22. package/dist/providers/catalog.js +1 -0
  23. package/dist/providers/index.d.ts +1 -0
  24. package/dist/providers/index.js +1 -0
  25. package/dist/providers/registry.d.ts +1 -0
  26. package/dist/providers/registry.js +4 -0
  27. package/dist/testing/fake-acp.d.ts +10 -0
  28. package/dist/testing/fake-acp.js +262 -0
  29. package/dist/testing/index.d.ts +1 -0
  30. package/dist/testing/index.js +1 -0
  31. package/package.json +3 -3
  32. package/src/chat/acp-backend.ts +476 -0
  33. package/src/chat/acp-protocol.ts +763 -0
  34. package/src/chat/chat-session.ts +17 -8
  35. package/src/chat/codex-transport.ts +6 -207
  36. package/src/chat/fixtures/acp-v1-turn.json +240 -0
  37. package/src/chat/index.ts +2 -0
  38. package/src/chat/json-rpc-transport.ts +253 -0
  39. package/src/providers/accounts/accounts.ts +28 -25
  40. package/src/providers/accounts/service.ts +37 -14
  41. package/src/providers/accounts/store.ts +14 -17
  42. package/src/providers/acp-provider.ts +73 -0
  43. package/src/providers/catalog.ts +1 -0
  44. package/src/providers/index.ts +1 -0
  45. package/src/providers/registry.ts +5 -0
  46. package/src/testing/fake-acp.ts +296 -0
  47. package/src/testing/index.ts +1 -0
@@ -0,0 +1,605 @@
1
+ import { isRecord } from '@adecore/agent-contracts';
2
+ import { z } from 'zod';
3
+ import { MODE_ORDER } from '../modes.js';
4
+ /*
5
+ * The Agent Client Protocol (https://agentclientprotocol.com), version 1, as far as a chat needs it.
6
+ * Every schema is loose: an agent may add fields, and a field or an update this version does not
7
+ * know is ignored rather than refused, as the protocol asks of a client.
8
+ */
9
+ export const ACP_PROTOCOL_VERSION = 1;
10
+ // The code an agent answers `session/new` or `session/load` with when it needs `authenticate` first.
11
+ export const ACP_AUTH_REQUIRED = -32000;
12
+ const ContentBlockSchema = z.looseObject({ type: z.string(), text: z.string().optional() });
13
+ const ContentChunkSchema = z.looseObject({ content: ContentBlockSchema, messageId: z.string().nullish() });
14
+ const ToolCallSchema = z.looseObject({
15
+ toolCallId: z.string(),
16
+ title: z.string().nullish(),
17
+ name: z.string().nullish(),
18
+ kind: z.string().nullish(),
19
+ status: z.string().nullish(),
20
+ content: z.array(z.unknown()).nullish(),
21
+ locations: z.array(z.unknown()).nullish(),
22
+ rawInput: z.unknown().optional(),
23
+ rawOutput: z.unknown().optional()
24
+ });
25
+ const DiffSchema = z.looseObject({ type: z.literal('diff'), path: z.string(), oldText: z.string().nullish(), newText: z.string() });
26
+ const ToolContentSchema = z.looseObject({ type: z.literal('content'), content: ContentBlockSchema });
27
+ const LocationSchema = z.looseObject({ path: z.string() });
28
+ const PlanEntrySchema = z.looseObject({ content: z.string(), status: z.string(), priority: z.string().optional() });
29
+ const CommandSchema = z.looseObject({ name: z.string() });
30
+ const UsageSchema = z.looseObject({
31
+ used: z.number(),
32
+ size: z.number(),
33
+ cost: z.looseObject({ amount: z.number(), currency: z.string() }).nullish()
34
+ });
35
+ const PermissionOptionSchema = z.looseObject({ optionId: z.string(), name: z.string(), kind: z.string() });
36
+ const PermissionRequestSchema = z.looseObject({
37
+ sessionId: z.string(),
38
+ toolCall: ToolCallSchema,
39
+ options: z.array(PermissionOptionSchema)
40
+ });
41
+ const SessionNotificationSchema = z.looseObject({ sessionId: z.string(), update: z.looseObject({ sessionUpdate: z.string() }) });
42
+ const PromptResponseSchema = z.looseObject({ stopReason: z.string() });
43
+ const ModeStateSchema = z.looseObject({
44
+ currentModeId: z.string(),
45
+ availableModes: z.array(z.unknown())
46
+ });
47
+ const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
48
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
49
+ const ConfigOptionSchema = z.looseObject({
50
+ id: z.string(),
51
+ name: z.string(),
52
+ type: z.string(),
53
+ category: z.string().nullish(),
54
+ currentValue: z.unknown().optional(),
55
+ options: z.array(z.unknown()).optional()
56
+ });
57
+ // Models as agents reported them before the protocol moved them into config options; still sent by some.
58
+ const ModelStateSchema = z.looseObject({
59
+ currentModelId: z.string(),
60
+ availableModels: z.array(z.unknown())
61
+ });
62
+ const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
63
+ const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
64
+ const InitializeResponseSchema = z.looseObject({
65
+ protocolVersion: z.number(),
66
+ agentCapabilities: z
67
+ .looseObject({
68
+ loadSession: z.boolean().optional(),
69
+ promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
70
+ sessionCapabilities: z
71
+ .looseObject({
72
+ resume: z.unknown().optional(),
73
+ additionalDirectories: z.unknown().optional()
74
+ })
75
+ .optional()
76
+ })
77
+ .optional(),
78
+ authMethods: z.array(z.unknown()).optional(),
79
+ agentInfo: z.looseObject({ name: z.string(), title: z.string().nullish(), version: z.string() }).nullish()
80
+ });
81
+ const SessionResponseSchema = z.looseObject({ sessionId: z.string().optional() });
82
+ /* Parses each item on its own, dropping the ones that do not fit instead of the whole list. */
83
+ function validItems(schema, items) {
84
+ return (items ?? []).flatMap((item) => {
85
+ const parsed = schema.safeParse(item);
86
+ return parsed.success ? [parsed.data] : [];
87
+ });
88
+ }
89
+ export function parseInitialize(result) {
90
+ const parsed = InitializeResponseSchema.safeParse(result);
91
+ if (!parsed.success) {
92
+ throw new Error('initialize failed: the agent answered with something that is not an initialize response');
93
+ }
94
+ const capabilities = parsed.data.agentCapabilities;
95
+ const sessions = capabilities?.sessionCapabilities;
96
+ const agent = parsed.data.agentInfo;
97
+ return {
98
+ protocolVersion: parsed.data.protocolVersion,
99
+ loadSession: capabilities?.loadSession === true,
100
+ resumeSession: sessions?.resume !== undefined && sessions.resume !== null,
101
+ additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
102
+ images: capabilities?.promptCapabilities?.image === true,
103
+ authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
104
+ agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
105
+ };
106
+ }
107
+ /* The values of a select option, grouped or not. */
108
+ function selectValues(option) {
109
+ const flat = (option.options ?? []).flatMap((entry) => (isRecord(entry) && Array.isArray(entry.options) ? entry.options : [entry]));
110
+ return validItems(SelectValueSchema, flat);
111
+ }
112
+ function selectOption(options, category) {
113
+ return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
114
+ }
115
+ export function parseSessionInfo(result, sessionId, agent) {
116
+ const record = isRecord(result) ? result : {};
117
+ const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
118
+ return { sessionId, agent, ...sessionState(options, record.modes, record.models) };
119
+ }
120
+ function sessionState(options, modes, models) {
121
+ const modelOption = selectOption(options, 'model');
122
+ const modeOption = selectOption(options, 'mode');
123
+ const thoughtOption = selectOption(options, 'thought_level');
124
+ const modeState = ModeStateSchema.safeParse(modes);
125
+ const modelState = ModelStateSchema.safeParse(models);
126
+ return {
127
+ models: modelOption
128
+ ? {
129
+ current: modelOption.currentValue,
130
+ models: selectValues(modelOption).map((value) => ({ id: value.value, name: value.name, description: null })),
131
+ configId: modelOption.id
132
+ }
133
+ : modelState.success
134
+ ? {
135
+ current: modelState.data.currentModelId,
136
+ models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
137
+ id: model.modelId,
138
+ name: model.name,
139
+ description: model.description ?? null
140
+ })),
141
+ configId: null
142
+ }
143
+ : null,
144
+ modes: modeOption
145
+ ? {
146
+ current: modeOption.currentValue,
147
+ modes: selectValues(modeOption).map((value) => ({ id: value.value, name: value.name })),
148
+ configId: modeOption.id
149
+ }
150
+ : modeState.success
151
+ ? {
152
+ current: modeState.data.currentModeId,
153
+ modes: validItems(ModeSchema, modeState.data.availableModes).map((mode) => ({ id: mode.id, name: mode.name })),
154
+ configId: null
155
+ }
156
+ : null,
157
+ thoughtLevel: thoughtOption
158
+ ? { configId: thoughtOption.id, current: thoughtOption.currentValue, values: selectValues(thoughtOption).map((value) => value.value) }
159
+ : null
160
+ };
161
+ }
162
+ export function sessionIdOf(result) {
163
+ const parsed = SessionResponseSchema.safeParse(result);
164
+ return parsed.success ? (parsed.data.sessionId ?? null) : null;
165
+ }
166
+ /*
167
+ * The agent mode a runtime mode runs in: the first id the agent offers for that mode, else for the
168
+ * next narrower one, and so on down to `supervised`. Never a wider one; null when nothing fits.
169
+ */
170
+ export function resolveAcpMode(requested, map, offered) {
171
+ for (let index = MODE_ORDER.indexOf(requested); index >= 0; index--) {
172
+ const runtimeMode = MODE_ORDER[index];
173
+ const modeId = (map[runtimeMode] ?? []).find((candidate) => offered.includes(candidate));
174
+ if (modeId !== undefined) {
175
+ return { modeId, runtimeMode };
176
+ }
177
+ }
178
+ return null;
179
+ }
180
+ /* The widest runtime mode a map puts an agent mode under, which is what that mode grants; null for one it does not name. */
181
+ export function runtimeModeOf(modeId, map) {
182
+ for (let index = MODE_ORDER.length - 1; index >= 0; index--) {
183
+ const runtimeMode = MODE_ORDER[index];
184
+ if ((map[runtimeMode] ?? []).includes(modeId)) {
185
+ return runtimeMode;
186
+ }
187
+ }
188
+ return null;
189
+ }
190
+ // The tool kinds whose requests a runtime mode answers on its own when the agent has no mode for it.
191
+ const AUTO_ALLOWED = {
192
+ supervised: new Set(),
193
+ 'auto-accept-edits': new Set(['read', 'search', 'think', 'edit', 'move']),
194
+ auto: new Set(['read', 'search', 'think', 'edit', 'move', 'fetch']),
195
+ 'full-access': 'all'
196
+ };
197
+ export function autoAllows(mode, kind) {
198
+ const allowed = AUTO_ALLOWED[mode];
199
+ return allowed === 'all' || (kind !== null && allowed.has(kind));
200
+ }
201
+ /* The trimmed hunk between two texts, as one unified diff; null when they are the same. */
202
+ export function unifiedDiff(oldText, newText) {
203
+ const lines = (text) => {
204
+ const split = text === '' ? [] : text.split('\n');
205
+ if (split.at(-1) === '') {
206
+ split.pop();
207
+ }
208
+ return split;
209
+ };
210
+ const before = lines(oldText);
211
+ const after = lines(newText);
212
+ let prefix = 0;
213
+ while (prefix < before.length && prefix < after.length && before[prefix] === after[prefix]) {
214
+ prefix++;
215
+ }
216
+ let suffix = 0;
217
+ while (suffix < before.length - prefix && suffix < after.length - prefix && before[before.length - 1 - suffix] === after[after.length - 1 - suffix]) {
218
+ suffix++;
219
+ }
220
+ const removed = before.slice(prefix, before.length - suffix);
221
+ const added = after.slice(prefix, after.length - suffix);
222
+ if (removed.length === 0 && added.length === 0) {
223
+ return null;
224
+ }
225
+ const range = (count) => `${count === 0 ? prefix : prefix + 1},${count}`;
226
+ return ([`@@ -${range(removed.length)} +${range(added.length)} @@`, ...removed.map((line) => `-${line}`), ...added.map((line) => `+${line}`)].join('\n') + '\n');
227
+ }
228
+ // `Edit` would have the thread draw a diff of nothing until the agent sends one.
229
+ const KIND_NAMES = {
230
+ edit: 'Change',
231
+ delete: 'Delete',
232
+ move: 'Move',
233
+ think: 'Think',
234
+ switch_mode: 'Switch mode',
235
+ other: 'Tool'
236
+ };
237
+ function str(value) {
238
+ return typeof value === 'string' && value !== '' ? value : null;
239
+ }
240
+ /* A field of the agent's own input, which no schema promises; the first one that is a non-empty string. */
241
+ function rawField(raw, ...keys) {
242
+ if (!isRecord(raw)) {
243
+ return null;
244
+ }
245
+ for (const key of keys) {
246
+ const value = raw[key];
247
+ if (Array.isArray(value) && value.every((part) => typeof part === 'string') && value.length > 0) {
248
+ return value.join(' ');
249
+ }
250
+ if (str(value) !== null) {
251
+ return value;
252
+ }
253
+ }
254
+ return null;
255
+ }
256
+ /* The row a tool call becomes: a name the thread has words for where the kind says which, and an input it reads a subject from. */
257
+ export function toolRow(tool) {
258
+ const description = tool.title;
259
+ const path = tool.locations[0] ?? rawField(tool.rawInput, 'file_path', 'path', 'filePath') ?? null;
260
+ if (tool.diffs.length > 0) {
261
+ const paths = new Set(tool.diffs.map((diff) => diff.path));
262
+ if (paths.size === 1) {
263
+ const [first] = tool.diffs;
264
+ if (tool.diffs.length === 1 && (first.oldText ?? null) === null) {
265
+ return { name: 'Write', input: { file_path: first.path, content: first.newText, description } };
266
+ }
267
+ if (tool.diffs.length === 1) {
268
+ return { name: 'Edit', input: { file_path: first.path, old_string: first.oldText, new_string: first.newText, description } };
269
+ }
270
+ return {
271
+ name: 'MultiEdit',
272
+ input: { file_path: first.path, edits: tool.diffs.map((diff) => ({ old_string: diff.oldText ?? '', new_string: diff.newText })), description }
273
+ };
274
+ }
275
+ const changes = tool.diffs.flatMap((diff) => {
276
+ const text = unifiedDiff(diff.oldText ?? '', diff.newText);
277
+ return text === null ? [] : [{ path: diff.path, kind: (diff.oldText ?? null) === null ? 'add' : 'update', diff: text }];
278
+ });
279
+ return { name: 'ApplyPatch', input: { summary: [...paths].join(', '), description }, changes };
280
+ }
281
+ const raw = isRecord(tool.rawInput) ? tool.rawInput : {};
282
+ switch (tool.kind) {
283
+ case 'execute':
284
+ return { name: 'Bash', input: { ...raw, description, command: rawField(tool.rawInput, 'command', 'cmd') ?? description } };
285
+ case 'read':
286
+ return { name: 'Read', input: { ...raw, description, ...(path === null ? {} : { file_path: path }) } };
287
+ case 'search':
288
+ return { name: 'Grep', input: { ...raw, description, pattern: rawField(tool.rawInput, 'pattern', 'query', 'regex') ?? description } };
289
+ case 'fetch': {
290
+ const url = rawField(tool.rawInput, 'url', 'uri');
291
+ return url === null
292
+ ? { name: 'WebSearch', input: { ...raw, description, query: rawField(tool.rawInput, 'query') ?? description } }
293
+ : { name: 'WebFetch', input: { ...raw, description, url } };
294
+ }
295
+ default:
296
+ return {
297
+ name: tool.name ?? KIND_NAMES[tool.kind ?? 'other'] ?? 'Tool',
298
+ input: { description, ...raw, ...(path === null ? {} : { path }) }
299
+ };
300
+ }
301
+ }
302
+ function toolOutput(tool) {
303
+ if (tool.text.length > 0) {
304
+ return tool.text.join('\n');
305
+ }
306
+ if (typeof tool.rawOutput === 'string') {
307
+ return tool.rawOutput;
308
+ }
309
+ if (isRecord(tool.rawOutput)) {
310
+ const text = rawField(tool.rawOutput, 'output', 'stdout', 'content', 'result', 'error');
311
+ return text ?? JSON.stringify(tool.rawOutput);
312
+ }
313
+ return null;
314
+ }
315
+ export function acpStopWords(agentName) {
316
+ return {
317
+ maxTokens: `${agentName} stopped: the response reached its token limit.`,
318
+ maxTurnRequests: `${agentName} stopped: the turn reached its limit of model requests.`,
319
+ refusal: `${agentName} refused to continue this turn.`
320
+ };
321
+ }
322
+ /*
323
+ * One ACP session's `session/update` stream, mapped onto backend events. A message's chunks become
324
+ * one text item, cut where the agent switches to a thought or a tool; a tool call becomes a row that
325
+ * is drawn again whenever an update changes what it shows, and settles when it completes or fails.
326
+ */
327
+ export class AcpProtocol {
328
+ generation;
329
+ modes;
330
+ words;
331
+ tools = new Map();
332
+ permissions = new Map();
333
+ text = null;
334
+ thought = null;
335
+ counter = 0;
336
+ costUsd = 0;
337
+ constructor(generation, modes, words) {
338
+ this.generation = generation;
339
+ this.modes = modes;
340
+ this.words = words;
341
+ }
342
+ /* One `session/update` notification; empty for another session, an unknown update or one that does not parse. */
343
+ update(sessionId, params) {
344
+ const notification = SessionNotificationSchema.safeParse(params);
345
+ if (!notification.success || notification.data.sessionId !== sessionId) {
346
+ return [];
347
+ }
348
+ const update = notification.data.update;
349
+ switch (update.sessionUpdate) {
350
+ case 'agent_message_chunk':
351
+ return this.messageChunk(update);
352
+ case 'agent_thought_chunk':
353
+ return this.thoughtChunk(update);
354
+ case 'tool_call':
355
+ case 'tool_call_update': {
356
+ const call = ToolCallSchema.safeParse(update);
357
+ return call.success ? [...this.closeText(), ...this.closeThought(), ...this.toolCall(call.data)] : [];
358
+ }
359
+ case 'plan':
360
+ return this.plan(update);
361
+ case 'available_commands_update': {
362
+ const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
363
+ return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
364
+ }
365
+ case 'current_mode_update':
366
+ return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
367
+ case 'config_option_update': {
368
+ const options = validItems(ConfigOptionSchema, Array.isArray(update.configOptions) ? update.configOptions : []);
369
+ const state = sessionState(options, null, null);
370
+ return [
371
+ ...(state.models === null ? [] : [{ type: 'model', model: state.models.current }]),
372
+ ...(state.modes === null ? [] : [this.modeEvent(state.modes.current)])
373
+ ];
374
+ }
375
+ case 'usage_update': {
376
+ const usage = UsageSchema.safeParse(update);
377
+ if (!usage.success) {
378
+ return [];
379
+ }
380
+ const cost = usage.data.cost?.currency === 'USD' ? usage.data.cost.amount : undefined;
381
+ if (cost !== undefined) {
382
+ this.costUsd = cost;
383
+ }
384
+ return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
385
+ }
386
+ case 'session_info_update':
387
+ return str(update.title) === null ? [] : [{ type: 'title', title: update.title }];
388
+ default:
389
+ return [];
390
+ }
391
+ }
392
+ modeEvent(modeId) {
393
+ const runtimeMode = runtimeModeOf(modeId, this.modes);
394
+ return { type: 'permissions', permissionMode: modeId, ...(runtimeMode === null ? {} : { effectiveRuntimeMode: runtimeMode }) };
395
+ }
396
+ /* A `session/request_permission` request; null when it does not parse, which the caller answers with an error. */
397
+ permission(rpcId, sessionId, params) {
398
+ const request = PermissionRequestSchema.safeParse(params);
399
+ if (!request.success || request.data.sessionId !== sessionId) {
400
+ return null;
401
+ }
402
+ const requestId = `acp-${this.generation}-${String(rpcId)}`;
403
+ const events = [...this.closeText(), ...this.closeThought(), ...this.toolCall(request.data.toolCall, false)];
404
+ const tool = this.tools.get(request.data.toolCall.toolCallId);
405
+ const row = toolRow(tool);
406
+ const options = request.data.options;
407
+ this.permissions.set(requestId, { rpcId, options });
408
+ events.push({
409
+ type: 'approval.requested',
410
+ requestId,
411
+ ref: request.data.toolCall.toolCallId,
412
+ toolName: row.name,
413
+ input: row.changes ? { ...row.input, changes: row.changes } : row.input,
414
+ description: tool.title === '' ? null : tool.title,
415
+ canAllowAlways: options.some((option) => option.kind === 'allow_always')
416
+ });
417
+ return { requestId, rpcId, kind: tool.kind, events };
418
+ }
419
+ /*
420
+ * The answer to a permission request: allow once, reject, or allow always only when the agent offered it.
421
+ * A decision the agent offered no option for falls to the narrower one, and a refusal with no reject option
422
+ * at all is a cancelled outcome.
423
+ */
424
+ answer(requestId, decision) {
425
+ const pending = this.permissions.get(requestId);
426
+ if (!pending) {
427
+ return null;
428
+ }
429
+ this.permissions.delete(requestId);
430
+ const byKind = (...kinds) => {
431
+ for (const kind of kinds) {
432
+ const option = pending.options.find((candidate) => candidate.kind === kind);
433
+ if (option) {
434
+ return option.optionId;
435
+ }
436
+ }
437
+ return null;
438
+ };
439
+ const optionId = decision === 'allow-always'
440
+ ? byKind('allow_always', 'allow_once')
441
+ : decision === 'allow'
442
+ ? byKind('allow_once', 'allow_always')
443
+ : byKind('reject_once', 'reject_always');
444
+ return { rpcId: pending.rpcId, result: { outcome: optionId === null ? { outcome: 'cancelled' } : { outcome: 'selected', optionId } } };
445
+ }
446
+ /* Every permission request still open, answered as cancelled, which the protocol asks of a client that cancels a turn. */
447
+ cancelPermissions() {
448
+ const cancelled = [...this.permissions].map(([requestId, pending]) => ({
449
+ requestId,
450
+ rpcId: pending.rpcId,
451
+ result: { outcome: { outcome: 'cancelled' } }
452
+ }));
453
+ this.permissions.clear();
454
+ return cancelled;
455
+ }
456
+ hasPermission(requestId) {
457
+ return this.permissions.has(requestId);
458
+ }
459
+ /* The end of a turn, from the `stopReason` of its `session/prompt` response. */
460
+ finish(result, promptId) {
461
+ const parsed = PromptResponseSchema.safeParse(result);
462
+ const reason = parsed.success ? parsed.data.stopReason : 'end_turn';
463
+ const events = [...this.closeText(), ...this.closeThought()];
464
+ const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
465
+ switch (reason) {
466
+ case 'cancelled':
467
+ events.push({ type: 'turn.done', ...base, state: 'aborted' });
468
+ break;
469
+ case 'refusal':
470
+ events.push({ type: 'turn.done', ...base, state: 'error', error: this.words.refusal });
471
+ break;
472
+ case 'max_tokens':
473
+ events.push({ type: 'note', level: 'warning', text: this.words.maxTokens }, { type: 'turn.done', ...base, state: 'done' });
474
+ break;
475
+ case 'max_turn_requests':
476
+ events.push({ type: 'note', level: 'warning', text: this.words.maxTurnRequests }, { type: 'turn.done', ...base, state: 'done' });
477
+ break;
478
+ default:
479
+ events.push({ type: 'turn.done', ...base, state: 'done' });
480
+ }
481
+ return events;
482
+ }
483
+ /* A turn that ended without a `stopReason`: the request failed, or was cancelled before the agent said so. */
484
+ abort(promptId, error) {
485
+ const base = { ...(promptId === undefined ? {} : { promptId }), costUsd: this.costUsd };
486
+ return [
487
+ ...this.closeText(),
488
+ ...this.closeThought(),
489
+ error === null ? { type: 'turn.done', ...base, state: 'aborted' } : { type: 'turn.done', ...base, state: 'error', error }
490
+ ];
491
+ }
492
+ messageChunk(update) {
493
+ const chunk = ContentChunkSchema.safeParse(update);
494
+ if (!chunk.success || chunk.data.content.type !== 'text' || chunk.data.content.text === undefined) {
495
+ return [];
496
+ }
497
+ const messageId = chunk.data.messageId ?? null;
498
+ const events = this.closeThought();
499
+ if (this.text !== null && messageId !== null && this.text.messageId !== messageId) {
500
+ events.push(...this.closeText());
501
+ }
502
+ if (this.text === null) {
503
+ this.text = { ref: messageId === null ? `message-${++this.counter}` : `message-${messageId}`, messageId, text: '' };
504
+ }
505
+ this.text.text += chunk.data.content.text;
506
+ events.push({ type: 'text.delta', ref: this.text.ref, text: chunk.data.content.text });
507
+ return events;
508
+ }
509
+ thoughtChunk(update) {
510
+ const chunk = ContentChunkSchema.safeParse(update);
511
+ if (!chunk.success || chunk.data.content.type !== 'text' || chunk.data.content.text === undefined) {
512
+ return [];
513
+ }
514
+ const events = this.closeText();
515
+ if (this.thought === null) {
516
+ this.thought = { ref: `thought-${++this.counter}`, text: '' };
517
+ }
518
+ this.thought.text += chunk.data.content.text;
519
+ events.push({ type: 'thinking.delta', ref: this.thought.ref, text: chunk.data.content.text });
520
+ return events;
521
+ }
522
+ closeText() {
523
+ const text = this.text;
524
+ this.text = null;
525
+ return text === null ? [] : [{ type: 'text.done', ref: text.ref, text: text.text }];
526
+ }
527
+ closeThought() {
528
+ const thought = this.thought;
529
+ this.thought = null;
530
+ return thought === null ? [] : [{ type: 'thinking.done', ref: thought.ref, text: thought.text }];
531
+ }
532
+ /* A tool call or an update to one; fields an update leaves out keep what the call said before, and a list it sends replaces the old one. */
533
+ toolCall(call, settle = true) {
534
+ const known = this.tools.get(call.toolCallId);
535
+ const tool = known ?? {
536
+ title: '',
537
+ name: null,
538
+ kind: null,
539
+ status: null,
540
+ rawInput: undefined,
541
+ rawOutput: undefined,
542
+ locations: [],
543
+ diffs: [],
544
+ text: [],
545
+ shown: null,
546
+ settled: false
547
+ };
548
+ tool.title = call.title ?? tool.title;
549
+ tool.name = call.name ?? tool.name;
550
+ tool.kind = call.kind ?? tool.kind;
551
+ tool.status = call.status ?? tool.status;
552
+ tool.rawInput = call.rawInput ?? tool.rawInput;
553
+ tool.rawOutput = call.rawOutput ?? tool.rawOutput;
554
+ if (call.locations) {
555
+ tool.locations = validItems(LocationSchema, call.locations).map((location) => location.path);
556
+ }
557
+ if (call.content) {
558
+ tool.diffs = validItems(DiffSchema, call.content);
559
+ tool.text = validItems(ToolContentSchema, call.content).flatMap((entry) => entry.content.type === 'text' && entry.content.text ? [entry.content.text] : []);
560
+ }
561
+ this.tools.set(call.toolCallId, tool);
562
+ const events = [];
563
+ const row = toolRow(tool);
564
+ const shown = JSON.stringify(row);
565
+ if (shown !== tool.shown) {
566
+ tool.shown = shown;
567
+ events.push({
568
+ type: 'tool.started',
569
+ ref: call.toolCallId,
570
+ name: row.name,
571
+ input: row.input,
572
+ parentRef: null,
573
+ ...(row.changes ? { changes: row.changes } : {})
574
+ });
575
+ }
576
+ if (settle && !tool.settled && (tool.status === 'completed' || tool.status === 'failed')) {
577
+ tool.settled = true;
578
+ events.push({
579
+ type: 'tool.done',
580
+ ref: call.toolCallId,
581
+ output: toolOutput(tool),
582
+ state: tool.status === 'completed' ? 'done' : 'error',
583
+ ...(row.changes ? { changes: row.changes } : {})
584
+ });
585
+ }
586
+ return events;
587
+ }
588
+ /* A plan is the whole list each time; it becomes a to-do row of its own, as a plan update in the thread. */
589
+ plan(update) {
590
+ const entries = validItems(PlanEntrySchema, Array.isArray(update.entries) ? update.entries : []);
591
+ const ref = `plan-${++this.counter}`;
592
+ const todos = entries.map((entry) => ({
593
+ content: entry.content,
594
+ status: entry.status,
595
+ activeForm: entry.content,
596
+ ...(entry.priority ? { priority: entry.priority } : {})
597
+ }));
598
+ return [
599
+ ...this.closeText(),
600
+ ...this.closeThought(),
601
+ { type: 'tool.started', ref, name: 'TodoWrite', input: { todos }, parentRef: null },
602
+ { type: 'tool.done', ref, output: null, state: 'done' }
603
+ ];
604
+ }
605
+ }
@@ -104,6 +104,7 @@ export declare class ChatSession {
104
104
  private staleResults;
105
105
  private spokenTurnId;
106
106
  private titleTimer;
107
+ private readonly checkpointWork;
107
108
  private naming;
108
109
  private frozen;
109
110
  private readonly subagentOwners;
@@ -232,6 +233,7 @@ export declare class ChatSession {
232
233
  private settleAgentTurn;
233
234
  private checkpoint;
234
235
  private settleCheckpoint;
236
+ private trackCheckpoint;
235
237
  private contextNote;
236
238
  private submit;
237
239
  private acceptPrompt;