@robota-sdk/agent-transport 3.0.0-beta.83 → 3.0.0-beta.84

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 (43) hide show
  1. package/CHANGELOG.md +43 -0
  2. package/README.md +80 -118
  3. package/dist/browser/client.d.ts +1 -1
  4. package/dist/browser/client.js +1 -1
  5. package/dist/browser/index.d.ts +60 -6
  6. package/dist/browser/index.d.ts.map +1 -1
  7. package/dist/browser/index.js +1 -1
  8. package/dist/browser/index.js.map +1 -1
  9. package/dist/browser/observer-messages-CEeisRaF.js +2 -0
  10. package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
  11. package/dist/browser/{observer-messages-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
  12. package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
  13. package/dist/node/index.cjs +1 -1
  14. package/dist/node/index.d.cts +181 -6
  15. package/dist/node/index.d.cts.map +1 -1
  16. package/dist/node/index.d.ts +181 -6
  17. package/dist/node/index.d.ts.map +1 -1
  18. package/dist/node/index.js +1 -1
  19. package/dist/node/index.js.map +1 -1
  20. package/package.json +12 -11
  21. package/src/__tests__/message-decoders.test.ts +295 -1
  22. package/src/__tests__/project-read-messages.test.ts +118 -0
  23. package/src/__tests__/session-directory-messages.test.ts +130 -0
  24. package/src/__tests__/session-error-classification.test.ts +113 -0
  25. package/src/__tests__/session-message-handler-observe.test.ts +25 -1
  26. package/src/__tests__/session-message-handler.test.ts +57 -3
  27. package/src/__tests__/settings-messages.test.ts +193 -0
  28. package/src/index.ts +4 -0
  29. package/src/message-decoders.ts +157 -3
  30. package/src/observer-messages.ts +11 -0
  31. package/src/project-read-messages.ts +74 -0
  32. package/src/protocol-session.ts +17 -1
  33. package/src/session-directory-messages.ts +78 -1
  34. package/src/session-error-classification.ts +91 -0
  35. package/src/session-events.ts +7 -1
  36. package/src/session-message-handler.ts +58 -8
  37. package/src/session-query-messages.ts +18 -1
  38. package/src/session-resume-bridge.ts +8 -0
  39. package/src/settings-messages.ts +103 -0
  40. package/src/wire-messages.ts +133 -4
  41. package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
  42. package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
  43. package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +0 -1
@@ -14,7 +14,10 @@
14
14
  * owner and is tracked there, not duplicated in a transport package.
15
15
  */
16
16
 
17
- import { SESSION_CHANGE_REFUSAL_CODES } from '@robota-sdk/agent-interface-session';
17
+ import {
18
+ SESSION_CHANGE_REFUSAL_CODES,
19
+ SESSION_DELETE_REFUSAL_CODES,
20
+ } from '@robota-sdk/agent-interface-session';
18
21
 
19
22
  import type { TBackgroundControlAction, TClientMessage, TServerMessage } from './wire-messages.js';
20
23
 
@@ -58,6 +61,11 @@ const isWireHistoryEntry = (v: unknown): boolean =>
58
61
  isString(v['type']);
59
62
  const isWireHistoryEntries = (v: unknown): boolean =>
60
63
  Array.isArray(v) && v.every(isWireHistoryEntry);
64
+ /** #3282 §4: one row of `agent_definitions` — name, one-line description, plain-words location. */
65
+ const isWireAgentDefinition = (v: unknown): boolean =>
66
+ isRecord(v) && isString(v['name']) && isString(v['description']) && isString(v['definedIn']);
67
+ const isWireAgentDefinitions = (v: unknown): boolean =>
68
+ Array.isArray(v) && v.every(isWireAgentDefinition);
61
69
 
62
70
  type TFieldCheck = (value: unknown) => boolean;
63
71
  type TVariantShape = Readonly<Record<string, TFieldCheck>>;
@@ -96,6 +104,38 @@ const isActionResponse: TFieldCheck = (v) =>
96
104
  isOptional(isString)(v['text'])) ||
97
105
  (v['type'] === 'cancelled' && v['values'] === undefined && v['text'] === undefined));
98
106
 
