1265 lines
40 KiB
TypeScript
1265 lines
40 KiB
TypeScript
|
|
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();
|
||
|
|
});
|
||
|
|
});
|