@adecore/agents 0.31.3 → 0.31.5

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.
@@ -1,4 +1,6 @@
1
- import { readFileSync } from 'node:fs';
1
+ import { readFileSync, realpathSync, statSync } from 'node:fs';
2
+ import { isAbsolute, relative, resolve } from 'node:path';
3
+ import { pathToFileURL } from 'node:url';
2
4
  import { attachmentImageMime, type ChatAttachment, type ModelSelection } from '@adecore/agent-contracts';
3
5
  import { errorText } from '../error-text.ts';
4
6
  import { MODE_ORDER } from '../modes.ts';
@@ -14,10 +16,13 @@ import {
14
16
  resolveAcpMode,
15
17
  runtimeModeOf,
16
18
  sessionIdOf,
19
+ sessionUpdateOf,
20
+ thoughtLevelOf,
17
21
  type AcpAgentCapabilities,
18
22
  type AcpModeMap,
19
23
  type AcpPermissionRequest,
20
24
  type AcpSessionInfo,
25
+ type AcpThoughtLevel,
21
26
  type AcpTurnUsage
22
27
  } from './acp-protocol.ts';
23
28
  import type { ApprovalDecision, BackendEvent, BackendHost, BackendLaunch, ChatBackend, TurnInput } from './backend.ts';
