@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.
- package/CHANGELOG.md +43 -0
- package/README.md +80 -118
- package/dist/browser/client.d.ts +1 -1
- package/dist/browser/client.js +1 -1
- package/dist/browser/index.d.ts +60 -6
- package/dist/browser/index.d.ts.map +1 -1
- package/dist/browser/index.js +1 -1
- package/dist/browser/index.js.map +1 -1
- package/dist/browser/observer-messages-CEeisRaF.js +2 -0
- package/dist/browser/observer-messages-CEeisRaF.js.map +1 -0
- package/dist/browser/{observer-messages-DC_8VHk9.d.ts → observer-messages-Dnff2Tdb.d.ts} +125 -3
- package/dist/browser/observer-messages-Dnff2Tdb.d.ts.map +1 -0
- package/dist/node/index.cjs +1 -1
- package/dist/node/index.d.cts +181 -6
- package/dist/node/index.d.cts.map +1 -1
- package/dist/node/index.d.ts +181 -6
- package/dist/node/index.d.ts.map +1 -1
- package/dist/node/index.js +1 -1
- package/dist/node/index.js.map +1 -1
- package/package.json +12 -11
- package/src/__tests__/message-decoders.test.ts +295 -1
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/session-directory-messages.test.ts +130 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-message-handler-observe.test.ts +25 -1
- package/src/__tests__/session-message-handler.test.ts +57 -3
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/index.ts +4 -0
- package/src/message-decoders.ts +157 -3
- package/src/observer-messages.ts +11 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +17 -1
- package/src/session-directory-messages.ts +78 -1
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +7 -1
- package/src/session-message-handler.ts +58 -8
- package/src/session-query-messages.ts +18 -1
- package/src/session-resume-bridge.ts +8 -0
- package/src/settings-messages.ts +103 -0
- package/src/wire-messages.ts +133 -4
- package/dist/browser/observer-messages-Bx-xReUQ.js +0 -2
- package/dist/browser/observer-messages-Bx-xReUQ.js.map +0 -1
- package/dist/browser/observer-messages-DC_8VHk9.d.ts.map +0 -1
package/src/message-decoders.ts
CHANGED
|
@@ -14,7 +14,10 @@
|
|
|
14
14
|
* owner and is tracked there, not duplicated in a transport package.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import {
|
|
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: {
|
|
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 }>(
|
package/src/observer-messages.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/protocol-session.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/session-events.ts
CHANGED
|
@@ -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({
|
|
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 =>
|