107
+ type TSettingsPatch = Extract<TClientMessage, { type: 'update-settings' }>['patch'];
108
+ /** Keyed by `field`, so a patch variant added to the union without a shape here fails to compile. */
109
+ const SETTINGS_PATCH_SHAPES: Readonly<Record<TSettingsPatch['field'], TVariantShape>> = {
110
+ language: { language: isNonEmptyString },
111
+ outputStyle: { styleId: isNonEmptyString },
112
+ preset: { presetId: isNonEmptyString },
113
+ permissionMode: { mode: isNonEmptyString },
114
+ sandbox: { enabled: isBoolean },
115
+ removePermissionRule: {
116
+ scope: isNonEmptyString,
117
+ kind: oneOf(['allow', 'deny', 'ask']),
118
+ pattern: isNonEmptyString,
119
+ },
120
+ mcpServerEnabled: { serverId: isNonEmptyString, enabled: isBoolean },
121
+ reloadMcpServers: {},
122
+ pluginEnabled: { pluginId: isNonEmptyString, enabled: isBoolean },
123
+ reloadPlugins: {},
124
+ installPlugin: { pluginId: isNonEmptyString },
125
+ uninstallPlugin: { pluginId: isNonEmptyString },
126
+ // #3282 §4b: "Providers & Models" — Use, Model, Delete.
127
+ providerProfile: { profileName: isNonEmptyString },
128
+ providerModel: { profileName: isNonEmptyString, modelId: isNonEmptyString },
129
+ deleteProviderProfile: { profileName: isNonEmptyString },
130
+ };
131
+ const isSettingsPatch: TFieldCheck = (v) => {
132
+ if (!isRecord(v) || !isString(v['field'])) return false;
133
+ const field = v['field'];
134
+ if (!Object.prototype.hasOwnProperty.call(SETTINGS_PATCH_SHAPES, field)) return false;
135
+ const shape = SETTINGS_PATCH_SHAPES[field as TSettingsPatch['field']];
136
+ return Object.entries(shape).every(([key, check]) => check(v[key]));
137
+ };
138
+
99
139
  type TWaitingLoopStopOutcome = Extract<TServerMessage, { type: 'waiting_loop_stop' }>['outcome'];
100
140
  /** Keyed by `kind`, so an outcome added to the union without a shape here fails to compile. */
101
141
  const WAITING_LOOP_STOP_OUTCOME_SHAPES: Readonly<
@@ -114,6 +154,72 @@ const isWaitingLoopStopOutcome: TFieldCheck = (v) => {
114
154
  return Object.entries(shape).every(([field, check]) => check(v[field]));
115
155
  };
116
156
 
