GPU_GUARD_MONOREPO/packages/backend/test/session_tree_provider_contract.test.ts

1265 lines
40 KiB
TypeScript
Raw Normal View History

2026-05-20 21:39:12 +08:00
import { ROOT_PARENT_KEY, buildEntryIndex, getPathToLeaf, groupChildrenByParent, resolveLatestLabels } from '../src/modules/netaclaw/session-tree/path.js';
import { buildSessionContext } from '../src/modules/netaclaw/session-tree/context_builder.js';
import {
SessionTreeProvider,
SessionTreeProviderError,
} from '../src/modules/netaclaw/session-tree/provider.js';
import type {
AppendSessionTreeEntryInput,
CreateSessionTreeInput,
SessionTreeEntry,
SessionTreeMessage,
SessionTreeSession,
SessionTreeSnapshot,
UpdateSessionTreeEntryPatch,
} from '../src/modules/netaclaw/session-tree/types.js';
type ProviderFactory = () => SessionTreeProvider;
export function runSessionTreeProviderContract(name: string, createProvider: ProviderFactory): void {
describe(`${name} session tree provider contract`, () => {
it('createSession defaults active session pointers to null', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_001',
provider: 'file',
cwd: 'C:/workspace/neta',
});
expect(session.sessionId).toBe('session_contract_001');
expect(session.rootEntryId).toBeNull();
expect(session.leafEntryId).toBeNull();
expect(session.status).toBe('active');
});
it('appendEntry advances root and leaf, and getActivePath returns the root-to-leaf path', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_002',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'root',
},
});
const leaf = await provider.appendEntry(session.sessionId, {
id: 'entry_leaf',
parentId: root.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'leaf',
},
});
const updatedSession = await provider.getSession(session.sessionId);
const activePath = await provider.getActivePath(session.sessionId);
expect(updatedSession?.rootEntryId).toBe(root.id);
expect(updatedSession?.leafEntryId).toBe(leaf.id);
expect(activePath.map(entry => entry.id)).toEqual([root.id, leaf.id]);
});
it('switchLeaf changes only the active branch and preserves sibling branches', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_003',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'root',
},
});
const branchA = await provider.appendEntry(session.sessionId, {
id: 'entry_branch_a',
parentId: root.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'branch a',
},
});
const branchB = await provider.appendEntry(session.sessionId, {
id: 'entry_branch_b',
parentId: root.id,
timestamp: '2026-04-19T00:02:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'branch b',
},
});
const switched = await provider.switchLeaf(session.sessionId, branchA.id);
const entries = await provider.listEntries(session.sessionId);
const activePath = await provider.getActivePath(session.sessionId);
expect(switched.leafEntryId).toBe(branchA.id);
expect(entries.map(entry => entry.id).sort()).toEqual([root.id, branchA.id, branchB.id].sort());
expect(activePath.map(entry => entry.id)).toEqual([root.id, branchA.id]);
});
it('updateEntry only applies to existing entries and throws SessionTreeProviderError for missing ids', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_004',
provider: 'file',
});
await provider.appendEntry(session.sessionId, {
id: 'entry_existing',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'custom_message',
customType: 'notice',
content: 'before',
display: true,
});
const updated = await provider.updateEntry(session.sessionId, 'entry_existing', {
content: 'after',
});
expect(updated.type).toBe('custom_message');
if (updated.type !== 'custom_message') {
throw new Error('expected custom_message entry');
}
expect(updated.content).toBe('after');
await expect(
provider.updateEntry(session.sessionId, 'entry_missing', {
metadata: {
updated: true,
},
}),
).rejects.toBeInstanceOf(SessionTreeProviderError);
});
it('appendEntry rejects missing parents, cross-session parents, and duplicate roots', async () => {
const provider = createProvider();
const firstSession = await provider.createSession({
sessionId: 'session_contract_005',
provider: 'file',
});
const secondSession = await provider.createSession({
sessionId: 'session_contract_006',
provider: 'file',
});
const firstRoot = await provider.appendEntry(firstSession.sessionId, {
id: 'entry_contract_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'root',
},
});
await expect(
provider.appendEntry(firstSession.sessionId, {
id: 'entry_missing_parent',
parentId: 'entry_missing',
timestamp: '2026-04-19T00:01:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'missing parent',
},
}),
).rejects.toBeInstanceOf(SessionTreeProviderError);
await expect(
provider.appendEntry(secondSession.sessionId, {
id: 'entry_cross_session_parent',
parentId: firstRoot.id,
timestamp: '2026-04-19T00:02:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'cross session parent',
},
}),
).rejects.toBeInstanceOf(SessionTreeProviderError);
await expect(
provider.appendEntry(firstSession.sessionId, {
id: 'entry_duplicate_root',
parentId: null,
timestamp: '2026-04-19T00:03:00.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'duplicate root',
},
}),
).rejects.toBeInstanceOf(SessionTreeProviderError);
});
it('updateEntry rejects structural patches that mutate immutable entry fields', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_007',
provider: 'file',
});
const entry = await provider.appendEntry(session.sessionId, {
id: 'entry_patch_target',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'custom_message',
customType: 'notice',
content: 'before',
display: true,
});
const invalidPatches = [
{ id: 'entry_other' },
{ type: 'message' },
{ parentId: 'entry_other_parent' },
{ timestamp: '2026-04-19T00:05:00.000Z' },
];
for (const patch of invalidPatches) {
await expect(
provider.updateEntry(
session.sessionId,
entry.id,
patch as unknown as Partial<SessionTreeEntry>,
),
).rejects.toBeInstanceOf(SessionTreeProviderError);
}
});
it('updateEntry rejects semantic patches that mutate append-only historical facts', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_007b',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'root',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_label',
parentId: root.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'label',
targetId: root.id,
label: 'important',
});
await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_branch_summary',
parentId: root.id,
timestamp: '2026-04-19T00:02:00.000Z',
type: 'branch_summary',
fromId: root.id,
summary: 'branch summary',
details: {
source: 'contract',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_compaction',
parentId: root.id,
timestamp: '2026-04-19T00:03:00.000Z',
type: 'compaction',
summary: 'compacted',
firstKeptEntryId: root.id,
tokensBefore: 42,
details: {
source: 'contract',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_model_change',
parentId: root.id,
timestamp: '2026-04-19T00:04:00.000Z',
type: 'model_change',
provider: 'openai',
modelId: 'gpt-4.1',
});
await provider.appendEntry(session.sessionId, {
id: 'entry_semantic_thinking_level',
parentId: root.id,
timestamp: '2026-04-19T00:05:00.000Z',
type: 'thinking_level_change',
thinkingLevel: 'high',
});
const invalidPatches: Array<{
entryId: string;
patch: Partial<SessionTreeEntry>;
}> = [
{
entryId: 'entry_semantic_label',
patch: {
targetId: 'entry_other',
},
},
{
entryId: 'entry_semantic_label',
patch: {
label: 'retagged',
},
},
{
entryId: 'entry_semantic_branch_summary',
patch: {
fromId: 'entry_other',
},
},
{
entryId: 'entry_semantic_branch_summary',
patch: {
summary: 'rewritten summary',
},
},
{
entryId: 'entry_semantic_compaction',
patch: {
firstKeptEntryId: 'entry_other',
},
},
{
entryId: 'entry_semantic_compaction',
patch: {
tokensBefore: 7,
},
},
{
entryId: 'entry_semantic_model_change',
patch: {
provider: 'anthropic',
},
},
{
entryId: 'entry_semantic_model_change',
patch: {
modelId: 'claude-sonnet-4.5',
},
},
{
entryId: 'entry_semantic_thinking_level',
patch: {
thinkingLevel: 'low',
},
},
];
for (const { entryId, patch } of invalidPatches) {
await expect(
provider.updateEntry(
session.sessionId,
entryId,
patch as unknown as UpdateSessionTreeEntryPatch,
),
).rejects.toBeInstanceOf(SessionTreeProviderError);
}
});
it('createSession, getSession, listEntries, and getSnapshot return clones instead of live internal references', async () => {
const provider = createProvider();
const created = await provider.createSession({
sessionId: 'session_contract_008',
provider: 'file',
metadata: {
origin: 'contract',
},
});
created.status = 'archived';
created.metadata = {
origin: 'mutated create result',
};
const root = await provider.appendEntry(created.sessionId, {
id: 'entry_clone_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: {
text: 'root',
},
},
metadata: {
source: 'original',
},
});
const fetched = await provider.getSession(created.sessionId);
if (!fetched) {
throw new Error('expected fetched session');
}
fetched.status = 'paused';
fetched.metadata = {
origin: 'mutated fetched session',
};
const entries = await provider.listEntries(created.sessionId);
entries[0]!.metadata = {
source: 'mutated list entry',
};
if (entries[0]?.type === 'message' && typeof entries[0].message.content === 'object' && entries[0].message.content) {
(entries[0].message.content as { text: string }).text = 'mutated list content';
}
const snapshot = await provider.getSnapshot(created.sessionId);
snapshot.session.status = 'deleted';
snapshot.entries[0]!.metadata = {
source: 'mutated snapshot entry',
};
const reloadedSession = await provider.getSession(created.sessionId);
const reloadedEntries = await provider.listEntries(created.sessionId);
const reloadedSnapshot = await provider.getSnapshot(created.sessionId);
expect(root.metadata).toEqual({
source: 'original',
});
expect(reloadedSession?.status).toBe('active');
expect(reloadedSession?.metadata).toEqual({
origin: 'contract',
});
expect(reloadedEntries[0]?.metadata).toEqual({
source: 'original',
});
if (reloadedEntries[0]?.type !== 'message') {
throw new Error('expected message entry');
}
expect(reloadedEntries[0].message.content).toEqual({
text: 'root',
});
expect(reloadedSnapshot.session.status).toBe('active');
expect(reloadedSnapshot.entries[0]?.metadata).toEqual({
source: 'original',
});
});
it('getSnapshot returns a reusable contract snapshot shape with runtime context derived from the active path', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_009',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_snapshot_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'root',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_snapshot_thinking',
parentId: root.id,
timestamp: '2026-04-19T00:00:30.000Z',
type: 'thinking_level_change',
thinkingLevel: 'high',
});
await provider.appendEntry(session.sessionId, {
id: 'entry_snapshot_model',
parentId: 'entry_snapshot_thinking',
timestamp: '2026-04-19T00:00:45.000Z',
type: 'model_change',
provider: 'openai',
modelId: 'gpt-4.1',
});
const assistant = await provider.appendEntry(session.sessionId, {
id: 'entry_snapshot_assistant',
parentId: 'entry_snapshot_model',
timestamp: '2026-04-19T00:00:50.000Z',
type: 'message',
message: {
role: 'assistant',
content: 'answer',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_snapshot_label',
parentId: assistant.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'label',
targetId: root.id,
label: 'important',
});
const snapshot = await provider.getSnapshot(session.sessionId);
expect(snapshot.session.sessionId).toBe(session.sessionId);
expect(snapshot.entries).toHaveLength(5);
expect(snapshot.activePath.map(entry => entry.id)).toEqual([
root.id,
'entry_snapshot_thinking',
'entry_snapshot_model',
assistant.id,
'entry_snapshot_label',
]);
expect(snapshot.childrenByParentId[ROOT_PARENT_KEY]).toEqual([root.id]);
expect(snapshot.labelsByEntryId[root.id]?.label).toBe('important');
expect(snapshot.runtimeContext.messages).toEqual([
{
role: 'user',
content: 'root',
},
{
role: 'assistant',
content: 'answer',
},
]);
expect(snapshot.runtimeContext.thinkingLevel).toBe('high');
expect(snapshot.runtimeContext.model).toEqual({
provider: 'openai',
modelId: 'gpt-4.1',
});
});
it('round-trips subagent batch metadata patches without leaking runtime-only events into runtime context', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_010',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_subagent_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'run subagents',
},
});
await provider.appendEntry(session.sessionId, {
id: 'entry_subagent_batch',
parentId: root.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'subagent_batch',
content: {
batchId: 'batch_001',
mode: 'parallel',
tasks: [
{
id: 'task_1',
prompt: 'collect data',
},
],
status: 'running',
parentEntryId: root.id,
},
});
const runtimeEvent = {
sequence: 1,
timestamp: '2026-04-19T00:01:05.000Z',
event: {
kind: 'result',
status: 'completed',
subagent: {
id: 'subagent_1',
summary: 'done',
},
},
};
await provider.updateEntry(session.sessionId, 'entry_subagent_batch', {
content: {
batchId: 'batch_001',
mode: 'parallel',
tasks: [
{
id: 'task_1',
prompt: 'collect data',
},
],
status: 'completed',
parentEntryId: root.id,
},
metadata: {
latestEvent: runtimeEvent,
events: [runtimeEvent],
finalResults: [
{
id: 'subagent_1',
summary: 'done',
},
],
},
});
const entries = await provider.listEntries(session.sessionId);
const activePath = await provider.getActivePath(session.sessionId);
const snapshot = await provider.getSnapshot(session.sessionId);
const subagentBatch = entries.find(entry => entry.id === 'entry_subagent_batch');
expect(activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_batch']);
expect(subagentBatch).toMatchObject({
id: 'entry_subagent_batch',
parentId: root.id,
type: 'subagent_batch',
content: {
batchId: 'batch_001',
mode: 'parallel',
status: 'completed',
parentEntryId: root.id,
},
metadata: {
latestEvent: runtimeEvent,
events: [runtimeEvent],
finalResults: [
{
id: 'subagent_1',
summary: 'done',
},
],
},
});
expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_batch']);
expect(snapshot.runtimeContext.messages).toEqual([
{
role: 'user',
content: 'run subagents',
},
]);
expect(snapshot.runtimeContext.thinkingLevel).toBe('off');
expect(snapshot.runtimeContext.model).toBeNull();
expect(snapshot.entries.find(entry => entry.id === 'entry_subagent_batch')).toMatchObject({
metadata: {
latestEvent: runtimeEvent,
events: [runtimeEvent],
finalResults: [
{
id: 'subagent_1',
summary: 'done',
},
],
},
});
});
it('round-trips subagent result evidence and process metadata without leaking it into runtime context', async () => {
const provider = createProvider();
const session = await provider.createSession({
sessionId: 'session_contract_011',
provider: 'file',
});
const root = await provider.appendEntry(session.sessionId, {
id: 'entry_subagent_result_root',
parentId: null,
timestamp: '2026-04-19T00:00:00.000Z',
type: 'message',
message: {
role: 'user',
content: 'run subagents',
},
});
const evidenceSummary = {
kind: 'file-count',
title: '图片文件统计',
count: 2,
files: ['1.jpg', '2.png'],
summary: ['根据工具结果统计,图片文件共 2 个。', '- 1.jpg', '- 2.png'].join('\n'),
};
const processEvent = {
type: 'tool_call',
runId: 'subagent-101',
timestamp: '2026-04-19T00:01:02.000Z',
name: 'find_files',
toolCallId: 'tool-call-1',
};
await provider.appendEntry(session.sessionId, {
id: 'entry_subagent_result',
parentId: root.id,
timestamp: '2026-04-19T00:01:00.000Z',
type: 'subagent_result',
content: {
batchId: 'batch_001',
results: [
{
id: 'subagent_1',
status: 'completed',
summary: 'found 2 images',
resultPayload: {
evidenceSummary,
processEvents: [processEvent],
},
},
],
status: 'completed',
parentEntryId: root.id,
},
metadata: {
evidenceSummaries: [evidenceSummary],
processEvents: [processEvent],
},
});
const entries = await provider.listEntries(session.sessionId);
const activePath = await provider.getActivePath(session.sessionId);
const snapshot = await provider.getSnapshot(session.sessionId);
const subagentResult = entries.find(entry => entry.id === 'entry_subagent_result');
expect(activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_result']);
expect(subagentResult).toMatchObject({
id: 'entry_subagent_result',
parentId: root.id,
type: 'subagent_result',
content: {
batchId: 'batch_001',
status: 'completed',
parentEntryId: root.id,
},
metadata: {
evidenceSummaries: [evidenceSummary],
processEvents: [processEvent],
},
});
expect(snapshot.activePath.map(entry => entry.id)).toEqual([root.id, 'entry_subagent_result']);
expect(snapshot.entries.find(entry => entry.id === 'entry_subagent_result')).toMatchObject({
metadata: {
evidenceSummaries: [evidenceSummary],
processEvents: [processEvent],
},
});
expect(snapshot.runtimeContext.messages).toEqual([
{
role: 'user',
content: 'run subagents',
},
]);
expect(snapshot.runtimeContext.thinkingLevel).toBe('off');
expect(snapshot.runtimeContext.model).toBeNull();
});
});
}
class InMemorySessionTreeProvider implements SessionTreeProvider {
private readonly sessions = new Map<string, SessionTreeSession>();
private readonly entriesBySessionId = new Map<string, SessionTreeEntry[]>();
async createSession(input: CreateSessionTreeInput): Promise<SessionTreeSession> {
const session: SessionTreeSession = {
sessionId: input.sessionId ?? `session_${this.sessions.size + 1}`,
provider: input.provider ?? 'file',
rootEntryId: input.rootEntryId ?? null,
leafEntryId: input.leafEntryId ?? null,
cwd: input.cwd,
sessionFile: input.sessionFile,
parentSessionId: input.parentSessionId,
agentId: input.agentId,
userId: input.userId,
title: input.title,
status: input.status ?? 'active',
metadata: cloneValue(input.metadata),
createdAt: input.createdAt,
updatedAt: input.updatedAt,
};
this.sessions.set(session.sessionId, cloneSession(session));
this.entriesBySessionId.set(session.sessionId, []);
return cloneSession(session);
}
async getSession(sessionId: string): Promise<SessionTreeSession | null> {
const session = this.sessions.get(sessionId);
return session ? cloneSession(session) : null;
}
async updateSession(sessionId: string, patch: Partial<SessionTreeSession>): Promise<SessionTreeSession> {
const existing = this.requireSession(sessionId);
const updated = {
...existing,
...cloneSessionPatch(patch),
sessionId: existing.sessionId,
};
this.sessions.set(sessionId, cloneSession(updated));
return cloneSession(updated);
}
async deleteSession(sessionId: string): Promise<void> {
this.sessions.delete(sessionId);
this.entriesBySessionId.delete(sessionId);
}
async listEntries(sessionId: string): Promise<SessionTreeEntry[]> {
return cloneEntries(this.requireEntries(sessionId));
}
async appendEntry(sessionId: string, input: AppendSessionTreeEntryInput): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
const entries = this.requireEntries(sessionId);
this.assertAppendParentConstraint(session, entries, input.parentId);
const entry: SessionTreeEntry = {
...input,
id: input.id ?? `entry_${entries.length + 1}`,
timestamp: input.timestamp ?? new Date(0).toISOString(),
};
const storedEntry = cloneEntry(entry);
entries.push(storedEntry);
const updatedSession: SessionTreeSession = {
...session,
rootEntryId: session.rootEntryId ?? storedEntry.id,
leafEntryId: storedEntry.id,
};
this.sessions.set(sessionId, cloneSession(updatedSession));
return cloneEntry(storedEntry);
}
async appendMessage(sessionId: string, message: SessionTreeMessage): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'message',
message,
});
}
async appendThinkingLevelChange(sessionId: string, thinkingLevel: string): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'thinking_level_change',
thinkingLevel,
});
}
async appendModelChange(sessionId: string, provider: string, modelId: string): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'model_change',
provider,
modelId,
});
}
async appendCompaction(sessionId: string, input: {
summary: string;
firstKeptEntryId: string;
tokensBefore: number;
details?: Record<string, unknown>;
}): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'compaction',
...input,
});
}
async appendBranchSummary(sessionId: string, input: {
fromId: string;
summary: string;
details?: Record<string, unknown>;
}): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'branch_summary',
...input,
});
}
async appendLabelChange(sessionId: string, targetId: string, label: string | undefined): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'label',
targetId,
label,
});
}
async appendSessionInfo(sessionId: string, name?: string): Promise<SessionTreeEntry> {
const session = this.requireSession(sessionId);
return this.appendEntry(sessionId, {
parentId: session.leafEntryId,
type: 'session_info',
name,
});
}
async updateEntry(sessionId: string, id: string, patch: UpdateSessionTreeEntryPatch): Promise<SessionTreeEntry> {
const entries = this.requireEntries(sessionId);
const index = entries.findIndex(entry => entry.id === id);
if (index < 0) {
throw new SessionTreeProviderError(`Entry ${id} not found in session ${sessionId}`);
}
assertImmutableEntryPatch(entries[index]!, patch as Record<string, unknown>);
const updated = {
...entries[index],
...cloneEntryPatch(patch),
id,
type: entries[index]!.type,
parentId: entries[index]!.parentId,
timestamp: entries[index]!.timestamp,
} as SessionTreeEntry;
entries[index] = cloneEntry(updated);
return cloneEntry(updated);
}
async switchLeaf(sessionId: string, leafEntryId: string | null): Promise<SessionTreeSession> {
if (leafEntryId !== null) {
const exists = this.requireEntries(sessionId).some(entry => entry.id === leafEntryId);
if (!exists) {
throw new SessionTreeProviderError(`Leaf entry ${leafEntryId} not found in session ${sessionId}`);
}
}
return this.updateSession(sessionId, {
leafEntryId,
});
}
async resetLeaf(sessionId: string): Promise<SessionTreeSession> {
return this.switchLeaf(sessionId, null);
}
async createBranchedSession(
sessionId: string,
leafEntryId: string,
input: CreateSessionTreeInput = {},
): Promise<SessionTreeSession> {
const session = this.requireSession(sessionId);
const path = getPathToLeaf(this.requireEntries(sessionId), leafEntryId);
const created = await this.createSession({
...input,
parentSessionId: input.parentSessionId ?? sessionId,
cwd: input.cwd ?? session.cwd,
agentId: input.agentId ?? session.agentId,
userId: input.userId ?? session.userId,
title: input.title ?? session.title,
metadata: input.metadata ?? session.metadata,
});
for (const entry of path) {
await this.appendEntry(created.sessionId, cloneEntry(entry) as AppendSessionTreeEntryInput);
}
await this.switchLeaf(created.sessionId, leafEntryId);
return this.requireSession(created.sessionId);
}
async getActivePath(sessionId: string): Promise<SessionTreeEntry[]> {
const session = this.requireSession(sessionId);
const entries = this.requireEntries(sessionId);
if (session.leafEntryId === null) {
return [];
}
return cloneEntries(getPathToLeaf(entries, session.leafEntryId));
}
async getSnapshot(sessionId: string): Promise<SessionTreeSnapshot> {
const session = this.requireSession(sessionId);
const entries = this.requireEntries(sessionId);
const activePath = session.leafEntryId === null ? [] : getPathToLeaf(entries, session.leafEntryId);
return cloneSnapshot({
session,
entries,
activePath,
childrenByParentId: groupChildrenByParent(entries),
labelsByEntryId: resolveLatestLabels(entries),
runtimeContext: buildSessionContext(activePath, session.leafEntryId ?? undefined),
});
}
private requireSession(sessionId: string): SessionTreeSession {
const session = this.sessions.get(sessionId);
if (!session) {
throw new SessionTreeProviderError(`Session ${sessionId} not found`);
}
return session;
}
private requireEntries(sessionId: string): SessionTreeEntry[] {
const entries = this.entriesBySessionId.get(sessionId);
if (!entries) {
throw new SessionTreeProviderError(`Session ${sessionId} not found`);
}
return entries;
}
private assertAppendParentConstraint(
session: SessionTreeSession,
entries: SessionTreeEntry[],
parentId: string | null,
): void {
if (parentId === null) {
if (session.rootEntryId !== null || entries.length > 0) {
throw new SessionTreeProviderError(`Session ${session.sessionId} already has a root entry`);
}
return;
}
const hasParent = entries.some(entry => entry.id === parentId);
if (!hasParent) {
throw new SessionTreeProviderError(
`Parent entry ${parentId} not found in session ${session.sessionId}`,
);
}
}
}
function assertImmutableEntryPatch(
entry: SessionTreeEntry,
patch: Record<string, unknown>,
): void {
const immutableKeys: Array<keyof Pick<SessionTreeEntry, 'id' | 'type' | 'parentId' | 'timestamp'>> = [
'id',
'type',
'parentId',
'timestamp',
];
for (const key of immutableKeys) {
if (Object.prototype.hasOwnProperty.call(patch, key) && patch[key] !== entry[key]) {
throw new SessionTreeProviderError(`Entry field ${key} is immutable`);
}
}
const allowedKeys = getAllowedPatchKeys(entry.type);
for (const key of Object.keys(patch)) {
if (!allowedKeys.has(key)) {
throw new SessionTreeProviderError(
`Entry field ${key} cannot be updated for entry type ${entry.type}`,
);
}
}
}
function getAllowedPatchKeys(entryType: SessionTreeEntry['type']): ReadonlySet<string> {
switch (entryType) {
case 'message':
return new Set(['message', 'metadata']);
case 'custom_message':
case 'subagent_batch':
case 'subagent_result':
return new Set(['content', 'details', 'display', 'metadata']);
case 'custom':
return new Set(['data', 'metadata']);
case 'thinking_level_change':
case 'model_change':
case 'compaction':
case 'branch_summary':
case 'label':
case 'session_info':
return new Set(['metadata']);
default: {
const exhaustive: never = entryType;
return exhaustive;
}
}
}
function cloneSnapshot(snapshot: SessionTreeSnapshot): SessionTreeSnapshot {
return {
session: cloneSession(snapshot.session),
entries: cloneEntries(snapshot.entries),
activePath: cloneEntries(snapshot.activePath),
childrenByParentId: cloneRecordOfArrays(snapshot.childrenByParentId),
labelsByEntryId: cloneValue(snapshot.labelsByEntryId),
runtimeContext: cloneValue(snapshot.runtimeContext),
};
}
function cloneEntries(entries: SessionTreeEntry[]): SessionTreeEntry[] {
return entries.map(entry => cloneEntry(entry));
}
function cloneEntry(entry: SessionTreeEntry): SessionTreeEntry {
return cloneValue(entry);
}
function cloneEntryPatch(patch: UpdateSessionTreeEntryPatch): UpdateSessionTreeEntryPatch {
return cloneValue(patch);
}
function cloneSession(session: SessionTreeSession): SessionTreeSession {
return cloneValue(session);
}
function cloneSessionPatch(patch: Partial<SessionTreeSession>): Partial<SessionTreeSession> {
return cloneValue(patch);
}
function cloneRecordOfArrays(value: Record<string, string[]>): Record<string, string[]> {
return cloneValue(value);
}
function cloneValue<T>(value: T): T {
if (value === undefined) {
return value;
}
return JSON.parse(JSON.stringify(value)) as T;
}
describe('session tree provider contract scaffold', () => {
runSessionTreeProviderContract('in-memory fake', () => new InMemorySessionTreeProvider());
it('AppendSessionTreeEntryInput preserves the distributive union and rejects mixed legacy fields', () => {
const messageInput: AppendSessionTreeEntryInput = {
parentId: null,
type: 'message',
message: {
role: 'assistant',
content: 'hello',
},
};
const labelInput: AppendSessionTreeEntryInput = {
parentId: 'entry_target',
type: 'label',
targetId: 'entry_target',
label: 'important',
};
// @ts-expect-error message entries must keep role/content nested under message
const invalidMessageTopLevel: AppendSessionTreeEntryInput = { parentId: null, type: 'message', role: 'assistant', content: 'hello' };
// @ts-expect-error label entries require targetId
const invalidLabelMissingTargetId: AppendSessionTreeEntryInput = {
parentId: null,
type: 'label',
label: 'missing target',
};
// @ts-expect-error union members cannot mix unrelated fields
const invalidLabelWithMessage: AppendSessionTreeEntryInput = { parentId: null, type: 'label', targetId: 'entry_target', label: 'mixed', message: { role: 'assistant', content: 'nope' } };
// @ts-expect-error different union members cannot cross-wire fields
const invalidModelWithCustomField: AppendSessionTreeEntryInput = { parentId: null, type: 'model_change', provider: 'openai', modelId: 'gpt-4.1', customType: 'invalid' };
expect(messageInput.parentId).toBeNull();
expect(messageInput.type).toBe('message');
if (messageInput.type !== 'message') {
throw new Error('expected message input');
}
expect(messageInput.message.role).toBe('assistant');
expect(labelInput.targetId).toBe('entry_target');
expect('content' in messageInput).toBe(false);
expect('role' in messageInput).toBe(false);
expect(invalidMessageTopLevel).toBeDefined();
expect(invalidLabelMissingTargetId).toBeDefined();
expect(invalidLabelWithMessage).toBeDefined();
expect(invalidModelWithCustomField).toBeDefined();
});
it('UpdateSessionTreeEntryPatch preserves entry-specific patch whitelists', () => {
const messagePatch: UpdateSessionTreeEntryPatch = {
message: {
role: 'assistant' as const,
content: 'updated',
},
};
const customMessagePatch: UpdateSessionTreeEntryPatch = {
content: 'updated',
details: {
stage: 'done',
},
display: false,
metadata: {
source: 'contract',
},
};
const labelMetadataPatch: UpdateSessionTreeEntryPatch = {
metadata: {
reviewed: true,
},
};
// @ts-expect-error label targetId is append-only and cannot be updated
const invalidLabelTargetPatch: UpdateSessionTreeEntryPatch = { targetId: 'entry_other' };
// @ts-expect-error label text is append-only and cannot be updated
const invalidLabelValuePatch: UpdateSessionTreeEntryPatch = { label: 'updated' };
// @ts-expect-error branch summary source is append-only and cannot be updated
const invalidBranchSummaryFromPatch: UpdateSessionTreeEntryPatch = { fromId: 'entry_other' };
// @ts-expect-error branch summary text is append-only and cannot be updated
const invalidBranchSummarySummaryPatch: UpdateSessionTreeEntryPatch = { summary: 'updated summary' };
// @ts-expect-error compaction anchor is append-only and cannot be updated
const invalidCompactionFirstKeptPatch: UpdateSessionTreeEntryPatch = { firstKeptEntryId: 'entry_other' };
// @ts-expect-error compaction token snapshot is append-only and cannot be updated
const invalidCompactionTokensPatch: UpdateSessionTreeEntryPatch = { tokensBefore: 10 };
// @ts-expect-error model provider is append-only and cannot be updated
const invalidModelProviderPatch: UpdateSessionTreeEntryPatch = { provider: 'anthropic' };
// @ts-expect-error model id is append-only and cannot be updated
const invalidModelIdPatch: UpdateSessionTreeEntryPatch = { modelId: 'claude-sonnet-4.5' };
// @ts-expect-error thinking level is append-only and cannot be updated
const invalidThinkingLevelPatch: UpdateSessionTreeEntryPatch = { thinkingLevel: 'low' };
expect(messagePatch.message?.role).toBe('assistant');
expect(customMessagePatch.display).toBe(false);
expect(labelMetadataPatch.metadata).toEqual({
reviewed: true,
});
expect(invalidLabelTargetPatch).toBeDefined();
expect(invalidLabelValuePatch).toBeDefined();
expect(invalidBranchSummaryFromPatch).toBeDefined();
expect(invalidBranchSummarySummaryPatch).toBeDefined();
expect(invalidCompactionFirstKeptPatch).toBeDefined();
expect(invalidCompactionTokensPatch).toBeDefined();
expect(invalidModelProviderPatch).toBeDefined();
expect(invalidModelIdPatch).toBeDefined();
expect(invalidThinkingLevelPatch).toBeDefined();
});
});