@robota-sdk/agent-transport 3.0.0-beta.78 → 3.0.0-beta.81

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/README.md +84 -26
  2. package/dist/browser/client.d.ts +2 -0
  3. package/dist/browser/client.js +1 -0
  4. package/dist/browser/index.d.ts +520 -0
  5. package/dist/browser/index.d.ts.map +1 -0
  6. package/dist/browser/index.js +2 -0
  7. package/dist/browser/index.js.map +1 -0
  8. package/dist/browser/message-decoders-BB8n-H2X.d.ts +252 -0
  9. package/dist/browser/message-decoders-BB8n-H2X.d.ts.map +1 -0
  10. package/dist/browser/message-decoders-CDmDoZPR.js +2 -0
  11. package/dist/browser/message-decoders-CDmDoZPR.js.map +1 -0
  12. package/dist/node/index.cjs +1 -1
  13. package/dist/node/index.d.cts +766 -0
  14. package/dist/node/index.d.cts.map +1 -0
  15. package/dist/node/index.d.ts +765 -27
  16. package/dist/node/index.d.ts.map +1 -1
  17. package/dist/node/index.js +1 -1
  18. package/dist/node/index.js.map +1 -1
  19. package/dist/node/node.cjs +1 -0
  20. package/dist/node/node.d.cts +109 -0
  21. package/dist/node/node.d.cts.map +1 -0
  22. package/dist/node/node.d.ts +109 -0
  23. package/dist/node/node.d.ts.map +1 -0
  24. package/dist/node/node.js +2 -0
  25. package/dist/node/node.js.map +1 -0
  26. package/package.json +68 -47
  27. package/src/__tests__/access-token-verifier.test.ts +672 -0
  28. package/src/__tests__/admission.test.ts +130 -0
  29. package/src/__tests__/channel-frames.test.ts +214 -0
  30. package/src/__tests__/driver-attribution-capability.test.ts +83 -0
  31. package/src/__tests__/handoff-chunking.test.ts +215 -0
  32. package/src/__tests__/handoff-manifest.test.ts +52 -0
  33. package/src/__tests__/message-decoders.test.ts +249 -0
  34. package/src/__tests__/outbound-delivery.test.ts +558 -0
  35. package/src/__tests__/peer-message-ledger.test.ts +200 -0
  36. package/src/__tests__/personal-usage-report.test.ts +193 -0
  37. package/src/__tests__/resume-buffer.test.ts +93 -0
  38. package/src/__tests__/session-event-delivery.test.ts +101 -0
  39. package/src/__tests__/session-message-handler-e5.test.ts +99 -0
  40. package/src/__tests__/session-message-handler.test.ts +590 -0
  41. package/src/__tests__/session-resume-bridge.test.ts +406 -0
  42. package/src/__tests__/submit-prompt-shape.test.ts +78 -0
  43. package/src/__tests__/usage-report-carrier.test.ts +98 -0
  44. package/src/__tests__/ws-broadcast-events.test.ts +84 -0
  45. package/src/background-messages.ts +169 -0
  46. package/src/channel-frames.ts +182 -0
  47. package/src/client.ts +9 -0
  48. package/src/handoff-chunking.ts +235 -0
  49. package/src/index.ts +112 -3
  50. package/src/message-decoders.ts +229 -0
  51. package/src/message-parser.ts +33 -0
  52. package/src/node/access-token-verifier.ts +363 -0
  53. package/src/node/admission.ts +153 -0
  54. package/src/node/handoff-manifest.ts +77 -0
  55. package/src/node/index.ts +10 -0
  56. package/src/outbound-delivery.ts +231 -0
  57. package/src/peer-message-ledger.ts +193 -0
  58. package/src/protocol-session.ts +28 -0
  59. package/src/resume-buffer.ts +123 -0
  60. package/src/session-events.ts +198 -0
  61. package/src/session-message-handler.ts +282 -0
  62. package/src/session-query-messages.ts +42 -0
  63. package/src/session-resume-bridge.ts +252 -0
  64. package/src/usage-messages.ts +195 -0
  65. package/src/wire-messages.ts +165 -0
  66. package/dist/node/headless/index.cjs +0 -1
  67. package/dist/node/headless/index.d.ts +0 -2
  68. package/dist/node/headless/index.js +0 -1
  69. package/dist/node/headless-DG_MEbf0.js +0 -19
  70. package/dist/node/headless-DG_MEbf0.js.map +0 -1
  71. package/dist/node/headless-xq77JBq0.cjs +0 -18
  72. package/dist/node/index-CrJ2Lhn3.d.ts +0 -96
  73. package/dist/node/index-CrJ2Lhn3.d.ts.map +0 -1
  74. package/dist/node/index-JIvzrtaA.d.ts +0 -52
  75. package/dist/node/index-JIvzrtaA.d.ts.map +0 -1
  76. package/dist/node/index-JTD_xtEQ.d.ts +0 -96
  77. package/dist/node/index-JTD_xtEQ.d.ts.map +0 -1
  78. package/dist/node/index-y_HBOfzO.d.ts +0 -52
  79. package/dist/node/index-y_HBOfzO.d.ts.map +0 -1
  80. package/dist/node/programmatic/index.cjs +0 -1
  81. package/dist/node/programmatic/index.d.ts +0 -2
  82. package/dist/node/programmatic/index.js +0 -1
  83. package/dist/node/programmatic-6wLf68qJ.cjs +0 -1
  84. package/dist/node/programmatic-70dxIXtS.js +0 -2
  85. package/dist/node/programmatic-70dxIXtS.js.map +0 -1
  86. package/dist/node/testing/index.cjs +0 -1
  87. package/dist/node/testing/index.d.ts +0 -2
  88. package/dist/node/testing/index.js +0 -1
  89. package/src/__tests__/programmatic/programmatic-ask-user.test.ts +0 -32
  90. package/src/__tests__/programmatic/programmatic-driver.test.ts +0 -107
  91. package/src/__tests__/programmatic/programmatic-flows.test.ts +0 -140
  92. package/src/__tests__/transport-registry.stop-all.test.ts +0 -53
  93. package/src/headless/HeadlessInteractionChannel.ts +0 -129
  94. package/src/headless/__tests__/headless-channel-options.test.ts +0 -106
  95. package/src/headless/__tests__/headless-goal-runner.test.ts +0 -108
  96. package/src/headless/__tests__/headless-provider-failure.integration.test.ts +0 -143
  97. package/src/headless/__tests__/headless-runner-initialization.test.ts +0 -45
  98. package/src/headless/__tests__/headless-runner.test.ts +0 -508
  99. package/src/headless/__tests__/headless-skill-activation.integration.test.ts +0 -430
  100. package/src/headless/__tests__/headless-transport.test.ts +0 -267
  101. package/src/headless/cli-input.ts +0 -50
  102. package/src/headless/headless-runner.ts +0 -230
  103. package/src/headless/headless-stream-json.ts +0 -148
  104. package/src/headless/headless-transport.ts +0 -43
  105. package/src/headless/index.ts +0 -8
  106. package/src/headless/print-terminal.ts +0 -57
  107. package/src/programmatic/ProgrammaticInteractionChannel.ts +0 -82
  108. package/src/programmatic/createProgrammaticAgent.ts +0 -71
  109. package/src/programmatic/index.ts +0 -5
  110. package/src/testing/index.ts +0 -10
  111. package/src/transport-registry.ts +0 -103