157
+ /** Decode a `kind`-discriminated result union (the `TWaitingLoopStopOutcome` convention) from a shape
158
+ * map keyed by `kind`. */
159
+ function isKindDiscriminated(shapes: Readonly<Record<string, TVariantShape>>): TFieldCheck {
160
+ return (v) => {
161
+ if (!isRecord(v) || !isString(v['kind'])) return false;
162
+ const shape = Object.prototype.hasOwnProperty.call(shapes, v['kind']) ? shapes[v['kind']] : undefined;
163
+ if (shape === undefined) return false;
164
+ return Object.entries(shape).every(([field, check]) => check(v[field]));
165
+ };
166
+ }
167
+
168
+ // #3282 §4c: the Project panel's reads. Each result is the session method's own return value, crossing
169
+ // the wire unchanged (the `TWaitingLoopStopOutcome` convention above) — decoded from a `kind`-keyed
170
+ // shape map the same way.
171
+ const PROJECT_FILE_STATUSES = [
172
+ 'Added',
173
+ 'Modified',
174
+ 'Deleted',
175
+ 'Renamed',
176
+ 'Copied',
177
+ 'Untracked',
178
+ 'Conflicted',
179
+ ] as const;
180
+ const isProjectStatusFile: TFieldCheck = (v) =>
181
+ isRecord(v) &&
182
+ isNonEmptyString(v['path']) &&
183
+ oneOf(PROJECT_FILE_STATUSES)(v['status']) &&
184
+ isOptional(isNonEmptyString)(v['renamedFrom']) &&
185
+ isOptional(isFiniteNumber)(v['added']) &&
186
+ isOptional(isFiniteNumber)(v['removed']);
187
+ const isProjectStatusFiles: TFieldCheck = (v) => Array.isArray(v) && v.every(isProjectStatusFile);
188
+
189
+ type TProjectStatusReadResult = Extract<TServerMessage, { type: 'project_status' }>['result'];
190
+ const PROJECT_STATUS_READ_SHAPES: Readonly<Record<TProjectStatusReadResult['kind'], TVariantShape>> = {
191
+ status: {
192
+ branch: isOptional(isString),
193
+ unborn: isBoolean,
194
+ files: isProjectStatusFiles,
195
+ truncated: isBoolean,
196
+ },
197
+ 'not-a-repository': {},
198
+ failed: { message: isString },
199
+ };
200
+ const isProjectStatusRead = isKindDiscriminated(PROJECT_STATUS_READ_SHAPES);
201
+
202
+ const DIFF_LINE_TYPES = ['add', 'remove', 'context', 'hunk'] as const;
203
+ const isDiffLine: TFieldCheck = (v) =>
204
+ isRecord(v) && oneOf(DIFF_LINE_TYPES)(v['type']) && isString(v['text']) && isFiniteNumber(v['lineNumber']);
205
+ const isDiffLines: TFieldCheck = (v) => Array.isArray(v) && v.every(isDiffLine);
206
+
207
+ type TProjectDiffReadResult = Extract<TServerMessage, { type: 'project_diff' }>['result'];
208
+ const PROJECT_DIFF_READ_SHAPES: Readonly<Record<TProjectDiffReadResult['kind'], TVariantShape>> = {
209
+ diff: { diffLines: isDiffLines, truncated: isBoolean },
210
+ 'not-a-repository': {},
211
+ 'outside-workspace': {},
212
+ failed: { message: isString },
213
+ };
214
+ const isProjectDiffRead = isKindDiscriminated(PROJECT_DIFF_READ_SHAPES);
215
+
216
+ type TProjectMemoryReadResult = Extract<TServerMessage, { type: 'project_memory' }>['result'];
217
+ const PROJECT_MEMORY_READ_SHAPES: Readonly<Record<TProjectMemoryReadResult['kind'], TVariantShape>> = {
218
+ memory: { content: isString, path: isNonEmptyString, truncated: isBoolean },
219
+ unavailable: { message: isString },
220
+ };
221
+ const isProjectMemoryRead = isKindDiscriminated(PROJECT_MEMORY_READ_SHAPES);
222
+
117
223
  /** One entry per `TClientMessage` variant — a variant added to the union without one fails the test. */
118
224
  export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVariantShape>> = {
119
225
  submit: { prompt: isNonEmptyString },
@@ -130,9 +236,17 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
130
236
  'get-context': {},
131
237
  'get-commands': {},
132
238
  'get-status': {},
239
+ 'list-models': { requestId: isNonEmptyString },
240
+ 'get-agent-definitions': { requestId: isNonEmptyString },
133
241
  'list-sessions': { requestId: isNonEmptyString },
134
242
  'new-session': { requestId: isOptional(isNonEmptyString) },
135
243
  'switch-session': { sessionId: isNonEmptyString, requestId: isOptional(isNonEmptyString) },
244
+ 'rename-session': {
245
+ sessionId: isNonEmptyString,
246
+ name: isNonEmptyString,
247
+ requestId: isNonEmptyString,
248
+ },
249
+ 'delete-session': { sessionId: isNonEmptyString, requestId: isNonEmptyString },
136
250
  'get-usage-report': {},
137
251
  'get-personal-usage-report': {
138
252
  requestId: isNonEmptyString,
@@ -152,6 +266,9 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
152
266
  cursor: isOptional(isExecutionDetailCursor),
153
267
  },
154
268
  'stop-waiting-loop': { requestId: isNonEmptyString },
269
+ 'project-status': { requestId: isNonEmptyString },
270
+ 'project-diff': { requestId: isNonEmptyString, path: isNonEmptyString },
271
+ 'project-memory': { requestId: isNonEmptyString },
155
272
  'get-background-tasks': { filter: isOptional(isBackgroundTaskListFilter) },
156
273
  'get-background-task': { taskId: isNonEmptyString },
157
274
  'get-background-job-groups': {},
@@ -168,6 +285,8 @@ export const CLIENT_MESSAGE_SHAPES: Readonly<Record<TClientMessage['type'], TVar
168
285
  'ask-response': { id: isNonEmptyString, response: isActionResponse },
169
286
  resume: { lastSeq: isFiniteNumber },
170
287
  ack: { seq: isFiniteNumber },
288
+ 'get-settings': { requestId: isNonEmptyString },
289
+ 'update-settings': { requestId: isNonEmptyString, patch: isSettingsPatch },
171
290
  };
