Skip to content

Commit 8009266

Browse files
ndemiancclaude
andcommitted
feat(sessions): sessionEvents.js — verbatim message<->event translation (pure)
Third slice, completing the pure sessions engine. The seam between the live agent's provider messages and the stored session events, so extension.js stays thin glue: - toolStatsFromMessages derives the sparkline (tool-calls/turn) and files-edited straight from a turn's messages — one source of truth, the transcript. - userTurnEvent/agentTurnEvent/end/title/label build the append-only events. - eventsToMessages rebuilds the messages array BYTE-IDENTICALLY (verbatim, lossless resume — design §4); tailFrom appends only the per-turn delta. Tested incl. a cross-module check that events built here derive the right card in sessionStore. sessionEvents: 6 tests. Full suite: 30 suites, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent bb7307e commit 8009266

2 files changed

Lines changed: 195 additions & 0 deletions

File tree

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// @ts-check
2+
'use strict';
3+
4+
/*
5+
* sessionEvents.js — the pure translation between the live agent's provider MESSAGES and the stored
6+
* session EVENTS. It is the seam that lets extension.js stay thin glue: the agent loop hands us the
7+
* messages it already has; we hand back event objects to append (sessionStore.appendEvent) and, on
8+
* resume, rebuild the exact messages array back.
9+
*
10+
* Two guarantees, both tested:
11+
* • VERBATIM & LOSSLESS. Events store provider message shapes as-is, so `eventsToMessages(...)` rebuilds
12+
* a byte-identical array — verbatim resume sees precisely the conversation it left (design §4 rules).
13+
* • The card data is DERIVED, not hand-passed. `toolStatsFromMessages` reads the sparkline (tool-calls
14+
* per turn) and files-edited straight out of the turn's messages, so the extension never computes or
15+
* duplicates them — one source of truth (the transcript).
16+
*/
17+
18+
/** Tool names that MUTATE files → count as "edited" for the files-touched chip (not read/list/run). */
19+
const EDIT_TOOLS = new Set(['edit_file', 'write_file']);
20+
21+
// ── read stats out of a turn's messages (pure) ───────────────────────────────────────────────────
22+
23+
/**
24+
* Walk a messages array and pull { tools, edits } from its assistant `tool_use` blocks:
25+
* tools — total tool calls (the sparkline height for the turn);
26+
* edits — one `{ path }` per edit_file/write_file call (repeats kept, so "most-edited" ordering survives).
27+
* Tolerant of any shape — a message without array content, a tool_use without input, etc.
28+
*/
29+
function toolStatsFromMessages(messages) {
30+
let tools = 0;
31+
const edits = [];
32+
for (const m of (Array.isArray(messages) ? messages : [])) {
33+
if (!m || m.role !== 'assistant' || !Array.isArray(m.content)) { continue; }
34+
for (const b of m.content) {
35+
if (!b || b.type !== 'tool_use') { continue; }
36+
tools++;
37+
if (EDIT_TOOLS.has(b.name) && b.input && typeof b.input.path === 'string') { edits.push({ path: b.input.path }); }
38+
}
39+
}
40+
return { tools, edits };
41+
}
42+
43+
// ── build events to append (pure) ────────────────────────────────────────────────────────────────
44+
45+
/** The user turn: `content` for preview/title/turn-count, `messages` (verbatim) for the rebuild. */
46+
function userTurnEvent(userMessage, t) {
47+
const content = userMessage && typeof userMessage.content === 'string' ? userMessage.content
48+
: (userMessage && userMessage.content != null ? userMessage.content : '');
49+
return { kind: 'user', t: t || null, content, messages: userMessage ? [userMessage] : [] };
50+
}
51+
52+
/**
53+
* The agent turn: the NEW messages the loop produced this turn (assistant + tool_result messages),
54+
* stored verbatim, plus the model and the derived tool/edit stats the card reads.
55+
*/
56+
function agentTurnEvent(newMessages, model, t) {
57+
const msgs = Array.isArray(newMessages) ? newMessages : [];
58+
const { tools, edits } = toolStatsFromMessages(msgs);
59+
return { kind: 'agent', t: t || null, model: model || null, messages: msgs, tools, edits };
60+
}
61+
62+
function endEvent(state, t) { return { kind: 'end', t: t || null, state: state || 'done' }; }
63+
function titleEvent(title, t) { return { kind: 'title', t: t || null, title: String(title == null ? '' : title) }; }
64+
/** A lifecycle/pin change — append-only, so archiving/pinning never rewrites the file (experience §4.9). */
65+
function labelEvent(fields, t) {
66+
const e = { kind: 'label', t: t || null };
67+
if (fields && fields.lifecycle) { e.lifecycle = String(fields.lifecycle); }
68+
if (fields && typeof fields.pinned === 'boolean') { e.pinned = fields.pinned; }
69+
return e;
70+
}
71+
72+
// ── rebuild messages on resume (pure — the verbatim guarantee) ───────────────────────────────────
73+
74+
/**
75+
* Concatenate the stored `messages` from every transcript event, in order, into the provider messages
76+
* array the agent loop resumes from. Non-transcript events (title/label/end/compact) carry no messages
77+
* and are skipped, so the rebuild is exactly the conversation — nothing more, nothing lost.
78+
*/
79+
function eventsToMessages(events) {
80+
const out = [];
81+
for (const e of (Array.isArray(events) ? events : [])) {
82+
if (e && Array.isArray(e.messages)) { for (const m of e.messages) { out.push(m); } }
83+
}
84+
return out;
85+
}
86+
87+
/** How many messages are already persisted — so a per-turn append stores only the new tail, not the lot. */
88+
function tailFrom(messages, storedCount) {
89+
const msgs = Array.isArray(messages) ? messages : [];
90+
const from = Number.isFinite(storedCount) && storedCount > 0 ? storedCount : 0;
91+
return msgs.slice(from);
92+
}
93+
94+
module.exports = {
95+
EDIT_TOOLS,
96+
toolStatsFromMessages,
97+
userTurnEvent, agentTurnEvent, endEvent, titleEvent, labelEvent,
98+
eventsToMessages, tailFrom
99+
};
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* sessionEvents — message ⇄ event translation — run: node test/sessionEvents.test.js
3+
*
4+
* Two guarantees: (1) VERBATIM — messages → events → messages is byte-identical, so a verbatim resume
5+
* sees exactly what it left; (2) the card stats (sparkline, files-edited) are DERIVED from the turn's
6+
* own messages, not hand-passed — so they can't drift from the transcript. The last test wires this to
7+
* sessionStore.deriveEntry to prove the two modules agree end-to-end.
8+
*--------------------------------------------------------------------------------------------*/
9+
// @ts-check
10+
'use strict';
11+
12+
const assert = require('assert');
13+
const E = require('../sessionEvents');
14+
const S = require('../sessionStore');
15+
16+
let n = 0;
17+
function test(name, fn) { fn(); n++; console.log(' ok - ' + name); }
18+
19+
// A realistic agent turn: read one file, then edit two, then answer.
20+
function conversation() {
21+
return [
22+
{ role: 'user', content: 'add idempotency to refunds' },
23+
{ role: 'assistant', content: [{ type: 'text', text: 'reading' }, { type: 'tool_use', id: 'a', name: 'read_file', input: { path: 'refund.rb' } }] },
24+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'a', content: '…' }] },
25+
{ role: 'assistant', content: [
26+
{ type: 'tool_use', id: 'b', name: 'edit_file', input: { path: 'refund.rb' } },
27+
{ type: 'tool_use', id: 'c', name: 'write_file', input: { path: 'redis_lock.rb' } }
28+
] },
29+
{ role: 'user', content: [{ type: 'tool_result', tool_use_id: 'b' }, { type: 'tool_result', tool_use_id: 'c' }] },
30+
{ role: 'assistant', content: 'Done: idempotent refunds via Redis keys' }
31+
];
32+
}
33+
34+
test('STATS: tool count is the sparkline; only edit_file/write_file count as files edited', () => {
35+
const { tools, edits } = E.toolStatsFromMessages(conversation());
36+
assert.strictEqual(tools, 3, 'read_file + edit_file + write_file = 3 tool calls');
37+
assert.deepStrictEqual(edits, [{ path: 'refund.rb' }, { path: 'redis_lock.rb' }], 'the read is not an edit');
38+
assert.deepStrictEqual(E.toolStatsFromMessages(null), { tools: 0, edits: [] }, 'never throws on junk');
39+
});
40+
41+
test('BUILD: user/agent events carry content + verbatim messages + derived stats', () => {
42+
const msgs = conversation();
43+
const u = E.userTurnEvent(msgs[0], 't1');
44+
assert.strictEqual(u.kind, 'user');
45+
assert.strictEqual(u.content, 'add idempotency to refunds');
46+
assert.deepStrictEqual(u.messages, [msgs[0]]);
47+
48+
const a = E.agentTurnEvent(msgs.slice(1), 'anthropic/claude-opus-5', 't2');
49+
assert.strictEqual(a.kind, 'agent');
50+
assert.strictEqual(a.model, 'anthropic/claude-opus-5');
51+
assert.strictEqual(a.tools, 3);
52+
assert.deepStrictEqual(a.messages, msgs.slice(1), 'the turn is stored verbatim');
53+
});
54+
55+
test('VERBATIM: messages → events → eventsToMessages is byte-identical (lossless resume)', () => {
56+
const msgs = conversation();
57+
const events = [E.userTurnEvent(msgs[0], 't1'), E.agentTurnEvent(msgs.slice(1), 'm', 't2')];
58+
assert.deepStrictEqual(E.eventsToMessages(events), msgs, 'rebuild == original, exactly');
59+
// non-transcript events contribute nothing to the rebuild
60+
const withMeta = events.concat([E.titleEvent('T', 't3'), E.labelEvent({ pinned: true }, 't4'), E.endEvent('done', 't5')]);
61+
assert.deepStrictEqual(E.eventsToMessages(withMeta), msgs, 'title/label/end carry no messages');
62+
});
63+
64+
test('TAIL: only the new messages since the stored count are appended (incremental, not the whole lot)', () => {
65+
const msgs = conversation();
66+
assert.deepStrictEqual(E.tailFrom(msgs, 3), msgs.slice(3));
67+
assert.deepStrictEqual(E.tailFrom(msgs, 0), msgs);
68+
assert.deepStrictEqual(E.tailFrom(msgs, msgs.length), []);
69+
});
70+
71+
test('LABEL: archiving/pinning is an append-only event, never a rewrite', () => {
72+
assert.deepStrictEqual(E.labelEvent({ lifecycle: 'archived' }, 't'), { kind: 'label', t: 't', lifecycle: 'archived' });
73+
assert.deepStrictEqual(E.labelEvent({ pinned: true }, 't'), { kind: 'label', t: 't', pinned: true });
74+
});
75+
76+
// ── cross-module: sessionEvents output feeds sessionStore.deriveEntry correctly ───────────────────
77+
78+
test('END-TO-END: events built here derive the right card in sessionStore (one source of truth)', () => {
79+
const msgs = conversation();
80+
const meta = { kind: 'meta', v: 1, id: 's1', project: '/p', createdAt: 'c0', title: null };
81+
const events = [
82+
E.userTurnEvent(msgs[0], 't1'),
83+
E.agentTurnEvent(msgs.slice(1), 'anthropic/claude-opus-5', 't2'),
84+
E.titleEvent('Idempotent refunds via Redis keys', 't3'),
85+
E.endEvent('done', 't4')
86+
];
87+
const card = S.deriveEntry(meta, events);
88+
assert.strictEqual(card.title, 'Idempotent refunds via Redis keys');
89+
assert.strictEqual(card.turns, 1);
90+
assert.strictEqual(card.model, 'anthropic/claude-opus-5');
91+
assert.strictEqual(card.state, 'done');
92+
assert.deepStrictEqual(card.spark, [3], 'the sparkline came from the turn messages via the agent event');
93+
assert.deepStrictEqual(card.filesEdited, ['refund.rb', 'redis_lock.rb'], 'and so did the files-edited chips');
94+
});
95+
96+
console.log('sessionEvents: ' + n + ' tests passed');

0 commit comments

Comments
 (0)