@robota-sdk/agent-transport 3.0.0-beta.83 → 3.0.0-beta.85
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 +62 -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__/handoff-chunking.test.ts +44 -1
- 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/handoff-chunking.ts +52 -12
- 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/handoff-chunking.ts
CHANGED
|
@@ -56,6 +56,39 @@ export interface IHandoffChunk {
|
|
|
56
56
|
*/
|
|
57
57
|
export const DEFAULT_MAX_CHUNK_BYTES = 16 * 1024;
|
|
58
58
|
|
|
59
|
+
// This module is exported from the package root, which also builds for the browser, so it uses the
|
|
60
|
+
// web platform's bytes, text codecs and base64 (`btoa`/`atob`) rather than Node's `Buffer`.
|
|
61
|
+
|
|
62
|
+
/** How many bytes go through `String.fromCharCode` at once, well under the argument-count limit. */
|
|
63
|
+
const BINARY_STRING_STEP = 0x8000;
|
|
64
|
+
|
|
65
|
+
function toBase64(bytes: Uint8Array): string {
|
|
66
|
+
let binary = '';
|
|
67
|
+
for (let offset = 0; offset < bytes.length; offset += BINARY_STRING_STEP) {
|
|
68
|
+
binary += String.fromCharCode(...bytes.subarray(offset, offset + BINARY_STRING_STEP));
|
|
69
|
+
}
|
|
70
|
+
return btoa(binary);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The bytes `data` encodes, or `undefined` when it is not base64 at all. */
|
|
74
|
+
function fromBase64(data: string): Uint8Array | undefined {
|
|
75
|
+
let binary: string;
|
|
76
|
+
try {
|
|
77
|
+
binary = atob(data);
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
const bytes = new Uint8Array(binary.length);
|
|
82
|
+
for (let i = 0; i < binary.length; i += 1) bytes[i] = binary.charCodeAt(i);
|
|
83
|
+
return bytes;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function sameBytes(a: Uint8Array, b: Uint8Array): boolean {
|
|
87
|
+
if (a.length !== b.length) return false;
|
|
88
|
+
for (let i = 0; i < a.length; i += 1) if (a[i] !== b[i]) return false;
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
|
|
59
92
|
/**
|
|
60
93
|
* Cut a sealed payload into chunks.
|
|
61
94
|
*
|
|
@@ -75,14 +108,14 @@ export function chunkHandoffPayload(
|
|
|
75
108
|
'as a hang rather than as the configuration error it is.',
|
|
76
109
|
);
|
|
77
110
|
}
|
|
78
|
-
const bytes =
|
|
111
|
+
const bytes = new TextEncoder().encode(serialized);
|
|
79
112
|
// An empty payload is still one chunk. Zero chunks would leave the receiver unable to tell a
|
|
80
113
|
// completed empty transfer from one that never started.
|
|
81
114
|
const total = Math.max(1, Math.ceil(bytes.byteLength / maxChunkBytes));
|
|
82
115
|
const chunks: IHandoffChunk[] = [];
|
|
83
116
|
for (let index = 0; index < total; index += 1) {
|
|
84
117
|
const slice = bytes.subarray(index * maxChunkBytes, (index + 1) * maxChunkBytes);
|
|
85
|
-
chunks.push({ handoffId, index, total, data: slice
|
|
118
|
+
chunks.push({ handoffId, index, total, data: toBase64(slice) });
|
|
86
119
|
}
|
|
87
120
|
return chunks;
|
|
88
121
|
}
|
|
@@ -127,7 +160,7 @@ export interface IChunkResult {
|
|
|
127
160
|
* would make this a dispatcher as well as a buffer.
|
|
128
161
|
*/
|
|
129
162
|
export class HandoffChunkAssembler {
|
|
130
|
-
private readonly chunks = new Map<number,
|
|
163
|
+
private readonly chunks = new Map<number, Uint8Array>();
|
|
131
164
|
private declaredTotal: number | undefined;
|
|
132
165
|
|
|
133
166
|
constructor(private readonly handoffId: string) {}
|
|
@@ -161,19 +194,19 @@ export class HandoffChunkAssembler {
|
|
|
161
194
|
return refuse('out-of-range');
|
|
162
195
|
}
|
|
163
196
|
|
|
164
|
-
const decoded =
|
|
165
|
-
//
|
|
166
|
-
// chunk
|
|
167
|
-
// digest would catch, after the whole transfer completed. Re-encoding is how the leniency
|
|
168
|
-
// detected here instead.
|
|
169
|
-
if (decoded
|
|
197
|
+
const decoded = fromBase64(chunk.data);
|
|
198
|
+
// Base64 decoding is lenient — it skips whitespace and accepts non-canonical padding — so a
|
|
199
|
+
// mangled chunk could silently become a different one and corrupt the payload in a way only the
|
|
200
|
+
// final digest would catch, after the whole transfer completed. Re-encoding is how the leniency
|
|
201
|
+
// is detected here instead.
|
|
202
|
+
if (decoded === undefined || toBase64(decoded) !== chunk.data) return refuse('undecodable');
|
|
170
203
|
|
|
171
204
|
this.declaredTotal = chunk.total;
|
|
172
205
|
const held = this.chunks.get(chunk.index);
|
|
173
206
|
if (held !== undefined) {
|
|
174
207
|
// A retry re-sending the same bytes is normal. Different bytes for the same index is the
|
|
175
208
|
// inconsistent case again, and the same reasoning applies.
|
|
176
|
-
if (held
|
|
209
|
+
if (sameBytes(held, decoded)) {
|
|
177
210
|
return { outcome: 'duplicate', received: this.chunks.size, expected: chunk.total };
|
|
178
211
|
}
|
|
179
212
|
return refuse('inconsistent-total');
|
|
@@ -206,7 +239,7 @@ export class HandoffChunkAssembler {
|
|
|
206
239
|
}
|
|
207
240
|
|
|
208
241
|
private reassemble(total: number): string {
|
|
209
|
-
const ordered:
|
|
242
|
+
const ordered: Uint8Array[] = [];
|
|
210
243
|
for (let i = 0; i < total; i += 1) {
|
|
211
244
|
const part = this.chunks.get(i);
|
|
212
245
|
if (part === undefined) {
|
|
@@ -217,7 +250,14 @@ export class HandoffChunkAssembler {
|
|
|
217
250
|
}
|
|
218
251
|
ordered.push(part);
|
|
219
252
|
}
|
|
220
|
-
|
|
253
|
+
const joined = new Uint8Array(ordered.reduce((size, part) => size + part.length, 0));
|
|
254
|
+
let offset = 0;
|
|
255
|
+
for (const part of ordered) {
|
|
256
|
+
joined.set(part, offset);
|
|
257
|
+
offset += part.length;
|
|
258
|
+
}
|
|
259
|
+
// Keep a leading byte-order mark: it is part of the payload, as it was with `Buffer`.
|
|
260
|
+
return new TextDecoder('utf-8', { ignoreBOM: true }).decode(joined);
|
|
221
261
|
}
|
|
222
262
|
}
|
|
223
263
|
|
package/src/index.ts
CHANGED
|
@@ -57,6 +57,7 @@ export { isObserverMessageType } from './observer-messages.js';
|
|
|
57
57
|
export type { TProtocolSessionEventClassification } from './session-events.js';
|
|
58
58
|
export type { IProtocolSession } from './protocol-session.js';
|
|
59
59
|
export type {
|
|
60
|
+
IWireAgentDefinitionSummary,
|
|
60
61
|
IWireHistoryEntry,
|
|
61
62
|
TClientMessage,
|
|
62
63
|
TServerMessage,
|
|
@@ -88,6 +89,9 @@ export type {
|
|
|
88
89
|
TResumeSink,
|
|
89
90
|
IAttachOptions,
|
|
90
91
|
} from './session-resume-bridge.js';
|
|
92
|
+
// #3282 §4a: the Settings screen's host-owned read/write seam (parallels `IUsageQueryReporters`).
|
|
93
|
+
export { handleSettingsMessage, isSettingsMessage } from './settings-messages.js';
|
|
94
|
+
export type { ISettingsReporter, TSettingsUpdateOutcome } from './settings-messages.js';
|
|
91
95
|
|
|
92
96
|
// SEC-008 admission is NOT here: `src/node/admission.ts` mints and compares transport tokens with
|
|
93
97
|
// `node:crypto`. A browser never mints one — it presents one it was handed — so the exclusion costs
|
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
|
+
}
|