172
291
 
173
292
  const authored: TVariantShape = { driverId: isOptional(isString) };
@@ -181,20 +300,38 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
181
300
  thinking: { isThinking: isBoolean, ...authored },
182
301
  complete: { result: isRecord, ...authored },
183
302
  interrupted: { result: isRecord, ...authored },
184
- error: { message: isString, ...authored },
303
+ error: {
304
+ message: isString,
305
+ code: isOptional(oneOf(['auth', 'rate_limit', 'model_unavailable', 'network', 'provider'])),
306
+ provider: isOptional(isString),
307
+ retryAfterSeconds: isOptional(isFiniteNumber),
308
+ model: isOptional(isString),
309
+ ...authored,
310
+ },
185
311
  command_result: {
186
312
  name: isString,
187
313
  message: isString,
188
314
  success: isBoolean,
189
315
  requestId: isOptional(isString),
190
316
  },
191
- messages: { messages: isRecordArray },
317
+ messages: { messages: isRecordArray, display: isOptional(isRecordArray), ...authored },
192
318
  history: { startIndex: isCount, total: isCount, entries: isWireHistoryEntries },
193
319
  context: { state: isRecord },
194
320
  history_changed: {},
195
321
  turn_source: { source: oneOf(Object.keys(TURN_SOURCES)) },
196
322
  commands: { commands: isRecordArray, skills: isRecordArray },
197
323
  session_status: { status: isRecord },
324
+ model_list: {
325
+ requestId: isNonEmptyString,
326
+ groups: isRecordArray,
327
+ currentProfile: isOptional(isString),
328
+ currentModel: isString,
329
+ },
330
+ agent_definitions: {
331
+ requestId: isNonEmptyString,
332
+ agents: isWireAgentDefinitions,
333
+ current: isNonEmptyString,
334
+ },
198
335
  sessions: { requestId: isNonEmptyString, listing: isRecord },
199
336
  sessions_error: {
200
337
  requestId: isNonEmptyString,
@@ -207,6 +344,14 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
207
344
  message: isString,
208
345
  requestId: isOptional(isString),
209
346
  },
347
+ session_renamed_in_list: { requestId: isNonEmptyString, sessionId: isString, name: isString },
348
+ session_rename_failed: { requestId: isNonEmptyString, message: isString },
349
+ session_deleted: { requestId: isNonEmptyString, sessionId: isString },
350
+ session_delete_failed: {
351
+ requestId: isNonEmptyString,
352
+ code: oneOf(SESSION_DELETE_REFUSAL_CODES),
353
+ message: isString,
354
+ },
210
355
  usage_report: { report: isRecord },
211
356
  personal_usage_report: { requestId: isNonEmptyString, report: isRecord },
212
357
  personal_usage_report_error: {
@@ -232,6 +377,9 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
232
377
  execution_detail: { requestId: isNonEmptyString, page: isRecord },
233
378
  execution_detail_error: { requestId: isNonEmptyString, message: isString },
234
379
  waiting_loop_stop: { requestId: isNonEmptyString, outcome: isWaitingLoopStopOutcome },
380
+ project_status: { requestId: isNonEmptyString, result: isProjectStatusRead },
381
+ project_diff: { requestId: isNonEmptyString, result: isProjectDiffRead },
382
+ project_memory: { requestId: isNonEmptyString, result: isProjectMemoryRead },
235
383
  background_task_event: { event: isRecord },
236
384
  background_job_group_event: { event: isRecord },
237
385
  plan_event: { event: isRecord },
@@ -256,6 +404,12 @@ export const SERVER_MESSAGE_SHAPES: Readonly<Record<TServerMessage['type'], TVar
256
404
  },
