@robota-sdk/agent-transport 3.0.0-beta.100
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 +1137 -0
- package/LICENSE +661 -0
- package/README.md +121 -0
- package/dist/browser/client.d.ts +2 -0
- package/dist/browser/client.js +1 -0
- package/dist/browser/index.d.ts +624 -0
- package/dist/browser/index.d.ts.map +1 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/index.js.map +1 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts +499 -0
- package/dist/browser/observer-messages-4fRQZU2D.d.ts.map +1 -0
- package/dist/browser/observer-messages-O8jTPEQO.js +2 -0
- package/dist/browser/observer-messages-O8jTPEQO.js.map +1 -0
- package/dist/node/index.cjs +1 -0
- package/dist/node/index.d.cts +1115 -0
- package/dist/node/index.d.cts.map +1 -0
- package/dist/node/index.d.ts +1115 -0
- package/dist/node/index.d.ts.map +1 -0
- package/dist/node/index.js +2 -0
- package/dist/node/index.js.map +1 -0
- package/dist/node/node.cjs +1 -0
- package/dist/node/node.d.cts +317 -0
- package/dist/node/node.d.cts.map +1 -0
- package/dist/node/node.d.ts +317 -0
- package/dist/node/node.d.ts.map +1 -0
- package/dist/node/node.js +2 -0
- package/dist/node/node.js.map +1 -0
- package/package.json +122 -0
- package/src/__tests__/__fixtures__/korean-text.json +6 -0
- package/src/__tests__/access-token-verifier.test.ts +672 -0
- package/src/__tests__/admission.test.ts +130 -0
- package/src/__tests__/app-tool-messages.test.ts +463 -0
- package/src/__tests__/bearer-resource-gate.test.ts +120 -0
- package/src/__tests__/channel-frames.test.ts +215 -0
- package/src/__tests__/driver-attribution-capability.test.ts +83 -0
- package/src/__tests__/file-transfer.test.ts +281 -0
- package/src/__tests__/handoff-chunking.test.ts +261 -0
- package/src/__tests__/handoff-manifest.test.ts +52 -0
- package/src/__tests__/history-attached-budget.test.ts +216 -0
- package/src/__tests__/message-decoders.test.ts +870 -0
- package/src/__tests__/outbound-delivery.test.ts +558 -0
- package/src/__tests__/peer-message-ledger.test.ts +200 -0
- package/src/__tests__/permission-summary.test.ts +18 -0
- package/src/__tests__/personal-usage-report.test.ts +193 -0
- package/src/__tests__/project-read-messages.test.ts +118 -0
- package/src/__tests__/resume-buffer.test.ts +93 -0
- package/src/__tests__/session-detail-loop-status.test.ts +169 -0
- package/src/__tests__/session-directory-messages.test.ts +322 -0
- package/src/__tests__/session-error-classification.test.ts +113 -0
- package/src/__tests__/session-event-delivery.test.ts +101 -0
- package/src/__tests__/session-history-wire.test.ts +364 -0
- package/src/__tests__/session-message-handler-e5.test.ts +99 -0
- package/src/__tests__/session-message-handler-observe.test.ts +223 -0
- package/src/__tests__/session-message-handler.test.ts +726 -0
- package/src/__tests__/session-resume-bridge.test.ts +472 -0
- package/src/__tests__/settings-messages.test.ts +193 -0
- package/src/__tests__/stdio-carrier.test.ts +85 -0
- package/src/__tests__/submit-prompt-shape.test.ts +78 -0
- package/src/__tests__/usage-report-carrier.test.ts +98 -0
- package/src/__tests__/ws-broadcast-events.test.ts +94 -0
- package/src/app-tool-messages.ts +140 -0
- package/src/background-messages.ts +169 -0
- package/src/channel-frames.ts +182 -0
- package/src/client.ts +17 -0
- package/src/handoff-chunking.ts +275 -0
- package/src/index.ts +135 -0
- package/src/loop-control-messages.ts +34 -0
- package/src/message-decoders.ts +485 -0
- package/src/message-parser.ts +33 -0
- package/src/node/access-token-verifier.ts +363 -0
- package/src/node/admission.ts +153 -0
- package/src/node/bearer-resource-gate.ts +331 -0
- package/src/node/file-transfer.ts +530 -0
- package/src/node/handoff-manifest.ts +77 -0
- package/src/node/index.ts +45 -0
- package/src/node/stdio-carrier.ts +59 -0
- package/src/observer-messages.ts +47 -0
- package/src/open-prompts.ts +76 -0
- package/src/outbound-delivery.ts +240 -0
- package/src/peer-message-ledger.ts +193 -0
- package/src/permission-summary.ts +18 -0
- package/src/project-read-messages.ts +74 -0
- package/src/protocol-session.ts +56 -0
- package/src/resume-buffer.ts +123 -0
- package/src/session-directory-messages.ts +162 -0
- package/src/session-error-classification.ts +91 -0
- package/src/session-events.ts +290 -0
- package/src/session-message-handler.ts +413 -0
- package/src/session-query-messages.ts +160 -0
- package/src/session-resume-bridge.ts +275 -0
- package/src/settings-messages.ts +103 -0
- package/src/usage-messages.ts +195 -0
- package/src/wire-messages.ts +436 -0
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
acknowledgePeerMessage,
|
|
5
|
+
admitPeerMessage,
|
|
6
|
+
createPeerMessageLedger,
|
|
7
|
+
forgetPeerOrigin,
|
|
8
|
+
} from '../peer-message-ledger.js';
|
|
9
|
+
|
|
10
|
+
import type { IPeerMessage } from '@robota-sdk/agent-interface-session-mobility';
|
|
11
|
+
|
|
12
|
+
function message(over: Partial<IPeerMessage> = {}): IPeerMessage {
|
|
13
|
+
return {
|
|
14
|
+
id: 'msg_1',
|
|
15
|
+
sequence: 1,
|
|
16
|
+
origin: { sessionId: 'peer_a' },
|
|
17
|
+
text: 'hello',
|
|
18
|
+
sentAt: 1,
|
|
19
|
+
...over,
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('PEER-001 — a retry gets the ORIGINAL answer back (#1809)', () => {
|
|
24
|
+
it('delivers a first message and refuses to deliver its retry', () => {
|
|
25
|
+
const ledger = createPeerMessageLedger();
|
|
26
|
+
|
|
27
|
+
const first = admitPeerMessage(ledger, message());
|
|
28
|
+
const retry = admitPeerMessage(ledger, message({ sentAt: 2 }));
|
|
29
|
+
|
|
30
|
+
expect(first.deliver).toBe(true);
|
|
31
|
+
expect(first.ack.state).toBe('delivered');
|
|
32
|
+
expect(retry.deliver).toBe(false);
|
|
33
|
+
expect(retry.ack.state).toBe('duplicate');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('a retry of a REFUSED message stays refused, not silently accepted', () => {
|
|
37
|
+
// The property that makes a retry safe: a message must not get two contradictory answers. If
|
|
38
|
+
// the second attempt were re-judged, an admission that failed once could pass on a retry.
|
|
39
|
+
const ledger = createPeerMessageLedger();
|
|
40
|
+
const refusal = { state: 'refused', reason: 'not admitted' } as const;
|
|
41
|
+
|
|
42
|
+
const first = admitPeerMessage(ledger, message(), refusal);
|
|
43
|
+
const retry = admitPeerMessage(ledger, message({ sentAt: 2 }));
|
|
44
|
+
|
|
45
|
+
expect(first.ack.state).toBe('refused');
|
|
46
|
+
expect(retry.ack.state).toBe('duplicate');
|
|
47
|
+
expect(retry.deliver).toBe(false);
|
|
48
|
+
// The original reason survives, so the sender can still tell WHY it never landed.
|
|
49
|
+
expect(retry.ack.reason).toBe('not admitted');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('carries the original sequence on the duplicate ack', () => {
|
|
53
|
+
const ledger = createPeerMessageLedger();
|
|
54
|
+
admitPeerMessage(ledger, message({ sequence: 7 }));
|
|
55
|
+
|
|
56
|
+
const retry = admitPeerMessage(ledger, message({ sequence: 7, sentAt: 9 }));
|
|
57
|
+
|
|
58
|
+
expect(retry.ack.sequence).toBe(7);
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
describe('PEER-001 — sequence is per origin (#1809)', () => {
|
|
63
|
+
it('does not let one peer create a gap in another peer’s stream', () => {
|
|
64
|
+
// Two peers are independent senders. A shared counter would make peer B's traffic look like a
|
|
65
|
+
// gap in peer A's, and the session would wait for a message that was never coming.
|
|
66
|
+
const ledger = createPeerMessageLedger();
|
|
67
|
+
|
|
68
|
+
const a = admitPeerMessage(ledger, message({ id: 'a1', sequence: 1 }));
|
|
69
|
+
const b = admitPeerMessage(
|
|
70
|
+
ledger,
|
|
71
|
+
message({ id: 'b1', sequence: 1, origin: { sessionId: 'peer_b' } }),
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
expect(a.deliver).toBe(true);
|
|
75
|
+
expect(b.deliver).toBe(true);
|
|
76
|
+
expect(a.gapBefore).toBeUndefined();
|
|
77
|
+
expect(b.gapBefore).toBeUndefined();
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
it('reports a gap rather than silently reordering', () => {
|
|
81
|
+
// Buffer-and-reorder is a policy the session layer may choose. Inventing it here would hide a
|
|
82
|
+
// lost message behind an apparent success — the failure this contract exists to prevent.
|
|
83
|
+
const ledger = createPeerMessageLedger();
|
|
84
|
+
admitPeerMessage(ledger, message({ id: 'm1', sequence: 1 }));
|
|
85
|
+
|
|
86
|
+
const ahead = admitPeerMessage(ledger, message({ id: 'm3', sequence: 3 }));
|
|
87
|
+
|
|
88
|
+
expect(ahead.deliver).toBe(true);
|
|
89
|
+
expect(ahead.gapBefore).toBe(2);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('delivers a gap when it arrives LATE, instead of refusing what it reported as missing', () => {
|
|
93
|
+
// Found in review. The high-water form refused this: deliver 1, receive 3 (gap at 2 reported),
|
|
94
|
+
// then 2 arrives and is rejected as "already used" — although it was never delivered. That
|
|
95
|
+
// contradicted this module's own rule that a gap is REPORTED so the session layer can decide,
|
|
96
|
+
// because the session never got the chance.
|
|
97
|
+
const ledger = createPeerMessageLedger();
|
|
98
|
+
admitPeerMessage(ledger, message({ id: 'm1', sequence: 1 }));
|
|
99
|
+
const ahead = admitPeerMessage(ledger, message({ id: 'm3', sequence: 3 }));
|
|
100
|
+
|
|
101
|
+
const late = admitPeerMessage(ledger, message({ id: 'm2', sequence: 2 }));
|
|
102
|
+
|
|
103
|
+
expect(ahead.gapBefore).toBe(2);
|
|
104
|
+
expect(late.deliver).toBe(true);
|
|
105
|
+
expect(late.ack.state).toBe('delivered');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('does not rewind the frontier when a gap is filled late', () => {
|
|
109
|
+
// The frontier only moves forward, so a LATER gap is still measured against the highest
|
|
110
|
+
// sequence seen — filling 2 after 3 must not make 4 look like it follows 2.
|
|
111
|
+
const ledger = createPeerMessageLedger();
|
|
112
|
+
admitPeerMessage(ledger, message({ id: 'm1', sequence: 1 }));
|
|
113
|
+
admitPeerMessage(ledger, message({ id: 'm3', sequence: 3 }));
|
|
114
|
+
admitPeerMessage(ledger, message({ id: 'm2', sequence: 2 }));
|
|
115
|
+
|
|
116
|
+
const next = admitPeerMessage(ledger, message({ id: 'm5', sequence: 5 }));
|
|
117
|
+
|
|
118
|
+
expect(next.gapBefore).toBe(4);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('refuses a NEW id reusing an old sequence — that is a protocol error, not a retry', () => {
|
|
122
|
+
// A retry repeats its id. A new id on an already-used sequence cannot be ordered against what
|
|
123
|
+
// was delivered, so it is refused with the reason rather than guessed at.
|
|
124
|
+
const ledger = createPeerMessageLedger();
|
|
125
|
+
admitPeerMessage(ledger, message({ id: 'm1', sequence: 1 }));
|
|
126
|
+
|
|
127
|
+
const reused = admitPeerMessage(ledger, message({ id: 'm2', sequence: 1 }));
|
|
128
|
+
|
|
129
|
+
expect(reused.deliver).toBe(false);
|
|
130
|
+
expect(reused.ack.state).toBe('refused');
|
|
131
|
+
expect(reused.ack.reason).toMatch(/already delivered by this origin/);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('PEER-001 — delivery and acknowledgement are different questions (#1809)', () => {
|
|
136
|
+
it('promotes a delivered message to acknowledged', () => {
|
|
137
|
+
const ledger = createPeerMessageLedger();
|
|
138
|
+
admitPeerMessage(ledger, message());
|
|
139
|
+
|
|
140
|
+
const acked = acknowledgePeerMessage(ledger, 'peer_a', 'msg_1');
|
|
141
|
+
|
|
142
|
+
expect(acked?.state).toBe('acknowledged');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('will not acknowledge something that was never delivered', () => {
|
|
146
|
+
const ledger = createPeerMessageLedger();
|
|
147
|
+
admitPeerMessage(ledger, message(), { state: 'refused', reason: 'no' });
|
|
148
|
+
|
|
149
|
+
expect(acknowledgePeerMessage(ledger, 'peer_a', 'msg_1')).toBeUndefined();
|
|
150
|
+
expect(acknowledgePeerMessage(ledger, 'peer_a', 'never_seen')).toBeUndefined();
|
|
151
|
+
expect(acknowledgePeerMessage(ledger, 'unknown_peer', 'msg_1')).toBeUndefined();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('a duplicate after acknowledgement still reads as duplicate', () => {
|
|
155
|
+
const ledger = createPeerMessageLedger();
|
|
156
|
+
admitPeerMessage(ledger, message());
|
|
157
|
+
acknowledgePeerMessage(ledger, 'peer_a', 'msg_1');
|
|
158
|
+
|
|
159
|
+
expect(admitPeerMessage(ledger, message({ sentAt: 5 })).ack.state).toBe('duplicate');
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe('PEER-001 — disconnect and shutdown (#1809)', () => {
|
|
164
|
+
it('forgetting an origin frees its sequence space for a reconnect', () => {
|
|
165
|
+
// A peer that reconnects gets a fresh sequence space. Without this, its first message after a
|
|
166
|
+
// restart would collide with the sequence it used before and be refused.
|
|
167
|
+
const ledger = createPeerMessageLedger();
|
|
168
|
+
admitPeerMessage(ledger, message({ id: 'm1', sequence: 5 }));
|
|
169
|
+
|
|
170
|
+
forgetPeerOrigin(ledger, 'peer_a');
|
|
171
|
+
const afterReconnect = admitPeerMessage(ledger, message({ id: 'm2', sequence: 1 }));
|
|
172
|
+
|
|
173
|
+
expect(afterReconnect.deliver).toBe(true);
|
|
174
|
+
expect(afterReconnect.ack.state).toBe('delivered');
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it('forgetting one origin leaves the others intact', () => {
|
|
178
|
+
const ledger = createPeerMessageLedger();
|
|
179
|
+
admitPeerMessage(ledger, message({ id: 'a1', sequence: 1 }));
|
|
180
|
+
admitPeerMessage(ledger, message({ id: 'b1', sequence: 1, origin: { sessionId: 'peer_b' } }));
|
|
181
|
+
|
|
182
|
+
forgetPeerOrigin(ledger, 'peer_a');
|
|
183
|
+
|
|
184
|
+
// peer_b's record survives, so its next message is still ordered against what it sent.
|
|
185
|
+
const b2 = admitPeerMessage(
|
|
186
|
+
ledger,
|
|
187
|
+
message({ id: 'b2', sequence: 1, origin: { sessionId: 'peer_b' } }),
|
|
188
|
+
);
|
|
189
|
+
expect(b2.ack.state).toBe('refused');
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('remembers nothing across a new ledger — state is per connection by construction', () => {
|
|
193
|
+
const first = createPeerMessageLedger();
|
|
194
|
+
admitPeerMessage(first, message());
|
|
195
|
+
|
|
196
|
+
const fresh = createPeerMessageLedger();
|
|
197
|
+
|
|
198
|
+
expect(admitPeerMessage(fresh, message()).ack.state).toBe('delivered');
|
|
199
|
+
});
|
|
200
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { permissionSummaryArgument } from '../permission-summary.js';
|
|
4
|
+
|
|
5
|
+
describe('permissionSummaryArgument', () => {
|
|
6
|
+
it('names a call by the argument a person recognises, file tools included', () => {
|
|
7
|
+
expect(permissionSummaryArgument({ filePath: '/w/a.txt', content: 'x' })).toBe('/w/a.txt');
|
|
8
|
+
expect(permissionSummaryArgument({ command: 'make', filePath: '/w/a.txt' })).toBe('make');
|
|
9
|
+
expect(permissionSummaryArgument({ file_path: 'b.txt' })).toBe('b.txt');
|
|
10
|
+
expect(permissionSummaryArgument({ url: 'https://x.test' })).toBe('https://x.test');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('has nothing to show for empty or non-text arguments', () => {
|
|
14
|
+
expect(permissionSummaryArgument({ command: '' })).toBeUndefined();
|
|
15
|
+
expect(permissionSummaryArgument({ path: 3 })).toBeUndefined();
|
|
16
|
+
expect(permissionSummaryArgument({})).toBeUndefined();
|
|
17
|
+
});
|
|
18
|
+
});
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
|
|
2
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
3
|
+
|
|
4
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
5
|
+
import { createSessionMessageHandler } from '../session-message-handler.js';
|
|
6
|
+
|
|
7
|
+
import type {
|
|
8
|
+
IPersonalUsageReport,
|
|
9
|
+
IUsageBySourceReport,
|
|
10
|
+
} from '@robota-sdk/agent-interface-analytics';
|
|
11
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
12
|
+
|
|
13
|
+
const report: IPersonalUsageReport = {
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
generatedAt: '2026-09-06T03:00:00.000Z',
|
|
16
|
+
period: '7d',
|
|
17
|
+
timezone: 'Asia/Seoul',
|
|
18
|
+
interval: { startDate: '2026-08-31', endDate: '2026-09-06' },
|
|
19
|
+
totals: {
|
|
20
|
+
sessions: 1,
|
|
21
|
+
turns: 2,
|
|
22
|
+
promptTokens: 10,
|
|
23
|
+
completionTokens: 5,
|
|
24
|
+
totalTokens: 15,
|
|
25
|
+
costUsd: 0.01,
|
|
26
|
+
costStatus: 'exact',
|
|
27
|
+
},
|
|
28
|
+
daily: [],
|
|
29
|
+
byModel: [],
|
|
30
|
+
byProvider: [],
|
|
31
|
+
bySurface: [],
|
|
32
|
+
bySource: [],
|
|
33
|
+
byActivity: [],
|
|
34
|
+
sessionIds: ['session-1'],
|
|
35
|
+
coverage: {
|
|
36
|
+
validSessions: 1,
|
|
37
|
+
corruptSessions: 0,
|
|
38
|
+
unsupportedSessions: 0,
|
|
39
|
+
duplicateObservations: 0,
|
|
40
|
+
legacyObservations: 0,
|
|
41
|
+
unknownModelObservations: 0,
|
|
42
|
+
unknownProviderObservations: 0,
|
|
43
|
+
unknownSurfaceObservations: 0,
|
|
44
|
+
corruptSessionIds: [],
|
|
45
|
+
unsupportedSessionIds: [],
|
|
46
|
+
},
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const storedReport: IUsageBySourceReport = {
|
|
50
|
+
sessionId: 'session-1',
|
|
51
|
+
totalTokens: 15,
|
|
52
|
+
promptTokens: 10,
|
|
53
|
+
completionTokens: 5,
|
|
54
|
+
costUsd: 0.01,
|
|
55
|
+
costExact: true,
|
|
56
|
+
bySource: [],
|
|
57
|
+
timeline: [],
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
describe('personal usage report protocol', () => {
|
|
61
|
+
it('routes a correlated request to the host reporter', async () => {
|
|
62
|
+
const sent: TServerMessage[] = [];
|
|
63
|
+
const reporter = vi.fn().mockResolvedValue(report);
|
|
64
|
+
const { onMessage } = createSessionMessageHandler({
|
|
65
|
+
session: createTestInteractiveSession(),
|
|
66
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
67
|
+
personalUsageReporter: reporter,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
onMessage(
|
|
71
|
+
JSON.stringify({
|
|
72
|
+
type: 'get-personal-usage-report',
|
|
73
|
+
requestId: 'request-1',
|
|
74
|
+
period: '7d',
|
|
75
|
+
timezone: 'Asia/Seoul',
|
|
76
|
+
}),
|
|
77
|
+
);
|
|
78
|
+
await Promise.resolve();
|
|
79
|
+
|
|
80
|
+
expect(reporter).toHaveBeenCalledWith({ period: '7d', timezone: 'Asia/Seoul' });
|
|
81
|
+
expect(sent).toContainEqual({
|
|
82
|
+
type: 'personal_usage_report',
|
|
83
|
+
requestId: 'request-1',
|
|
84
|
+
report,
|
|
85
|
+
});
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('returns an explicit correlated error when the host has no reporter', () => {
|
|
89
|
+
const sent: TServerMessage[] = [];
|
|
90
|
+
const { onMessage } = createSessionMessageHandler({
|
|
91
|
+
session: createTestInteractiveSession(),
|
|
92
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
onMessage(
|
|
96
|
+
JSON.stringify({
|
|
97
|
+
type: 'get-personal-usage-report',
|
|
98
|
+
requestId: 'request-2',
|
|
99
|
+
period: '30d',
|
|
100
|
+
timezone: 'UTC',
|
|
101
|
+
}),
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
expect(sent).toContainEqual({
|
|
105
|
+
type: 'personal_usage_report_error',
|
|
106
|
+
requestId: 'request-2',
|
|
107
|
+
code: 'not_available',
|
|
108
|
+
message: 'Personal usage reporting is not available on this host.',
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('correlates malformed report requests only when requestId itself is valid', () => {
|
|
113
|
+
const sent: TServerMessage[] = [];
|
|
114
|
+
const { onMessage } = createSessionMessageHandler({
|
|
115
|
+
session: createTestInteractiveSession(),
|
|
116
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
117
|
+
personalUsageReporter: vi.fn(),
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
onMessage(
|
|
121
|
+
JSON.stringify({
|
|
122
|
+
type: 'get-personal-usage-report',
|
|
123
|
+
requestId: 'safe-id',
|
|
124
|
+
period: '14d',
|
|
125
|
+
timezone: 'UTC',
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
onMessage(
|
|
129
|
+
JSON.stringify({
|
|
130
|
+
type: 'get-personal-usage-report',
|
|
131
|
+
requestId: 42,
|
|
132
|
+
period: '14d',
|
|
133
|
+
timezone: 'UTC',
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
expect(sent[0]).toEqual({
|
|
138
|
+
type: 'personal_usage_report_error',
|
|
139
|
+
requestId: 'safe-id',
|
|
140
|
+
code: 'report_failed',
|
|
141
|
+
message: 'Invalid personal usage report request.',
|
|
142
|
+
});
|
|
143
|
+
expect(sent[1]?.type).toBe('protocol_error');
|
|
144
|
+
expect(JSON.stringify(sent[1])).not.toContain('42');
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
it('routes a correlated stored-session drill-down through the host producer', async () => {
|
|
148
|
+
const sent: TServerMessage[] = [];
|
|
149
|
+
const reporter = vi.fn().mockResolvedValue(storedReport);
|
|
150
|
+
const { onMessage } = createSessionMessageHandler({
|
|
151
|
+
session: createTestInteractiveSession(),
|
|
152
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
153
|
+
storedSessionUsageReporter: reporter,
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
onMessage(
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
type: 'get-stored-session-usage-report',
|
|
159
|
+
requestId: 'drilldown-1',
|
|
160
|
+
sessionId: 'session-1',
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
await Promise.resolve();
|
|
164
|
+
|
|
165
|
+
expect(reporter).toHaveBeenCalledWith('session-1');
|
|
166
|
+
expect(sent).toContainEqual({
|
|
167
|
+
type: 'stored_session_usage_report',
|
|
168
|
+
requestId: 'drilldown-1',
|
|
169
|
+
sessionId: 'session-1',
|
|
170
|
+
report: storedReport,
|
|
171
|
+
});
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it('does not echo invalid stored-session correlation fields', () => {
|
|
175
|
+
const sent: TServerMessage[] = [];
|
|
176
|
+
const { onMessage } = createSessionMessageHandler({
|
|
177
|
+
session: createTestInteractiveSession(),
|
|
178
|
+
deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
|
|
179
|
+
storedSessionUsageReporter: vi.fn(),
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
onMessage(
|
|
183
|
+
JSON.stringify({
|
|
184
|
+
type: 'get-stored-session-usage-report',
|
|
185
|
+
requestId: 'drilldown-safe',
|
|
186
|
+
sessionId: 42,
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
|
|
190
|
+
expect(sent[0]?.type).toBe('protocol_error');
|
|
191
|
+
expect(JSON.stringify(sent[0])).not.toContain('42');
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* #3282 §4c — the Project panel's reads dispatch: success answers the matching `project_*` message
|
|
3
|
+
* under the request's `requestId`; a rejected read becomes a `failed`/`unavailable` result (never an
|
|
4
|
+
* uncaught rejection — the exact class of bug the `/memory` red-toast fix addresses at the command
|
|
5
|
+
* layer, guarded again here at the wire boundary); a session that does not implement the capability
|
|
6
|
+
* (`Partial<ISessionProjectRead>` — see `protocol-session.ts`) answers `protocol_error`, never a throw.
|
|
7
|
+
*/
|
|
8
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
9
|
+
|
|
10
|
+
import { createOutboundDelivery } from '../outbound-delivery.js';
|
|
11
|
+
import { handleProjectReadMessage, isProjectReadMessage } from '../project-read-messages.js';
|
|
12
|
+
|
|
13
|
+
import type { TOutboundDeliver } from '../outbound-delivery.js';
|
|
14
|
+
import type { TProjectReadCapableSession } from '../protocol-session.js';
|
|
15
|
+
import type { TClientMessage, TServerMessage } from '../wire-messages.js';
|
|
16
|
+
|
|
17
|
+
function collect(): { sent: TServerMessage[]; deliver: TOutboundDeliver } {
|
|
18
|
+
const sent: TServerMessage[] = [];
|
|
19
|
+
return { sent, deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()) };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
describe('isProjectReadMessage', () => {
|
|
23
|
+
it('recognizes exactly the three Project panel read types', () => {
|
|
24
|
+
expect(isProjectReadMessage({ type: 'project-status', requestId: 'r' })).toBe(true);
|
|
25
|
+
expect(isProjectReadMessage({ type: 'project-diff', requestId: 'r', path: 'a.txt' })).toBe(true);
|
|
26
|
+
expect(isProjectReadMessage({ type: 'project-memory', requestId: 'r' })).toBe(true);
|
|
27
|
+
expect(isProjectReadMessage({ type: 'get-status' } as TClientMessage)).toBe(false);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
describe('handleProjectReadMessage', () => {
|
|
32
|
+
it('answers project_status with the session result under the same requestId', async () => {
|
|
33
|
+
const { sent, deliver } = collect();
|
|
34
|
+
const session = {
|
|
35
|
+
readProjectStatus: vi.fn().mockResolvedValue({ kind: 'not-a-repository' }),
|
|
36
|
+
} as unknown as TProjectReadCapableSession;
|
|
37
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r1' });
|
|
38
|
+
await Promise.resolve();
|
|
39
|
+
expect(sent).toEqual([
|
|
40
|
+
{ type: 'project_status', requestId: 'r1', result: { kind: 'not-a-repository' } },
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('passes the path through to readProjectDiff and answers project_diff', async () => {
|
|
45
|
+
const { sent, deliver } = collect();
|
|
46
|
+
const readProjectDiff = vi.fn().mockResolvedValue({ kind: 'diff', diffLines: [], truncated: false });
|
|
47
|
+
const session = { readProjectDiff } as unknown as TProjectReadCapableSession;
|
|
48
|
+
handleProjectReadMessage(session, deliver, {
|
|
49
|
+
type: 'project-diff',
|
|
50
|
+
requestId: 'r2',
|
|
51
|
+
path: 'src/a.ts',
|
|
52
|
+
});
|
|
53
|
+
await Promise.resolve();
|
|
54
|
+
expect(readProjectDiff).toHaveBeenCalledExactlyOnceWith('src/a.ts');
|
|
55
|
+
expect(sent).toEqual([
|
|
56
|
+
{ type: 'project_diff', requestId: 'r2', result: { kind: 'diff', diffLines: [], truncated: false } },
|
|
57
|
+
]);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('answers project_memory with the session result', async () => {
|
|
61
|
+
const { sent, deliver } = collect();
|
|
62
|
+
const session = {
|
|
63
|
+
readProjectMemory: vi
|
|
64
|
+
.fn()
|
|
65
|
+
.mockResolvedValue({ kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false }),
|
|
66
|
+
} as unknown as TProjectReadCapableSession;
|
|
67
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r3' });
|
|
68
|
+
await Promise.resolve();
|
|
69
|
+
expect(sent).toEqual([
|
|
70
|
+
{
|
|
71
|
+
type: 'project_memory',
|
|
72
|
+
requestId: 'r3',
|
|
73
|
+
result: { kind: 'memory', content: 'x', path: 'MEMORY.md', truncated: false },
|
|
74
|
+
},
|
|
75
|
+
]);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('turns a rejected readProjectStatus into a failed result, never an uncaught rejection', async () => {
|
|
79
|
+
const { sent, deliver } = collect();
|
|
80
|
+
const session = {
|
|
81
|
+
readProjectStatus: vi.fn().mockRejectedValue(new Error('git not found')),
|
|
82
|
+
} as unknown as TProjectReadCapableSession;
|
|
83
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r4' });
|
|
84
|
+
await Promise.resolve();
|
|
85
|
+
await Promise.resolve();
|
|
86
|
+
expect(sent).toEqual([
|
|
87
|
+
{ type: 'project_status', requestId: 'r4', result: { kind: 'failed', message: 'git not found' } },
|
|
88
|
+
]);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('turns a rejected readProjectMemory into an unavailable result', async () => {
|
|
92
|
+
const { sent, deliver } = collect();
|
|
93
|
+
const session = {
|
|
94
|
+
readProjectMemory: vi.fn().mockRejectedValue(new Error('no store')),
|
|
95
|
+
} as unknown as TProjectReadCapableSession;
|
|
96
|
+
handleProjectReadMessage(session, deliver, { type: 'project-memory', requestId: 'r5' });
|
|
97
|
+
await Promise.resolve();
|
|
98
|
+
await Promise.resolve();
|
|
99
|
+
expect(sent).toEqual([
|
|
100
|
+
{ type: 'project_memory', requestId: 'r5', result: { kind: 'unavailable', message: 'no store' } },
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it('answers protocol_error, never a throw, when the session has no project-read capability', () => {
|
|
105
|
+
const { sent, deliver } = collect();
|
|
106
|
+
const session = {} as unknown as TProjectReadCapableSession;
|
|
107
|
+
expect(() =>
|
|
108
|
+
handleProjectReadMessage(session, deliver, { type: 'project-status', requestId: 'r6' }),
|
|
109
|
+
).not.toThrow();
|
|
110
|
+
expect(sent).toEqual([
|
|
111
|
+
{
|
|
112
|
+
type: 'protocol_error',
|
|
113
|
+
message: 'This host does not support the Project panel.',
|
|
114
|
+
requestId: 'r6',
|
|
115
|
+
},
|
|
116
|
+
]);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { ResumeBuffer } from '../resume-buffer.js';
|
|
4
|
+
import type { TServerMessage } from '../wire-messages.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* REMOTE-013 E4 TC-01 — the bounded un-acked resume buffer: monotonic seq, ack-drop, tail replay, overrun
|
|
8
|
+
* marker (never a silent gap), and drop-oldest bound enforcement.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function msg(delta: string): TServerMessage {
|
|
12
|
+
return { type: 'text_delta', delta };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe('ResumeBuffer (REMOTE-013 TC-01)', () => {
|
|
16
|
+
it('append assigns a monotonic seq starting at 1 and tracks lastSeq', () => {
|
|
17
|
+
const b = new ResumeBuffer();
|
|
18
|
+
expect(b.append(msg('a'))).toBe(1);
|
|
19
|
+
expect(b.append(msg('b'))).toBe(2);
|
|
20
|
+
expect(b.append(msg('c'))).toBe(3);
|
|
21
|
+
expect(b.lastSeq).toBe(3);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
it('tailAfter returns the ordered frames after lastSeq', () => {
|
|
25
|
+
const b = new ResumeBuffer();
|
|
26
|
+
b.append(msg('a'));
|
|
27
|
+
b.append(msg('b'));
|
|
28
|
+
b.append(msg('c'));
|
|
29
|
+
const tail = b.tailAfter(1);
|
|
30
|
+
expect(tail.kind).toBe('tail');
|
|
31
|
+
if (tail.kind === 'tail') {
|
|
32
|
+
expect(tail.frames.map((f) => f.seq)).toEqual([2, 3]);
|
|
33
|
+
expect(tail.frames.map((f) => (f.message as { delta: string }).delta)).toEqual(['b', 'c']);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it('tailAfter at/after the newest seq is an empty tail (caught up, not overrun)', () => {
|
|
38
|
+
const b = new ResumeBuffer();
|
|
39
|
+
b.append(msg('a'));
|
|
40
|
+
b.append(msg('b'));
|
|
41
|
+
expect(b.tailAfter(2)).toEqual({ kind: 'tail', frames: [] });
|
|
42
|
+
expect(b.tailAfter(5)).toEqual({ kind: 'tail', frames: [] });
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('ackThrough drops frames <= seq; the tail then starts after the ack', () => {
|
|
46
|
+
const b = new ResumeBuffer();
|
|
47
|
+
b.append(msg('a')); // 1
|
|
48
|
+
b.append(msg('b')); // 2
|
|
49
|
+
b.append(msg('c')); // 3
|
|
50
|
+
b.ackThrough(2);
|
|
51
|
+
expect(b.size).toBe(1);
|
|
52
|
+
const tail = b.tailAfter(2);
|
|
53
|
+
expect(tail.kind === 'tail' && tail.frames.map((f) => f.seq)).toEqual([3]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('tailAfter returns overrun only when the client MISSED an evicted frame', () => {
|
|
57
|
+
const b = new ResumeBuffer({ maxFrames: 2 });
|
|
58
|
+
b.append(msg('a')); // 1 (evicted)
|
|
59
|
+
b.append(msg('b')); // 2
|
|
60
|
+
b.append(msg('c')); // 3 → buffer now holds [2,3]
|
|
61
|
+
expect(b.size).toBe(2);
|
|
62
|
+
// client saw NOTHING (lastSeq 0): it needs frame 1, which is evicted → overrun.
|
|
63
|
+
expect(b.tailAfter(0)).toEqual({ kind: 'overrun' });
|
|
64
|
+
// client already saw seq 1: it needs 2,3 — both retained → clean tail (no gap).
|
|
65
|
+
const t1 = b.tailAfter(1);
|
|
66
|
+
expect(t1.kind === 'tail' && t1.frames.map((f) => f.seq)).toEqual([2, 3]);
|
|
67
|
+
// client saw seq 2 → only 3 remains.
|
|
68
|
+
const t2 = b.tailAfter(2);
|
|
69
|
+
expect(t2.kind === 'tail' && t2.frames.map((f) => f.seq)).toEqual([3]);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('enforces the frame bound (drop-oldest) — never exceeds maxFrames', () => {
|
|
73
|
+
const b = new ResumeBuffer({ maxFrames: 3 });
|
|
74
|
+
for (let i = 0; i < 100; i += 1) b.append(msg(`m${i}`));
|
|
75
|
+
expect(b.size).toBe(3);
|
|
76
|
+
expect(b.lastSeq).toBe(100); // seq keeps advancing even as old frames drop
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('enforces the byte bound (drop-oldest) with an injected sizeOf', () => {
|
|
80
|
+
const b = new ResumeBuffer({ maxBytes: 30, sizeOf: () => 10 }); // ~3 frames fit
|
|
81
|
+
for (let i = 0; i < 10; i += 1) b.append(msg(`m${i}`));
|
|
82
|
+
expect(b.size).toBeLessThanOrEqual(3);
|
|
83
|
+
// A fresh client can still resume the retained tail.
|
|
84
|
+
const tail = b.tailAfter(b.lastSeq - 1);
|
|
85
|
+
expect(tail.kind).toBe('tail');
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('overrun after eviction triggers the full-refresh path for a far-behind client', () => {
|
|
89
|
+
const b = new ResumeBuffer({ maxFrames: 2 });
|
|
90
|
+
for (let i = 0; i < 10; i += 1) b.append(msg(`m${i}`)); // seqs 1..10, retains [9,10]
|
|
91
|
+
expect(b.tailAfter(0)).toEqual({ kind: 'overrun' }); // brand-new / far-behind client
|
|
92
|
+
});
|
|
93
|
+
});
|