@@ -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,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,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
+ });
@@ -0,0 +1,101 @@
1
+ import { describe, expect, it, vi } from 'vitest';
2
+
3
+ import { createOutboundDelivery } from '../outbound-delivery.js';
4
+ import { createSessionMessageHandler } from '../session-message-handler.js';
5
+ import { PROTOCOL_SESSION_EVENT_CLASSIFICATION } from '../session-events.js';
6
+
7
+ import type { TServerMessage } from '../wire-messages.js';
8
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
9
+
10
+ type TEmittingSession = IInteractiveSession & {
11
+ emitForTest: (event: string, payload: unknown) => void;
12
+ };
13
+
14
+ function createSession(): TEmittingSession {
15
+ const listeners = new Map<string, Set<(payload: unknown) => void>>();
16
+ return {
17
+ getActiveDriverId: () => null,
18
+ on: vi.fn((event: string, handler: (payload: unknown) => void) => {
19
+ if (!listeners.has(event)) listeners.set(event, new Set());
20
+ listeners.get(event)!.add(handler);
21
+ }),
22
+ off: vi.fn((event: string, handler: (payload: unknown) => void) => {
23
+ listeners.get(event)?.delete(handler);
24
+ }),
25
+ emitForTest: (event: string, payload: unknown) => {
26
+ for (const handler of listeners.get(event) ?? []) handler(payload);
27
+ },
28
+ } as unknown as TEmittingSession;
29
+ }
30
+
31
+ describe('protocol session-event delivery policy (ARCH-020/ARCH-028)', () => {
32
+ it('mechanically matches every surface classification to an actual subscription', () => {
33
+ const session = createSession();
34
+ const { cleanup } = createSessionMessageHandler({
35
+ session,
36
+ deliver: createOutboundDelivery(vi.fn(), vi.fn()),
37
+ });
38
+ const classifiedForSubscription = Object.entries(PROTOCOL_SESSION_EVENT_CLASSIFICATION)
39
+ .filter(([, classification]) => classification !== 'non-surface')
40
+ .map(([event]) => event)
41
+ .sort();
42
+ const onCalls = vi.mocked(session.on).mock.calls;
43
+ expect(onCalls.map(([event]) => event).sort()).toEqual(classifiedForSubscription);
44
+
45
+ cleanup();
46
+ const offCalls = vi.mocked(session.off).mock.calls;
47
+ expect(offCalls.map(([event]) => event).sort()).toEqual(classifiedForSubscription);
48
+ for (const [event, handler] of onCalls) {
49
+ expect(offCalls).toContainEqual([event, handler]);
50
+ }
51
+ });
52
+
53
+ it('forwards plan, context refresh, and branch events as typed protocol frames', () => {
54
+ const session = createSession();
55
+ const sent: TServerMessage[] = [];
56
+ createSessionMessageHandler({
57
+ session,
58
+ deliver: createOutboundDelivery((message) => sent.push(message), vi.fn()),
59
+ });
60
+
61
+ session.emitForTest('plan_event', { type: 'plan_created', plan: { id: 'plan-1' } });
62
+ session.emitForTest('context_file_refreshed', { filePath: '/repo/AGENTS.md' });
63
+ session.emitForTest('branch_event', {
64
+ kind: 'branch_switched',
65
+ checkpointId: 'turn-0002',
66
+ branchId: 'branch-2',
67
+ });
68
+
69
+ expect(sent.map((message) => message.type)).toEqual([
70
+ 'plan_event',
71
+ 'context_file_refreshed',
72
+ 'branch_event',
73
+ ]);
74
+ });
75
+
76
+ it('isolates a carrier send failure and reports its owning session event', () => {
77
+ const session = createSession();
78
+ const failures: Array<{ message: string; event: string }> = [];
79
+ createSessionMessageHandler({
80
+ session,
81
+ deliver: createOutboundDelivery(
82
+ () => {
83
+ throw new Error('socket closed');
84
+ },
85
+ (error, event) => {
86
+ failures.push({ message: error.message, event });
87
+ throw new Error('diagnostic callback failed');
88
+ },
89
+ ),
90
+ });
91
+
92
+ expect(() =>
93
+ session.emitForTest('branch_event', {
94
+ kind: 'checkpoint_created',
95
+ checkpointId: 'turn-0001',
96
+ branchId: 'main',
97
+ }),
98
+ ).not.toThrow();
99
+ expect(failures).toEqual([{ message: 'socket closed', event: 'branch_event' }]);
100
+ });
101
+ });
@@ -0,0 +1,99 @@
1
+ import { createTestInteractiveSession } from '@robota-sdk/agent-interface-session/testing';
2
+
3
+ import { describe, it, expect, vi } from 'vitest';
4
+
5
+ import { createOutboundDelivery } from '../outbound-delivery.js';
6
+ import { createSessionMessageHandler } from '../session-message-handler.js';
7
+ import type { TServerMessage } from '../wire-messages.js';
8
+ import type { IInteractiveSession } from '@robota-sdk/agent-interface-session';
9
+ import type { TBackgroundTaskEvent } from '@robota-sdk/agent-interface-execution';
10
+
11
+ /**
12
+ * REMOTE-014 E5 TC-03/05 — the protocol layer: the SERVER-ASSIGNED driver id is injected into inbound
13
+ * submit/command/prompt-response (a client cannot send its own), and turn-authored events are SELECTIVELY
14
+ * stamped with the active turn's driver (background events are never stamped).
15
+ */
16
+
17
+ function mockSession(activeDriverId: string | null) {
18
+ const listeners = new Map<string, Set<(...a: unknown[]) => void>>();
19
+ return Object.assign(createTestInteractiveSession(), {
20
+ submit: vi.fn().mockResolvedValue(undefined),
21
+ executeCommand: vi.fn().mockResolvedValue({ message: 'ok', success: true }),
22
+ resolvePermission: vi.fn(),
23
+ resolveAsk: vi.fn(),
24
+ getActiveDriverId: vi.fn().mockReturnValue(activeDriverId),
25
+ on: (e: string, h: (...a: unknown[]) => void) => {
26
+ if (!listeners.has(e)) listeners.set(e, new Set());
27
+ listeners.get(e)!.add(h);
28
+ },
29
+ off: (e: string, h: (...a: unknown[]) => void) => listeners.get(e)?.delete(h),
30
+ _emit: (e: string, ...a: unknown[]) => listeners.get(e)?.forEach((h) => h(...a)),
31
+ });
32
+ }
33
+
34
+ describe('session-message-handler E5 driver attribution (REMOTE-014)', () => {
35
+ it('TC-05: injects the SERVER-ASSIGNED driver id into submit + prompt-response (client cannot forge it)', () => {
36
+ const session = mockSession(null);
37
+ const { onMessage } = createSessionMessageHandler({
38
+ session,
39
+ deliver: createOutboundDelivery(() => {}, vi.fn()),
40
+ driverId: 'device-42',
41
+ });
42
+ // A client submit frame carries NO driverId field (structurally — TClientMessage has none); the handler
43
+ // injects the bound server id.
44
+ onMessage(JSON.stringify({ type: 'submit', prompt: 'hi', driverId: 'spoofed' }));
45
+ expect(session.submit as ReturnType<typeof vi.fn>).toHaveBeenCalledWith(
46
+ 'hi',
47
+ undefined,
48
+ undefined,
49
+ {
50
+ driverId: 'device-42',
51
+ },
52
+ );
53
+ onMessage(JSON.stringify({ type: 'permission-response', id: 'p1', result: true }));
54
+ expect(session.resolvePermission).toHaveBeenCalledWith('p1', true, 'device-42');
55
+ onMessage(
56
+ JSON.stringify({
57
+ type: 'ask-response',
58
+ id: 'a1',
59
+ response: { type: 'answer', values: ['y'] },
60
+ }),
61
+ );
62
+ expect(session.resolveAsk).toHaveBeenCalledWith(
63
+ 'a1',
64
+ { type: 'answer', values: ['y'] },
65
+ 'device-42',
66
+ );
67
+ });
68
+
69
+ it('TC-03: SELECTIVELY stamps turn-authored events with the active driver; background events carry none', () => {
70
+ const session = mockSession('driver-A');
71
+ const sent: TServerMessage[] = [];
72
+ createSessionMessageHandler({
73
+ session,
74
+ deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
75
+ });
76
+
77
+ session._emit('user_message', 'hello');
78
+ session._emit('text_delta', 'chunk');
79
+ session._emit('background_task_event', { type: 'noop' } as unknown as TBackgroundTaskEvent);
80
+
81
+ const userMsg = sent.find((m) => m.type === 'user_message') as { driverId?: string };
82
+ const textDelta = sent.find((m) => m.type === 'text_delta') as { driverId?: string };
83
+ const bgEvent = sent.find((m) => m.type === 'background_task_event') as { driverId?: string };
84
+ expect(userMsg.driverId).toBe('driver-A');
85
+ expect(textDelta.driverId).toBe('driver-A');
86
+ expect(bgEvent.driverId).toBeUndefined(); // background events are NOT turn-authored
87
+ });
88
+
89
+ it('TC-03: an unattributed (idle) turn stamps no driverId', () => {
90
+ const session = mockSession(null); // getActiveDriverId → null
91
+ const sent: TServerMessage[] = [];
92
+ createSessionMessageHandler({
93
+ session,
94
+ deliver: createOutboundDelivery((m) => sent.push(m), vi.fn()),
95
+ });
96
+ session._emit('user_message', 'hi');
97
+ expect((sent[0] as { driverId?: string }).driverId).toBeUndefined();
98
+ });
99
+ });