257
405
  protocol_error: { message: isString, requestId: isOptional(isString) },
258
406
  resume_gap: {},
407
+ settings: { requestId: isNonEmptyString, settings: isRecord },
408
+ settings_error: {
409
+ requestId: isNonEmptyString,
410
+ code: oneOf(['not_available', 'invalid', 'refused', 'update_failed']),
411
+ message: isString,
412
+ },
259
413
  };
260
414
 
261
415
  function decodeVariant<TMessage extends { type: string }>(
@@ -16,10 +16,12 @@ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
16
16
  'get-context',
17
17
  'get-status',
18
18
  'get-commands',
19
+ 'list-models',
19
20
  'get-executing',
20
21
  'get-pending',
21
22
  'get-execution-workspace',
22
23
  'read-execution-detail',
24
+ 'get-agent-definitions',
23
25
  'get-usage-report',
24
26
  'list-sessions',
25
27
  'get-background-tasks',
@@ -28,6 +30,15 @@ export const OBSERVER_MESSAGES: ReadonlySet<TClientMessage['type']> = new Set<
28
30
  'get-background-job-group',
29
31
  'wait-background-job-group',
30
32
  'read-background-task-log',
33
+ // #3282 §4a: reading the Settings snapshot is a pure read of this session's own configuration.
34
+ // `update-settings` stays out — an observer never writes.
35
+ 'get-settings',
36
+ // #3282 §4c: the Project panel's reads — git status, one file's diff, project memory. All three are
37
+ // read-only (there is no "restore"/write counterpart in this feature — see the SPEC.md design
38
+ // decision on why checkpoints/rewind are excluded entirely), so all three are observer-safe.
39
+ 'project-status',
40
+ 'project-diff',
41
+ 'project-memory',
31
42
  ]);
32
43
 
33
44
  /** Whether an observer may send a message of this type. */
