@nanobpm/agentic 0.1.0 → 0.5.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 +2 -1
- package/dist/demand/model.d.ts +7 -4
- package/dist/demand/model.js +22 -4
- package/dist/demand/taskdef.d.ts +13 -1
- package/dist/demand/taskdef.js +20 -2
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/protocol/conformance/frames.js +32 -4
- package/dist/protocol/index.d.ts +1 -1
- package/dist/protocol/payloads.d.ts +44 -0
- package/dist/protocol/payloads.js +61 -7
- package/dist/session/acp/client.d.ts +109 -0
- package/dist/session/acp/client.js +254 -0
- package/dist/session/acp/index.d.ts +27 -0
- package/dist/session/acp/index.js +27 -0
- package/dist/session/acp/jsonrpc.d.ts +25 -0
- package/dist/session/acp/jsonrpc.js +148 -0
- package/dist/session/acp/normalize.d.ts +48 -0
- package/dist/session/acp/normalize.js +162 -0
- package/dist/session/acp/protocol.d.ts +94 -0
- package/dist/session/acp/protocol.js +136 -0
- package/dist/session/acp/spawn.d.ts +36 -0
- package/dist/session/acp/spawn.js +68 -0
- package/dist/session/acp/transport.d.ts +62 -0
- package/dist/session/acp/transport.js +126 -0
- package/dist/session/adapter.d.ts +135 -0
- package/dist/session/adapter.js +24 -0
- package/dist/session/backend.d.ts +43 -0
- package/dist/session/backend.js +95 -0
- package/dist/session/events.d.ts +152 -0
- package/dist/session/events.js +192 -0
- package/dist/session/index.d.ts +31 -0
- package/dist/session/index.js +5 -0
- package/dist/session/log.d.ts +107 -0
- package/dist/session/log.js +351 -0
- package/dist/session/normalizer/claude.d.ts +23 -0
- package/dist/session/normalizer/claude.js +138 -0
- package/dist/session/normalizer/copilot.d.ts +27 -0
- package/dist/session/normalizer/copilot.js +105 -0
- package/dist/session/normalizer/deepseek.d.ts +11 -0
- package/dist/session/normalizer/deepseek.js +68 -0
- package/dist/session/normalizer/index.d.ts +36 -0
- package/dist/session/normalizer/index.js +29 -0
- package/dist/session/normalizer/kimi.d.ts +10 -0
- package/dist/session/normalizer/kimi.js +80 -0
- package/dist/session/normalizer/link.d.ts +36 -0
- package/dist/session/normalizer/link.js +56 -0
- package/dist/session/normalizer/pi.d.ts +13 -0
- package/dist/session/normalizer/pi.js +61 -0
- package/dist/session/normalizer/qwen.d.ts +11 -0
- package/dist/session/normalizer/qwen.js +65 -0
- package/dist/session/normalizer/record.d.ts +21 -0
- package/dist/session/normalizer/record.js +87 -0
- package/dist/session/normalizer/types.d.ts +139 -0
- package/dist/session/normalizer/types.js +31 -0
- package/dist/session/schema.d.ts +38 -0
- package/dist/session/schema.js +74 -0
- package/dist/transcript/index.d.ts +2 -2
- package/dist/transcript/index.js +1 -1
- package/dist/transcript/schema.d.ts +23 -1
- package/dist/transcript/schema.js +34 -1
- package/dist/transcript/store.d.ts +93 -5
- package/dist/transcript/store.js +287 -6
- package/package.json +17 -1
- package/src/demand/model.test.ts +82 -4
- package/src/demand/model.ts +30 -9
- package/src/demand/taskdef.test.ts +51 -6
- package/src/demand/taskdef.ts +31 -2
- package/src/index.ts +1 -0
- package/src/protocol/conformance/frames.ts +32 -4
- package/src/protocol/index.ts +4 -0
- package/src/protocol/payloads.test.ts +31 -1
- package/src/protocol/payloads.ts +110 -7
- package/src/session/acp/client.test.ts +222 -0
- package/src/session/acp/client.ts +356 -0
- package/src/session/acp/fake-agent.ts +71 -0
- package/src/session/acp/index.ts +68 -0
- package/src/session/acp/integration.test.ts +37 -0
- package/src/session/acp/jsonrpc.test.ts +75 -0
- package/src/session/acp/jsonrpc.ts +171 -0
- package/src/session/acp/normalize.test.ts +150 -0
- package/src/session/acp/normalize.ts +204 -0
- package/src/session/acp/protocol.ts +178 -0
- package/src/session/acp/spawn.test.ts +45 -0
- package/src/session/acp/spawn.ts +91 -0
- package/src/session/acp/transport.test.ts +82 -0
- package/src/session/acp/transport.ts +155 -0
- package/src/session/adapter.ts +159 -0
- package/src/session/backend.test.ts +198 -0
- package/src/session/backend.ts +128 -0
- package/src/session/events.test.ts +168 -0
- package/src/session/events.ts +347 -0
- package/src/session/index.ts +67 -0
- package/src/session/log.test.ts +215 -0
- package/src/session/log.ts +525 -0
- package/src/session/normalizer/backend-integration.test.ts +103 -0
- package/src/session/normalizer/claude.test.ts +68 -0
- package/src/session/normalizer/claude.ts +136 -0
- package/src/session/normalizer/copilot.test.ts +59 -0
- package/src/session/normalizer/copilot.ts +133 -0
- package/src/session/normalizer/deepseek.ts +80 -0
- package/src/session/normalizer/index.ts +61 -0
- package/src/session/normalizer/kimi.ts +82 -0
- package/src/session/normalizer/link.test.ts +24 -0
- package/src/session/normalizer/link.ts +81 -0
- package/src/session/normalizer/pi.ts +75 -0
- package/src/session/normalizer/probe.test.ts +49 -0
- package/src/session/normalizer/qwen.test.ts +20 -0
- package/src/session/normalizer/qwen.ts +77 -0
- package/src/session/normalizer/record.test.ts +68 -0
- package/src/session/normalizer/record.ts +88 -0
- package/src/session/normalizer/resume.test.ts +25 -0
- package/src/session/normalizer/types.ts +152 -0
- package/src/session/normalizer/vectors.test.ts +180 -0
- package/src/session/schema.test.ts +84 -0
- package/src/session/schema.ts +78 -0
- package/src/session/test-db.ts +56 -0
- package/src/transcript/index.ts +8 -0
- package/src/transcript/schema.test.ts +31 -4
- package/src/transcript/schema.ts +36 -1
- package/src/transcript/store.ts +438 -6
- package/src/transcript/turns.test.ts +334 -0
- package/dist/blackboard/test-db.d.ts +0 -5
- package/dist/blackboard/test-db.js +0 -42
- package/dist/presence/test-db.d.ts +0 -5
- package/dist/presence/test-db.js +0 -42
- package/dist/transcript/test-db.d.ts +0 -5
- package/dist/transcript/test-db.js +0 -41
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `@nanobpm/agentic/session` adapter contract — ADR 0062, slice 1.
|
|
3
|
+
*
|
|
4
|
+
* The three-method interface every session backend implements. It is the *mind*
|
|
5
|
+
* side of a durable agent activation: the authoritative log of what the agent
|
|
6
|
+
* thought/did ({@link SessionAdapter.emit}, the "mind tap"), the mind/world join
|
|
7
|
+
* points at a push boundary ({@link SessionAdapter.checkpoint}), and the seed a
|
|
8
|
+
* re-leased incarnation replays to resume ({@link SessionAdapter.restore}).
|
|
9
|
+
*
|
|
10
|
+
* Publishing this interface + the {@link SessionEvent} types first is the whole
|
|
11
|
+
* point of slice 1: the ACP client (slice 2), the stream-json/native normalisers
|
|
12
|
+
* (slice 3) and the nano-workforce world-restore (slice 4) all code against this
|
|
13
|
+
* stable contract in parallel. Keep it small and additive.
|
|
14
|
+
*/
|
|
15
|
+
import type { AppendedSessionEvent, SessionEvent } from "./events.ts";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Identifies one **activation** — a single leased run of a BPMN element. This is
|
|
19
|
+
* the log's identity for fencing and replay: every incarnation of the same
|
|
20
|
+
* `(processInstanceKey, elementId)` shares one causal log and one fence, so a
|
|
21
|
+
* re-lease resumes the same session rather than starting a new one.
|
|
22
|
+
*
|
|
23
|
+
* The authoritative log is keyed `(processInstanceKey, elementId)` (ADR 0062):
|
|
24
|
+
* this pair identifies the activation and shares one fence. The current
|
|
25
|
+
* `incarnation` is stored as the fence token, and each appended event carries an
|
|
26
|
+
* `incarnation` stamp recording which generation wrote that row.
|
|
27
|
+
*/
|
|
28
|
+
export interface ActivationKey {
|
|
29
|
+
readonly processInstanceKey: string;
|
|
30
|
+
readonly elementId: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Render an {@link ActivationKey} as an opaque, collision-free string key. */
|
|
34
|
+
export function activationKeyString(key: ActivationKey): string {
|
|
35
|
+
// NUL separates the two components so no pair of distinct (pik, elementId)
|
|
36
|
+
// values can collide by concatenation (NUL cannot appear in either).
|
|
37
|
+
return `${key.processInstanceKey}\u0000${key.elementId}`;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* One recorded side effect at a checkpoint's push boundary. The *shape* of an
|
|
42
|
+
* effect (its `kind` vocabulary, how it is replayed/fenced) is owned by slice 4
|
|
43
|
+
* (nano-workforce world-restore); slice 1 stores the ledger opaquely so the
|
|
44
|
+
* contract is stable before that lands. `detail` is an opaque JSON value.
|
|
45
|
+
*/
|
|
46
|
+
export interface EffectEntry {
|
|
47
|
+
readonly id: string;
|
|
48
|
+
readonly kind: string;
|
|
49
|
+
readonly detail?: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The world-side effects committed at a checkpoint. Stored verbatim (JSON) and
|
|
54
|
+
* handed back unchanged at {@link SessionAdapter.restore}; slice 1 never
|
|
55
|
+
* interprets it.
|
|
56
|
+
*/
|
|
57
|
+
export type EffectLedger = readonly EffectEntry[];
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A mind/world join recorded at a push boundary: the agent's mind is at log
|
|
61
|
+
* `offset`, the world is at git `commitSha`, and `effectLedger` lists the effects
|
|
62
|
+
* that boundary committed. On re-lease the newest checkpoint's seed is replayed.
|
|
63
|
+
*/
|
|
64
|
+
export interface SessionCheckpoint {
|
|
65
|
+
/** Unique checkpoint id (producer- or backend-assigned). */
|
|
66
|
+
readonly id: string;
|
|
67
|
+
/**
|
|
68
|
+
* The log offset the checkpoint pins: exactly the log's `nextOffset` at the
|
|
69
|
+
* moment it was taken, so the seed is events `[0, offset)`.
|
|
70
|
+
*/
|
|
71
|
+
readonly offset: number;
|
|
72
|
+
/** The world (git) commit the mind is joined to at this boundary. */
|
|
73
|
+
readonly commitSha: string;
|
|
74
|
+
/** The effects committed at this boundary (opaque to slice 1). */
|
|
75
|
+
readonly effectLedger: EffectLedger;
|
|
76
|
+
/** The incarnation that took the checkpoint. */
|
|
77
|
+
readonly incarnation: number;
|
|
78
|
+
/** When the checkpoint was taken, ISO-8601. */
|
|
79
|
+
readonly at: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The mind seed handed back on re-lease: everything a resumed incarnation needs
|
|
84
|
+
* to reconstruct the agent's mind up to a checkpoint and continue from there.
|
|
85
|
+
*/
|
|
86
|
+
export interface SessionSeed {
|
|
87
|
+
/**
|
|
88
|
+
* The checkpoint the seed restores from, or `null` when the session has no
|
|
89
|
+
* checkpoint yet (a fresh start — `events` is empty and `nextOffset` is 0).
|
|
90
|
+
*/
|
|
91
|
+
readonly checkpoint: SessionCheckpoint | null;
|
|
92
|
+
/**
|
|
93
|
+
* The authoritative log replayed up to (and excluding) the checkpoint offset,
|
|
94
|
+
* in offset order — the events the resumed agent replays to rebuild its mind.
|
|
95
|
+
*/
|
|
96
|
+
readonly events: readonly AppendedSessionEvent[];
|
|
97
|
+
/**
|
|
98
|
+
* The offset the resumed incarnation continues appending at — the checkpoint
|
|
99
|
+
* offset (0 when there is no checkpoint). Any events a dead incarnation wrote
|
|
100
|
+
* *past* the checkpoint are uncommitted and are not part of the seed.
|
|
101
|
+
*/
|
|
102
|
+
readonly nextOffset: number;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* The three-method session adapter. An instance is bound to one
|
|
107
|
+
* {@link ActivationKey} and one `incarnation`; a re-lease constructs a new
|
|
108
|
+
* adapter at a higher incarnation over the same authoritative log.
|
|
109
|
+
*/
|
|
110
|
+
export interface SessionAdapter {
|
|
111
|
+
/** The activation this adapter writes to. */
|
|
112
|
+
readonly key: ActivationKey;
|
|
113
|
+
/** The generation of this writer (the fencing token). */
|
|
114
|
+
readonly incarnation: number;
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The **mind tap**: append `event` to the authoritative session log at the
|
|
118
|
+
* current offset, returning it stamped with its assigned `offset` and this
|
|
119
|
+
* writer's `incarnation`. Fenced — a call from an incarnation that a newer one
|
|
120
|
+
* has superseded throws {@link StaleIncarnationError} and writes nothing.
|
|
121
|
+
*/
|
|
122
|
+
emit(event: SessionEvent): AppendedSessionEvent;
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Mark a mind/world join at a push boundary: record a checkpoint pinning the
|
|
126
|
+
* current log offset to `commitSha` and `effectLedger`. Fenced like
|
|
127
|
+
* {@link emit}. Returns the recorded checkpoint.
|
|
128
|
+
*/
|
|
129
|
+
checkpoint(commitSha: string, effectLedger: EffectLedger): SessionCheckpoint;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Hand back the mind seed to resume from. With no argument (or an unknown id)
|
|
133
|
+
* it resolves the **latest** checkpoint; given a checkpoint id it restores that
|
|
134
|
+
* specific one. Returns a {@link SessionSeed} of the log up to the checkpoint
|
|
135
|
+
* offset. Read-only — it does not mutate the log or the fence.
|
|
136
|
+
*/
|
|
137
|
+
restore(fromCheckpoint?: string): SessionSeed;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Raised when a write (emit/checkpoint) is attempted by an incarnation that a
|
|
142
|
+
* newer one has already superseded — the fence rejected it. The stale writer's
|
|
143
|
+
* call has no effect; the log stays owned by the current incarnation.
|
|
144
|
+
*/
|
|
145
|
+
export class StaleIncarnationError extends Error {
|
|
146
|
+
readonly key: ActivationKey;
|
|
147
|
+
readonly incarnation: number;
|
|
148
|
+
readonly current: number;
|
|
149
|
+
constructor(key: ActivationKey, incarnation: number, current: number) {
|
|
150
|
+
super(
|
|
151
|
+
`incarnation ${incarnation} is fenced for activation ` +
|
|
152
|
+
`${key.processInstanceKey}/${key.elementId}: a newer incarnation ${current} has taken over`,
|
|
153
|
+
);
|
|
154
|
+
this.name = "StaleIncarnationError";
|
|
155
|
+
this.key = key;
|
|
156
|
+
this.incarnation = incarnation;
|
|
157
|
+
this.current = current;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import type { TestContext } from "node:test";
|
|
4
|
+
import { type ActivationKey, StaleIncarnationError } from "./adapter.ts";
|
|
5
|
+
import type { SessionEvent } from "./events.ts";
|
|
6
|
+
import { InMemorySessionLog, type SessionLog, SqliteSessionLog } from "./log.ts";
|
|
7
|
+
import { SessionBackend } from "./backend.ts";
|
|
8
|
+
import { openTestDb } from "./test-db.ts";
|
|
9
|
+
|
|
10
|
+
const KEY: ActivationKey = { processInstanceKey: "pik-1", elementId: "implement-task" };
|
|
11
|
+
const LEDGER = [{ id: "eff-1", kind: "git-push", detail: { ref: "feat/x" } }];
|
|
12
|
+
|
|
13
|
+
function seqIds(prefix: string): () => string {
|
|
14
|
+
let n = 0;
|
|
15
|
+
return () => `${prefix}-${n++}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function msg(id: string, parentId: string | null, text: string): SessionEvent {
|
|
19
|
+
return { type: "assistant", id, parentId, text };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
interface Harness {
|
|
23
|
+
readonly name: string;
|
|
24
|
+
/**
|
|
25
|
+
* A fresh shared log (persists across incarnations of the same activation).
|
|
26
|
+
* Pass the test context so any backing SQLite DB is closed on test teardown.
|
|
27
|
+
*/
|
|
28
|
+
makeLog(t: TestContext): SessionLog;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const HARNESSES: readonly Harness[] = [
|
|
32
|
+
{ name: "in-memory", makeLog: (_t: TestContext) => new InMemorySessionLog() },
|
|
33
|
+
{
|
|
34
|
+
name: "sqlite",
|
|
35
|
+
makeLog: (t: TestContext) => {
|
|
36
|
+
const log = new SqliteSessionLog(openTestDb(t));
|
|
37
|
+
log.ensureSchema();
|
|
38
|
+
return log;
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
for (const h of HARNESSES) {
|
|
44
|
+
test(`[${h.name}] emit* → checkpoint → restore reproduces the seed at the checkpoint offset`, (t) => {
|
|
45
|
+
const log = h.makeLog(t);
|
|
46
|
+
const backend = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
47
|
+
|
|
48
|
+
const e0 = backend.emit(msg("e0", null, "a"));
|
|
49
|
+
const e1 = backend.emit(msg("e1", "e0", "b"));
|
|
50
|
+
const e2 = backend.emit(msg("e2", "e1", "c"));
|
|
51
|
+
assert.deepEqual([e0.offset, e1.offset, e2.offset], [0, 1, 2]);
|
|
52
|
+
|
|
53
|
+
const cp = backend.checkpoint("sha-abc", LEDGER);
|
|
54
|
+
assert.equal(cp.offset, 3, "checkpoint pins the current next offset");
|
|
55
|
+
assert.equal(cp.incarnation, 1);
|
|
56
|
+
assert.deepEqual(cp.effectLedger, LEDGER);
|
|
57
|
+
|
|
58
|
+
// A dead-tail event emitted after the checkpoint must NOT appear in the seed.
|
|
59
|
+
backend.emit(msg("e3", "e2", "d"));
|
|
60
|
+
|
|
61
|
+
const seed = backend.restore();
|
|
62
|
+
assert.deepEqual(seed.checkpoint, cp);
|
|
63
|
+
assert.equal(seed.nextOffset, 3);
|
|
64
|
+
assert.deepEqual(seed.events, [e0, e1, e2], "seed is exactly the log up to the checkpoint offset");
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test(`[${h.name}] restore repositions the cursor so a resume overwrites the uncommitted tail`, (t) => {
|
|
68
|
+
const log = h.makeLog(t);
|
|
69
|
+
const backend = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
70
|
+
backend.emit(msg("e0", null, "a"));
|
|
71
|
+
backend.emit(msg("e1", "e0", "b"));
|
|
72
|
+
backend.checkpoint("sha", LEDGER); // offset 2
|
|
73
|
+
backend.emit(msg("e2-dead", "e1", "dead")); // uncommitted tail at offset 2
|
|
74
|
+
|
|
75
|
+
backend.restore(); // cursor back to 2
|
|
76
|
+
const replacement = backend.emit(msg("e2-live", "e1", "live"));
|
|
77
|
+
assert.equal(replacement.offset, 2);
|
|
78
|
+
|
|
79
|
+
// The authoritative log now holds the live event, not the dead one.
|
|
80
|
+
const all = log.replay(KEY, 0);
|
|
81
|
+
assert.deepEqual(all.map((e) => e.id), ["e0", "e1", "e2-live"]);
|
|
82
|
+
assert.equal(log.nextOffset(KEY), 3);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test(`[${h.name}] restore of a session with no checkpoint yields a fresh seed`, (t) => {
|
|
86
|
+
const log = h.makeLog(t);
|
|
87
|
+
const backend = new SessionBackend(log, KEY, 1);
|
|
88
|
+
backend.emit(msg("e0", null, "a"));
|
|
89
|
+
const seed = backend.restore();
|
|
90
|
+
assert.deepEqual(seed, { checkpoint: null, events: [], nextOffset: 0 });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test(`[${h.name}] restore(id) resolves a specific earlier checkpoint`, (t) => {
|
|
94
|
+
const log = h.makeLog(t);
|
|
95
|
+
const backend = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
96
|
+
backend.emit(msg("e0", null, "a"));
|
|
97
|
+
const cp0 = backend.checkpoint("sha0", LEDGER); // offset 1
|
|
98
|
+
backend.emit(msg("e1", "e0", "b"));
|
|
99
|
+
const cp1 = backend.checkpoint("sha1", LEDGER); // offset 2
|
|
100
|
+
|
|
101
|
+
assert.deepEqual(backend.restore().checkpoint, cp1, "no arg → latest");
|
|
102
|
+
assert.deepEqual(backend.restore(cp0.id).checkpoint, cp0, "id → that checkpoint");
|
|
103
|
+
assert.equal(backend.restore(cp0.id).events.length, 1);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test(`[${h.name}] resuming from an earlier checkpoint prunes now-dangling later checkpoints`, (t) => {
|
|
107
|
+
const log = h.makeLog(t);
|
|
108
|
+
const backend = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
109
|
+
backend.emit(msg("e0", null, "a"));
|
|
110
|
+
const cp0 = backend.checkpoint("sha0", LEDGER); // offset 1
|
|
111
|
+
backend.emit(msg("e1", "e0", "b"));
|
|
112
|
+
backend.emit(msg("e2", "e1", "c"));
|
|
113
|
+
const cpLate = backend.checkpoint("sha-late", LEDGER); // offset 3
|
|
114
|
+
|
|
115
|
+
// Rewind to the earlier checkpoint and rewrite the tail from there. This
|
|
116
|
+
// truncates events [1, 3), so cpLate (offset 3) now points past the head.
|
|
117
|
+
backend.restore(cp0.id);
|
|
118
|
+
backend.emit(msg("e1b", "e0", "b2")); // offset 1
|
|
119
|
+
|
|
120
|
+
// The now-dangling later checkpoint must be pruned, and the surviving
|
|
121
|
+
// earlier checkpoint (whose offset is still backed by real events) kept.
|
|
122
|
+
assert.equal(log.getCheckpoint(KEY, cpLate.id), undefined, "dangling later checkpoint is pruned");
|
|
123
|
+
assert.deepEqual(log.latestCheckpoint(KEY), cp0, "latest is the surviving earlier checkpoint");
|
|
124
|
+
assert.ok(log.nextOffset(KEY) <= cpLate.offset, "head did not advance past the rewritten tail");
|
|
125
|
+
|
|
126
|
+
// A subsequent restore() must not reposition the cursor past the head, so
|
|
127
|
+
// the next emit continues gap-free instead of throwing a gap RangeError.
|
|
128
|
+
const seed = backend.restore();
|
|
129
|
+
assert.equal(seed.nextOffset, cp0.offset, "restore resumes at the surviving checkpoint, not a dangling one");
|
|
130
|
+
assert.doesNotThrow(() => backend.emit(msg("e-next", "e0", "n")), "resume after prune does not gap");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test(`[${h.name}] restore(unknown id) falls back to the latest checkpoint`, (t) => {
|
|
134
|
+
const log = h.makeLog(t);
|
|
135
|
+
const backend = new SessionBackend(log, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
136
|
+
backend.emit(msg("e0", null, "a"));
|
|
137
|
+
backend.checkpoint("sha0", LEDGER); // offset 1
|
|
138
|
+
backend.emit(msg("e1", "e0", "b"));
|
|
139
|
+
const latest = backend.checkpoint("sha1", LEDGER); // offset 2
|
|
140
|
+
|
|
141
|
+
// The contract: an unknown id resolves the latest checkpoint, not a fresh seed.
|
|
142
|
+
assert.deepEqual(backend.restore("does-not-exist").checkpoint, latest);
|
|
143
|
+
assert.equal(backend.restore("does-not-exist").nextOffset, latest.offset);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test(`[${h.name}] the incarnation fence rejects a stale writer`, (t) => {
|
|
147
|
+
const log = h.makeLog(t);
|
|
148
|
+
const inc1 = new SessionBackend(log, KEY, 1);
|
|
149
|
+
inc1.emit(msg("e0", null, "a"));
|
|
150
|
+
|
|
151
|
+
// A re-lease at a higher incarnation fences the prior one.
|
|
152
|
+
const inc2 = new SessionBackend(log, KEY, 2);
|
|
153
|
+
assert.throws(() => inc1.emit(msg("stale", "e0", "x")), StaleIncarnationError);
|
|
154
|
+
assert.throws(() => inc1.checkpoint("sha", LEDGER), StaleIncarnationError);
|
|
155
|
+
|
|
156
|
+
// The current incarnation still writes fine.
|
|
157
|
+
const ok = inc2.emit(msg("e1", "e0", "b"));
|
|
158
|
+
assert.equal(ok.incarnation, 2);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test(`[${h.name}] constructing a stale incarnation throws at lease time`, (t) => {
|
|
162
|
+
const log = h.makeLog(t);
|
|
163
|
+
void new SessionBackend(log, KEY, 5);
|
|
164
|
+
assert.throws(() => new SessionBackend(log, KEY, 3), StaleIncarnationError);
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
test("[sqlite] the fence high-water is durable across a fresh store (restart)", (t) => {
|
|
169
|
+
const db = openTestDb(t);
|
|
170
|
+
const first = new SqliteSessionLog(db);
|
|
171
|
+
first.ensureSchema();
|
|
172
|
+
const inc1 = new SessionBackend(first, KEY, 1);
|
|
173
|
+
inc1.emit(msg("e0", null, "a"));
|
|
174
|
+
|
|
175
|
+
// A brand-new store over the same DB (a process restart) leases at inc 2.
|
|
176
|
+
const revived = new SqliteSessionLog(db);
|
|
177
|
+
const inc2 = new SessionBackend(revived, KEY, 2);
|
|
178
|
+
inc2.emit(msg("e1", "e0", "b"));
|
|
179
|
+
|
|
180
|
+
// The original in-process writer is now fenced by the persisted high-water.
|
|
181
|
+
assert.throws(() => inc1.emit(msg("zombie", "e0", "z")), StaleIncarnationError);
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("[sqlite] a resumed incarnation replays the durable seed across a restart", (t) => {
|
|
185
|
+
const db = openTestDb(t);
|
|
186
|
+
const first = new SqliteSessionLog(db);
|
|
187
|
+
first.ensureSchema();
|
|
188
|
+
const inc1 = new SessionBackend(first, KEY, 1, { newCheckpointId: seqIds("cp") });
|
|
189
|
+
const a = inc1.emit(msg("e0", null, "a"));
|
|
190
|
+
const b = inc1.emit(msg("e1", "e0", "b"));
|
|
191
|
+
inc1.checkpoint("sha", LEDGER); // offset 2
|
|
192
|
+
|
|
193
|
+
const revived = new SqliteSessionLog(db);
|
|
194
|
+
const inc2 = new SessionBackend(revived, KEY, 2);
|
|
195
|
+
const seed = inc2.restore();
|
|
196
|
+
assert.deepEqual(seed.events, [a, b]);
|
|
197
|
+
assert.equal(seed.nextOffset, 2);
|
|
198
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* {@link SessionBackend} — the canonical {@link SessionAdapter} implementation,
|
|
3
|
+
* ADR 0062 slice 1.
|
|
4
|
+
*
|
|
5
|
+
* There is exactly **one** adapter implementation (derivation over duplication):
|
|
6
|
+
* it is bound to a {@link SessionLog} port, so the *same* emit/checkpoint/restore
|
|
7
|
+
* semantics run over either the in-memory reference log ({@link InMemorySessionLog}
|
|
8
|
+
* — the stub slices 2–5 code against) or the durable SQLite log
|
|
9
|
+
* ({@link SqliteSessionLog}). The backend owns the append cursor and the fencing
|
|
10
|
+
* token; the log owns storage and the fence high-water.
|
|
11
|
+
*/
|
|
12
|
+
import { randomUUID } from "node:crypto";
|
|
13
|
+
import {
|
|
14
|
+
type ActivationKey,
|
|
15
|
+
type EffectLedger,
|
|
16
|
+
type SessionAdapter,
|
|
17
|
+
type SessionCheckpoint,
|
|
18
|
+
type SessionSeed,
|
|
19
|
+
} from "./adapter.ts";
|
|
20
|
+
import type { AppendedSessionEvent, SessionEvent } from "./events.ts";
|
|
21
|
+
import { type Clock, InMemorySessionLog, type SessionLog, type SqliteDb, SqliteSessionLog, systemClock } from "./log.ts";
|
|
22
|
+
|
|
23
|
+
export interface SessionBackendOptions {
|
|
24
|
+
/** Injectable clock for deterministic checkpoint timestamps. Default {@link systemClock}. */
|
|
25
|
+
clock?: Clock;
|
|
26
|
+
/** Injectable checkpoint-id generator (deterministic tests). Default `crypto.randomUUID`. */
|
|
27
|
+
newCheckpointId?: () => string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* A session adapter bound to one activation and one incarnation. Constructing it
|
|
32
|
+
* takes the lease at `incarnation` (advancing the fence), so a re-lease at a
|
|
33
|
+
* higher incarnation immediately fences every prior one — a subsequent `emit`
|
|
34
|
+
* from an older adapter throws {@link StaleIncarnationError}.
|
|
35
|
+
*/
|
|
36
|
+
export class SessionBackend implements SessionAdapter {
|
|
37
|
+
readonly key: ActivationKey;
|
|
38
|
+
readonly incarnation: number;
|
|
39
|
+
readonly #log: SessionLog;
|
|
40
|
+
readonly #clock: Clock;
|
|
41
|
+
readonly #newCheckpointId: () => string;
|
|
42
|
+
/** The offset the next `emit` writes at. Advanced by `emit`, repositioned by `restore`. */
|
|
43
|
+
#cursor: number;
|
|
44
|
+
|
|
45
|
+
constructor(log: SessionLog, key: ActivationKey, incarnation: number, options: SessionBackendOptions = {}) {
|
|
46
|
+
log.lease(key, incarnation);
|
|
47
|
+
this.#log = log;
|
|
48
|
+
this.key = key;
|
|
49
|
+
this.incarnation = incarnation;
|
|
50
|
+
this.#clock = options.clock ?? systemClock;
|
|
51
|
+
this.#newCheckpointId = options.newCheckpointId ?? randomUUID;
|
|
52
|
+
// Default to appending after whatever is already committed; `restore`
|
|
53
|
+
// repositions the cursor to a checkpoint boundary when resuming.
|
|
54
|
+
this.#cursor = log.nextOffset(key);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The offset the next `emit` will assign. */
|
|
58
|
+
get nextOffset(): number {
|
|
59
|
+
return this.#cursor;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
emit(event: SessionEvent): AppendedSessionEvent {
|
|
63
|
+
const appended = this.#log.append(this.key, this.incarnation, this.#cursor, event);
|
|
64
|
+
this.#cursor = appended.offset + 1;
|
|
65
|
+
return appended;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
checkpoint(commitSha: string, effectLedger: EffectLedger): SessionCheckpoint {
|
|
69
|
+
const checkpoint: SessionCheckpoint = {
|
|
70
|
+
id: this.#newCheckpointId(),
|
|
71
|
+
offset: this.#cursor,
|
|
72
|
+
commitSha,
|
|
73
|
+
effectLedger,
|
|
74
|
+
incarnation: this.incarnation,
|
|
75
|
+
at: new Date(this.#clock.now()).toISOString(),
|
|
76
|
+
};
|
|
77
|
+
return this.#log.putCheckpoint(this.key, this.incarnation, checkpoint);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
restore(fromCheckpoint?: string): SessionSeed {
|
|
81
|
+
// Contract: no argument OR an unknown id resolves the latest checkpoint; a
|
|
82
|
+
// known id restores exactly that checkpoint.
|
|
83
|
+
const checkpoint =
|
|
84
|
+
fromCheckpoint === undefined
|
|
85
|
+
? this.#log.latestCheckpoint(this.key)
|
|
86
|
+
: (this.#log.getCheckpoint(this.key, fromCheckpoint) ?? this.#log.latestCheckpoint(this.key));
|
|
87
|
+
if (checkpoint === undefined) {
|
|
88
|
+
// No checkpoint to resume from: start the mind fresh at offset 0. The next
|
|
89
|
+
// emit at offset 0 discards any uncommitted events a dead incarnation left.
|
|
90
|
+
this.#cursor = 0;
|
|
91
|
+
return { checkpoint: null, events: [], nextOffset: 0 };
|
|
92
|
+
}
|
|
93
|
+
const events = this.#log.replay(this.key, 0, checkpoint.offset);
|
|
94
|
+
// Reposition the write cursor to the checkpoint boundary: the resumed
|
|
95
|
+
// incarnation continues from there, overwriting any uncommitted tail.
|
|
96
|
+
this.#cursor = checkpoint.offset;
|
|
97
|
+
return { checkpoint, events, nextOffset: checkpoint.offset };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Open a session adapter over an in-memory reference log. Pass a shared
|
|
103
|
+
* {@link InMemorySessionLog} across incarnations of the same activation so the
|
|
104
|
+
* fence and events persist across a re-lease (the reference resume scenario);
|
|
105
|
+
* omit it for a throwaway single-incarnation log.
|
|
106
|
+
*/
|
|
107
|
+
export function openInMemorySession(
|
|
108
|
+
key: ActivationKey,
|
|
109
|
+
incarnation: number,
|
|
110
|
+
options: SessionBackendOptions & { log?: InMemorySessionLog } = {},
|
|
111
|
+
): { backend: SessionBackend; log: InMemorySessionLog } {
|
|
112
|
+
const log = options.log ?? new InMemorySessionLog();
|
|
113
|
+
const backend = new SessionBackend(log, key, incarnation, options);
|
|
114
|
+
return { backend, log };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Open a session adapter over the durable SQLite log. Applies the schema idempotently. */
|
|
118
|
+
export function openSqliteSession(
|
|
119
|
+
db: SqliteDb,
|
|
120
|
+
key: ActivationKey,
|
|
121
|
+
incarnation: number,
|
|
122
|
+
options: SessionBackendOptions = {},
|
|
123
|
+
): { backend: SessionBackend; log: SqliteSessionLog } {
|
|
124
|
+
const log = new SqliteSessionLog(db, { clock: options.clock });
|
|
125
|
+
log.ensureSchema();
|
|
126
|
+
const backend = new SessionBackend(log, key, incarnation, options);
|
|
127
|
+
return { backend, log };
|
|
128
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { test } from "node:test";
|
|
3
|
+
import {
|
|
4
|
+
parseSessionEvent,
|
|
5
|
+
SESSION_EVENT_TYPES,
|
|
6
|
+
type SessionEvent,
|
|
7
|
+
SessionEventShapeError,
|
|
8
|
+
} from "./events.ts";
|
|
9
|
+
|
|
10
|
+
/** Build a runtime value from untyped JSON (never an `as`-cast — see AGENTS.md). */
|
|
11
|
+
function fromJson(json: string): unknown {
|
|
12
|
+
return JSON.parse(json);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const sample: readonly SessionEvent[] = [
|
|
16
|
+
{ type: "system", id: "s0", parentId: null, text: "you are nano" },
|
|
17
|
+
{ type: "user", id: "u1", parentId: "s0", text: "hi" },
|
|
18
|
+
{ type: "assistant", id: "a2", parentId: "u1", text: "hello" },
|
|
19
|
+
{ type: "reasoning", id: "r3", parentId: "u1", text: "thinking", providerContinuation: "OPAQUE==" },
|
|
20
|
+
{ type: "reasoning", id: "r3b", parentId: "u1" },
|
|
21
|
+
{ type: "tool-call", id: "tc4", parentId: "a2", callId: "c1", name: "read", args: { path: "/x" } },
|
|
22
|
+
{ type: "tool-result", id: "tr5", parentId: "tc4", callId: "c1", ok: true, result: { bytes: 12 } },
|
|
23
|
+
{ type: "tool-result", id: "tr5b", parentId: "tc4", callId: "c2", ok: false, result: "boom" },
|
|
24
|
+
{ type: "compaction", id: "cp6", parentId: "a2", reason: "compaction", replacesFrom: 0, replacesTo: 3, summary: "…" },
|
|
25
|
+
{ type: "compaction", id: "cp6b", parentId: "a2", reason: "truncation", replacesFrom: 4, replacesTo: 6 },
|
|
26
|
+
{ type: "usage", id: "us7", parentId: "a2", inputTokens: 100, outputTokens: 42, model: "opus" },
|
|
27
|
+
{ type: "usage", id: "us7b", parentId: "a2", inputTokens: 1, outputTokens: 2 },
|
|
28
|
+
{ type: "turn-start", id: "ts8", parentId: null, turn: 1 },
|
|
29
|
+
{ type: "turn-end", id: "te9", parentId: "ts8", turn: 1 },
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
test("every SessionEvent variant round-trips through JSON + parseSessionEvent unchanged", () => {
|
|
33
|
+
for (const event of sample) {
|
|
34
|
+
const restored = parseSessionEvent(fromJson(JSON.stringify(event)));
|
|
35
|
+
assert.deepEqual(restored, event, `variant ${event.type} (${event.id}) must round-trip`);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("the sample corpus covers every declared SessionEventType", () => {
|
|
40
|
+
const covered = new Set(sample.map((e) => e.type));
|
|
41
|
+
for (const type of SESSION_EVENT_TYPES) {
|
|
42
|
+
assert.ok(covered.has(type), `missing round-trip coverage for event type "${type}"`);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("parseSessionEvent rejects an unknown event type", () => {
|
|
47
|
+
assert.throws(() => parseSessionEvent(fromJson('{"type":"bogus","id":"x","parentId":null}')), SessionEventShapeError);
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("parseSessionEvent rejects a missing required field", () => {
|
|
51
|
+
assert.throws(() => parseSessionEvent(fromJson('{"type":"user","id":"u","parentId":null}')), SessionEventShapeError);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test("parseSessionEvent rejects a malformed parentId", () => {
|
|
55
|
+
assert.throws(
|
|
56
|
+
() => parseSessionEvent(fromJson('{"type":"system","id":"s","parentId":7,"text":"x"}')),
|
|
57
|
+
SessionEventShapeError,
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
test("parseSessionEvent rejects a compaction with an invalid reason", () => {
|
|
62
|
+
assert.throws(
|
|
63
|
+
() =>
|
|
64
|
+
parseSessionEvent(
|
|
65
|
+
fromJson('{"type":"compaction","id":"c","parentId":null,"reason":"nope","replacesFrom":0,"replacesTo":1}'),
|
|
66
|
+
),
|
|
67
|
+
SessionEventShapeError,
|
|
68
|
+
);
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test("parseSessionEvent rejects a non-object", () => {
|
|
72
|
+
assert.throws(() => parseSessionEvent(fromJson("42")), SessionEventShapeError);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("parseSessionEvent normalises an omitted opaque tool-call `args` to null (JSON-stable)", () => {
|
|
76
|
+
// A dialect that omits args (e.g. `obj.arguments ?? obj.args`, both absent)
|
|
77
|
+
// yields `undefined`, which `JSON.stringify` drops from the object — the
|
|
78
|
+
// persisted event would then no longer round-trip to the same shape on replay.
|
|
79
|
+
// The boundary must coerce the absence to the JSON "no value" (`null`).
|
|
80
|
+
const event = parseSessionEvent({
|
|
81
|
+
type: "tool-call",
|
|
82
|
+
id: "tc",
|
|
83
|
+
parentId: null,
|
|
84
|
+
callId: "c1",
|
|
85
|
+
name: "read",
|
|
86
|
+
args: undefined,
|
|
87
|
+
});
|
|
88
|
+
assert.equal(event.type, "tool-call");
|
|
89
|
+
if (event.type !== "tool-call") return;
|
|
90
|
+
assert.equal(event.args, null);
|
|
91
|
+
assert.deepEqual(parseSessionEvent(fromJson(JSON.stringify(event))), event, "must survive a JSON round-trip unchanged");
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("parseSessionEvent normalises an omitted opaque tool-result `result` to null (JSON-stable)", () => {
|
|
95
|
+
const event = parseSessionEvent({
|
|
96
|
+
type: "tool-result",
|
|
97
|
+
id: "tr",
|
|
98
|
+
parentId: null,
|
|
99
|
+
callId: "c1",
|
|
100
|
+
ok: false,
|
|
101
|
+
result: undefined,
|
|
102
|
+
});
|
|
103
|
+
assert.equal(event.type, "tool-result");
|
|
104
|
+
if (event.type !== "tool-result") return;
|
|
105
|
+
assert.equal(event.result, null);
|
|
106
|
+
assert.deepEqual(parseSessionEvent(fromJson(JSON.stringify(event))), event, "must survive a JSON round-trip unchanged");
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test("parseSessionEvent preserves a falsy-but-present opaque payload (0 / false / null are not undefined)", () => {
|
|
110
|
+
for (const payload of [0, false, null, ""]) {
|
|
111
|
+
const call = parseSessionEvent({ type: "tool-call", id: "tc", parentId: null, callId: "c", name: "n", args: payload });
|
|
112
|
+
assert.equal(call.type, "tool-call");
|
|
113
|
+
if (call.type !== "tool-call") return;
|
|
114
|
+
assert.equal(call.args, payload, `present args ${JSON.stringify(payload)} must be preserved, not coerced`);
|
|
115
|
+
}
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("parseSessionEvent rejects a negative or non-integer turn index", () => {
|
|
119
|
+
assert.throws(
|
|
120
|
+
() => parseSessionEvent(fromJson('{"type":"turn-start","id":"t","parentId":null,"turn":-1}')),
|
|
121
|
+
SessionEventShapeError,
|
|
122
|
+
);
|
|
123
|
+
assert.throws(
|
|
124
|
+
() => parseSessionEvent(fromJson('{"type":"turn-end","id":"t","parentId":null,"turn":1.5}')),
|
|
125
|
+
SessionEventShapeError,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
test("parseSessionEvent rejects negative or fractional token counts", () => {
|
|
130
|
+
assert.throws(
|
|
131
|
+
() =>
|
|
132
|
+
parseSessionEvent(
|
|
133
|
+
fromJson('{"type":"usage","id":"u","parentId":null,"inputTokens":-1,"outputTokens":2}'),
|
|
134
|
+
),
|
|
135
|
+
SessionEventShapeError,
|
|
136
|
+
);
|
|
137
|
+
assert.throws(
|
|
138
|
+
() =>
|
|
139
|
+
parseSessionEvent(
|
|
140
|
+
fromJson('{"type":"usage","id":"u","parentId":null,"inputTokens":1,"outputTokens":2.5}'),
|
|
141
|
+
),
|
|
142
|
+
SessionEventShapeError,
|
|
143
|
+
);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("parseSessionEvent rejects a compaction with a negative or inverted offset range", () => {
|
|
147
|
+
assert.throws(
|
|
148
|
+
() =>
|
|
149
|
+
parseSessionEvent(
|
|
150
|
+
fromJson('{"type":"compaction","id":"c","parentId":null,"reason":"compaction","replacesFrom":-1,"replacesTo":3}'),
|
|
151
|
+
),
|
|
152
|
+
SessionEventShapeError,
|
|
153
|
+
);
|
|
154
|
+
assert.throws(
|
|
155
|
+
() =>
|
|
156
|
+
parseSessionEvent(
|
|
157
|
+
fromJson('{"type":"compaction","id":"c","parentId":null,"reason":"compaction","replacesFrom":5,"replacesTo":3}'),
|
|
158
|
+
),
|
|
159
|
+
SessionEventShapeError,
|
|
160
|
+
);
|
|
161
|
+
assert.throws(
|
|
162
|
+
() =>
|
|
163
|
+
parseSessionEvent(
|
|
164
|
+
fromJson('{"type":"compaction","id":"c","parentId":null,"reason":"compaction","replacesFrom":0,"replacesTo":2.5}'),
|
|
165
|
+
),
|
|
166
|
+
SessionEventShapeError,
|
|
167
|
+
);
|
|
168
|
+
});
|