@adecore/agents 0.31.6 → 0.31.8

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.
@@ -0,0 +1,11 @@
1
+ import type { FakeCli } from './fake-cli.ts';
2
+ export interface AcpCapture {
3
+ sessionId: string;
4
+ initialize: unknown;
5
+ sessionNew: unknown;
6
+ commands: unknown[];
7
+ setConfigOption: unknown;
8
+ promptUpdates: unknown[];
9
+ promptResponse: unknown;
10
+ }
11
+ export declare function fakeAcpCapture(capture: AcpCapture): FakeCli;
@@ -0,0 +1,50 @@
1
+ export function fakeAcpCapture(capture) {
2
+ return (io) => {
3
+ const send = (frame) => {
4
+ io.out({ jsonrpc: '2.0', ...frame });
5
+ };
6
+ const update = (fields) => {
7
+ send({ method: 'session/update', params: { sessionId: capture.sessionId, update: fields } });
8
+ };
9
+ return {
10
+ onLine: (line) => {
11
+ const frame = JSON.parse(line);
12
+ const id = frame.id;
13
+ switch (frame.method) {
14
+ case undefined:
15
+ return;
16
+ case 'initialize':
17
+ send({ id, result: capture.initialize });
18
+ return;
19
+ case 'session/new':
20
+ update({ sessionUpdate: 'available_commands_update', availableCommands: capture.commands });
21
+ send({ id, result: capture.sessionNew });
22
+ return;
23
+ case 'session/set_config_option':
24
+ send({ id, result: capture.setConfigOption });
25
+ return;
26
+ case 'session/set_mode':
27
+ case 'session/close':
28
+ send({ id, result: {} });
29
+ return;
30
+ case 'session/prompt': {
31
+ const blocks = Array.isArray(frame.params?.prompt) ? frame.params.prompt : [];
32
+ const text = blocks.find((block) => block.type === 'text')?.text;
33
+ for (const fields of capture.promptUpdates) {
34
+ const named = fields.sessionUpdate === 'session_info_update';
35
+ update(named ? { ...fields, title: text } : fields);
36
+ }
37
+ send({ id, result: capture.promptResponse });
38
+ return;
39
+ }
40
+ case 'session/cancel':
41
+ return;
42
+ default:
43
+ if (id !== undefined) {
44
+ send({ id, error: { code: -32601, message: `Method not found: ${String(frame.method)}` } });
45
+ }
46
+ }
47
+ }
48
+ };
49
+ };
50
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adecore/agents",
3
- "version": "0.31.6",
3
+ "version": "0.31.8",
4
4
  "description": "Node-compatible agent chat hosts, providers, accounts, usage and durable task coordination.",
5
5
  "type": "module",
6
6
  "exports": {
@@ -81,8 +81,8 @@
81
81
  "test": "bun --conditions=source test"
82
82
  },
83
83
  "dependencies": {
84
- "@adecore/agent-contracts": "0.31.6",
85
- "@adecore/intelligent-ui": "0.31.6",
84
+ "@adecore/agent-contracts": "0.31.8",
85
+ "@adecore/intelligent-ui": "0.31.8",
86
86
  "zod": "^4.6.5"
87
87
  },
88
88
  "devDependencies": {
@@ -357,7 +357,9 @@ export class AcpBackend implements ChatBackend {
357
357
  const named: ChatAttachment[] = input.attachments.filter((attachment) => !images.includes(attachment));
358
358
  const parts = [this.contextPending, input.preamble, input.text, attachmentNote(named)].filter((part): part is string => part !== null && part !== '');
359
359
  this.contextPending = null;
360
- return [{ type: 'text', text: parts.join('\n\n') }, ...blocks, ...this.mentionBlocks(input.mentions)];
360
+ const text = parts.join('\n\n');
361
+ this.protocol.prompted(text);
362
+ return [{ type: 'text', text }, ...blocks, ...this.mentionBlocks(input.mentions)];
361
363
  }
362
364
 
363
365
  /*
@@ -577,13 +579,17 @@ export class AcpBackend implements ChatBackend {
577
579
  this.emit({ type: 'exit', exitCode, ...(stderr === null ? {} : { stderr }) });
578
580
  }
579
581
 
580
- /* A cancelled turn still spent what it reports. */
582
+ /* A cancelled turn still spent what it reports. A host that fails to keep it costs the record, never the end of the turn. */
581
583
  private report(result: unknown): void {
582
584
  const usage = this.spec.onUsage === undefined ? null : promptUsageOf(result);
583
585
  if (usage === null) {
584
586
  return;
585
587
  }
586
- this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
588
+ try {
589
+ this.spec.onUsage?.({ ...usage, sessionId: this.sessionId, cwd: this.launch.cwd, model: this.model ?? this.launch.selection.model });
590
+ } catch (error) {
591
+ console.warn(`Could not keep the usage of a ${this.spec.name} turn:`, errorText(error));
592
+ }
587
593
  }
588
594
 
589
595
  private emitAll(events: BackendEvent[]): void {
@@ -1,6 +1,7 @@
1
1
  import { isRecord, type ChatFileChange, type RuntimeMode } from '@adecore/agent-contracts';
2
2
  import { z } from 'zod';
3
3
  import { MODE_ORDER } from '../modes.ts';
4
+ import { cleanTitle } from '../title-file.ts';
4
5
  import type { ApprovalDecision, BackendEvent } from './backend.ts';
5
6
 
6
7
  /*
@@ -463,6 +464,13 @@ const KIND_NAMES: Record<string, string> = {
463
464
  other: 'Tool'
464
465
  };
465
466
 
467
+ // A session's title comes from its opening prompts; later ones are not kept.
468
+ const KEPT_PROMPTS = 3;
469
+
470
+ function foldSpace(text: string): string {
471
+ return text.replace(/\s+/g, ' ').trim();
472
+ }
473
+
466
474
  function str(value: unknown): string | null {
467
475
  return typeof value === 'string' && value !== '' ? value : null;
468
476
  }
@@ -598,6 +606,8 @@ export class AcpProtocol {
598
606
  private thought: { ref: string; text: string } | null = null;
599
607
  private counter = 0;
600
608
  private costUsd = 0;
609
+ // The opening prompts of the process, whitespace folded, which an agent may hand back as the session's title.
610
+ private readonly prompts: string[] = [];
601
611
 
602
612
  constructor(generation: number, modes: AcpModeMap, words: AcpStopWords) {
603
613
  this.generation = generation;
@@ -647,13 +657,31 @@ export class AcpProtocol {
647
657
  }
648
658
  return [{ type: 'usage', contextTokens: usage.data.used, contextWindow: usage.data.size, ...(cost === undefined ? {} : { costUsd: cost }) }];
649
659
  }
650
- case 'session_info_update':
651
- return str(update.title) === null ? [] : [{ type: 'title', title: update.title as string }];
660
+ case 'session_info_update': {
661
+ const title = cleanTitle(update.title);
662
+ return title === null || this.restatesPrompt(title) ? [] : [{ type: 'title', title }];
663
+ }
652
664
  default:
653
665
  return [];
654
666
  }
655
667
  }
656
668
 
669
+ /* Notes a prompt sent to the agent, so a title that only repeats its start is not taken for a name. */
670
+ prompted(text: string): void {
671
+ if (this.prompts.length < KEPT_PROMPTS) {
672
+ this.prompts.push(foldSpace(text));
673
+ }
674
+ }
675
+
676
+ /*
677
+ * Whether a title is only the start of a prompt, as Copilot CLI 1.0.101 names a session: with a host's instructions in
678
+ * front of the message that names every chat after them, and it keeps the host from naming the chat itself.
679
+ */
680
+ private restatesPrompt(title: string): boolean {
681
+ const start = foldSpace(title.endsWith('…') ? title.slice(0, -1) : title);
682
+ return start !== '' && this.prompts.some((prompt) => prompt.startsWith(start));
683
+ }
684
+
657
685
  modeEvent(modeId: string): BackendEvent {
658
686
  const runtimeMode = runtimeModeOf(modeId, this.modes);
659
687
  return { type: 'permissions', permissionMode: modeId, ...(runtimeMode === null ? {} : { effectiveRuntimeMode: runtimeMode }) };
@@ -36,6 +36,7 @@ import { ChatThread } from './thread.ts';
36
36
  import { UiReplies, type UiReplyHost } from './ui-replies.ts';
37
37
  import { errorText } from '../error-text.ts';
38
38
  import { systemClock, type OutboxClock } from '../outbox/outbox-worker.ts';
39
+ import { cleanTitle } from '../title-file.ts';
39
40
 
40
41
  /* What a host keeps of a chat's turns in git (or anything like it), so a turn can show what it changed. */
41
42
  export interface TurnCheckpoints {
@@ -2171,8 +2172,10 @@ export class ChatSession {
2171
2172
  .catch(() => undefined);
2172
2173
  }
2173
2174
 
2174
- private applyTitle(title: string): void {
2175
- if (title === this.thread.info.suggestedTitle) {
2175
+ private applyTitle(raw: string): void {
2176
+ // Every info a client reads carries the title, so one it would refuse would leave the chat stuck there.
2177
+ const title = cleanTitle(raw);
2178
+ if (title === null || title === this.thread.info.suggestedTitle) {
2176
2179
  return;
2177
2180
  }
2178
2181
  this.emit([this.thread.patchInfo({ suggestedTitle: title })]);