@@ -0,0 +1,74 @@
1
+ /**
2
+ * #3282 §4c — the Project panel's reads (git status, one file's diff, project memory).
3
+ *
4
+ * `TProjectReadCapableSession` is `IProtocolSession & Partial<ISessionProjectRead>` (see
5
+ * `protocol-session.ts`'s doc comment): the three methods are runtime-probed, never assumed, so
6
+ * adding this capability changes nothing for a session or test double that does not have it — a
7
+ * request against one answers `protocol_error` (host-neutral, not a made-up "kind"), the same way an
8
+ * unknown message type already does.
9
+ */
10
+ import type { TOutboundDeliver } from './outbound-delivery.js';
11
+ import type { TProjectReadCapableSession } from './protocol-session.js';
12
+ import type { TClientMessage } from './wire-messages.js';
13
+
14
+ type TProjectReadMessage = Extract<
15
+ TClientMessage,
16
+ { type: 'project-status' | 'project-diff' | 'project-memory' }
17
+ >;
18
+
19
+ export function isProjectReadMessage(msg: TClientMessage): msg is TProjectReadMessage {
20
+ return msg.type === 'project-status' || msg.type === 'project-diff' || msg.type === 'project-memory';
21
+ }
22
+
23
+ function notSupported(deliver: TOutboundDeliver, requestId: string): void {
24
+ deliver({
25
+ type: 'protocol_error',
26
+ message: 'This host does not support the Project panel.',
27
+ requestId,
28
+ });
29
+ }
30
+
31
+ export function handleProjectReadMessage(
32
+ session: TProjectReadCapableSession,
33
+ deliver: TOutboundDeliver,
34
+ msg: TProjectReadMessage,
35
+ ): void {
36
+ const { requestId } = msg;
37
+ if (msg.type === 'project-status') {
38
+ if (!session.readProjectStatus) return notSupported(deliver, requestId);
39
+ session.readProjectStatus().then(
40
+ (result) => deliver({ type: 'project_status', requestId, result }),
41
+ (error: unknown) =>
42
+ deliver({
43
+ type: 'project_status',
44
+ requestId,
45
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
46
+ }),
47
+ );
48
+ } else if (msg.type === 'project-diff') {
49
+ if (!session.readProjectDiff) return notSupported(deliver, requestId);
50
+ session.readProjectDiff(msg.path).then(
51
+ (result) => deliver({ type: 'project_diff', requestId, result }),
52
+ (error: unknown) =>
53
+ deliver({
54
+ type: 'project_diff',
55
+ requestId,
56
+ result: { kind: 'failed', message: error instanceof Error ? error.message : String(error) },
57
+ }),
58
+ );
59
+ } else {
60
+ if (!session.readProjectMemory) return notSupported(deliver, requestId);
61
+ session.readProjectMemory().then(
62
+ (result) => deliver({ type: 'project_memory', requestId, result }),
63
+ (error: unknown) =>
64
+ deliver({
65
+ type: 'project_memory',
66
+ requestId,
67
+ result: {
68
+ kind: 'unavailable',
69
+ message: error instanceof Error ? error.message : String(error),
70
+ },
71
+ }),
72
+ );
73
+ }
74
+ }
@@ -1,4 +1,5 @@
1
1
  import type {
2
+ ISessionAgentJobs,
2
3
  ISessionBackgroundGroups,
3
4
  ISessionBackgroundTasks,
4
5
  ISessionCommands,
@@ -8,6 +9,7 @@ import type {
8
9
  ISessionExecutionDetail,
9
10
  ISessionExecutionState,
10
11
  ISessionExecutionWorkspace,
12
+ ISessionProjectRead,
11
13
  ISessionPromptResolution,
12
14
  ISessionSelfPacedLoopControl,
13
15
  ISessionStatusRead,
@@ -31,4 +33,18 @@ export interface IProtocolSession
31
33
  ISessionBackgroundGroups,
32
34
  ISessionExecutionWorkspace,
33
35
  ISessionExecutionDetail,
34
- ISessionSelfPacedLoopControl {}
36
+ ISessionSelfPacedLoopControl,
37
+ // #3282 §4: the agent switcher's read (`listAgentDefinitions`/`getDefaultAgentType`) — the
38
+ // group's write members (`spawnAgentJob` and friends) stay unused by any wire handler; they run
39
+ // only through `executeCommand`, same as every other command-only write.
40
+ ISessionAgentJobs {}
41
+
42
+ /**
43
+ * #3282 §4c: a protocol session that ALSO answers the Project panel's reads. `Partial` (not a fourth
44
+ * mandatory role on {@link IProtocolSession} itself) deliberately: every existing test double and
45
+ * every other host that builds an `IProtocolSession` keeps type-checking unchanged, and the panel's
46
+ * three methods are runtime-probed (`typeof session.readProjectStatus === 'function'`) rather than
47
+ * assumed — `InteractiveSession` (the one production implementation) provides all three, satisfying
48
+ * this type with no further wiring.
49
+ */
50
+ export type TProjectReadCapableSession = IProtocolSession & Partial<ISessionProjectRead>;
@@ -1,4 +1,4 @@
1
- import { isSessionChangeRefusal } from '@robota-sdk/agent-interface-session';
1
+ import { isSessionChangeRefusal, isSessionDeleteRefusal } from '@robota-sdk/agent-interface-session';
2
2
 
3
3
  import type { TOutboundDeliver } from './outbound-delivery.js';
4
4
  import type { TClientMessage } from './wire-messages.js';
@@ -15,6 +15,17 @@ export function isSessionDirectoryMessage(msg: TClientMessage): msg is TSessionD
15
15
  );
16
16
  }
17
17
 