@@ -54,6 +59,10 @@ export interface AcpAgentSpec {
54
59
  onSession?(info: AcpSessionInfo): void;
55
60
  // The tokens of every turn whose `session/prompt` response reports them, for a host that keeps a usage log.
56
61
  onUsage?(usage: AcpUsageReport): void;
62
+ // Called once per process, at the first `usage_update` that names the context window.
63
+ onContextReport?(): void;
64
+ // The slash commands a session offers (without the slash), each time the agent lists them.
65
+ onCommands?(commands: string[]): void;
57
66
  // A permission request the host answers itself, in any runtime mode: true allows it once without asking the
58
67
  // person, for a call the host already guards. A throw counts as false.
59
68
  autoAllow?(request: AcpPermissionRequest): boolean;
@@ -69,6 +78,15 @@ export interface AcpUsageReport extends AcpTurnUsage {
69
78
  // What `initialize` and a session request may take; a prompt and a login wait for as long as the agent runs.
70
79
  const REQUEST_TIMEOUT_MS = 60_000;
71
80
 
81
+ // What a prompt carries of mentioned files; a larger file stays a path the agent opens with its own tools.
82
+ const MAX_EMBEDDED_FILE_BYTES = 256 * 1024;
83
+ const MAX_EMBEDDED_BYTES = 1024 * 1024;
84
+
85
+ function inside(folder: string, path: string): boolean {
86
+ const rest = relative(folder, path);
87
+ return rest === '' || (!rest.startsWith('..') && !isAbsolute(rest));
88
+ }
89
+
72
90
  function nameOf(client: AcpClientInfo): string {
73
91
  return client.title ?? client.name;
74
92
  }
@@ -93,6 +111,10 @@ export class AcpBackend implements ChatBackend {
93
111
  private contextPending: string | null = null;
94
112
  private prompt: { promptId: string | undefined; cancelled: boolean } | null = null;
95
113
  private model: string | null = null;
114
+ private contextReported = false;
115
+ private commands: string[] = [];
116
+ // An agent may send updates for a new session before it answers with the session's id.
117
+ private early: unknown[] = [];
96
118
 
97
119
  constructor(launch: BackendLaunch, host: BackendHost, spec: AcpAgentSpec) {
98
120
  this.launch = launch;
@@ -154,9 +176,9 @@ export class AcpBackend implements ChatBackend {
154
176
  this.capabilities = capabilities;
155
177
  const info = await this.openSession(transport, capabilities);
156
178
  const modeId = await this.applyMode(transport, info);
157
- const model = await this.applyModel(transport, info);
179
+ const { model, thoughtLevel } = await this.applyModel(transport, info);
158
180
  this.model = model;
159
- await this.applyThoughtLevel(transport, info);
181
+ await this.applyThoughtLevel(transport, thoughtLevel);
160
182
  this.spec.onSession?.(info);
161
183
  this.emit({
162
184
  type: 'session',
@@ -165,6 +187,9 @@ export class AcpBackend implements ChatBackend {
165
187
  effectiveRuntimeMode: this.launch.runtimeMode,
166
188
  ...(modeId === null ? {} : { permissionMode: modeId })
167
189
  });
190
+ for (const params of this.early.splice(0)) {
191
+ this.emitAll(this.protocol.update(this.sessionId, params));
192
+ }
168
193
  }
169
194
 
170
195
  private sessionParams(): Record<string, unknown> {
@@ -263,24 +288,29 @@ export class AcpBackend implements ChatBackend {
263
288
  return resolved.modeId;
264
289
  }
265
290
 
266
- /* Switches to the selected model where the agent offers it; answers the model the session runs. */
267
- private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<string | null> {
291
+ /*
292
+ * Switches to the selected model where the agent offers it; answers the model the session runs and the
293
+ * reasoning effort it offers there, which may change with the model.
294
+ */
295
+ private async applyModel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<{ model: string | null; thoughtLevel: AcpThoughtLevel | null }> {
268
296
  const models = info.models;
297
+ const thoughtLevel = info.thoughtLevel;
269
298
  const wanted = this.spec.model ? this.spec.model(this.launch.selection) : this.launch.selection.model;
270
299
  if (models === null || wanted === null || wanted === models.current) {
271
- return models?.current ?? wanted;
300
+ return { model: models?.current ?? wanted, thoughtLevel };
272
301
  }
273
302
  if (!models.models.some((model) => model.id === wanted)) {
274
303
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} does not offer ${this.launch.modelName}. It runs ${models.current}.` });
275
- return models.current;
304
+ return { model: models.current, thoughtLevel };
276
305
  }
277
306
  try {
278
307
  if (models.configId === null) {
279
308
  await transport.request('session/set_model', { sessionId: this.sessionId, modelId: wanted });
280
- } else {
281
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
309
+ return { model: wanted, thoughtLevel };
282
310
  }
283
- return wanted;
311
+ const result = await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: models.configId, value: wanted });
312
+ const after = thoughtLevelOf(result);
313
+ return { model: wanted, thoughtLevel: after === undefined ? thoughtLevel : after };
284
314
  } catch (error) {
285
315
  if (!(error instanceof JsonRpcError)) {
286
316
  throw error;
@@ -290,21 +320,26 @@ export class AcpBackend implements ChatBackend {
290
320
  level: 'warning',
291
321
  text: `${this.spec.name} did not switch to ${this.launch.modelName} (${errorText(error)}). It runs ${models.current}.`
292
322
  });
293
- return models.current;
323
+ return { model: models.current, thoughtLevel };
294
324
  }
295
325
  }
296
326
 
297
- private async applyThoughtLevel(transport: JsonRpcTransport, info: AcpSessionInfo): Promise<void> {
298
- const level = info.thoughtLevel;
327
+ /* Sets the selected effort where the session offers it; a model without one runs as it is. */
328
+ private async applyThoughtLevel(transport: JsonRpcTransport, level: AcpThoughtLevel | null): Promise<void> {
299
329
  const effort = this.launch.selection.options.effort;
300
- if (level === null || typeof effort !== 'string' || effort === level.current || !level.values.includes(effort)) {
330
+ const choice = level?.choices.find((entry) => entry.id === effort);
331
+ if (level === null || choice === undefined || effort === level.current) {
301
332
  return;
302
333
  }
303
- await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: effort }).catch((error: unknown) => {
334
+ try {
335
+ await transport.request('session/set_config_option', { sessionId: this.sessionId, configId: level.configId, value: choice.id });
336
+ } catch (error) {
304
337
  if (!(error instanceof JsonRpcError)) {
305
338
  throw error;
306
339
  }
307
- });
340
+ const current = level.choices.find((entry) => entry.id === level.current)?.name ?? level.current;
341
+ this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} kept its reasoning at ${current} instead of ${choice.name}.` });
342
+ }
308
343
  }
309
344
 
310
345
  private promptBlocks(input: TurnInput): unknown[] {
@@ -322,7 +357,50 @@ export class AcpBackend implements ChatBackend {
322
357
  const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
323
358
  const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
324
359
  this.contextPending = null;
325
- return [{ type: 'text', text: parts.join('\n\n') }, ...blocks];
360
+ return [{ type: 'text', text: parts.join('\n\n') }, ...blocks, ...this.mentionBlocks(input.mentions)];
361
+ }
362
+
363
+ /*
364
+ * The text of each mentioned file as a `resource` block, for an agent that takes embedded context. Only a text file
365
+ * inside the chat's folders goes along, since a mention is a path anyone who may send could name; the rest stays
366
+ * in the prompt as the path it is.
367
+ */
368
+ private mentionBlocks(mentions: readonly string[]): unknown[] {
369
+ if (this.capabilities?.embeddedContext !== true || mentions.length === 0) {
370
+ return [];
371
+ }
372
+ const real = (path: string): string | null => {
373
+ try {
374
+ return realpathSync(path);
375
+ } catch {
376
+ return null;
377
+ }
378
+ };
379
+ const folders = [this.launch.cwd, ...(this.launch.folders ?? [])].flatMap((folder) => real(folder) ?? []);
380
+ const blocks: unknown[] = [];
381
+ let budget = MAX_EMBEDDED_BYTES;
382
+ for (const mention of new Set(mentions)) {
383
+ const path = real(resolve(this.launch.cwd, mention));
384
+ if (path === null || !folders.some((folder) => inside(folder, path))) {
385
+ continue;
386
+ }
387
+ let text: string;
388
+ try {
389
+ const stat = statSync(path);
390
+ if (!stat.isFile() || stat.size > MAX_EMBEDDED_FILE_BYTES || stat.size > budget) {
391
+ continue;
392
+ }
393
+ text = readFileSync(path, 'utf8');
394
+ } catch {
395
+ continue;
396
+ }
397
+ if (text.includes('\u0000')) {
398
+ continue;
399
+ }
400
+ budget -= Buffer.byteLength(text);
401
+ blocks.push({ type: 'resource', resource: { uri: pathToFileURL(path).href, text } });
402
+ }
403
+ return blocks;
326
404
  }
327
405
 
328
406
  sendTurn(input: TurnInput): void {
@@ -352,8 +430,12 @@ export class AcpBackend implements ChatBackend {
352
430
  });
353
431
  }
354
432
 
355
- /* ACP has no call to fold the context; a host that offers it sends the agent's own command as a prompt. */
433
+ /* ACP has no call to fold the context, so an agent that lists a `compact` command gets it as a prompt. */
356
434
  compact(): void {
435
+ if (this.commands.includes('compact')) {
436
+ this.sendTurn({ text: '/compact', preamble: null, attachments: [], mentions: [], skills: [] });
437
+ return;
438
+ }
357
439
  this.emit({ type: 'note', level: 'warning', text: `${this.spec.name} cannot fold its context on request.` });
358
440
  this.emit({ type: 'turn.done', state: 'done', costUsd: 0 });
359
441
  }
@@ -431,9 +513,11 @@ export class AcpBackend implements ChatBackend {
431
513
  }
432
514
  const id = typeof frame.id === 'number' || typeof frame.id === 'string' ? frame.id : null;
433
515
  if (frame.method === 'session/update' && id === null) {
434
- if (!this.replaying) {
435
- this.emitAll(this.protocol.update(this.sessionId, frame.params));
516
+ if (this.sessionId === '' || this.replaying) {
517
+ this.holdEarly(frame.params);
518
+ return;
436
519
  }
520
+ this.emitAll(this.protocol.update(this.sessionId, frame.params));
437
521
  return;
438
522
  }
439
523
  if (id === null) {
@@ -467,6 +551,14 @@ export class AcpBackend implements ChatBackend {
467
551
  this.emitAll(permission.events);
468
552
  }
469
553
 
554
+ /* Holds an update until the session is known; of a history a load replays, only the commands, since the thread has the rest. */
555
+ private holdEarly(params: unknown): void {
556
+ const update = sessionUpdateOf(params);
557
+ if (update !== null && (!this.replaying || update.commands !== null)) {
558
+ this.early.push(params);
559
+ }
560
+ }
561
+
470
562
  private hostAllows(request: AcpPermissionRequest): boolean {
471
563
  try {
472
564
  return this.spec.autoAllow?.(request) === true;
@@ -504,6 +596,14 @@ export class AcpBackend implements ChatBackend {
504
596
  if (event.type === 'model') {
505
597
  this.model = event.model;
506
598
  }
599
+ if (event.type === 'session' && event.slashCommands !== undefined) {
600
+ this.commands = event.slashCommands;
601
+ this.spec.onCommands?.(event.slashCommands);
602
+ }
603
+ if (event.type === 'usage' && event.contextWindow !== undefined && !this.contextReported) {
604
+ this.contextReported = true;
605
+ this.spec.onContextReport?.();
606
+ }
507
607
  this.host.onEvent(event);
508
608
  }
509
609
  }
@@ -107,7 +107,7 @@ const ModeStateSchema = z.looseObject({
107
107
 
108
108
  const ModeSchema = z.looseObject({ id: z.string(), name: z.string() });
109
109
 
110
- const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string() });
110
+ const SelectValueSchema = z.looseObject({ value: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
111
111
 
112
112
  const ConfigOptionSchema = z.looseObject({
113
113
  id: z.string(),
@@ -124,7 +124,13 @@ const ModelStateSchema = z.looseObject({
124
124
  availableModels: z.array(z.unknown())
125
125
  });
126
126
 
127
- const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish() });
127
+ const ModelEntrySchema = z.looseObject({ modelId: z.string(), name: z.string(), description: z.string().nullish(), _meta: z.unknown().optional() });
128
+
129
+ /* The context window an agent names for a model in the model's `_meta` (`totalContextTokens`, not part of the protocol); null where it names none. */
130
+ function contextWindowOf(meta: unknown): number | null {
131
+ const tokens = isRecord(meta) ? meta.totalContextTokens : undefined;
132
+ return typeof tokens === 'number' && Number.isSafeInteger(tokens) && tokens > 0 ? tokens : null;
133
+ }
128
134
 
129
135
  const AuthMethodSchema = z.looseObject({ id: z.string(), name: z.string() });
130
136
 
@@ -133,7 +139,7 @@ const InitializeResponseSchema = z.looseObject({
133
139
  agentCapabilities: z
134
140
  .looseObject({
135
141
  loadSession: z.boolean().optional(),
136
- promptCapabilities: z.looseObject({ image: z.boolean().optional() }).optional(),
142
+ promptCapabilities: z.looseObject({ image: z.boolean().optional(), embeddedContext: z.boolean().optional() }).optional(),
137
143
  sessionCapabilities: z
138
144
  .looseObject({
139
145
  resume: z.unknown().optional(),
@@ -172,6 +178,8 @@ export interface AcpAgentCapabilities {
172
178
  // Whether the agent takes `session/close`, which ends a session it would otherwise keep.
173
179
  closeSession: boolean;
174
180
  images: boolean;
181
+ // Whether a prompt may carry a file's text as a `resource` block.
182
+ embeddedContext: boolean;
175
183
  authMethods: string[];
176
184
  agent: AcpAgentInfo | null;
177
185
  }
@@ -191,6 +199,7 @@ export function parseInitialize(result: unknown): AcpAgentCapabilities {
191
199
  additionalDirectories: sessions?.additionalDirectories !== undefined && sessions.additionalDirectories !== null,
192
200
  closeSession: sessions?.close !== undefined && sessions.close !== null,
193
201
  images: capabilities?.promptCapabilities?.image === true,
202
+ embeddedContext: capabilities?.promptCapabilities?.embeddedContext === true,
194
203
  authMethods: validItems(AuthMethodSchema, parsed.data.authMethods).map((method) => method.id),
195
204
  agent: agent ? { name: agent.name, title: agent.title ?? null, version: agent.version } : null
196
205
  };
@@ -200,6 +209,8 @@ export interface AcpModel {
200
209
  id: string;
201
210
  name: string;
202
211
  description: string | null;
212
+ // In tokens, where the agent names it.
213
+ contextWindow: number | null;
203
214
  }
204
215
 
205
216
  /* The models a session offers. `configId` is the config option that picks one, or null where the agent takes `session/set_model`. */
@@ -221,14 +232,27 @@ export interface AcpModeState {
221
232
  configId: string | null;
222
233
  }
223
234
 
235
+ export interface AcpChoice {
236
+ id: string;
237
+ name: string;
238
+ description: string | null;
239
+ }
240
+
241
+ /* The reasoning effort a session offers: the config option that sets it, the value it runs at and the values it takes. */
242
+ export interface AcpThoughtLevel {
243
+ configId: string;
244
+ current: string;
245
+ choices: AcpChoice[];
246
+ }
247
+
224
248
  /* What `session/new`, `session/load` or `session/resume` said about the session, for a host that fills its catalog from it. */
225
249
  export interface AcpSessionInfo {
226
250
  sessionId: string;
227
251
  agent: AcpAgentInfo | null;
228
252
  models: AcpModelState | null;
229
253
  modes: AcpModeState | null;
230
- // The reasoning effort a session offers, as a config option with the `thought_level` category.
231
- thoughtLevel: { configId: string; current: string; values: string[] } | null;
254
+ // A config option of the `thought_level` category, or one with the id `reasoning_effort` where an agent sets no category.
255
+ thoughtLevel: AcpThoughtLevel | null;
232
256
  }
233
257
 
234
258
  type ConfigOption = z.infer<typeof ConfigOptionSchema>;
@@ -243,6 +267,22 @@ function selectOption(options: ConfigOption[], category: string): ConfigOption |
243
267
  return options.find((option) => option.type === 'select' && option.category === category && typeof option.currentValue === 'string') ?? null;
244
268
  }
245
269
 
270
+ function thoughtOption(options: ConfigOption[]): ConfigOption | null {
271
+ return (
272
+ selectOption(options, 'thought_level') ??
273
+ options.find((option) => option.type === 'select' && option.id === 'reasoning_effort' && typeof option.currentValue === 'string') ??
274
+ null
275
+ );
276
+ }
277
+
278
+ /* The reasoning effort in a response that carries the session's config options; undefined when it carries none. */
279
+ export function thoughtLevelOf(result: unknown): AcpThoughtLevel | null | undefined {
280
+ if (!isRecord(result) || !Array.isArray(result.configOptions)) {
281
+ return undefined;
282
+ }
283
+ return sessionState(validItems(ConfigOptionSchema, result.configOptions), null, null).thoughtLevel;
284
+ }
285
+
246
286
  export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpAgentInfo | null): AcpSessionInfo {
247
287
  const record = isRecord(result) ? result : {};
248
288
  const options = validItems(ConfigOptionSchema, Array.isArray(record.configOptions) ? record.configOptions : []);
@@ -252,14 +292,19 @@ export function parseSessionInfo(result: unknown, sessionId: string, agent: AcpA
252
292
  function sessionState(options: ConfigOption[], modes: unknown, models: unknown): Omit<AcpSessionInfo, 'sessionId' | 'agent'> {
253
293
  const modelOption = selectOption(options, 'model');
254
294
  const modeOption = selectOption(options, 'mode');
255
- const thoughtOption = selectOption(options, 'thought_level');
295
+ const thought = thoughtOption(options);
256
296
  const modeState = ModeStateSchema.safeParse(modes);
257
297
  const modelState = ModelStateSchema.safeParse(models);
258
298
  return {
259
299
  models: modelOption
260
300
  ? {
261
301
  current: modelOption.currentValue as string,
262
- models: selectValues(modelOption).map((value) => ({ id: value.value, name: value.name, description: null })),
302
+ models: selectValues(modelOption).map((value) => ({
303
+ id: value.value,
304
+ name: value.name,
305
+ description: null,
306
+ contextWindow: contextWindowOf(value._meta)
307
+ })),
263
308
  configId: modelOption.id
264
309
  }
265
310
  : modelState.success
@@ -268,7 +313,8 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
268
313
  models: validItems(ModelEntrySchema, modelState.data.availableModels).map((model) => ({
269
314
  id: model.modelId,
270
315
  name: model.name,
271
- description: model.description ?? null
316
+ description: model.description ?? null,
317
+ contextWindow: contextWindowOf(model._meta)
272
318
  })),
273
319
  configId: null
274
320
  }
@@ -286,12 +332,33 @@ function sessionState(options: ConfigOption[], modes: unknown, models: unknown):
286
332
  configId: null
287
333
  }
288
334
  : null,
289
- thoughtLevel: thoughtOption
290
- ? { configId: thoughtOption.id, current: thoughtOption.currentValue as string, values: selectValues(thoughtOption).map((value) => value.value) }
335
+ thoughtLevel: thought
336
+ ? {
337
+ configId: thought.id,
338
+ current: thought.currentValue as string,
339
+ choices: selectValues(thought).map((value) => ({ id: value.value, name: value.name, description: value.description ?? null }))
340
+ }
291
341
  : null
292
342
  };
293
343
  }
294
344
 
345
+ function commandNames(commands: unknown): string[] {
346
+ return validItems(CommandSchema, Array.isArray(commands) ? commands : []).map((command) => command.name);
347
+ }
348
+
349
+ /* The session a `session/update` notification is for, and the commands it lists when it is an `available_commands_update`; null when it does not parse. */
350
+ export function sessionUpdateOf(params: unknown): { sessionId: string; commands: string[] | null } | null {
351
+ const notification = SessionNotificationSchema.safeParse(params);
352
+ if (!notification.success) {
353
+ return null;
354
+ }
355
+ const update = notification.data.update;
356
+ return {
357
+ sessionId: notification.data.sessionId,
358
+ commands: update.sessionUpdate === 'available_commands_update' ? commandNames(update.availableCommands) : null
359
+ };
360
+ }
361
+
295
362
  export function sessionIdOf(result: unknown): string | null {
296
363
  const parsed = SessionResponseSchema.safeParse(result);
297
364
  return parsed.success ? (parsed.data.sessionId ?? null) : null;
@@ -557,10 +624,8 @@ export class AcpProtocol {
557
624
  }
558
625
  case 'plan':
559
626
  return this.plan(update);
560
- case 'available_commands_update': {
561
- const commands = validItems(CommandSchema, Array.isArray(update.availableCommands) ? update.availableCommands : []);
562
- return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commands.map((command) => command.name) }];
563
- }
627
+ case 'available_commands_update':
628
+ return [{ type: 'session', agentSessionId: null, model: null, slashCommands: commandNames(update.availableCommands) }];
564
629
  case 'current_mode_update':
565
630
  return typeof update.currentModeId === 'string' ? [this.modeEvent(update.currentModeId)] : [];
566
631
  case 'config_option_update': {
package/src/chat/index.ts CHANGED
@@ -9,6 +9,8 @@ export type {
9
9
  AcpPermissionRequest,
10
10
  AcpSessionInfo,
11
11
  AcpModeMap,
12
+ AcpChoice,
13
+ AcpThoughtLevel,
12
14
  AcpTurnUsage
13
15
  } from './acp-protocol.ts';
14
16
  export { AttachmentStore } from './attachment-store.ts';
@@ -2,7 +2,15 @@ import { mkdtemp, rm } from 'node:fs/promises';
2
2
  import { tmpdir } from 'node:os';
3
3
  import { join } from 'node:path';
4
4
  import { DEFAULT_ACP_CLIENT, type AcpClientInfo } from '../../chat/acp-backend.ts';
5
- import { ACP_AUTH_REQUIRED, ACP_PROTOCOL_VERSION, parseInitialize, parseSessionInfo, sessionIdOf, type AcpSessionInfo } from '../../chat/acp-protocol.ts';
5
+ import {
6
+ ACP_AUTH_REQUIRED,
7
+ ACP_PROTOCOL_VERSION,
8
+ parseInitialize,
9
+ parseSessionInfo,
10
+ sessionIdOf,
11
+ sessionUpdateOf,
12
+ type AcpSessionInfo
13
+ } from '../../chat/acp-protocol.ts';
6
14
  import type { SpawnChatProcess } from '../../chat/chat-process.ts';
7
15
  import { JsonRpcError, JsonRpcTransport, type JsonRpcFrame } from '../../chat/json-rpc-transport.ts';
8
16
  import { ASK_TIMEOUT_MS, type AccountReading } from './status.ts';
@@ -24,6 +32,8 @@ export interface AcpAccountProbe {
24
32
  timer?: ProbeTimer;
25
33
  // What the probe's session offered, for a host that fills its model catalog from the agent.
26
34
  onSession?(info: AcpSessionInfo): void;
35
+ // The slash commands the probe's session offered, when the agent listed them before the session closed.
36
+ onCommands?(commands: string[]): void;
27
37
  }
28
38
 
29
39
  const SIGNED_OUT: AccountReading = { signedIn: false, email: null, plan: null, organization: null };
@@ -63,6 +73,20 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
63
73
  const folder = await mkdtemp(join(tmpdir(), 'acp-account-'));
64
74
  const client = probe.client ?? DEFAULT_ACP_CLIENT;
65
75
  let transport: JsonRpcTransport | null = null;
76
+ // An agent may list its commands before it answers with the session's id.
77
+ const listed = new Map<string, string[]>();
78
+ let sessionId: string | null = null;
79
+ const noteCommands = (frame: JsonRpcFrame): void => {
80
+ const update = frame.method === 'session/update' && (frame.id === undefined || frame.id === null) ? sessionUpdateOf(frame.params) : null;
81
+ if (update === null || update.commands === null) {
82
+ return;
83
+ }
84
+ if (sessionId === null) {
85
+ listed.set(update.sessionId, update.commands);
86
+ } else if (update.sessionId === sessionId) {
87
+ probe.onCommands?.(update.commands);
88
+ }
89
+ };
66
90
  try {
67
91
  const started = new JsonRpcTransport({
68
92
  command: [...probe.command],
@@ -74,7 +98,10 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
74
98
  // The probe's own deadline covers every request.
75
99
  requestTimeoutMs: null,
76
100
  versionField: true,
77
- onFrame: (frame) => refuseRequest(started, frame),
101
+ onFrame: (frame) => {
102
+ noteCommands(frame);
103
+ refuseRequest(started, frame);
104
+ },
78
105
  onExit: () => undefined
79
106
  });
80
107
  transport = started;
@@ -98,11 +125,15 @@ export async function askAcpAccount(probe: AcpAccountProbe): Promise<AccountRead
98
125
  }
99
126
  throw error;
100
127
  }
101
- const sessionId = sessionIdOf(result);
128
+ sessionId = sessionIdOf(result);
102
129
  if (sessionId === null) {
103
130
  throw new Error(`${probe.name} opened a session without an id`);
104
131
  }
105
132
  probe.onSession?.(parseSessionInfo(result, sessionId, capabilities.agent));
133
+ const commands = listed.get(sessionId);
134
+ if (commands !== undefined) {
135
+ probe.onCommands?.(commands);
136
+ }
106
137
  if (capabilities.closeSession) {
107
138
  // The answer is known by now; an agent that cannot close it keeps an empty session, nothing worse.
108
139
  await started.request('session/close', { sessionId }).catch(() => undefined);