@animalabs/agent-framework 0.18.0 → 0.20.0
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/README.md +29 -0
- package/dist/src/agent.d.ts +22 -0
- package/dist/src/agent.d.ts.map +1 -1
- package/dist/src/agent.js +128 -10
- package/dist/src/agent.js.map +1 -1
- package/dist/src/api/types.d.ts +1 -1
- package/dist/src/api/types.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.d.ts +10 -1
- package/dist/src/code-execution/py-runner.d.ts.map +1 -1
- package/dist/src/code-execution/py-runner.js +27 -2
- package/dist/src/code-execution/py-runner.js.map +1 -1
- package/dist/src/code-execution/tool-definition.d.ts +21 -0
- package/dist/src/code-execution/tool-definition.d.ts.map +1 -1
- package/dist/src/code-execution/tool-definition.js +34 -0
- package/dist/src/code-execution/tool-definition.js.map +1 -1
- package/dist/src/framework.d.ts +161 -7
- package/dist/src/framework.d.ts.map +1 -1
- package/dist/src/framework.js +1183 -87
- package/dist/src/framework.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/index.js +4 -0
- package/dist/src/index.js.map +1 -1
- package/dist/src/mcpl/capability-grant.d.ts +1 -1
- package/dist/src/mcpl/capability-grant.d.ts.map +1 -1
- package/dist/src/mcpl/capability-grant.js +49 -5
- package/dist/src/mcpl/capability-grant.js.map +1 -1
- package/dist/src/mcpl/channel-registry.d.ts +74 -2
- package/dist/src/mcpl/channel-registry.d.ts.map +1 -1
- package/dist/src/mcpl/channel-registry.js +156 -10
- package/dist/src/mcpl/channel-registry.js.map +1 -1
- package/dist/src/mcpl/index.d.ts +3 -0
- package/dist/src/mcpl/index.d.ts.map +1 -1
- package/dist/src/mcpl/index.js +3 -0
- package/dist/src/mcpl/index.js.map +1 -1
- package/dist/src/mcpl/push-coalescer.d.ts +318 -0
- package/dist/src/mcpl/push-coalescer.d.ts.map +1 -0
- package/dist/src/mcpl/push-coalescer.js +709 -0
- package/dist/src/mcpl/push-coalescer.js.map +1 -0
- package/dist/src/mcpl/push-handler.d.ts +7 -2
- package/dist/src/mcpl/push-handler.d.ts.map +1 -1
- package/dist/src/mcpl/push-handler.js +43 -4
- package/dist/src/mcpl/push-handler.js.map +1 -1
- package/dist/src/mcpl/server-connection.d.ts +18 -0
- package/dist/src/mcpl/server-connection.d.ts.map +1 -1
- package/dist/src/mcpl/server-connection.js +44 -1
- package/dist/src/mcpl/server-connection.js.map +1 -1
- package/dist/src/mcpl/tool-classes.d.ts +71 -0
- package/dist/src/mcpl/tool-classes.d.ts.map +1 -0
- package/dist/src/mcpl/tool-classes.js +170 -0
- package/dist/src/mcpl/tool-classes.js.map +1 -0
- package/dist/src/mcpl/tool-glob.d.ts +16 -0
- package/dist/src/mcpl/tool-glob.d.ts.map +1 -0
- package/dist/src/mcpl/tool-glob.js +41 -0
- package/dist/src/mcpl/tool-glob.js.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts +253 -0
- package/dist/src/mcpl/tool-lifecycle.d.ts.map +1 -0
- package/dist/src/mcpl/tool-lifecycle.js +592 -0
- package/dist/src/mcpl/tool-lifecycle.js.map +1 -0
- package/dist/src/mcpl/transport.d.ts +12 -0
- package/dist/src/mcpl/transport.d.ts.map +1 -1
- package/dist/src/mcpl/transport.js +33 -1
- package/dist/src/mcpl/transport.js.map +1 -1
- package/dist/src/mcpl/types.d.ts +66 -1
- package/dist/src/mcpl/types.d.ts.map +1 -1
- package/dist/src/mcpl/types.js +6 -0
- package/dist/src/mcpl/types.js.map +1 -1
- package/dist/src/modules/discord/index.js +1 -1
- package/dist/src/modules/discord/index.js.map +1 -1
- package/dist/src/modules/history/index.d.ts +85 -0
- package/dist/src/modules/history/index.d.ts.map +1 -1
- package/dist/src/modules/history/index.js +1019 -24
- package/dist/src/modules/history/index.js.map +1 -1
- package/dist/src/modules/history/semantic.d.ts +202 -0
- package/dist/src/modules/history/semantic.d.ts.map +1 -0
- package/dist/src/modules/history/semantic.js +418 -0
- package/dist/src/modules/history/semantic.js.map +1 -0
- package/dist/src/modules/index.d.ts +2 -1
- package/dist/src/modules/index.d.ts.map +1 -1
- package/dist/src/modules/index.js.map +1 -1
- package/dist/src/tool-result-guard.d.ts +82 -0
- package/dist/src/tool-result-guard.d.ts.map +1 -0
- package/dist/src/tool-result-guard.js +306 -0
- package/dist/src/tool-result-guard.js.map +1 -0
- package/dist/src/types/agent.d.ts +14 -0
- package/dist/src/types/agent.d.ts.map +1 -1
- package/dist/src/types/framework.d.ts +21 -0
- package/dist/src/types/framework.d.ts.map +1 -1
- package/dist/src/types/trace.d.ts +17 -0
- package/dist/src/types/trace.d.ts.map +1 -1
- package/dist/test/channel-registry-routing.test.js +59 -0
- package/dist/test/channel-registry-routing.test.js.map +1 -1
- package/dist/test/channels-changed-respond-first.test.d.ts +2 -0
- package/dist/test/channels-changed-respond-first.test.d.ts.map +1 -0
- package/dist/test/channels-changed-respond-first.test.js +74 -0
- package/dist/test/channels-changed-respond-first.test.js.map +1 -0
- package/dist/test/code-execution.test.js +139 -0
- package/dist/test/code-execution.test.js.map +1 -1
- package/dist/test/helpers/coalescing-fixture.d.ts +72 -0
- package/dist/test/helpers/coalescing-fixture.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-fixture.js +145 -0
- package/dist/test/helpers/coalescing-fixture.js.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts +2 -0
- package/dist/test/helpers/coalescing-kill-child.d.ts.map +1 -0
- package/dist/test/helpers/coalescing-kill-child.js +24 -0
- package/dist/test/helpers/coalescing-kill-child.js.map +1 -0
- package/dist/test/history-module-realstore.test.d.ts +2 -0
- package/dist/test/history-module-realstore.test.d.ts.map +1 -0
- package/dist/test/history-module-realstore.test.js +363 -0
- package/dist/test/history-module-realstore.test.js.map +1 -0
- package/dist/test/history-module-semantic.test.d.ts +2 -0
- package/dist/test/history-module-semantic.test.d.ts.map +1 -0
- package/dist/test/history-module-semantic.test.js +213 -0
- package/dist/test/history-module-semantic.test.js.map +1 -0
- package/dist/test/history-module-ux.test.d.ts +2 -0
- package/dist/test/history-module-ux.test.d.ts.map +1 -0
- package/dist/test/history-module-ux.test.js +304 -0
- package/dist/test/history-module-ux.test.js.map +1 -0
- package/dist/test/history-module.test.js +25 -1
- package/dist/test/history-module.test.js.map +1 -1
- package/dist/test/history-semantic-review-findings.test.d.ts +2 -0
- package/dist/test/history-semantic-review-findings.test.d.ts.map +1 -0
- package/dist/test/history-semantic-review-findings.test.js +338 -0
- package/dist/test/history-semantic-review-findings.test.js.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts +2 -0
- package/dist/test/history-semantic-sync-converges.test.d.ts.map +1 -0
- package/dist/test/history-semantic-sync-converges.test.js +109 -0
- package/dist/test/history-semantic-sync-converges.test.js.map +1 -0
- package/dist/test/journal-tool.test.d.ts +2 -0
- package/dist/test/journal-tool.test.d.ts.map +1 -0
- package/dist/test/journal-tool.test.js +33 -0
- package/dist/test/journal-tool.test.js.map +1 -0
- package/dist/test/mcpl-capability-grant.test.js +3 -1
- package/dist/test/mcpl-capability-grant.test.js.map +1 -1
- package/dist/test/mcpl-child-env.test.d.ts +2 -0
- package/dist/test/mcpl-child-env.test.d.ts.map +1 -0
- package/dist/test/mcpl-child-env.test.js +69 -0
- package/dist/test/mcpl-child-env.test.js.map +1 -0
- package/dist/test/mcpl-coalescing.test.d.ts +2 -0
- package/dist/test/mcpl-coalescing.test.d.ts.map +1 -0
- package/dist/test/mcpl-coalescing.test.js +1050 -0
- package/dist/test/mcpl-coalescing.test.js.map +1 -0
- package/dist/test/mcpl-list-status.test.js +4 -0
- package/dist/test/mcpl-list-status.test.js.map +1 -1
- package/dist/test/mcpl-tool-class-listing.test.d.ts +2 -0
- package/dist/test/mcpl-tool-class-listing.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-class-listing.test.js +147 -0
- package/dist/test/mcpl-tool-class-listing.test.js.map +1 -0
- package/dist/test/mcpl-tool-classes.test.d.ts +7 -0
- package/dist/test/mcpl-tool-classes.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-classes.test.js +80 -0
- package/dist/test/mcpl-tool-classes.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts +2 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js +237 -0
- package/dist/test/mcpl-tool-lifecycle-e2e.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js +307 -0
- package/dist/test/mcpl-tool-lifecycle-emitter.test.js.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts +11 -0
- package/dist/test/mcpl-tool-lifecycle.test.d.ts.map +1 -0
- package/dist/test/mcpl-tool-lifecycle.test.js +270 -0
- package/dist/test/mcpl-tool-lifecycle.test.js.map +1 -0
- package/dist/test/overbudget-drain-breaker.test.js +44 -4
- package/dist/test/overbudget-drain-breaker.test.js.map +1 -1
- package/dist/test/silent-heartbeat.test.d.ts +2 -0
- package/dist/test/silent-heartbeat.test.d.ts.map +1 -0
- package/dist/test/silent-heartbeat.test.js +154 -0
- package/dist/test/silent-heartbeat.test.js.map +1 -0
- package/dist/test/tool-result-guard.test.d.ts +2 -0
- package/dist/test/tool-result-guard.test.d.ts.map +1 -0
- package/dist/test/tool-result-guard.test.js +1081 -0
- package/dist/test/tool-result-guard.test.js.map +1 -0
- package/dist/test/trunk-channel-routing.test.js +67 -0
- package/dist/test/trunk-channel-routing.test.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/agent.ts +128 -10
- package/src/api/types.ts +2 -0
- package/src/code-execution/py-runner.ts +34 -2
- package/src/code-execution/tool-definition.ts +46 -0
- package/src/framework.ts +1215 -91
- package/src/index.ts +13 -0
- package/src/mcpl/capability-grant.ts +54 -6
- package/src/mcpl/channel-registry.ts +170 -10
- package/src/mcpl/index.ts +29 -0
- package/src/mcpl/push-coalescer.ts +821 -0
- package/src/mcpl/push-handler.ts +40 -4
- package/src/mcpl/server-connection.ts +48 -2
- package/src/mcpl/tool-classes.ts +188 -0
- package/src/mcpl/tool-glob.ts +36 -0
- package/src/mcpl/tool-lifecycle.ts +730 -0
- package/src/mcpl/transport.ts +35 -1
- package/src/mcpl/types.ts +69 -1
- package/src/modules/discord/index.ts +1 -1
- package/src/modules/history/index.ts +1089 -24
- package/src/modules/history/semantic.ts +473 -0
- package/src/modules/index.ts +2 -1
- package/src/tool-result-guard.ts +318 -0
- package/src/types/agent.ts +15 -0
- package/src/types/framework.ts +23 -0
- package/src/types/trace.ts +17 -0
|
@@ -0,0 +1,1050 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RFC-006 event coalescing (mcpl PR #5, revision 7) — wire-level tests over a
|
|
3
|
+
* real loopback WebSocket MCPL server and a mock model provider.
|
|
4
|
+
*
|
|
5
|
+
* Vector numbers refer to RFC-006 §14.
|
|
6
|
+
*/
|
|
7
|
+
import { test } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { fork } from 'node:child_process';
|
|
10
|
+
import { once } from 'node:events';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join, dirname } from 'node:path';
|
|
14
|
+
import { fileURLToPath } from 'node:url';
|
|
15
|
+
import { createMockResponse } from './helpers/mock-membrane.js';
|
|
16
|
+
import { fixture, eventually, crash, TS } from './helpers/coalescing-fixture.js';
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Plain coalescing, feature-set scope (vectors 1–6, 35)
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
test('host advertises eventCoalescing with the default retry window', async (t) => {
|
|
21
|
+
const f = await fixture();
|
|
22
|
+
t.after(f.close);
|
|
23
|
+
assert.deepEqual(f.hostCaps[0].eventCoalescing, { pushEvents: true, channelsIncoming: true, deferred: true, channelScopedPush: true, retryWindowMs: 3_600_000 });
|
|
24
|
+
});
|
|
25
|
+
test('vectors 2, 6, 35: unread replacement keeps only the newest content; a retry returns the receipt', async (t) => {
|
|
26
|
+
const f = await fixture();
|
|
27
|
+
t.after(f.close);
|
|
28
|
+
const first = await f.send('push/event', f.params('1', 'edit_original', { initial: true }));
|
|
29
|
+
const second = await f.send('push/event', f.params('2', 'edit_middle'));
|
|
30
|
+
const last = await f.send('push/event', f.params('3', 'edit_latest'));
|
|
31
|
+
assert.equal(first.result.coalesce.outcome, 'first');
|
|
32
|
+
assert.equal(second.result.coalesce.outcome, 'replaced');
|
|
33
|
+
assert.deepEqual(last.result.coalesce, { outcome: 'replaced', priorEventId: '2' });
|
|
34
|
+
// In context immediately (not staged), but only the newest occurrence.
|
|
35
|
+
assert(f.context().includes('edit_latest'));
|
|
36
|
+
assert(!f.context().includes('edit_original') && !f.context().includes('edit_middle'));
|
|
37
|
+
await f.framework.runUntilIdle();
|
|
38
|
+
assert.equal(f.membrane.calls.length, 1);
|
|
39
|
+
assert(f.lastRequest().includes('edit_latest') && !f.lastRequest().includes('edit_original'));
|
|
40
|
+
const retry = await f.send('push/event', f.params('3', 'edit_latest'));
|
|
41
|
+
assert.deepEqual(retry.result, last.result);
|
|
42
|
+
await f.framework.runUntilIdle();
|
|
43
|
+
assert.equal(f.membrane.calls.length, 1, 'a retry has no second effect');
|
|
44
|
+
});
|
|
45
|
+
test('vector 3: a consumed occurrence is history; the next one appends', async (t) => {
|
|
46
|
+
const f = await fixture();
|
|
47
|
+
t.after(f.close);
|
|
48
|
+
await f.send('push/event', f.params('1', 'battery_79', { initial: true }));
|
|
49
|
+
await f.framework.runUntilIdle();
|
|
50
|
+
const r = await f.send('push/event', f.params('2', 'battery_78'));
|
|
51
|
+
assert.deepEqual(r.result.coalesce, { outcome: 'appended', priorEventId: '1' });
|
|
52
|
+
await f.turn();
|
|
53
|
+
const req = f.lastRequest();
|
|
54
|
+
assert(req.includes('battery_79') && req.includes('battery_78'));
|
|
55
|
+
assert(req.indexOf('battery_79') < req.indexOf('battery_78'));
|
|
56
|
+
});
|
|
57
|
+
test('vector 28: retraction of never-read content leaves no trace and no turn', async (t) => {
|
|
58
|
+
const f = await fixture();
|
|
59
|
+
t.after(f.close);
|
|
60
|
+
await f.send('push/event', f.params('1', 'unread_original', { initial: true }));
|
|
61
|
+
const r = await f.send('push/event', f.params('2', 'deletion_notice', { retract: true }));
|
|
62
|
+
assert.equal(r.result.coalesce.outcome, 'retracted');
|
|
63
|
+
await f.framework.runUntilIdle();
|
|
64
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
65
|
+
assert(!f.context().includes('unread_original') && !f.context().includes('deletion_notice'));
|
|
66
|
+
});
|
|
67
|
+
test('vectors 29/34: read original → unread edit → delete keeps the original and appends only the notice', async (t) => {
|
|
68
|
+
const f = await fixture();
|
|
69
|
+
t.after(f.close);
|
|
70
|
+
await f.send('push/event', f.params('1', 'read_original', { initial: true }));
|
|
71
|
+
await f.framework.runUntilIdle();
|
|
72
|
+
const edit = await f.send('push/event', f.params('2', 'unread_edit'));
|
|
73
|
+
assert.equal(edit.result.coalesce.outcome, 'appended');
|
|
74
|
+
const del = await f.send('push/event', f.params('3', 'deletion_notice', { retract: true }));
|
|
75
|
+
assert.equal(del.result.coalesce.outcome, 'noted');
|
|
76
|
+
await f.turn();
|
|
77
|
+
const req = f.lastRequest();
|
|
78
|
+
assert(req.includes('read_original') && req.includes('deletion_notice') && !req.includes('unread_edit'));
|
|
79
|
+
// History persists past the empty slot: a new create under the same key, then delete → noted.
|
|
80
|
+
await f.send('push/event', f.params('4', 'new_create'));
|
|
81
|
+
const again = await f.send('push/event', f.params('5', 'second_notice', { retract: true }));
|
|
82
|
+
assert.equal(again.result.coalesce.outcome, 'noted');
|
|
83
|
+
});
|
|
84
|
+
test('vector 30/33: empty retraction of consumed content is "consumed" and appends nothing', async (t) => {
|
|
85
|
+
const f = await fixture();
|
|
86
|
+
t.after(f.close);
|
|
87
|
+
await f.register();
|
|
88
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'presence_line', { initial: true }, 'chat', 'p1')] });
|
|
89
|
+
await f.framework.runUntilIdle();
|
|
90
|
+
const count = f.framework.getAgent('agent').getContextManager().getMessageCount();
|
|
91
|
+
const r = await f.send('push/event', { featureSet: 'doc', eventId: 'gone', timestamp: TS, coalesce: { channelId: 'chat', key: 'message:p1', retract: true }, payload: { content: [] } });
|
|
92
|
+
assert.equal(r.result.coalesce.outcome, 'consumed');
|
|
93
|
+
assert.equal(f.framework.getAgent('agent').getContextManager().getMessageCount(), count);
|
|
94
|
+
});
|
|
95
|
+
test('vectors 31/32/34a: history is unknown without `initial`; `initial` makes "retracted" reachable after a restart', async (t) => {
|
|
96
|
+
const f = await fixture();
|
|
97
|
+
t.after(f.close);
|
|
98
|
+
await f.framework.stop();
|
|
99
|
+
await f.create();
|
|
100
|
+
const plainCreate = await f.send('push/event', f.params('a', 'no_birth_marker', { key: 'k1' }));
|
|
101
|
+
assert.equal(plainCreate.result.coalesce.outcome, 'appended');
|
|
102
|
+
const plainDelete = await f.send('push/event', f.params('b', '[deleted]', { key: 'k1', retract: true }));
|
|
103
|
+
assert.equal(plainDelete.result.coalesce.outcome, 'noted', 'unknown history yields the notice');
|
|
104
|
+
const born = await f.send('push/event', f.params('c', 'born_here', { key: 'k2', initial: true }));
|
|
105
|
+
assert.equal(born.result.coalesce.outcome, 'first');
|
|
106
|
+
const gone = await f.send('push/event', f.params('d', '[deleted]', { key: 'k2', retract: true }));
|
|
107
|
+
assert.equal(gone.result.coalesce.outcome, 'retracted');
|
|
108
|
+
assert(!f.context().includes('born_here'));
|
|
109
|
+
// vector 34b: initial cannot lower history.
|
|
110
|
+
await f.send('push/event', f.params('e', 'seen', { key: 'k3', initial: true }));
|
|
111
|
+
await f.framework.runUntilIdle();
|
|
112
|
+
await f.send('push/event', f.params('f', 'again', { key: 'k3', initial: true }));
|
|
113
|
+
const later = await f.send('push/event', f.params('g', 'notice', { key: 'k3', retract: true }));
|
|
114
|
+
assert.equal(later.result.coalesce.outcome, 'noted');
|
|
115
|
+
});
|
|
116
|
+
test('vector 9: feature set and key isolate subjects', async (t) => {
|
|
117
|
+
const f = await fixture();
|
|
118
|
+
t.after(f.close);
|
|
119
|
+
await f.send('push/event', f.params('1', 'doc_a', { key: 'A', initial: true }));
|
|
120
|
+
await f.send('push/event', f.params('2', 'doc_b', { key: 'B', initial: true }));
|
|
121
|
+
const r = await f.send('push/event', f.params('3', 'doc_a2', { key: 'A' }));
|
|
122
|
+
assert.equal(r.result.coalesce.outcome, 'replaced');
|
|
123
|
+
assert(f.context().includes('doc_b') && f.context().includes('doc_a2') && !f.context().includes('doc_a"'));
|
|
124
|
+
});
|
|
125
|
+
test('vector 38: malformed coalesce is a -32602 on push/event and per-message on channels/incoming', async (t) => {
|
|
126
|
+
const f = await fixture();
|
|
127
|
+
t.after(f.close);
|
|
128
|
+
await f.register();
|
|
129
|
+
const bad = await f.send('push/event', f.params('1', 'x', { key: 'k'.repeat(257) }));
|
|
130
|
+
assert.equal(bad.error.code, -32602);
|
|
131
|
+
const both = await f.send('push/event', f.params('2', 'x', { deferred: true, retract: true }));
|
|
132
|
+
assert.equal(both.error.code, -32602);
|
|
133
|
+
const missing = { ...f.channel('bad', 'bad'), eventId: undefined };
|
|
134
|
+
const deferred = f.channel('bad2', 'bad2', { deferred: true });
|
|
135
|
+
const r = await f.send('channels/incoming', { messages: [missing, f.channel('good', 'good', { initial: true }), deferred] });
|
|
136
|
+
assert.deepEqual(r.result.results.map((x) => x.accepted), [false, true, false]);
|
|
137
|
+
assert.equal(r.result.results[0].reason, 'coalesce_invalid');
|
|
138
|
+
assert.equal(r.result.results[2].reason, 'coalesce_invalid');
|
|
139
|
+
await f.framework.runUntilIdle();
|
|
140
|
+
assert(f.lastRequest().includes('good'));
|
|
141
|
+
});
|
|
142
|
+
// ---------------------------------------------------------------------------
|
|
143
|
+
// Channel scope, mixed lanes (vectors 8, 10, 13, 14, 15a, 43)
|
|
144
|
+
// ---------------------------------------------------------------------------
|
|
145
|
+
test('vector 10: channel create, push edit, push delete share one unread subject', async (t) => {
|
|
146
|
+
const f = await fixture();
|
|
147
|
+
t.after(f.close);
|
|
148
|
+
await f.register();
|
|
149
|
+
const first = await f.send('channels/incoming', { messages: [f.channel('c', 'chat_original', { initial: true })] });
|
|
150
|
+
const edit = await f.send('push/event', f.params('e', 'chat_edit', { channelId: 'chat', key: 'message:m' }));
|
|
151
|
+
const del = await f.send('push/event', f.params('d', 'chat_deleted', { channelId: 'chat', key: 'message:m', retract: true }));
|
|
152
|
+
assert.equal(first.result.results[0].coalesce.outcome, 'first');
|
|
153
|
+
assert.equal(edit.result.coalesce.outcome, 'replaced');
|
|
154
|
+
assert.equal(del.result.coalesce.outcome, 'retracted');
|
|
155
|
+
await f.framework.runUntilIdle();
|
|
156
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
157
|
+
for (const text of ['chat_original', 'chat_edit', 'chat_deleted'])
|
|
158
|
+
assert(!f.context().includes(text));
|
|
159
|
+
});
|
|
160
|
+
test('vector 8: repeated channel edits keep the platform message id and deduplicate a retried occurrence', async (t) => {
|
|
161
|
+
const f = await fixture();
|
|
162
|
+
t.after(f.close);
|
|
163
|
+
await f.register();
|
|
164
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'original', { initial: true })] });
|
|
165
|
+
const edit = await f.send('channels/incoming', { messages: [f.channel('e', 'newest')] });
|
|
166
|
+
const retry = await f.send('channels/incoming', { messages: [f.channel('e', 'newest')] });
|
|
167
|
+
assert.deepEqual(retry.result, edit.result);
|
|
168
|
+
assert.equal(edit.result.results[0].coalesce.outcome, 'replaced');
|
|
169
|
+
await f.framework.runUntilIdle();
|
|
170
|
+
assert.equal(f.membrane.calls.length, 1);
|
|
171
|
+
assert(f.lastRequest().includes('newest') && !f.lastRequest().includes('original'));
|
|
172
|
+
const stored = f.framework.getAgent('agent').getContextManager().getAllMessages().find((m) => m.metadata?.eventId === 'e');
|
|
173
|
+
assert.equal(stored?.metadata?.messageId, 'm');
|
|
174
|
+
});
|
|
175
|
+
test('vector 43: a mixed-lane edit keeps the stable reply target', async (t) => {
|
|
176
|
+
const f = await fixture();
|
|
177
|
+
t.after(f.close);
|
|
178
|
+
await f.register();
|
|
179
|
+
await f.send('channels/incoming', { messages: [{ ...f.channel('c', 'first_version', { initial: true }), threadId: 'thread' }] });
|
|
180
|
+
await f.framework.runUntilIdle();
|
|
181
|
+
await f.send('push/event', { ...f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }), origin: { messageId: 'm', authorId: 'u', authorName: 'User', threadId: 'thread' } });
|
|
182
|
+
await f.turn();
|
|
183
|
+
assert.equal(f.published.at(-1)?.channelId, 'chat');
|
|
184
|
+
});
|
|
185
|
+
test('vectors 13/15: a channel-scoped push needs current channel authority and a declared channel', async (t) => {
|
|
186
|
+
const f = await fixture();
|
|
187
|
+
t.after(f.close);
|
|
188
|
+
const unknown = await f.send('push/event', f.params('1', 'text', { channelId: 'chat' }));
|
|
189
|
+
assert.equal(unknown.error.code, -32023);
|
|
190
|
+
// A channel id that only ever appeared in a push's origin is not declared.
|
|
191
|
+
await f.send('push/event', { featureSet: 'doc', eventId: 'dm', timestamp: TS, payload: { content: [{ type: 'text', text: 'dm' }] },
|
|
192
|
+
tags: ['chat:dm'], origin: { source: 'discord', channelId: '42', guildId: null, authorId: 'u', authorName: 'User' } });
|
|
193
|
+
await f.framework.runUntilIdle();
|
|
194
|
+
const forged = await f.send('push/event', f.params('2', 'text', { channelId: 'discord:dm:42' }));
|
|
195
|
+
assert.equal(forged.error.code, -32023);
|
|
196
|
+
await f.register();
|
|
197
|
+
const okNow = await f.send('push/event', f.params('3', 'text', { channelId: 'chat', initial: true }));
|
|
198
|
+
assert.equal(okNow.result.coalesce.outcome, 'first');
|
|
199
|
+
});
|
|
200
|
+
test('vector 14: scope is never inferred from origin', async (t) => {
|
|
201
|
+
const f = await fixture();
|
|
202
|
+
t.after(f.close);
|
|
203
|
+
await f.register('chat:one');
|
|
204
|
+
await f.send('channels/incoming', { messages: [f.channel('a', 'channel_one', { initial: true }, 'chat:one')] });
|
|
205
|
+
await f.send('push/event', { ...f.params('feature', 'feature_scope', { key: 'message:m' }), origin: { channelId: 'chat:one' } });
|
|
206
|
+
await f.framework.runUntilIdle();
|
|
207
|
+
assert(f.lastRequest().includes('channel_one') && f.lastRequest().includes('feature_scope'));
|
|
208
|
+
});
|
|
209
|
+
test('vector 33 (channel lane): an ordinary channels/incoming message without coalesce is unchanged', async (t) => {
|
|
210
|
+
const f = await fixture();
|
|
211
|
+
t.after(f.close);
|
|
212
|
+
await f.register();
|
|
213
|
+
const r = await f.send('channels/incoming', { messages: [{ channelId: 'chat', messageId: 'p', timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'plain' }] }] });
|
|
214
|
+
assert.deepEqual(r.result.results[0], { messageId: 'p', accepted: true });
|
|
215
|
+
});
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
// Wake treatment (vectors 36, 43 rev-6 behavioural)
|
|
218
|
+
// ---------------------------------------------------------------------------
|
|
219
|
+
test('a retraction withdraws the unstarted wake its subject queued', async (t) => {
|
|
220
|
+
const f = await fixture({ server: { shouldTriggerInference: () => true } });
|
|
221
|
+
t.after(f.close);
|
|
222
|
+
const internals = f.framework;
|
|
223
|
+
await f.send('push/event', f.params('1', 'withdrawn', { initial: true }));
|
|
224
|
+
assert.equal(internals.pendingRequests.length, 1);
|
|
225
|
+
await f.send('push/event', f.params('2', 'gone', { retract: true }));
|
|
226
|
+
assert.equal(internals.pendingRequests.length, 0);
|
|
227
|
+
await f.framework.runUntilIdle();
|
|
228
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
229
|
+
});
|
|
230
|
+
test('a replacement is evaluated by the gate as a fresh occurrence; a qualifying replacement wakes once', async (t) => {
|
|
231
|
+
const f = await fixture({ framework: { gate: { config: { policies: [{ name: 'attention', match: { tagsAny: ['doc:wake'] }, behavior: 'always' }], default: 'skip' } } } });
|
|
232
|
+
t.after(f.close);
|
|
233
|
+
const internals = f.framework;
|
|
234
|
+
await f.send('push/event', { ...f.params('1', 'quiet', { initial: true }), tags: ['doc:quiet'] });
|
|
235
|
+
assert.equal(internals.pendingRequests.length, 0);
|
|
236
|
+
await f.send('push/event', { ...f.params('2', 'loud', {}), tags: ['doc:wake'] });
|
|
237
|
+
assert.equal(internals.pendingRequests.length, 1);
|
|
238
|
+
await f.send('push/event', { ...f.params('3', 'louder', {}), tags: ['doc:wake'] });
|
|
239
|
+
assert.equal(internals.pendingRequests.length, 1, 'no second wake for the same subject');
|
|
240
|
+
await f.send('push/event', { ...f.params('4', 'quiet_again', {}), tags: ['doc:quiet'] });
|
|
241
|
+
assert.equal(internals.pendingRequests.length, 0, 'a non-qualifying replacement withdraws the wake');
|
|
242
|
+
await f.framework.runUntilIdle();
|
|
243
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
244
|
+
});
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// Deferred rendering (vectors 16–27)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
test('vectors 16/17/18: notices render once at assembly with their private data; never the fallback', async (t) => {
|
|
249
|
+
const f = await fixture();
|
|
250
|
+
t.after(f.close);
|
|
251
|
+
await f.send('push/event', f.params('1', 'fallback_unavailable', { deferred: true, data: { private: 'hidden_notice' } }));
|
|
252
|
+
const joined = await f.send('push/event', f.params('2', 'fallback_unavailable', { deferred: true, data: { private: 'hidden_notice_2' } }));
|
|
253
|
+
assert.equal(joined.result.coalesce.outcome, 'replaced');
|
|
254
|
+
assert.equal(f.renders.length, 0);
|
|
255
|
+
assert(!f.context().includes('fallback_unavailable'), 'a batch is not model-visible');
|
|
256
|
+
await f.framework.runUntilIdle();
|
|
257
|
+
assert.equal(f.renders.length, 1);
|
|
258
|
+
assert.equal(f.renders[0].notices ? f.renders[0].notices.length : 0, 2);
|
|
259
|
+
assert.deepEqual(f.renders[0].notices.map((n) => n.data), [{ private: 'hidden_notice' }, { private: 'hidden_notice_2' }]);
|
|
260
|
+
const req = f.lastRequest();
|
|
261
|
+
assert(req.includes('document_diff') && !req.includes('fallback_unavailable') && !req.includes('hidden_notice'));
|
|
262
|
+
// vector 18: a second inference issues no render; vector 19: a new notice does.
|
|
263
|
+
await f.turn();
|
|
264
|
+
assert.equal(f.renders.length, 1);
|
|
265
|
+
const again = await f.send('push/event', f.params('3', 'fallback', { deferred: true }));
|
|
266
|
+
assert.equal(again.result.coalesce.outcome, 'first');
|
|
267
|
+
await f.turn();
|
|
268
|
+
assert.equal(f.renders.length, 2);
|
|
269
|
+
});
|
|
270
|
+
test('vector 20: an empty render appends nothing', async (t) => {
|
|
271
|
+
const f = await fixture();
|
|
272
|
+
t.after(f.close);
|
|
273
|
+
f.renderer(async () => ({ content: [] }));
|
|
274
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true }));
|
|
275
|
+
await f.framework.runUntilIdle();
|
|
276
|
+
assert.equal(f.renders.length, 1);
|
|
277
|
+
assert(!f.lastRequest().includes('fallback'));
|
|
278
|
+
});
|
|
279
|
+
test('vectors 21/23: a render past the deadline materializes the fallback; the late result is discarded', async (t) => {
|
|
280
|
+
const f = await fixture();
|
|
281
|
+
t.after(f.close);
|
|
282
|
+
f.renderer(async () => { await new Promise((r) => setTimeout(r, 5300)); return { content: [{ type: 'text', text: 'wire_late_payload' }] }; });
|
|
283
|
+
await f.send('push/event', f.params('1', 'bounded_fallback', { deferred: true }));
|
|
284
|
+
await f.framework.runUntilIdle();
|
|
285
|
+
assert(f.lastRequest().includes('bounded_fallback') && !f.lastRequest().includes('wire_late_payload'));
|
|
286
|
+
await new Promise((r) => setTimeout(r, 400));
|
|
287
|
+
assert(!f.context().includes('wire_late_payload'));
|
|
288
|
+
});
|
|
289
|
+
test('vector 21: a render error uses the fallback', async (t) => {
|
|
290
|
+
const f = await fixture();
|
|
291
|
+
t.after(f.close);
|
|
292
|
+
f.renderer(async () => ({ content: [null] }));
|
|
293
|
+
await f.send('push/event', f.params('1', 'fallback_text', { deferred: true }));
|
|
294
|
+
await f.framework.runUntilIdle();
|
|
295
|
+
assert(f.lastRequest().includes('fallback_text'));
|
|
296
|
+
});
|
|
297
|
+
test('vector 26: inference/request during push/render is refused', async (t) => {
|
|
298
|
+
const f = await fixture();
|
|
299
|
+
t.after(f.close);
|
|
300
|
+
let refused;
|
|
301
|
+
f.renderer(async () => { refused = (await f.send('inference/request', { featureSet: 'doc', messages: [] })).error?.code; return { content: [{ type: 'text', text: 'rendered' }] }; });
|
|
302
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true }));
|
|
303
|
+
await f.framework.runUntilIdle();
|
|
304
|
+
assert.equal(refused, -32600);
|
|
305
|
+
});
|
|
306
|
+
test('vectors 27a/27c: retract or plain replacement during a render cancels the frozen batch', async (t) => {
|
|
307
|
+
for (const operation of ['retract', 'plain']) {
|
|
308
|
+
const f = await fixture();
|
|
309
|
+
try {
|
|
310
|
+
let release;
|
|
311
|
+
let began;
|
|
312
|
+
const started = new Promise((resolve) => { began = resolve; });
|
|
313
|
+
f.renderer(() => { began(); return new Promise((resolve) => { release = resolve; }); });
|
|
314
|
+
await f.send('push/event', f.params('1', 'fallback_one', { deferred: true, initial: true }));
|
|
315
|
+
const run = f.framework.runUntilIdle();
|
|
316
|
+
await started;
|
|
317
|
+
const r = await f.send('push/event', operation === 'retract'
|
|
318
|
+
? f.params('2', 'deletion_notice', { retract: true })
|
|
319
|
+
: f.params('2', 'complete_snapshot'));
|
|
320
|
+
assert.equal(r.result.coalesce.outcome, operation === 'retract' ? 'retracted' : 'replaced');
|
|
321
|
+
// The plain replacement arrived while the turn was alive: it lands at
|
|
322
|
+
// the turn boundary and wakes the agent again, so a second turn follows.
|
|
323
|
+
if (operation === 'plain')
|
|
324
|
+
f.alwaysRespond();
|
|
325
|
+
release({ content: [{ type: 'text', text: 'late_old_render' }] });
|
|
326
|
+
await run;
|
|
327
|
+
const req = f.lastRequest();
|
|
328
|
+
assert(!req.includes('late_old_render') && !req.includes('fallback_one') && !req.includes('deletion_notice'));
|
|
329
|
+
if (operation === 'plain')
|
|
330
|
+
assert(req.includes('complete_snapshot'));
|
|
331
|
+
}
|
|
332
|
+
finally {
|
|
333
|
+
await f.close();
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
test('vector 24: revoked authority before assembly drops the batch without a render', async (t) => {
|
|
338
|
+
const f = await fixture();
|
|
339
|
+
t.after(f.close);
|
|
340
|
+
await f.send('push/event', f.params('1', 'previously_permitted', { deferred: true }));
|
|
341
|
+
await f.framework.stop();
|
|
342
|
+
await f.create(false);
|
|
343
|
+
await f.framework.runUntilIdle();
|
|
344
|
+
assert.equal(f.renders.length, 0);
|
|
345
|
+
assert(!f.context().includes('previously_permitted'));
|
|
346
|
+
});
|
|
347
|
+
test('vector 27i: a deferred notice displaces an unread plain occurrence', async (t) => {
|
|
348
|
+
const f = await fixture();
|
|
349
|
+
t.after(f.close);
|
|
350
|
+
await f.send('push/event', f.params('1', 'plain_unread', { initial: true }));
|
|
351
|
+
const r = await f.send('push/event', f.params('2', 'fallback', { deferred: true }));
|
|
352
|
+
assert.equal(r.result.coalesce.outcome, 'replaced');
|
|
353
|
+
assert(!f.context().includes('plain_unread'));
|
|
354
|
+
await f.framework.runUntilIdle();
|
|
355
|
+
assert(f.lastRequest().includes('document_diff') && !f.lastRequest().includes('plain_unread'));
|
|
356
|
+
});
|
|
357
|
+
// ---------------------------------------------------------------------------
|
|
358
|
+
// Recovery (vectors 7, 39, 40, 41, 47) and the findings from PR #196
|
|
359
|
+
// ---------------------------------------------------------------------------
|
|
360
|
+
test('vector 41 (#196 finding 1): a transient reconnect does not reject ordinary channel traffic', async (t) => {
|
|
361
|
+
const f = await fixture({ server: { reconnect: true, reconnectIntervalMs: 20 } });
|
|
362
|
+
t.after(f.close);
|
|
363
|
+
await f.register();
|
|
364
|
+
const plain = (id) => ({ channelId: 'chat', messageId: id, timestamp: TS, author: { id: 'u', name: 'User' }, content: [{ type: 'text', text: 'plain_' + id }] });
|
|
365
|
+
const before = await f.send('channels/incoming', { messages: [plain('1')] });
|
|
366
|
+
f.disconnect();
|
|
367
|
+
await eventually(() => f.hostCaps.length === 2, 'reconnect');
|
|
368
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
369
|
+
const after = await f.send('channels/incoming', { messages: [plain('2')] });
|
|
370
|
+
assert.equal(before.result.results[0].accepted, true);
|
|
371
|
+
assert.equal(after.result.results[0].accepted, true);
|
|
372
|
+
});
|
|
373
|
+
test('vector 39/40: a reconnect keeps accepted content and answers a retry with its receipt', async (t) => {
|
|
374
|
+
const f = await fixture({ server: { reconnect: true, reconnectIntervalMs: 20 } });
|
|
375
|
+
t.after(f.close);
|
|
376
|
+
await f.register();
|
|
377
|
+
const params = { messages: [f.channel('c', 'survives_disconnect', { initial: true })] };
|
|
378
|
+
const accepted = await f.send('channels/incoming', params);
|
|
379
|
+
f.disconnect();
|
|
380
|
+
await eventually(() => f.hostCaps.length === 2, 'reconnect');
|
|
381
|
+
await f.register();
|
|
382
|
+
const retry = await f.send('channels/incoming', params);
|
|
383
|
+
assert.deepEqual(retry.result, accepted.result);
|
|
384
|
+
await f.framework.runUntilIdle();
|
|
385
|
+
assert.equal(f.membrane.calls.length, 1);
|
|
386
|
+
const req = f.lastRequest();
|
|
387
|
+
assert.equal(req.split('survives_disconnect').length - 1, 1, 'exactly once');
|
|
388
|
+
});
|
|
389
|
+
test('vectors 7/47: restart keeps accepted content, receipts, and appends after it', async (t) => {
|
|
390
|
+
const f = await fixture();
|
|
391
|
+
t.after(f.close);
|
|
392
|
+
const params = f.params('once', 'exactly_once', { initial: true });
|
|
393
|
+
const original = await f.send('push/event', params);
|
|
394
|
+
await f.framework.stop();
|
|
395
|
+
await f.create();
|
|
396
|
+
const retry = await f.send('push/event', params);
|
|
397
|
+
assert.deepEqual(retry.result, original.result);
|
|
398
|
+
const next = await f.send('push/event', f.params('two', 'after_restart'));
|
|
399
|
+
assert.equal(next.result.coalesce.outcome, 'appended');
|
|
400
|
+
await f.framework.runUntilIdle();
|
|
401
|
+
const req = f.lastRequest();
|
|
402
|
+
assert(req.includes('exactly_once') && req.includes('after_restart'));
|
|
403
|
+
assert.equal(req.split('exactly_once').length - 1, 1);
|
|
404
|
+
});
|
|
405
|
+
test('#196 finding 2 / vector 34c: a busy channel never hits a subject cap', async (t) => {
|
|
406
|
+
const f = await fixture({ framework: { gate: { config: { policies: [{ name: 'attention', match: { tagsAny: ['chat:mention'] }, behavior: 'always' }], default: 'skip' } } } });
|
|
407
|
+
t.after(f.close);
|
|
408
|
+
await f.register();
|
|
409
|
+
const msg = (i, tags) => ({ channelId: 'chat', messageId: 'm' + i, eventId: 'e' + i, timestamp: TS, author: { id: 'u', name: 'User' }, tags,
|
|
410
|
+
content: [{ type: 'text', text: 'text_' + i }], coalesce: { key: 'message:m' + i, initial: true } });
|
|
411
|
+
const t0 = Date.now();
|
|
412
|
+
for (let i = 0; i < 300; i++) {
|
|
413
|
+
const r = await f.send('channels/incoming', { messages: [msg(i, [])] });
|
|
414
|
+
assert.equal(r.result.results[0].accepted, true);
|
|
415
|
+
}
|
|
416
|
+
const elapsed = Date.now() - t0;
|
|
417
|
+
await f.framework.runUntilIdle();
|
|
418
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
419
|
+
const mention = await f.send('channels/incoming', { messages: [msg('mention', ['chat:mention'])] });
|
|
420
|
+
assert.equal(mention.result.results[0].accepted, true);
|
|
421
|
+
await f.framework.runUntilIdle();
|
|
422
|
+
assert.equal(f.membrane.calls.length, 1);
|
|
423
|
+
assert(f.lastRequest().includes('text_mention') && f.lastRequest().includes('text_0'));
|
|
424
|
+
// Each acceptance awaits a group-committed fsync (RFC-006 durability); serial
|
|
425
|
+
// sends cannot batch, so this is ~300 fsyncs. Bounded, and reported.
|
|
426
|
+
console.log(`# 300 serial coalesced channel messages: ${elapsed}ms (${(elapsed / 300).toFixed(1)} ms/msg incl. fsync)`);
|
|
427
|
+
assert(elapsed < 30_000, `300 coalesced messages took ${elapsed}ms`);
|
|
428
|
+
});
|
|
429
|
+
test('#196 finding 4: a context-budget restart does not assemble a pending batch', async (t) => {
|
|
430
|
+
const f = await fixture();
|
|
431
|
+
t.after(f.close);
|
|
432
|
+
await f.send('push/event', f.params('1', 'pending_batch', { deferred: true }));
|
|
433
|
+
const internals = f.framework;
|
|
434
|
+
internals.pendingRequests.length = 0;
|
|
435
|
+
await internals.startAgentStream(f.framework.getAgent('agent'), { agentName: 'agent', reason: 'context_budget_restart', source: 'probe', timestamp: Date.now() });
|
|
436
|
+
await f.framework.runUntilIdle();
|
|
437
|
+
assert.equal(f.renders.length, 0, 'not rendered mid-logical-turn');
|
|
438
|
+
assert(!f.lastRequest().includes('document_diff'));
|
|
439
|
+
const joined = await f.send('push/event', f.params('2', 'pending_batch', { deferred: true }));
|
|
440
|
+
assert.equal(joined.result.coalesce.outcome, 'replaced', 'the batch survived the restart turn');
|
|
441
|
+
await f.turn();
|
|
442
|
+
assert.equal(f.renders.length, 1, 'rendered at the next fresh turn');
|
|
443
|
+
assert.equal(f.renders[0].notices.length, 2);
|
|
444
|
+
});
|
|
445
|
+
test('#196 finding 5: undo of a turn keeps the coalesced input that preceded it', async (t) => {
|
|
446
|
+
const f = await fixture({ server: { shouldTriggerInference: () => true } });
|
|
447
|
+
t.after(f.close);
|
|
448
|
+
await f.send('push/event', f.params('a', 'turn_one', { key: 'a', initial: true }));
|
|
449
|
+
await f.framework.runUntilIdle();
|
|
450
|
+
await f.send('push/event', f.params('b', 'unread_before_undo', { key: 'b', initial: true }));
|
|
451
|
+
await f.turn();
|
|
452
|
+
assert(f.lastRequest().includes('unread_before_undo'));
|
|
453
|
+
f.framework.undoLastTurn('agent');
|
|
454
|
+
assert(f.context().includes('unread_before_undo'), 'the input survives the undo of the turn that read it');
|
|
455
|
+
const r = await f.send('push/event', f.params('c', 'after_undo', { key: 'b' }));
|
|
456
|
+
assert.equal(r.result.coalesce.outcome, 'appended', 'after a branch switch nothing is replaceable');
|
|
457
|
+
});
|
|
458
|
+
test('a replacement while a turn is alive edits the deferred queue, not the live turn', async (t) => {
|
|
459
|
+
const f = await fixture();
|
|
460
|
+
t.after(f.close);
|
|
461
|
+
const internals = f.framework;
|
|
462
|
+
internals.activeTurnTokens.set('agent', 100);
|
|
463
|
+
await f.send('push/event', f.params('1', 'queued_original', { initial: true }));
|
|
464
|
+
const r = await f.send('push/event', f.params('2', 'queued_replacement'));
|
|
465
|
+
assert.equal(r.result.coalesce.outcome, 'replaced');
|
|
466
|
+
assert.equal(internals.deferredMessages.length, 1);
|
|
467
|
+
assert(!f.context().includes('queued_original') && !f.context().includes('queued_replacement'));
|
|
468
|
+
internals.activeTurnTokens.delete('agent');
|
|
469
|
+
await f.turn();
|
|
470
|
+
assert(f.lastRequest().includes('queued_replacement') && !f.lastRequest().includes('queued_original'));
|
|
471
|
+
});
|
|
472
|
+
test('compression consumes: a message folded into a summary is no longer replaceable', async (t) => {
|
|
473
|
+
const { AutobiographicalStrategy } = await import('@animalabs/context-manager');
|
|
474
|
+
const strategy = new AutobiographicalStrategy({
|
|
475
|
+
adaptiveResolution: true, foldingStrategy: 'kv-stable', recentWindowTokens: 1_000, targetChunkTokens: 300,
|
|
476
|
+
kvStableReachTokens: 300, autoTickOnNewMessage: false, compressionModel: 'summarizer',
|
|
477
|
+
});
|
|
478
|
+
const f = await fixture({ agents: [{ name: 'agent', model: 'test', systemPrompt: 'test', strategy }] });
|
|
479
|
+
t.after(f.close);
|
|
480
|
+
f.membrane.complete = async (req) => { f.membrane.calls.push(req); return createMockResponse([{ type: 'text', text: 'SUMMARY' }]); };
|
|
481
|
+
await f.send('push/event', f.params('1', 'folded_original', { initial: true }));
|
|
482
|
+
const cm = f.framework.getAgent('agent').getContextManager();
|
|
483
|
+
for (let i = 0; i < 30; i++)
|
|
484
|
+
cm.addMessage('user', [{ type: 'text', text: `filler_${i} ` + 'lorem ipsum '.repeat(40) }]);
|
|
485
|
+
for (let i = 0; i < 12; i++)
|
|
486
|
+
await cm.tick();
|
|
487
|
+
assert(cm.getMaxSummaryLevel() > 0, 'folding happened');
|
|
488
|
+
const r = await f.send('push/event', f.params('2', 'after_fold'));
|
|
489
|
+
assert.equal(r.result.coalesce.outcome, 'appended');
|
|
490
|
+
assert(f.context().includes('folded_original') && f.context().includes('after_fold'));
|
|
491
|
+
});
|
|
492
|
+
test('conversation forks: a channel-scoped edit follows the routed fork and pins its reply channel', async (t) => {
|
|
493
|
+
const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } });
|
|
494
|
+
t.after(f.close);
|
|
495
|
+
await f.register();
|
|
496
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'fork_original', { initial: true })] });
|
|
497
|
+
const edit = await f.send('push/event', f.params('e', 'fork_latest', { channelId: 'chat', key: 'message:m' }));
|
|
498
|
+
assert.equal(edit.result.coalesce.outcome, 'replaced');
|
|
499
|
+
f.alwaysRespond();
|
|
500
|
+
await f.framework.runUntilIdle();
|
|
501
|
+
assert.equal(f.membrane.calls.length, 1);
|
|
502
|
+
assert(f.lastRequest().includes('fork_latest') && !f.lastRequest().includes('fork_original'));
|
|
503
|
+
assert(!f.context().includes('fork_latest'), 'the trunk never sees channel traffic');
|
|
504
|
+
assert.equal(f.published.at(-1)?.channelId, 'chat');
|
|
505
|
+
});
|
|
506
|
+
// ---------------------------------------------------------------------------
|
|
507
|
+
// Review round 1 (#197): Greptile G1–G15, Codex C1–C3
|
|
508
|
+
// ---------------------------------------------------------------------------
|
|
509
|
+
test('C1: a channel-scoped render result is dropped when channels.incoming is revoked mid-render', async (t) => {
|
|
510
|
+
const f = await fixture();
|
|
511
|
+
t.after(f.close);
|
|
512
|
+
await f.register();
|
|
513
|
+
const { CapabilityGrant } = await import('../src/mcpl/capability-grant.js');
|
|
514
|
+
let release;
|
|
515
|
+
const started = new Promise((resolve) => { f.renderer(() => { resolve(); return new Promise((r) => { release = r; }); }); });
|
|
516
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true, channelId: 'chat', key: 'activity' }));
|
|
517
|
+
const run = f.framework.runUntilIdle();
|
|
518
|
+
await started;
|
|
519
|
+
const conn = f.framework.mcplServerRegistry.getServer('editor');
|
|
520
|
+
conn.establishGrant(new CapabilityGrant(new Set(['pushEvents']), []));
|
|
521
|
+
release({ content: [{ type: 'text', text: 'revoked_render' }] });
|
|
522
|
+
await run;
|
|
523
|
+
assert(!f.context().includes('revoked_render') && !f.context().includes('fallback'));
|
|
524
|
+
});
|
|
525
|
+
test('C2 / vector 42: reassigning the server id to another endpoint starts a new binding', async (t) => {
|
|
526
|
+
const f = await fixture();
|
|
527
|
+
t.after(f.close);
|
|
528
|
+
const a = await f.send('push/event', f.params('E', 'from_endpoint_a', { key: 'K', initial: true }));
|
|
529
|
+
assert.equal(a.result.coalesce.outcome, 'first');
|
|
530
|
+
const config = f.framework.mcplServerConfigs.get('editor');
|
|
531
|
+
await f.framework.restartMcplServer('editor', { ...config, url: `${config.url}/reassigned` });
|
|
532
|
+
await eventually(() => f.hostCaps.length === 2, 'reconnect to the new endpoint');
|
|
533
|
+
const b = await f.send('push/event', f.params('E', 'from_endpoint_b', { key: 'K', initial: true }));
|
|
534
|
+
assert.equal(b.result.coalesce.outcome, 'first', "B's reuse of A's event id is a new occurrence");
|
|
535
|
+
const b2 = await f.send('push/event', f.params('E2', 'from_endpoint_b_2', { key: 'K' }));
|
|
536
|
+
assert.equal(b2.result.coalesce.outcome, 'replaced');
|
|
537
|
+
assert.equal(b2.result.coalesce.priorEventId, 'E');
|
|
538
|
+
assert(f.context().includes('from_endpoint_a'), "A's unread content is not touched by B");
|
|
539
|
+
assert(f.context().includes('from_endpoint_b_2') && !f.context().includes('from_endpoint_b"'));
|
|
540
|
+
});
|
|
541
|
+
test('C3: a replacement follows the prior delivery into its fork, never into a fresh one', async (t) => {
|
|
542
|
+
const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } } });
|
|
543
|
+
t.after(f.close);
|
|
544
|
+
await f.register();
|
|
545
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'fork_original', { initial: true })] });
|
|
546
|
+
const router = f.framework.conversationRouter;
|
|
547
|
+
const g1 = router.getBinding('chat').agentName;
|
|
548
|
+
router.unbind('chat');
|
|
549
|
+
const edit = await f.send('push/event', f.params('e', 'fork_edit', { channelId: 'chat', key: 'message:m' }));
|
|
550
|
+
assert.equal(edit.result.coalesce.outcome, 'replaced');
|
|
551
|
+
assert.equal(router.getBinding('chat'), undefined, 'no fresh fork was spawned for the edit');
|
|
552
|
+
const g1ctx = JSON.stringify(f.framework.getAgent(g1).getContextManager().getAllMessages());
|
|
553
|
+
assert(g1ctx.includes('fork_edit') && !g1ctx.includes('fork_original'));
|
|
554
|
+
});
|
|
555
|
+
test('G1: malformed content on a coalesced channel item fails that item only', async (t) => {
|
|
556
|
+
const f = await fixture();
|
|
557
|
+
t.after(f.close);
|
|
558
|
+
await f.register();
|
|
559
|
+
const bad = { ...f.channel('bad', 'x', { initial: true }, 'chat', 'b'), content: 'not-an-array' };
|
|
560
|
+
const r = await f.send('channels/incoming', { messages: [bad, f.channel('good', 'good_sibling', { initial: true }, 'chat', 'g')] });
|
|
561
|
+
assert.deepEqual(r.result.results.map((x) => x.accepted), [false, true]);
|
|
562
|
+
assert.equal(r.result.results[0].reason, 'coalesce_invalid');
|
|
563
|
+
const badPush = await f.send('push/event', { ...f.params('p', 'x'), payload: { content: [{ type: 'text' }] } });
|
|
564
|
+
assert.equal(badPush.error.code, -32602);
|
|
565
|
+
});
|
|
566
|
+
test('G3/G5: a deferred channel-scoped push renders with its feature set and wakes with its channel', async (t) => {
|
|
567
|
+
const f = await fixture({ server: { shouldTriggerInference: () => true } });
|
|
568
|
+
t.after(f.close);
|
|
569
|
+
await f.register();
|
|
570
|
+
await f.send('push/event', { ...f.params('1', 'fallback', { deferred: true, channelId: 'chat', key: 'activity' }), origin: { authorId: 'u', authorName: 'User' } });
|
|
571
|
+
const internals = f.framework;
|
|
572
|
+
assert.equal(internals.pendingRequests[0]?.channelId, 'chat');
|
|
573
|
+
assert.equal(internals.pendingRequests[0]?.counterparty, 'editor:user:u');
|
|
574
|
+
await f.framework.runUntilIdle();
|
|
575
|
+
assert.equal(f.renders[0]?.featureSet, 'doc');
|
|
576
|
+
assert.equal(f.renders[0]?.channelId, 'chat');
|
|
577
|
+
assert.equal(f.published.at(-1)?.channelId, 'chat', 'the reply goes to the batch\'s channel');
|
|
578
|
+
});
|
|
579
|
+
test('G4: a deferred channel-scoped push spawns the conversation fork it needs', async (t) => {
|
|
580
|
+
const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } }, server: { shouldTriggerInference: () => true } });
|
|
581
|
+
t.after(f.close);
|
|
582
|
+
await f.register();
|
|
583
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true, channelId: 'chat', key: 'activity' }));
|
|
584
|
+
f.alwaysRespond();
|
|
585
|
+
await f.framework.runUntilIdle();
|
|
586
|
+
assert.equal(f.renders.length, 1);
|
|
587
|
+
const router = f.framework.conversationRouter;
|
|
588
|
+
const fork = router.getBinding('chat')?.agentName;
|
|
589
|
+
assert(fork, 'a fork was bound');
|
|
590
|
+
assert(JSON.stringify(f.framework.getAgent(fork).getContextManager().getAllMessages()).includes('document_diff'));
|
|
591
|
+
assert(!f.context().includes('document_diff'));
|
|
592
|
+
});
|
|
593
|
+
test('G6: a replacement finds its prior after the deferred flush stored it', async (t) => {
|
|
594
|
+
const f = await fixture();
|
|
595
|
+
t.after(f.close);
|
|
596
|
+
const internals = f.framework;
|
|
597
|
+
internals.activeTurnTokens.set('agent', 100);
|
|
598
|
+
await f.send('push/event', f.params('1', 'queued_then_stored', { initial: true }));
|
|
599
|
+
internals.activeTurnTokens.delete('agent');
|
|
600
|
+
await internals.flushDeferredWrites('test');
|
|
601
|
+
assert(f.context().includes('queued_then_stored'), 'flushed into context, unread');
|
|
602
|
+
const r = await f.send('push/event', f.params('2', 'replacement_after_flush'));
|
|
603
|
+
assert.equal(r.result.coalesce.outcome, 'replaced');
|
|
604
|
+
assert(!f.context().includes('queued_then_stored') && f.context().includes('replacement_after_flush'));
|
|
605
|
+
const del = await f.send('push/event', f.params('3', 'notice', { retract: true }));
|
|
606
|
+
assert.equal(del.result.coalesce.outcome, 'retracted');
|
|
607
|
+
assert(!f.context().includes('replacement_after_flush'));
|
|
608
|
+
});
|
|
609
|
+
test('G9: a receipt is durable before the acceptance is acknowledged', async (t) => {
|
|
610
|
+
const f = await fixture();
|
|
611
|
+
t.after(f.close);
|
|
612
|
+
await f.send('push/event', f.params('durable', 'x', { initial: true }));
|
|
613
|
+
const store = f.framework.store;
|
|
614
|
+
const recent = store.getStateJson('mcpl/coalescing-recent');
|
|
615
|
+
assert.equal(recent.length, 1);
|
|
616
|
+
assert(recent[0].key.includes('"durable"'));
|
|
617
|
+
assert.equal(recent[0].born, 'none');
|
|
618
|
+
});
|
|
619
|
+
test('G10: a batch that never began rendering renders after a clean restart', async (t) => {
|
|
620
|
+
const f = await fixture();
|
|
621
|
+
t.after(f.close);
|
|
622
|
+
await f.send('push/event', f.params('1', 'fallback_text', { deferred: true }));
|
|
623
|
+
await f.framework.stop();
|
|
624
|
+
await f.create();
|
|
625
|
+
await f.framework.runUntilIdle();
|
|
626
|
+
assert.equal(f.renders.length, 1, 'push/render issued after restart');
|
|
627
|
+
assert(f.lastRequest().includes('document_diff') && !f.lastRequest().includes('fallback_text'));
|
|
628
|
+
});
|
|
629
|
+
test('G11: a push edit without origin identity keeps the channel message identity', async (t) => {
|
|
630
|
+
const f = await fixture();
|
|
631
|
+
t.after(f.close);
|
|
632
|
+
await f.register();
|
|
633
|
+
await f.send('channels/incoming', { messages: [{ ...f.channel('c', 'first_version', { initial: true }), threadId: 'thread' }] });
|
|
634
|
+
await f.send('push/event', f.params('e', 'second_version', { channelId: 'chat', key: 'message:m' }));
|
|
635
|
+
const stored = f.framework.getAgent('agent').getContextManager().getAllMessages().find((m) => m.metadata?.eventId === 'e');
|
|
636
|
+
assert.equal(stored?.metadata?.messageId, 'm');
|
|
637
|
+
assert.equal(stored?.metadata?.threadId, 'thread');
|
|
638
|
+
assert.deepEqual(stored?.metadata?.author, { id: 'u', name: 'User' });
|
|
639
|
+
});
|
|
640
|
+
test('G13: modules see coalesced deliveries', async (t) => {
|
|
641
|
+
const seen = [];
|
|
642
|
+
const spy = { name: 'spy', async start() { }, async stop() { }, async onProcess(e) { seen.push(e.type); return {}; } };
|
|
643
|
+
const f = await fixture({ framework: { modules: [spy] } });
|
|
644
|
+
t.after(f.close);
|
|
645
|
+
await f.register();
|
|
646
|
+
await f.send('push/event', f.params('1', 'x', { initial: true }));
|
|
647
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'y', { initial: true })] });
|
|
648
|
+
assert.deepEqual(seen.filter((x) => x.startsWith('mcpl:')), ['mcpl:push-event', 'mcpl:channel-incoming']);
|
|
649
|
+
});
|
|
650
|
+
test('G15: inference/request is refused while a cancelled render is still outstanding', async (t) => {
|
|
651
|
+
const f = await fixture();
|
|
652
|
+
t.after(f.close);
|
|
653
|
+
let refused;
|
|
654
|
+
f.renderer(async () => {
|
|
655
|
+
await f.send('push/event', f.params('2', 'notice', { retract: true }));
|
|
656
|
+
refused = (await f.send('inference/request', { featureSet: 'doc', messages: [] })).error?.code;
|
|
657
|
+
return { content: [{ type: 'text', text: 'late' }] };
|
|
658
|
+
});
|
|
659
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true, initial: true }));
|
|
660
|
+
await f.framework.runUntilIdle();
|
|
661
|
+
assert.equal(refused, -32600);
|
|
662
|
+
assert(!f.lastRequest().includes('late') && !f.lastRequest().includes('fallback'));
|
|
663
|
+
});
|
|
664
|
+
// ---------------------------------------------------------------------------
|
|
665
|
+
// Review round 2 (#197): Codex R1–R4 on d86afc1
|
|
666
|
+
// ---------------------------------------------------------------------------
|
|
667
|
+
test('R1: a deferred acceptance survives a crash before the snapshot flush, with its receipt', async (t) => {
|
|
668
|
+
const f = await fixture();
|
|
669
|
+
t.after(f.close);
|
|
670
|
+
const params = f.params('1', 'crash_fallback', { deferred: true, data: { k: 'v' } });
|
|
671
|
+
const original = await f.send('push/event', params);
|
|
672
|
+
await crash(f);
|
|
673
|
+
await f.create();
|
|
674
|
+
const retry = await f.send('push/event', params);
|
|
675
|
+
assert.deepEqual(retry.result, original.result, 'the receipt survived');
|
|
676
|
+
await f.framework.runUntilIdle();
|
|
677
|
+
assert.equal(f.membrane.calls.length, 1, 'the accepted work was delivered');
|
|
678
|
+
assert(f.lastRequest().includes('crash_fallback'), 'as its fallback: the host cannot prove no render started');
|
|
679
|
+
assert.equal(f.renders.length, 0);
|
|
680
|
+
});
|
|
681
|
+
test('R2: work admitted under a former binding never reaches the replacement endpoint', async (t) => {
|
|
682
|
+
const f = await fixture();
|
|
683
|
+
t.after(f.close);
|
|
684
|
+
await f.send('push/event', f.params('1', 'old_binding_fallback', { deferred: true, data: { secret: 'private_notice_data' } }));
|
|
685
|
+
const config = f.framework.mcplServerConfigs.get('editor');
|
|
686
|
+
await f.framework.restartMcplServer('editor', { ...config, url: `${config.url}/reassigned` });
|
|
687
|
+
await eventually(() => f.hostCaps.length === 2, 'reconnect to the new endpoint');
|
|
688
|
+
await f.framework.runUntilIdle();
|
|
689
|
+
assert.equal(f.renders.length, 0, 'no push/render to the new peer');
|
|
690
|
+
assert(!JSON.stringify(f.renders).includes('private_notice_data'));
|
|
691
|
+
assert(!f.context().includes('old_binding_fallback'));
|
|
692
|
+
});
|
|
693
|
+
test('R3: a deferred notice displacing an unread plain occurrence stays in its fork', async (t) => {
|
|
694
|
+
const f = await fixture({ framework: { conversations: { templateAgent: 'agent', bind: { channel: 'always' }, trigger: { channel: 'always' } } }, server: { shouldTriggerInference: () => true } });
|
|
695
|
+
t.after(f.close);
|
|
696
|
+
await f.register();
|
|
697
|
+
await f.send('channels/incoming', { messages: [f.channel('c', 'fork_plain', { initial: true })] });
|
|
698
|
+
const router = f.framework.conversationRouter;
|
|
699
|
+
const g1 = router.getBinding('chat').agentName;
|
|
700
|
+
router.unbind('chat');
|
|
701
|
+
const r = await f.send('push/event', f.params('n', 'fallback', { deferred: true, channelId: 'chat', key: 'message:m' }));
|
|
702
|
+
assert.equal(r.result.coalesce.outcome, 'replaced');
|
|
703
|
+
f.alwaysRespond();
|
|
704
|
+
await f.framework.runUntilIdle();
|
|
705
|
+
assert.equal(router.getBinding('chat'), undefined, 'no fresh fork');
|
|
706
|
+
const g1ctx = JSON.stringify(f.framework.getAgent(g1).getContextManager().getAllMessages());
|
|
707
|
+
assert(g1ctx.includes('document_diff') && !g1ctx.includes('fork_plain'));
|
|
708
|
+
});
|
|
709
|
+
test('R4: the render-start boundary is persisted before the RPC is issued', async (t) => {
|
|
710
|
+
const f = await fixture();
|
|
711
|
+
t.after(f.close);
|
|
712
|
+
let release;
|
|
713
|
+
const started = new Promise((resolve) => { f.renderer(() => { resolve(); return new Promise((r) => { release = r; }); }); });
|
|
714
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true }));
|
|
715
|
+
const run = f.framework.runUntilIdle();
|
|
716
|
+
await started;
|
|
717
|
+
const store = f.framework.store;
|
|
718
|
+
assert.equal(store.getStateJson('mcpl/coalescing').subjects[0]?.batch?.rendering, true, 'persisted as RENDERING while the RPC is outstanding');
|
|
719
|
+
release({ content: [{ type: 'text', text: 'rendered' }] });
|
|
720
|
+
await run;
|
|
721
|
+
assert(f.lastRequest().includes('rendered'));
|
|
722
|
+
});
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
// Review round 3 (#197): Codex on c03b23b — recovery from a mid-window image
|
|
725
|
+
// ---------------------------------------------------------------------------
|
|
726
|
+
test('R5: an acknowledged retraction is recoverable; a retry cannot resurrect the batch', async (t) => {
|
|
727
|
+
const f = await fixture();
|
|
728
|
+
t.after(f.close);
|
|
729
|
+
await f.send('push/event', f.params('1', 'withdrawn_fallback', { deferred: true, initial: true }));
|
|
730
|
+
const retract = f.params('2', 'notice', { retract: true });
|
|
731
|
+
const first = await f.send('push/event', retract);
|
|
732
|
+
assert.equal(first.result.coalesce.outcome, 'retracted');
|
|
733
|
+
await crash(f);
|
|
734
|
+
await f.create();
|
|
735
|
+
const retry = await f.send('push/event', retract);
|
|
736
|
+
assert.deepEqual(retry.result, first.result);
|
|
737
|
+
await f.framework.runUntilIdle();
|
|
738
|
+
assert.equal(f.membrane.calls.length, 0);
|
|
739
|
+
assert(!f.context().includes('withdrawn_fallback') && !f.context().includes('notice'));
|
|
740
|
+
});
|
|
741
|
+
test('R6: a completed render is not delivered again as fallback after recovery', async (t) => {
|
|
742
|
+
const f = await fixture();
|
|
743
|
+
t.after(f.close);
|
|
744
|
+
await f.send('push/event', f.params('1', 'stale_fallback', { deferred: true }));
|
|
745
|
+
await f.framework.runUntilIdle();
|
|
746
|
+
assert(f.lastRequest().includes('document_diff'));
|
|
747
|
+
await crash(f);
|
|
748
|
+
await f.create();
|
|
749
|
+
await f.framework.runUntilIdle();
|
|
750
|
+
const ctx = f.context();
|
|
751
|
+
assert.equal(ctx.split('document_diff').length - 1, 1, 'rendered content exactly once');
|
|
752
|
+
assert(!ctx.includes('stale_fallback'), 'no second delivery as fallback');
|
|
753
|
+
assert.equal(f.renders.length, 1);
|
|
754
|
+
// History is known: a retraction now appends the notice rather than withdrawing.
|
|
755
|
+
const r = await f.send('push/event', f.params('2', 'deleted_notice', { retract: true }));
|
|
756
|
+
assert.equal(r.result.coalesce.outcome, 'noted');
|
|
757
|
+
});
|
|
758
|
+
test('R7: a read plain occurrence recovered from a mid-window image still gets its deletion notice', async (t) => {
|
|
759
|
+
const f = await fixture();
|
|
760
|
+
t.after(f.close);
|
|
761
|
+
await f.send('push/event', f.params('1', 'read_before_crash', { initial: true }));
|
|
762
|
+
await f.framework.runUntilIdle();
|
|
763
|
+
assert(f.lastRequest().includes('read_before_crash'));
|
|
764
|
+
await crash(f);
|
|
765
|
+
await f.create();
|
|
766
|
+
const r = await f.send('push/event', f.params('2', 'deletion_notice_after_crash', { retract: true }));
|
|
767
|
+
assert.equal(r.result.coalesce.outcome, 'noted');
|
|
768
|
+
assert(f.context().includes('read_before_crash') && f.context().includes('deletion_notice_after_crash'));
|
|
769
|
+
// And the unread variant stays traceless: born, never read, crash, retract.
|
|
770
|
+
await f.send('push/event', f.params('3', 'never_read', { key: 'k2', initial: true }));
|
|
771
|
+
await crash(f);
|
|
772
|
+
await f.create();
|
|
773
|
+
const r2 = await f.send('push/event', f.params('4', 'notice2', { key: 'k2', retract: true }));
|
|
774
|
+
assert.equal(r2.result.coalesce.outcome, 'noted', 'a stored occurrence counts as read after recovery (watermark at head)');
|
|
775
|
+
assert(f.context().includes('never_read') && f.context().includes('notice2'));
|
|
776
|
+
});
|
|
777
|
+
// ---------------------------------------------------------------------------
|
|
778
|
+
// Review round 5 (#197): durability under SIGKILL (no stop, no close, no sync)
|
|
779
|
+
// ---------------------------------------------------------------------------
|
|
780
|
+
async function killChild(mode) {
|
|
781
|
+
const dir = mkdtempSync(join(tmpdir(), 'coalescing-kill-'));
|
|
782
|
+
const child = fork(join(dirname(fileURLToPath(import.meta.url)), 'helpers', 'coalescing-kill-child.js'), [dir, mode], { stdio: ['ignore', 'ignore', 'inherit', 'ipc'] });
|
|
783
|
+
const [msg] = await once(child, 'message');
|
|
784
|
+
child.kill('SIGKILL');
|
|
785
|
+
await once(child, 'exit');
|
|
786
|
+
return { dir, port: msg.port, receipt: msg.receipt };
|
|
787
|
+
}
|
|
788
|
+
test('R8 (kill, acceptance): the receipt and the accepted work survive SIGKILL after the wire ack', async (t) => {
|
|
789
|
+
const { dir, port, receipt } = await killChild('accept');
|
|
790
|
+
const f = await fixture({ dir, port });
|
|
791
|
+
t.after(async () => { await f.close(); rmSync(dir, { recursive: true, force: true }); });
|
|
792
|
+
const retry = await f.send('push/event', f.params('1', 'killed_fallback', { deferred: true, data: { k: 'v' } }));
|
|
793
|
+
assert.deepEqual(retry.result, receipt, 'the receipt survived the kill');
|
|
794
|
+
await f.framework.runUntilIdle();
|
|
795
|
+
assert.equal(f.membrane.calls.length, 1, 'the accepted work survived the kill');
|
|
796
|
+
assert(f.lastRequest().includes('killed_fallback'));
|
|
797
|
+
});
|
|
798
|
+
test('R9 (kill, render-start): a render that may have run is not issued again after SIGKILL', async (t) => {
|
|
799
|
+
const { dir, port } = await killChild('render');
|
|
800
|
+
const f = await fixture({ dir, port });
|
|
801
|
+
t.after(async () => { await f.close(); rmSync(dir, { recursive: true, force: true }); });
|
|
802
|
+
await f.framework.runUntilIdle();
|
|
803
|
+
assert.equal(f.renders.length, 0, 'no second push/render');
|
|
804
|
+
assert(f.lastRequest().includes('render_fallback'), 'the fallback is delivered instead');
|
|
805
|
+
});
|
|
806
|
+
// ---------------------------------------------------------------------------
|
|
807
|
+
// Review round 6 (#197): the asynchronous barrier's failure and interleaving paths
|
|
808
|
+
// ---------------------------------------------------------------------------
|
|
809
|
+
test('R10: a retry after a failed commit is acknowledged only once the commit succeeds', async (t) => {
|
|
810
|
+
const f = await fixture();
|
|
811
|
+
t.after(f.close);
|
|
812
|
+
const store = f.framework.store;
|
|
813
|
+
const orig = store.sync.bind(store);
|
|
814
|
+
let syncs = 0;
|
|
815
|
+
let failing = 1;
|
|
816
|
+
store.sync = () => { syncs++; if (failing > 0) {
|
|
817
|
+
failing--;
|
|
818
|
+
throw new Error('EIO simulated');
|
|
819
|
+
} orig(); };
|
|
820
|
+
const params = f.params('1', 'fallback', { deferred: true, initial: true });
|
|
821
|
+
const first = await f.send('push/event', params);
|
|
822
|
+
assert.equal(first.error?.code, -32000);
|
|
823
|
+
const before = syncs;
|
|
824
|
+
const retry = await f.send('push/event', params);
|
|
825
|
+
assert.equal(retry.result?.coalesce?.outcome, 'first', 'acknowledged on the retry');
|
|
826
|
+
assert(syncs > before, 'the retry committed; it did not just return the pending receipt');
|
|
827
|
+
// Still failing: the retry fails again rather than acknowledging.
|
|
828
|
+
failing = 1;
|
|
829
|
+
const again = await f.send('push/event', f.params('2', 'other', { key: 'k2', initial: true }));
|
|
830
|
+
assert.equal(again.error?.code, -32000);
|
|
831
|
+
const again2 = await f.send('push/event', f.params('2', 'other', { key: 'k2', initial: true }));
|
|
832
|
+
assert.equal(again2.result?.coalesce?.outcome, 'first');
|
|
833
|
+
assert.equal(f.framework.getAgent('agent').getContextManager().getAllMessages().filter((m) => JSON.stringify(m.content).includes('other')).length, 1, 'plain: delivered exactly once across the failed and successful acknowledgement');
|
|
834
|
+
});
|
|
835
|
+
/** Hold the host's next durability commit; returns a release function once it is held. */
|
|
836
|
+
function holdNextCommit(f) {
|
|
837
|
+
const internals = f.framework;
|
|
838
|
+
const orig = internals.commitCoalescingDurable.bind(internals);
|
|
839
|
+
return new Promise((held) => {
|
|
840
|
+
internals.commitCoalescingDurable = () => {
|
|
841
|
+
internals.commitCoalescingDurable = orig;
|
|
842
|
+
return new Promise((resolve) => { held(() => orig().then(resolve, resolve)); });
|
|
843
|
+
};
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
test('R11: a retraction during the render-start commit wait is final; the batch is not restored', async (t) => {
|
|
847
|
+
const f = await fixture();
|
|
848
|
+
t.after(f.close);
|
|
849
|
+
await f.send('push/event', f.params('1', 'withdrawn_fallback', { deferred: true, initial: true }));
|
|
850
|
+
const held = holdNextCommit(f);
|
|
851
|
+
const run = f.framework.runUntilIdle();
|
|
852
|
+
const release = await held;
|
|
853
|
+
// The freeze (including this commit wait) is one critical section with
|
|
854
|
+
// admission: the retraction is admitted only after it, so it is sent
|
|
855
|
+
// without awaiting here and settles either way as a cancellation.
|
|
856
|
+
const pending = f.send('push/event', f.params('2', 'notice', { retract: true }));
|
|
857
|
+
release();
|
|
858
|
+
const r = await pending;
|
|
859
|
+
assert.equal(r.result.coalesce.outcome, 'retracted');
|
|
860
|
+
await run;
|
|
861
|
+
assert.equal(f.framework.pushCoalescer.pendingBatches(), 0);
|
|
862
|
+
assert(!f.context().includes('withdrawn_fallback') && !f.lastRequest().includes('document_diff'), 'nothing of the withdrawn batch was published');
|
|
863
|
+
const renders = f.renders.length;
|
|
864
|
+
await f.turn();
|
|
865
|
+
assert.equal(f.renders.length, renders, 'and nothing renders later');
|
|
866
|
+
assert(!f.context().includes('withdrawn_fallback') && !f.context().includes('document_diff'));
|
|
867
|
+
});
|
|
868
|
+
test('R12: endpoint reassignment during the render-start commit wait never sends the batch to the new peer', async (t) => {
|
|
869
|
+
const f = await fixture();
|
|
870
|
+
t.after(f.close);
|
|
871
|
+
await f.send('push/event', f.params('1', 'old_fallback', { deferred: true, data: { secret: 'private_notice_data' } }));
|
|
872
|
+
const held = holdNextCommit(f);
|
|
873
|
+
const run = f.framework.runUntilIdle();
|
|
874
|
+
const release = await held;
|
|
875
|
+
const config = f.framework.mcplServerConfigs.get('editor');
|
|
876
|
+
await f.framework.restartMcplServer('editor', { ...config, url: `${config.url}/reassigned` });
|
|
877
|
+
await eventually(() => f.hostCaps.length === 2, 'reconnect to the new endpoint');
|
|
878
|
+
release();
|
|
879
|
+
await run;
|
|
880
|
+
assert.equal(f.renders.length, 0, 'no push/render to the new peer');
|
|
881
|
+
assert(!JSON.stringify(f.renders).includes('private_notice_data'));
|
|
882
|
+
assert(!f.context().includes('old_fallback'));
|
|
883
|
+
});
|
|
884
|
+
// ---------------------------------------------------------------------------
|
|
885
|
+
// Review round 7 (#197): neighbours of the round-6 fixes
|
|
886
|
+
// ---------------------------------------------------------------------------
|
|
887
|
+
test('R13: a retraction during the audience lookup is final; the stale batch is not frozen', async (t) => {
|
|
888
|
+
const f = await fixture();
|
|
889
|
+
t.after(f.close);
|
|
890
|
+
await f.send('push/event', f.params('1', 'withdrawn_fallback', { deferred: true, initial: true }));
|
|
891
|
+
const internals = f.framework;
|
|
892
|
+
const orig = internals.coalescedAudience.bind(internals);
|
|
893
|
+
let release;
|
|
894
|
+
const held = new Promise((resolve) => {
|
|
895
|
+
internals.coalescedAudience = (occ) => { internals.coalescedAudience = orig; return new Promise((res) => { release = () => orig(occ).then(res); resolve(); }); };
|
|
896
|
+
});
|
|
897
|
+
const run = f.framework.runUntilIdle();
|
|
898
|
+
await held;
|
|
899
|
+
const pending = f.send('push/event', f.params('2', 'notice', { retract: true }));
|
|
900
|
+
release();
|
|
901
|
+
const r = await pending;
|
|
902
|
+
assert.equal(r.result.coalesce.outcome, 'retracted');
|
|
903
|
+
await run;
|
|
904
|
+
assert(!f.context().includes('withdrawn_fallback') && !f.context().includes('document_diff'), 'nothing of the withdrawn batch was published');
|
|
905
|
+
await f.turn();
|
|
906
|
+
assert(!f.context().includes('document_diff'));
|
|
907
|
+
});
|
|
908
|
+
test('R14: two consecutive crash windows with an acceptance in between keep both recovered batches', async (t) => {
|
|
909
|
+
const f = await fixture();
|
|
910
|
+
t.after(f.close);
|
|
911
|
+
await f.send('push/event', f.params('a', 'first_window_fallback', { deferred: true, key: 'A' }));
|
|
912
|
+
await crash(f);
|
|
913
|
+
await f.create();
|
|
914
|
+
await f.send('push/event', f.params('b', 'second_window_fallback', { deferred: true, key: 'B' }));
|
|
915
|
+
await crash(f);
|
|
916
|
+
await f.create();
|
|
917
|
+
const retryA = await f.send('push/event', f.params('a', 'first_window_fallback', { deferred: true, key: 'A' }));
|
|
918
|
+
assert.equal(retryA.result?.coalesce?.outcome, 'first', "A's receipt survived the second window");
|
|
919
|
+
await f.framework.runUntilIdle();
|
|
920
|
+
const req = f.lastRequest();
|
|
921
|
+
assert(req.includes('first_window_fallback') && req.includes('second_window_fallback'), 'both recovered batches delivered');
|
|
922
|
+
});
|
|
923
|
+
test('R15: a retraction arriving between the render result and its publication waits for the settlement', async (t) => {
|
|
924
|
+
const f = await fixture();
|
|
925
|
+
t.after(f.close);
|
|
926
|
+
await f.send('push/event', f.params('1', 'fallback', { deferred: true, initial: true }));
|
|
927
|
+
const internals = f.framework;
|
|
928
|
+
const orig = internals.deliverCoalesced.bind(internals);
|
|
929
|
+
let release;
|
|
930
|
+
const held = new Promise((resolve) => {
|
|
931
|
+
internals.deliverCoalesced = (...a) => { internals.deliverCoalesced = orig; return new Promise((res) => { release = () => orig(...a).then(res); resolve(); }); };
|
|
932
|
+
});
|
|
933
|
+
f.alwaysRespond(); // the notice wakes a second turn
|
|
934
|
+
const run = f.framework.runUntilIdle();
|
|
935
|
+
await held; // the render result is in hand; publication is being settled
|
|
936
|
+
const pending = f.send('push/event', f.params('2', 'deleted_notice', { retract: true }));
|
|
937
|
+
release();
|
|
938
|
+
const r = await pending;
|
|
939
|
+
assert.equal(r.result.coalesce.outcome, 'noted', 'the retraction saw the published render as read');
|
|
940
|
+
await run;
|
|
941
|
+
const ctx = f.context();
|
|
942
|
+
assert.equal(ctx.split('document_diff').length - 1, 1, 'rendered exactly once');
|
|
943
|
+
assert(ctx.includes('deleted_notice'), 'and corrected by the notice');
|
|
944
|
+
});
|
|
945
|
+
// ---------------------------------------------------------------------------
|
|
946
|
+
// Review round 8 (#197): liveness after a failed render-start commit
|
|
947
|
+
// ---------------------------------------------------------------------------
|
|
948
|
+
test('R16: a batch whose render-start commit failed is re-woken once storage recovers', async (t) => {
|
|
949
|
+
const f = await fixture();
|
|
950
|
+
t.after(f.close);
|
|
951
|
+
const coalescer = f.framework.pushCoalescer;
|
|
952
|
+
coalescer.options.recoveryBackoffMs = 50;
|
|
953
|
+
const internals = f.framework;
|
|
954
|
+
const store = f.framework.store;
|
|
955
|
+
const orig = store.sync.bind(store);
|
|
956
|
+
let failing = 0;
|
|
957
|
+
store.sync = () => { if (failing > 0) {
|
|
958
|
+
failing--;
|
|
959
|
+
throw new Error('EIO simulated');
|
|
960
|
+
} orig(); };
|
|
961
|
+
await f.send('push/event', f.params('1', 'asleep_fallback', { deferred: true, initial: true }));
|
|
962
|
+
failing = 1; // the render-start commit fails; storage then recovers
|
|
963
|
+
await f.framework.runUntilIdle();
|
|
964
|
+
assert.equal(f.renders.length, 0);
|
|
965
|
+
assert.equal(coalescer.pendingBatches(), 1, 'the batch is kept');
|
|
966
|
+
assert(!f.lastRequest().includes('asleep_fallback'));
|
|
967
|
+
await eventually(() => internals.pendingRequests.length > 0, 'recovery wake');
|
|
968
|
+
await f.turn();
|
|
969
|
+
assert(f.lastRequest().includes('asleep_fallback'), 'delivered without unrelated traffic');
|
|
970
|
+
assert.equal(coalescer.pendingBatches(), 0);
|
|
971
|
+
});
|
|
972
|
+
test('R16b: a producer retry re-wakes a batch left asleep by a failed freeze', async (t) => {
|
|
973
|
+
const f = await fixture();
|
|
974
|
+
t.after(f.close);
|
|
975
|
+
const coalescer = f.framework.pushCoalescer;
|
|
976
|
+
coalescer.options.recoveryBackoffMs = 600_000; // park the timer; the retry must do it
|
|
977
|
+
const internals = f.framework;
|
|
978
|
+
const store = f.framework.store;
|
|
979
|
+
const orig = store.sync.bind(store);
|
|
980
|
+
let failing = 0;
|
|
981
|
+
store.sync = () => { if (failing > 0) {
|
|
982
|
+
failing--;
|
|
983
|
+
throw new Error('EIO simulated');
|
|
984
|
+
} orig(); };
|
|
985
|
+
const params = f.params('1', 'asleep_fallback', { deferred: true, initial: true });
|
|
986
|
+
const first = await f.send('push/event', params);
|
|
987
|
+
failing = 1;
|
|
988
|
+
await f.framework.runUntilIdle();
|
|
989
|
+
assert.equal(internals.pendingRequests.length, 0, 'asleep');
|
|
990
|
+
const retry = await f.send('push/event', params);
|
|
991
|
+
assert.deepEqual(retry.result, first.result);
|
|
992
|
+
assert.equal(internals.pendingRequests.length, 1, 'the retry re-woke it');
|
|
993
|
+
await f.turn();
|
|
994
|
+
assert(f.lastRequest().includes('asleep_fallback'));
|
|
995
|
+
});
|
|
996
|
+
test('R17: a persistent storage outage backs off (1×, 2×, 4× …) and the series resets on recovery', async (t) => {
|
|
997
|
+
const f = await fixture();
|
|
998
|
+
t.after(f.close);
|
|
999
|
+
const coalescer = f.framework.pushCoalescer;
|
|
1000
|
+
coalescer.options.recoveryBackoffMs = 40;
|
|
1001
|
+
const internals = f.framework;
|
|
1002
|
+
const store = f.framework.store;
|
|
1003
|
+
const orig = store.sync.bind(store);
|
|
1004
|
+
let failing = false;
|
|
1005
|
+
store.sync = () => { if (failing)
|
|
1006
|
+
throw new Error('EIO persistent'); orig(); };
|
|
1007
|
+
const wakes = [];
|
|
1008
|
+
f.framework.onTrace((e) => { if (e.type === 'mcpl:coalescing' && e.kind === 'recovery-wake')
|
|
1009
|
+
wakes.push(e); });
|
|
1010
|
+
await f.send('push/event', f.params('1', 'outage_fallback', { deferred: true, initial: true }));
|
|
1011
|
+
failing = true;
|
|
1012
|
+
f.alwaysRespond();
|
|
1013
|
+
for (let i = 0; i < 3; i++) {
|
|
1014
|
+
await f.framework.runUntilIdle(); // freeze fails → recovery scheduled
|
|
1015
|
+
await eventually(() => internals.pendingRequests.length > 0, `recovery wake ${i + 1}`);
|
|
1016
|
+
}
|
|
1017
|
+
assert.deepEqual(wakes.map((w) => w.attempt), [1, 2, 3], 'consecutive failures count up');
|
|
1018
|
+
assert.deepEqual(wakes.map((w) => w.delayMs), [40, 80, 160], 'and the delay doubles');
|
|
1019
|
+
assert.equal(coalescer.pendingBatches(), 1);
|
|
1020
|
+
assert(!f.context().includes('outage_fallback'));
|
|
1021
|
+
failing = false; // storage recovers
|
|
1022
|
+
await f.framework.runUntilIdle();
|
|
1023
|
+
assert(f.lastRequest().includes('outage_fallback'), 'delivered once storage recovered');
|
|
1024
|
+
assert.equal(coalescer.pendingBatches(), 0);
|
|
1025
|
+
assert.equal(coalescer.recovery.size, 0, 'the failure series is forgotten on success');
|
|
1026
|
+
});
|
|
1027
|
+
// ---------------------------------------------------------------------------
|
|
1028
|
+
// Review round 10 (#197): residents share one message slot
|
|
1029
|
+
// ---------------------------------------------------------------------------
|
|
1030
|
+
for (const reader of ['other', 'agent'])
|
|
1031
|
+
test(`R18 (${reader} read it): a message read by any resident of the shared slot is history for all of them`, async (t) => {
|
|
1032
|
+
const f = await fixture({ agents: [{ name: 'agent', model: 'test', systemPrompt: 'test' }, { name: 'other', model: 'test', systemPrompt: 'test' }], server: { shouldTriggerInference: () => true } });
|
|
1033
|
+
t.after(f.close);
|
|
1034
|
+
await f.send('push/event', f.params('1', 'read_by_one', { initial: true }));
|
|
1035
|
+
const internals = f.framework;
|
|
1036
|
+
internals.pendingRequests = internals.pendingRequests.filter((r) => r.agentName === reader);
|
|
1037
|
+
f.alwaysRespond();
|
|
1038
|
+
await f.framework.runUntilIdle();
|
|
1039
|
+
assert(f.lastRequest().includes('read_by_one'), `${reader} read it`);
|
|
1040
|
+
const edit = await f.send('push/event', f.params('2', 'later_edit'));
|
|
1041
|
+
assert.equal(edit.result.coalesce.outcome, 'appended');
|
|
1042
|
+
for (const name of ['agent', 'other']) {
|
|
1043
|
+
const ctx = JSON.stringify(f.framework.getAgent(name).getContextManager().getAllMessages());
|
|
1044
|
+
assert(ctx.includes('read_by_one') && ctx.includes('later_edit'), `${name} keeps the consumed original`);
|
|
1045
|
+
}
|
|
1046
|
+
const del = await f.send('push/event', f.params('3', 'deletion_notice', { retract: true }));
|
|
1047
|
+
assert.equal(del.result.coalesce.outcome, 'noted');
|
|
1048
|
+
assert(f.context().includes('read_by_one') && f.context().includes('deletion_notice') && !f.context().includes('later_edit'));
|
|
1049
|
+
});
|
|
1050
|
+
//# sourceMappingURL=mcpl-coalescing.test.js.map
|