18
+ type TSessionRenameMessage = Extract<TClientMessage, { type: 'rename-session' }>;
19
+ type TSessionDeleteMessage = Extract<TClientMessage, { type: 'delete-session' }>;
20
+
21
+ export function isSessionRenameMessage(msg: TClientMessage): msg is TSessionRenameMessage {
22
+ return msg.type === 'rename-session';
23
+ }
24
+
25
+ export function isSessionDeleteMessage(msg: TClientMessage): msg is TSessionDeleteMessage {
26
+ return msg.type === 'delete-session';
27
+ }
28
+
18
29
  /**
19
30
  * #3189: the host's sessions. Listing answers on its own correlated reply. A new session or a switch
20
31
  * answers nothing on success: `session_switched` from the connection's own session is the signal. A
@@ -83,3 +94,69 @@ function listSessions(
83
94
  function failureMessage(error: Error | string): string {
84
95
  return error instanceof Error ? error.message : String(error);
85
96
  }
97
+
98
+ /**
99
+ * #3289 §1: rename a session from the list — current or not. Success replies with the new name so
100
+ * the client can update it optimistically; the client also re-lists to pick up everything else a
101
+ * rename can imply (nothing today, but the reply shape matches `delete-session`'s on purpose).
102
+ */
103
+ export function handleSessionRenameMessage(
104
+ deliver: TOutboundDeliver,
105
+ msg: TSessionRenameMessage,
106
+ directory: ISessionDirectory | undefined,
107
+ ): void {
108
+ if (!directory) {
109
+ deliver({
110
+ type: 'session_rename_failed',
111
+ requestId: msg.requestId,
112
+ message: 'Sessions cannot be renamed on this host.',
113
+ });
114
+ return;
115
+ }
116
+ directory.renameSession(msg.sessionId, msg.name).then(
117
+ () =>
118
+ deliver({
119
+ type: 'session_renamed_in_list',
120
+ requestId: msg.requestId,
121
+ sessionId: msg.sessionId,
122
+ name: msg.name,
123
+ }),
124
+ (error: Error | string) =>
125
+ deliver({
126
+ type: 'session_rename_failed',
127
+ requestId: msg.requestId,
128
+ message: failureMessage(error),
129
+ }),
130
+ );
131
+ }
132
+
133
+ /**
134
+ * #3289 §1: remove a stored session's record. Refused when it is live on another client or running a
135
+ * turn; deleting the current session switches this binding away first (the directory's own job), so
136
+ * by the time this answers, the caller's own session is never the one just removed.
137
+ */
138
+ export function handleSessionDeleteMessage(
139
+ deliver: TOutboundDeliver,
140
+ msg: TSessionDeleteMessage,
141
+ directory: ISessionDirectory | undefined,
142
+ ): void {
143
+ if (!directory) {
144
+ deliver({
145
+ type: 'session_delete_failed',
146
+ requestId: msg.requestId,
147
+ code: 'not_available',
148
+ message: 'Sessions cannot be deleted on this host.',
149
+ });
150
+ return;
151
+ }
152
+ directory.deleteSession(msg.sessionId).then(
153
+ () => deliver({ type: 'session_deleted', requestId: msg.requestId, sessionId: msg.sessionId }),
154
+ (error: Error | string) =>
155
+ deliver({
156
+ type: 'session_delete_failed',
157
+ requestId: msg.requestId,
158
+ code: isSessionDeleteRefusal(error) ? error.code : 'failed',
159
+ message: failureMessage(error),
160
+ }),
161
+ );
162
+ }
@@ -0,0 +1,91 @@
1
+ /**
2
+ * What the `error` wire frame should say about a session failure, beyond its raw `message` (#3289
3
+ * §3). A renderer maps `code` to one plain sentence with the next step, keeping `message` behind a
4
+ * "Details" disclosure — see `agent-ui-web`'s `SessionNotices`.
5
+ *
6
+ * Walks wrapped layers (`originalError`/`cause`, a few deep) because a higher-level catch can wrap the
7
+ * provider's own typed error in another one (e.g. a "Conversation failed" `ProviderError`) before it
8
+ * reaches here — the same reason `agent-core`'s `classifyProviderFailure` walks layers rather than
9
+ * looking at the outermost error alone.
10
+ */
11
+
12
+ import {
13
+ AuthenticationError,
14
+ ModelNotAvailableError,
15
+ NetworkError,
16
+ ProviderError,
17
+ RateLimitError,
18
+ } from '@robota-sdk/agent-core';
19
+
20
+ /** Kept in sync with `wire-messages.ts`'s `error` variant — see that type for what each means. */
21
+ export type TSessionErrorCode =
22
+ 'auth' | 'rate_limit' | 'model_unavailable' | 'network' | 'provider';
23
+
24
+ export interface ISessionErrorClassification {
25
+ code?: TSessionErrorCode;
26
+ provider?: string;
27
+ retryAfterSeconds?: number;
28
+ /** For `code: 'model_unavailable'`, the model the request tried, when the failure named one. */
29
+ model?: string;
30
+ }
31
+
32
+ const MAX_WRAP_DEPTH = 8;
33
+
34
+ /** The error and everything it wraps, outermost first, followed to a small depth and never twice. */
35
+ function layersOf(error: unknown): unknown[] {
36
+ const layers: unknown[] = [];
37
+ const seen = new Set<unknown>();
38
+ let frontier: unknown[] = [error];
39
+ for (let depth = 0; depth <= MAX_WRAP_DEPTH && frontier.length > 0; depth++) {
40
+ const next: unknown[] = [];
41
+ for (const layer of frontier) {
42
+ if (layer === undefined || layer === null || seen.has(layer)) continue;
43
+ seen.add(layer);
44
+ layers.push(layer);
45
+ if (typeof layer === 'object') {
46
+ const record = layer as Record<string, unknown>;
47
+ next.push(record['originalError'], record['cause']);
48
+ }
49
+ }
50
+ frontier = next;
51
+ }
52
+ return layers;
53
+ }
54
+
55
+ /**
56
+ * Classify a session failure for the wire, or leave it uncoded when it names no provider at all —
57
+ * an unrelated internal failure keeps showing its raw `message` exactly as before this classification
58
+ * existed.
59
+ */
60
+ export function classifySessionErrorForWire(error: unknown): ISessionErrorClassification {
61
+ // A generic ProviderError never wins outright — a wrapper (e.g. "Conversation failed") can sit
62
+ // above the real, more specific cause, so it is kept only as a fallback while deeper layers are
63
+ // still searched for one of the specific types below.
64
+ let fallback: ISessionErrorClassification | undefined;
65
+ for (const layer of layersOf(error)) {
66
+ if (layer instanceof AuthenticationError) {
67
+ return { code: 'auth', ...(layer.provider !== undefined && { provider: layer.provider }) };
68
+ }
69
+ if (layer instanceof RateLimitError) {
70
+ return {
71
+ code: 'rate_limit',
72
+ ...(layer.provider !== undefined && { provider: layer.provider }),
73
+ ...(layer.retryAfter !== undefined && { retryAfterSeconds: layer.retryAfter }),
74
+ };
75
+ }
76
+ if (layer instanceof ModelNotAvailableError) {
77
+ return {
78
+ code: 'model_unavailable',
79
+ provider: layer.provider,
80
+ ...(layer.model !== undefined && { model: layer.model }),
81
+ };
82
+ }
83
+ if (layer instanceof NetworkError) {
84
+ return { code: 'network', ...(layer.provider !== undefined && { provider: layer.provider }) };
85
+ }
86
+ if (fallback === undefined && layer instanceof ProviderError) {
87
+ fallback = { code: 'provider', provider: layer.provider };
88
+ }
89
+ }
90
+ return fallback ?? {};
91
+ }
@@ -7,6 +7,7 @@
7
7
  */
8
8
 
9
9
  import { holdOpenPrompts } from './open-prompts.js';
10
+ import { classifySessionErrorForWire } from './session-error-classification.js';
10
11
 
11
12
  import type { TOutboundDeliver } from './outbound-delivery.js';
12
13
  import type { IProtocolSession } from './protocol-session.js';
@@ -131,7 +132,12 @@ export function subscribeSessionEvents(
131
132
  const onInterrupted = (result: IExecutionResult): void =>
132
133
  deliver({ type: 'interrupted', result: withoutHistory(result), ...attr() });
133
134
  const onError = (error: Error): void =>
134
- deliver({ type: 'error', message: error.message, ...attr() });
135
+ deliver({
136
+ type: 'error',
137
+ message: error.message,
138
+ ...classifySessionErrorForWire(error),
139
+ ...attr(),
140
+ });
135
141
  const onBackgroundTaskEvent = (event: TBackgroundTaskEvent): void =>
136
142
  deliver({ type: 'background_task_event', event });
137
143
  const onBackgroundJobGroupEvent = (event: TBackgroundJobGroupEvent): void =>