refactor: identify and freeze messages at creation

This commit is contained in:
_Kerman
2026-07-28 13:55:59 +08:00
parent c49c0ba497
commit fbf87e660c
345 changed files with 5220 additions and 2901 deletions

View File

@@ -1,3 +1,4 @@
import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm'
/**
* Derived-message cache contract against a scratch oracle: project new nodes
* once, rebuild on surface replacements, return fresh arrays over shared
@@ -8,7 +9,9 @@ import { describe, expect, it } from 'vitest'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
function userText(session: Session, text: string): void {
session.append('user/message', { content: [{ type: 'text', text }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
}
/** From-scratch oracle: replay the log into a fresh session and derive. */
@@ -23,9 +26,30 @@ describe('derived-message cache', () => {
userText(session, 'one')
expect(session.deriveMessages()).toEqual(scratch(session))
userText(session, 'two')
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'reply' }] }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'reply' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [], usage: { inputTokens: 1, outputTokens: 0 } }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
usage: { inputTokens: 1, outputTokens: 0 },
}, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual(scratch(session))
})
@@ -38,9 +62,9 @@ describe('derived-message cache', () => {
expect(beforeReplace).toHaveLength(2)
const nodes = session.surface.nodes
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
}), { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(session.deriveMessages()).toHaveLength(1)
expect(session.deriveMessages()).toEqual(scratch(session))
@@ -67,7 +91,9 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('projects one appended event exactly as the full derivation projects its node', () => {
const session = new Session(SessionId('per-event'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
// Full and per-event derivation share one projection.
expect(session.deriveEventMessage(event)).toEqual(session.deriveMessages().at(-1))
})
@@ -75,7 +101,9 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
it('reuses the logged event\'s already frozen content', () => {
const session = new Session(SessionId('per-event-clone'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const event = session.append('user/message', { content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
const event = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'orig' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const message = session.deriveEventMessage(event)!
expect(message.content).toBe(event.data.content)
expect(Object.isFrozen(message.content)).toBe(true)
@@ -89,7 +117,17 @@ describe('Session.deriveEventMessage — the per-event projection', () => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
const boundary = session.append('step/start', { turn: 1, step: 1 })
expect(session.deriveEventMessage(boundary)).toBeNull()
const empty = session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
const empty = session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
expect(session.deriveEventMessage(empty)).toBeNull()
})
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session'
@@ -17,19 +17,19 @@ function appendClosedTurn(
reason: TurnEndReason = { kind: 'completed' },
): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn, reason })
}
function appendOpenTurn(session: Session, turn: number): void {
session.append('turn/start', { turn, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: `open ${turn}` }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
}
function firstUserMessage(events: readonly SessionEvent[]): SessionEvent<'user/message'> {
@@ -189,23 +189,42 @@ describe('SessionStore.fork', () => {
}],
['user/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'open' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'open' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
return lastSeq(session)
}],
['assistant/message', (session) => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'partial' }] }, { surfaceOp: 'append' })
session.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'partial' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
return lastSeq(session)
}],
['tool/call', (session) => {
const callId = CallId('call-open')
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
session.append('assistant/message', {
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: callId, name: 'bash', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId, name: 'bash', arguments: '{}' })
return lastSeq(session)

View File

@@ -1,7 +1,7 @@
import { describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm'
import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session'
import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant'
import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants'
@@ -36,17 +36,32 @@ describe('session-log invariants', () => {
const session = ctx.sessions.create()
expect(() => {
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/call', { turn: 1, step: 1, callId: CallId('c1'), name: 'echo', arguments: '{}' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [], isError: false }, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
}).not.toThrow()
@@ -118,14 +133,16 @@ describe('session-log invariants', () => {
.toThrow(/expected turn 2, got 3/)
const outside = (await setup()).ctx.sessions.create()
expect(() => outside.append('user/message', {
expect(() => outside.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'idle context' }],
source: { kind: 'plugin', plugin: 'test' },
}, { surfaceOp: 'append' })).not.toThrow()
}), { surfaceOp: 'append' })).not.toThrow()
expect(() => outside.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'go' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })).toThrow(/outside any open turn/)
// Merge-extensible session events use the same default enclosure branch.
const appendUnknown = outside.append.bind(outside) as (type: string, data: unknown) => unknown
@@ -145,10 +162,16 @@ describe('session-log invariants', () => {
.toThrow(/while step 1 is still open/)
expect(() => nested.append('step/end', { turn: 1, step: 2 })).toThrow(/open is turn 1\/step 1/)
expect(() => nested.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 2,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step 1/)
const skipped = (await setup()).ctx.sessions.create()
@@ -174,9 +197,11 @@ describe('session-log invariants', () => {
expect(() => tool.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('ghost'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('ghost'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call/)
})
@@ -187,9 +212,11 @@ describe('session-log invariants', () => {
expect(() => session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('closed'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('closed'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/open is turn 1\/step null/)
})
@@ -208,9 +235,11 @@ describe('session-log invariants', () => {
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
message: createToolResultMessage({
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
@@ -218,7 +247,13 @@ describe('session-log invariants', () => {
session.append('turn/start', { turn: 2, trigger: { kind: 'message', source: { kind: 'user' } } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
message: freezeMessage({
...original.data.message,
content: [{
...original.data.message.content[0],
content: [{ type: 'text', text: 'pruned' }],
}],
}),
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
@@ -240,16 +275,24 @@ describe('session-log invariants', () => {
const original = session.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
message: createToolResultMessage({
callId: CallId('rewrite'),
content: [{ type: 'text', text: 'original' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(() => session.append('tool/result', {
...original.data,
content: [{ type: 'text', text: 'pruned' }],
message: freezeMessage({
...original.data.message,
content: [{
...original.data.message.content[0],
content: [{ type: 'text', text: 'pruned' }],
}],
}),
}, {
surfaceOp: { op: 'replace', start: original.seq, end: original.seq },
sourceEventSeqs: [original.seq],
@@ -264,9 +307,11 @@ describe('session-log invariants', () => {
repaired.append('tool/result', {
turn: 1,
step: 1,
callId: CallId('crashed'),
content: [],
isError: true,
message: createToolResultMessage({
callId: CallId('crashed'),
content: [],
isError: true,
}),
error: { name: 'ToolNotStartedError', code: TOOL_NOT_STARTED },
}, { surfaceOp: 'append' })
repaired.append('step/end', { turn: 1, step: 1 })
@@ -294,9 +339,11 @@ describe('session-log invariants', () => {
expect(() => session.append('tool/result', {
turn: 1,
step: 2,
callId: CallId('c1'),
content: [],
isError: false,
message: createToolResultMessage({
callId: CallId('c1'),
content: [],
isError: false,
}),
}, { surfaceOp: 'append' })).toThrow(/no prior tool\/call in this step/)
})

View File

@@ -9,7 +9,7 @@
import { describe, expect, it } from 'vitest'
import fc from 'fast-check'
import { CallId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import type { SessionEventMap, SessionEventType, SurfaceIntent } from '@deepseek-ai/dsh-session'
@@ -27,11 +27,45 @@ const textContentArb = fc.array(
// A message-producing event (these DO affect derived history). Each carries an
// explicit `surfaceOp: 'append'` intent — the marker the real loop passes.
const messageEventArb: fc.Arbitrary<Appendable> = fc.oneof(
textContentArb.map((content): Appendable => ({ type: 'user/message', data: { content, source: { kind: 'user' } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'assistant/message', data: { turn: 1, step: 1, content, provenance: { provider: 'mock', model: 'mock' }, usage: { inputTokens: 1, outputTokens: 1 } }, intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({ type: 'user/message', data: createUserMessage({
content, source: { kind: 'user' },
}), intent: { surfaceOp: 'append' } })),
textContentArb.map((content): Appendable => ({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
},
intent: { surfaceOp: 'append' },
})),
textContentArb.map((content): Appendable => ({
type: 'assistant/message',
data: {
turn: 1,
step: 1,
message: createMessage({
role: 'assistant',
content,
source: { kind: 'model', provider: 'mock', model: 'mock' },
}),
usage: { inputTokens: 1, outputTokens: 1 },
},
intent: { surfaceOp: 'append' },
})),
fc.record({ id: fc.string({ minLength: 1 }), content: textContentArb, isError: fc.boolean() })
.map((r): Appendable => ({ type: 'tool/result', data: { turn: 1, step: 1, callId: CallId(r.id), content: r.content, isError: r.isError }, intent: { surfaceOp: 'append' } })),
.map((r): Appendable => ({ type: 'tool/result', data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId(r.id),
content: r.content,
isError: r.isError,
}),
}, intent: { surfaceOp: 'append' } })),
)
// A non-message event (trace/replay data — must NOT affect derived history).

View File

@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest'
import { CallId } from '@deepseek-ai/dsh-llm'
import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm'
import { interruptedTurnClosers, TOOL_NOT_STARTED, TOOL_OUTCOME_UNKNOWN } from '../src/index.ts'
import type { SessionEvent, SurfaceEvent } from '../src/index.ts'
@@ -51,10 +51,20 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'text', text: 'calling a tool' },
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'text', text: 'calling a tool' },
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
]
const closers = interruptedTurnClosers(events)
// tool/result (for the orphaned call) → step/end → turn/end, contiguous seqs.
@@ -62,9 +72,15 @@ describe('interruptedTurnClosers', () => {
expect(closers.map(e => e.seq)).toEqual([3, 4, 5])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data).toMatchObject({
turn: 2, step: 1, callId: CallId('call-1'), isError: true, error: { code: TOOL_NOT_STARTED },
turn: 2,
step: 1,
message: {
source: { callId: CallId('call-1') },
content: [{ isError: true }],
},
error: { code: TOOL_NOT_STARTED },
})
expect(result.type === 'tool/result' && result.data.content).toEqual([{
expect(result.type === 'tool/result' && result.data.message.content[0].content).toEqual([{
type: 'text', text: 'The tool call was interrupted before the Harness recorded it as started. Retry it if it is still needed.',
}])
})
@@ -73,10 +89,27 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 2, step: 1, callId: CallId('call-1'), content: [{ type: 'text', text: 'ok' }], isError: false } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 2, step: 1,
message: createToolResultMessage({
callId: CallId('call-1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
} },
]
// The call is answered, so only the open step + turn need closing.
const closers = interruptedTurnClosers(events)
@@ -87,9 +120,19 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(2, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'step/end', seq: 3, time: 3, data: { turn: 2, step: 1 } },
]
@@ -104,48 +147,102 @@ describe('interruptedTurnClosers', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('old-call'), content: [], isError: false } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('old-call'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('old-call'),
content: [],
isError: false,
}),
} },
{ type: 'step/end', seq: 4, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 5, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
userTurnStart(2, 6),
{ type: 'step/start', seq: 7, time: 7, data: { turn: 2, step: 1 } },
{ type: 'assistant/message', seq: 8, time: 8, data: { turn: 2, step: 1, content: [
{ type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 8, time: 8, data: {
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('new-call'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('new-call')
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('new-call')
})
it('synthesizes a result for each of multiple unanswered calls, in log order', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
{ type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-a'), name: 'bash', arguments: '{}' },
{ type: 'tool-call', id: CallId('call-b'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
// call-a got answered before the crash; call-b did not.
{ type: 'tool/result', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-a'), content: [], isError: false } },
{ type: 'tool/result', seq: 3, time: 3, data: {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('call-a'),
content: [],
isError: false,
}),
} },
]
const closers = interruptedTurnClosers(events)
expect(closers.map(e => e.type)).toEqual(['tool/result', 'step/end', 'turn/end'])
const result = closers[0]!
expect(result.type === 'tool/result' && result.data.callId).toBe('call-b')
expect(result.type === 'tool/result' && result.data.message.source.callId).toBe('call-b')
})
it('synthesized tool/result carries surfaceOp and sourceEventSeqs when tool/call was logged', () => {
const events: SessionEvent[] = [
userTurnStart(1, 0),
{ type: 'step/start', seq: 1, time: 1, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 2, data: { turn: 1, step: 1, content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
], provenance: { provider: 'mock', model: 'mock' } } },
{ type: 'assistant/message', seq: 2, time: 2, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [
{ type: 'tool-call', id: CallId('call-1'), name: 'bash', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
} },
{ type: 'tool/call', seq: 3, time: 3, data: { turn: 1, step: 1, callId: CallId('call-1'), name: 'bash', arguments: '{}' } },
]
const closers = interruptedTurnClosers(events)
@@ -156,11 +253,11 @@ describe('interruptedTurnClosers', () => {
expect(result.type === 'tool/result' && result.data.error).toEqual({
name: 'ToolOutcomeUnknownError', code: TOOL_OUTCOME_UNKNOWN,
})
if (result.type !== 'tool/result' || result.data.content[0]?.type !== 'text') {
if (result.type !== 'tool/result' || result.data.message.content[0].content[0]?.type !== 'text') {
throw new Error('expected a text tool result')
}
expect(result.data.content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.content[0].text).toContain('first verify external state or ask the user')
expect(result.data.message.content[0].content[0].text).toContain('retry only if the operation is read-only or idempotent')
expect(result.data.message.content[0].content[0].text).toContain('first verify external state or ask the user')
})
it('handles tool/call without a matching assistant/message entry gracefully', () => {

View File

@@ -3,8 +3,8 @@
import { describe, expect, it } from 'vitest'
import { Session, SessionId, canonicalHeader, foldRequestHeader, headerEquals } from '@deepseek-ai/dsh-session'
import type { EpochHeader, SessionEvent } from '@deepseek-ai/dsh-session'
import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import type { ToolSchema } from '@deepseek-ai/dsh-llm'
import { ReasoningEffortId } from '@deepseek-ai/dsh-llm'
const CONFIG = { provider: 'mock', model: 'm' }
@@ -55,7 +55,9 @@ describe('foldRequestHeader', () => {
const session = new Session(SessionId('fold'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('request/header', { header: { config: CONFIG, system: 'first' }, reason: 'initial' })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('request/header', { header: { config: { provider: 'mock', model: 'other' }, tools: [] }, reason: 'change' })
expect(foldRequestHeader(session.events)).toEqual({ config: { provider: 'mock', model: 'other' } })
})

View File

@@ -1,6 +1,6 @@
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
import { Context } from 'cordis'
import { CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
import SessionStore, {
findLastMessageTurnEnd,
SESSION_FORMAT_VERSION,
@@ -22,16 +22,32 @@ describe('Session', () => {
it('derives message history from the event log', () => {
const session = new Session(SessionId('s1'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
session.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' },
session.append('assistant/message', {
turn: 1, step: 1,
content: [
{ type: 'text', text: 'let me check' },
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
],
message: createMessage({
role: 'assistant',
content: [
{ type: 'text', text: 'let me check' },
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const messages = session.deriveMessages()
@@ -61,10 +77,10 @@ describe('Session', () => {
turn: 1,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'before' }],
source: { kind: 'plugin', plugin: 'before' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBeUndefined()
@@ -72,19 +88,19 @@ describe('Session', () => {
turn: 2,
trigger: { kind: 'message', source: { kind: 'user' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'bounded prompt' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const messageEnd = session.append('turn/end', { turn: 2, reason: { kind: 'max-tokens' } })
session.append('turn/start', {
turn: 3,
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'after' } },
})
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'after' }],
source: { kind: 'plugin', plugin: 'after' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('turn/end', { turn: 3, reason: { kind: 'completed' } })
expect(findLastMessageTurnEnd(session.events)).toBe(messageEnd)
@@ -118,14 +134,16 @@ describe('Session', () => {
it('renders injected-context and steering messages as plain user content', () => {
const session = new Session(SessionId('s2'))
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed: a.ts' }],
source: { kind: 'plugin', plugin: 'watcher' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
session.append('steering/message', {
turn: 1,
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
message: createUserMessage({
content: [{ type: 'text', text: 'focus on tests' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
const [contextMessage, steeringMessage] = session.deriveMessages()
@@ -135,17 +153,15 @@ describe('Session', () => {
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
})
it('keeps context source durable in the event while hiding it from the projection', () => {
it('keeps the exact identified context message in durable history and projection', () => {
const session = new Session(SessionId('s2-raw'))
session.append('user/message', {
const message = createUserMessage({
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
source: { kind: 'plugin', plugin: 'workspace-context' },
}, { surfaceOp: 'append' })
})
session.append('user/message', message, { surfaceOp: 'append' })
expect(session.deriveMessages()).toEqual([{
role: 'user',
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
}])
expect(session.deriveMessages()).toEqual([message])
const event = session.events[0]
expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
})
@@ -153,8 +169,20 @@ describe('Session', () => {
it('replays identically from a seeded event log', () => {
const original = new Session(SessionId('s3'))
original.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
original.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] }, { surfaceOp: 'append' })
original.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
original.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'a' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const replayed = new Session(SessionId('s3-replay'), [...original.events])
@@ -176,7 +204,7 @@ describe('Session', () => {
surfaceOp: 'append',
} as unknown as SessionEvent
expect(() => new Session(SessionId('old-assistant'), [assistantMessage]))
.toThrow('seed assistant/message at index 0 lacks provider/model provenance')
.toThrow('seed assistant/message at index 0 lacks an identified message')
const malformedHeader = {
type: 'request/header', seq: 0, time: 1,
@@ -223,10 +251,16 @@ describe('Session', () => {
it('isolates the log from mutation through a derived message (append-only contract)', () => {
const session = new Session(SessionId('s4'))
session.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('tool/result', {
turn: 1, step: 1, callId: CallId('c1'),
content: [{ type: 'text', text: 'tool out' }], isError: false,
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'tool out' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const before = structuredClone(session.events)
@@ -282,7 +316,9 @@ describe('Session', () => {
// A widened SessionEventType bypasses the overload's conditional requirement,
// so the runtime guard must still reject the missing surface marker.
const widenedType = 'user/message' as SessionEventType
expect(() => session.append(widenedType, { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }))
expect(() => session.append(widenedType, createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
})))
.toThrow(/surface-eligible and requires a surfaceOp marker/)
// The rejected append never entered the log (only turn/start is present).
expect(session.events).toHaveLength(1)
@@ -318,7 +354,9 @@ describe('Session', () => {
// compile time; a raw seed must be rejected at runtime to match.
const markerlessSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}) },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
expect(() => new Session(SessionId('seed-no-marker'), markerlessSeed)).toThrow(/requires a surfaceOp marker/)
@@ -327,7 +365,9 @@ describe('Session', () => {
it('accepts a well-formed contiguous serializable seed', () => {
const goodSeed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
{ type: 'user/message' as const, seq: 1, time: 2, data: createUserMessage({
content: [{ type: 'text' as const, text: 'hi' }], source: { kind: 'user' as const },
}), surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-ok'), goodSeed)
@@ -380,7 +420,9 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: { op: 'replace', start: 1n, end: 2 },
}] as unknown as SessionEvent[]
@@ -398,7 +440,9 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: new ReplaceOp(),
}] as unknown as SessionEvent[]
@@ -445,13 +489,17 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp,
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
@@ -477,13 +525,17 @@ describe('Session', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
surfaceOp: 'append',
}, {
type: 'user/message',
seq: 1,
time: 2,
data: { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
surfaceOp: { op: 'replace', start: 0, end: 0 },
sourceEventSeqs: [0],
}] as unknown as SessionEvent[]
@@ -499,7 +551,11 @@ describe('Session', () => {
it('snapshots the seed: mutating the original after construction does not affect session.events', () => {
const seed = [
{ type: 'turn/start' as const, seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message' as const, source: { kind: 'user' as const } } } },
{ type: 'user/message' as const, seq: 1, time: 2, data: { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }, surfaceOp: 'append' as const },
{ type: 'user/message' as const, seq: 1, time: 2, data: {
id: MessageId('seed-input'),
role: 'user' as const,
content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const },
}, surfaceOp: 'append' as const },
{ type: 'turn/end' as const, seq: 2, time: 3, data: { turn: 1, reason: { kind: 'completed' as const } } },
] as SessionEvent[]
const session = new Session(SessionId('seed-snapshot'), seed)
@@ -516,7 +572,12 @@ describe('Session', () => {
it('snapshots append data: mutating the passed object after append does not affect session.events', () => {
const session = new Session(SessionId('append-snapshot'))
const data = { content: [{ type: 'text' as const, text: 'original' }], source: { kind: 'user' as const } }
const data = {
id: MessageId('append-input'),
role: 'user' as const,
content: [{ type: 'text' as const, text: 'original' }],
source: { kind: 'user' as const },
}
const event = session.append('user/message', data, { surfaceOp: 'append' })
// Mutate the caller's object after append returns. A shared reference would
// make session.events diverge from the value that passed validation.
@@ -552,7 +613,9 @@ describe('Session', () => {
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp: { op: 'replace', start: 1n, end: 2 } } as never,
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
@@ -568,7 +631,9 @@ describe('Session', () => {
expect(() => session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp: new ReplaceOp() },
)).toThrow(/non-JSON-serializable surface metadata/)
expect(session.events).toEqual([])
@@ -578,7 +643,9 @@ describe('Session', () => {
const session = new Session(SessionId('append-unstable-metadata'))
const source = session.append(
'user/message',
{ content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}),
{ surfaceOp: 'append' },
)
let reads = 0
@@ -592,7 +659,9 @@ describe('Session', () => {
const event = session.append(
'user/message',
{ content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } },
createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}),
{ surfaceOp, sourceEventSeqs: [0] } as never,
)
@@ -809,7 +878,9 @@ describe('SessionStore', () => {
// but cannot suppress the durable event feed.
expect(Reflect.set(session, 'onAppend', undefined)).toBe(true)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'x' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'x' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(events).toHaveLength(2)
expect(events[1]![0]).toBe(session)
expect(events[1]![1].type).toBe('user/message')
@@ -825,7 +896,9 @@ describe('SessionStore', () => {
expect(() => ctx.sessions.create(SessionId('fixed'))).toThrow('already exists')
a.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
a.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
a.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const forked = ctx.sessions.create(SessionId('fork'), { seed: [...a.events] })
expect(forked.deriveMessages()).toEqual(a.deriveMessages())
})
@@ -1043,7 +1116,9 @@ describe('SessionStore', () => {
await fiber.dispose()
expect(ctx.sessions.get(SessionId('scoped'))).toBeUndefined()
session.append('user/message', { content: [{ type: 'text', text: 'late' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'late' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(observed).toBe(0)
})
@@ -1070,7 +1145,9 @@ describe('SessionStore', () => {
const session = ctx.sessions.create(SessionId('fixed'))
expect(ctx.sessions.get(SessionId('fixed'))).toBe(session)
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('user/message', { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(events.at(-1)?.type).toBe('user/message')
})
@@ -1157,10 +1234,10 @@ describe('SessionStore', () => {
const session = ctx.sessions.create(SessionId('surface-dispatch-veto'))
session.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
session.append('step/start', { turn: 1, step: 1 })
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
const surface = session.surface
let reject = true
ctx.on('internal/dispatch', (_mode, name) => {
@@ -1171,10 +1248,16 @@ describe('SessionStore', () => {
})
expect(() => session.append('assistant/message', {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [{ type: 'text', text: 'replacement' }],
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'replacement' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, {
surfaceOp: { op: 'replace', start: 2, end: 2 },
sourceEventSeqs: [2],
@@ -1184,10 +1267,10 @@ describe('SessionStore', () => {
expect(surface.nodes).toEqual([2])
expect(surface.replaceGeneration).toBe(0)
session.append('user/message', {
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'next' }],
source: { kind: 'user' },
}, { surfaceOp: 'append' })
}), { surfaceOp: 'append' })
expect(surface.nodes).toEqual([2, 3])
expect(surface.replaceGeneration).toBe(0)
})
@@ -1393,7 +1476,9 @@ describe('todo/write event', () => {
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = new Session(SessionId('t3'))
session.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'q' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const before = session.deriveMessages().length
session.append('todo/write', { todos: [{ content: 'a task', status: 'pending' }] })
// The todo event must not add a message to the derived history…

View File

@@ -7,14 +7,33 @@ import {
isSurfaceEligibleType,
isSurfaceEvent,
} from '@deepseek-ai/dsh-session'
import { CallId } from '@deepseek-ai/dsh-llm'
import {
createMessage,
createToolResultMessage,
createUserMessage,
freezeMessage,
CallId,
MessageId,
} from '@deepseek-ai/dsh-llm'
/** Build a minimal session with turn boundaries and a single user message. */
function surfaceSession(): Session {
const s = new Session(SessionId('ss'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
return s
}
@@ -24,7 +43,9 @@ function provenanceEvent(seq: number, sourceEventSeqs: unknown): SessionEvent {
type: 'user/message',
seq,
time: seq,
data: { content: [], source: { kind: 'user' } },
data: createUserMessage({
content: [], source: { kind: 'user' },
}),
surfaceOp: 'append',
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
} as unknown as SessionEvent
@@ -43,9 +64,11 @@ function toolResultEvent(
data: {
turn: 1,
step: 1,
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
message: createToolResultMessage({
callId: CallId(callId),
content: [{ type: 'text', text: `result ${seq}` }],
isError: false,
}),
},
surfaceOp,
...sourceEventSeqs === undefined ? {} : { sourceEventSeqs },
@@ -82,10 +105,16 @@ describe('foldSurface provenance', () => {
seq: 0,
time: 0,
data: {
provenance: { provider: 'mock', model: 'mock' },
turn: 1,
step: 1,
content: [],
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
surfaceOp: 'append',
sourceEventSeqs: [],
@@ -145,7 +174,15 @@ describe('foldSurface tool-result rewrites', () => {
it('compares array-valued rest fields structurally (meta arrays: equal accepted, drifted rejected)', () => {
const withMeta = (seq: number, meta: unknown, surfaceOp: SurfaceEvent['surfaceOp'] = 'append', sourceEventSeqs?: number[]): SessionEvent => {
const event = toolResultEvent(seq, 'c-meta', surfaceOp, sourceEventSeqs)
return { ...event, data: { ...(event.data as object), meta } } as SessionEvent
const data = event.data as Extract<SessionEvent, { type: 'tool/result' }>['data']
return {
...event,
data: {
...data,
message: freezeMessage({ ...data.message, id: MessageId('meta-message') }),
meta,
},
} as SessionEvent
}
// Structurally equal arrays (fresh references) pass the rest-field equality.
expect(() => foldSurface([
@@ -178,10 +215,34 @@ describe('foldSurface tool-result rewrites', () => {
describe('SurfaceManager', () => {
it('shares ordered entries and nested replacement ranges with foldSurface', () => {
const s = new Session(SessionId('shared-fold'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 2, content: [{ type: 'text', text: 'summary 2' }] }, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('assistant/message', {
turn: 1, step: 2,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary 2' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 2, end: 1 }, sourceEventSeqs: [2, 1] })
const folded = foldSurface(s.events)
expect(folded.nodes).toEqual(s.surface.nodes)
@@ -198,8 +259,20 @@ describe('SurfaceManager', () => {
it('does not retain fold-only replacement history in incremental state', () => {
const s = new Session(SessionId('incremental-state'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'b' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'b' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
expect(s.surface.nodes).toEqual([1])
const manager = s.surface as unknown as { _state: object }
@@ -222,7 +295,9 @@ describe('SurfaceManager', () => {
it('leaves incremental state unchanged when candidate validation fails', () => {
const s = new Session(SessionId('atomic-validation'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const surface = s.surface
const nodes = surface.nodes
@@ -231,7 +306,17 @@ describe('SurfaceManager', () => {
expect(() => s.append(
'assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'invalid' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'invalid' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 0 } },
)).toThrow(/missing 0/)
@@ -241,7 +326,9 @@ describe('SurfaceManager', () => {
expect(surface.replaceGeneration).toBe(0)
expect(surface.nodes).toEqual(foldSurface(s.events).nodes)
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(surface.nodes).toBe(nodes)
expect(surface.nodes).toEqual([0, 1])
expect(surface.replaceGeneration).toBe(0)
@@ -253,7 +340,9 @@ describe('SurfaceManager', () => {
type: 'user/message',
seq: 0,
time: 1,
data: { content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hidden' }], source: { kind: 'user' },
}),
}
expect(() => foldSurface([malformed]))
@@ -294,14 +383,28 @@ describe('SurfaceManager', () => {
it('picks up new events incrementally (delta processing)', () => {
const s = surfaceSession()
expect(s.surface.nodes.length).toBe(2)
s.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
s.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
expect(s.surface.nodes.length).toBe(3)
expect(s.surface.nodes[2]!).toBe(4) // seq 4: after turn/end at seq 3
})
it('replays identically from a seeded log with surface markers', () => {
const original = surfaceSession()
original.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false }, { surfaceOp: 'append' })
original.append('tool/result', {
turn: 1, step: 1,
message: createToolResultMessage({
callId: CallId('c1'),
content: [{ type: 'text', text: 'ok' }],
isError: false,
}),
}, { surfaceOp: 'append' })
const replayed = new Session(SessionId('replay'), [...original.events])
expect(replayed.surface.nodes).toEqual([1, 2, 4])
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
@@ -310,7 +413,17 @@ describe('SurfaceManager', () => {
it('rebuild with replace operation splices out shadowed nodes', () => {
const s = surfaceSession()
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 2, step: 1, content: [{ type: 'text', text: 'summary' }] },
{
turn: 2, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 2 }, sourceEventSeqs: [1, 2] },
)
expect(s.surface.nodes).toEqual([4])
@@ -318,12 +431,28 @@ describe('SurfaceManager', () => {
it('replace with both ends at real nodes splices only the range', () => {
const s = new Session(SessionId('range'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace seq 0 through 1 inclusive: shadow a and b, keep c.
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'summary' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'summary' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 1 }, sourceEventSeqs: [0, 1] },
) // seq 3
expect(s.surface.nodes).toEqual([3, 2])
@@ -331,11 +460,25 @@ describe('SurfaceManager', () => {
it('single-node replacement (start === end)', () => {
const s = new Session(SessionId('single'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// Replace only seq 1 (single node).
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 2
expect(s.surface.nodes).toEqual([0, 2])
@@ -343,38 +486,88 @@ describe('SurfaceManager', () => {
it('throws when replace start is not found', () => {
const s = new Session(SessionId('bad-start'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 5, end: 0 }, sourceEventSeqs: [0] },
)).toThrow(/surface replace: start seq 5 not found/)
})
it('throws when replace end is not found', () => {
const s = new Session(SessionId('bad-end'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 0, end: 99 }, sourceEventSeqs: [0] },
)).toThrow(/surface replace: end seq 99 not found/)
})
it('throws when start is after end', () => {
const s = new Session(SessionId('reversed'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
// start=1, end=0 would be reversed order.
expect(() => s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'y' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'y' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 0 }, sourceEventSeqs: [1, 0] },
)).toThrow(/start seq 1.*after end seq 0/)
})
it('sourceEventSeqs is snapshot so caller mutation does not affect logged event', () => {
const s = new Session(SessionId('immutable'))
s.append('user/message', { content: [{ type: 'text', text: 'source' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'source' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const sources = [0]
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] }, { surfaceOp: 'append', sourceEventSeqs: sources })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'h' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append', sourceEventSeqs: sources })
// Mutate caller's array after append.
sources.push(1)
sources[0] = 99
@@ -384,12 +577,28 @@ describe('SurfaceManager', () => {
it('replace starting at non-head position preserves surrounding order', () => {
const s = new Session(SessionId('mid-replace'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 0
s.append('user/message', { content: [{ type: 'text', text: 'b' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 1
s.append('user/message', { content: [{ type: 'text', text: 'c' }], source: { kind: 'user' } }, { surfaceOp: 'append' }) // seq 2
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 0
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'b' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 1
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'c' }], source: { kind: 'user' },
}), { surfaceOp: 'append' }) // seq 2
// Replace the middle node (seq 1) only, keeping seq 0 and seq 2.
s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'x' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'x' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: { op: 'replace', start: 1, end: 1 }, sourceEventSeqs: [1] },
) // seq 3
expect(s.surface.nodes).toEqual([0, 3, 2])
@@ -397,9 +606,21 @@ describe('SurfaceManager', () => {
it('surfaceOp replace object is snapshot so caller mutation is isolated', () => {
const s = new Session(SessionId('immutable-op'))
s.append('user/message', { content: [{ type: 'text', text: 'a' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'a' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
const op = { op: 'replace' as const, start: 0, end: 0 }
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 's' }] }, { surfaceOp: op, sourceEventSeqs: [0] })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 's' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: op, sourceEventSeqs: [0] })
// Mutate caller's object after append.
op.start = 99
const logged = s.events[1]! as SurfaceEvent
@@ -423,8 +644,20 @@ describe('deriveMessages with surface', () => {
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'h' } })
s.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 1, text: 'i' } })
s.append('user/message', { content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'hi' }] }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'hello' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'hi' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
s.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Chunks and boundaries are NOT in the surface, so only 2 messages.
expect(s.deriveMessages()).toHaveLength(2)
@@ -432,8 +665,20 @@ describe('deriveMessages with surface', () => {
it('deriveMessages via surface respects replace (shadowed nodes are excluded)', () => {
const s = new Session(SessionId('compacted'))
s.append('user/message', { content: [{ type: 'text', text: 'original' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'compacted' }] }, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'original' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'compacted' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: { op: 'replace', start: 0, end: 0 }, sourceEventSeqs: [0] })
// Only the compaction node is visible.
const messages = s.deriveMessages()
expect(messages).toHaveLength(1)
@@ -442,8 +687,16 @@ describe('deriveMessages with surface', () => {
it('injected-context and steering/message appear on surface', () => {
const s = new Session(SessionId('ctx'))
s.append('user/message', { content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' } }, { surfaceOp: 'append' })
s.append('steering/message', { turn: 1, content: [{ type: 'text', text: 'focus' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'file changed' }], source: { kind: 'plugin', plugin: 'watcher' },
}), { surfaceOp: 'append' })
s.append('steering/message', {
turn: 1,
message: createUserMessage({
content: [{ type: 'text', text: 'focus' }],
source: { kind: 'user' },
}),
}, { surfaceOp: 'append' })
const messages = s.deriveMessages()
expect(messages).toHaveLength(2)
expect(messages[0]!.content).toEqual([{ type: 'text', text: 'file changed' }])
@@ -457,7 +710,17 @@ describe('Session.append surface opts', () => {
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('step/start', { turn: 1, step: 1 })
const event = s.append('assistant/message',
{ provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [{ type: 'text', text: 'h' }] },
{
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: 'h' }],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
},
{ surfaceOp: 'append', sourceEventSeqs: [0, 1] },
)
expect(event.sourceEventSeqs).toEqual([0, 1])
@@ -474,7 +737,17 @@ describe('Session.append surface opts', () => {
const seed: SessionEvent[] = [
{ type: 'turn/start', seq: 0, time: 1, data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } } },
{ type: 'step/start', seq: 1, time: 2, data: { turn: 1, step: 1 } },
{ type: 'assistant/message', seq: 2, time: 3, data: { turn: 1, step: 1, content: [], provenance: { provider: 'mock', model: 'mock' } }, surfaceOp: 'append' },
{ type: 'assistant/message', seq: 2, time: 3, data: {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, surfaceOp: 'append' },
{ type: 'step/end', seq: 3, time: 4, data: { turn: 1, step: 1 } },
{ type: 'turn/end', seq: 4, time: 5, data: { turn: 1, reason: { kind: 'completed' } } },
]
@@ -492,7 +765,17 @@ describe('Session.append surface opts', () => {
it('surfaceOp primitives are not cloned (they are immutable)', () => {
const s = new Session(SessionId('prim'))
const event = s.append('assistant/message', { provenance: { provider: 'mock', model: 'mock' }, turn: 1, step: 1, content: [] }, { surfaceOp: 'append' })
const event = s.append('assistant/message', {
turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [],
source: {
kind: 'model',
...{ provider: 'mock', model: 'mock' },
},
}),
}, { surfaceOp: 'append' })
// The string 'append' is a primitive — identity-preserving is fine.
expect(event.surfaceOp).toBe('append')
})
@@ -503,7 +786,9 @@ describe('Session.append surface opts', () => {
// SurfaceEvent — it would otherwise be silently dropped from the surface.
const noMarker: SessionEvent = {
type: 'user/message', seq: 0, time: 1,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isSurfaceEvent(noMarker)).toBe(false)
// A non-surface type is rejected too (the type gate).
@@ -545,7 +830,9 @@ describe('surface type guards', () => {
type: 'user/message',
seq: 0,
time: 0,
data: { content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } },
data: createUserMessage({
content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' },
}),
}
expect(isSurfaceEligibleType(markerless.type)).toBe(true)
expect(isSurfaceEvent(markerless)).toBe(false)
@@ -556,16 +843,20 @@ describe('SurfaceManager.replaceGeneration', () => {
it('folds the pending log delta on access and counts replaces', () => {
const s = new Session(SessionId('gen'))
s.append('turn/start', { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } })
s.append('user/message', { content: [{ type: 'text', text: 'one' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', { content: [{ type: 'text', text: 'two' }], source: { kind: 'user' } }, { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'one' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'two' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
// Read the generation FIRST — before nodes — so the getter itself folds
// the pending delta rather than piggybacking on a nodes read.
expect(s.surface.replaceGeneration).toBe(0)
const nodes = s.surface.nodes
s.append('user/message', {
s.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'summary' }], source: { kind: 'plugin', plugin: 'compact' },
}, { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
}), { surfaceOp: { op: 'replace', start: nodes[0]!, end: nodes[1]! }, sourceEventSeqs: [nodes[0]!, nodes[1]!] })
expect(s.surface.replaceGeneration).toBe(1)
})
})