Merge origin/master into web-permission-sandbox
39 conflicts resolved per the reattachment plan (missions worktree-projbiz 0728-1859): baseline wins for deleted packages (host/runtime, old ui/acp, ui-sidebar Rows/tree) and retired specs; unions for wire-layer exports and client summary fields; the approval takeover, waitingApprovals tracking, and PendingApproval domain face carry over onto the master structure. The two new host specs follow the runtime->apiproxy rename. Dead PR-side wiring (ConversationInjected permissions/setPermission spread, InputBar controls prop, boot.ts sandbox composition) resolves to master and its replacement lands in follow-up commits.
This commit is contained in:
@@ -1,14 +1,14 @@
|
||||
import { describe, expect, expectTypeOf, it, vi } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, {
|
||||
displayPromptContent,
|
||||
findLastMessageTurnEnd,
|
||||
hasOpenTurn,
|
||||
SESSION_FORMAT_VERSION,
|
||||
Session,
|
||||
SessionEvent,
|
||||
SessionId,
|
||||
snapshotSessionEvent,
|
||||
} from '@deepseek-ai/dsh-session'
|
||||
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
|
||||
|
||||
@@ -24,16 +24,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()
|
||||
@@ -63,10 +79,10 @@ describe('Session', () => {
|
||||
turn: 1,
|
||||
trigger: { kind: 'injection', source: { kind: 'plugin', plugin: 'before' } },
|
||||
})
|
||||
session.append('context/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()
|
||||
|
||||
@@ -74,19 +90,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('context/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)
|
||||
@@ -129,16 +145,18 @@ describe('Session', () => {
|
||||
.toThrow('seed turn/end at index 1 uses unsupported reason-bearing aborted format')
|
||||
})
|
||||
|
||||
it('renders context and steering messages as plain user content', () => {
|
||||
it('renders injected-context and steering messages as plain user content', () => {
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/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()
|
||||
@@ -148,61 +166,36 @@ describe('Session', () => {
|
||||
expect(steeringMessage!.content).toEqual([{ type: 'text', text: 'focus on tests' }])
|
||||
})
|
||||
|
||||
it('derives baked prompt context while exposing only the direct prompt for display', () => {
|
||||
const session = new Session(SessionId('prompt-envelope'))
|
||||
const event = session.append('user/message', {
|
||||
content: [
|
||||
{ type: 'text', text: 'background' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'question' },
|
||||
],
|
||||
source: { kind: 'user' },
|
||||
envelope: {
|
||||
displayContent: [{ type: 'text', text: 'question' }],
|
||||
prefixContexts: [{ source: { kind: 'plugin', plugin: 'reference' }, meta: { kind: 'card' } }],
|
||||
},
|
||||
}, { surfaceOp: 'append' })
|
||||
|
||||
expect(session.deriveMessages()).toEqual([{
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'text', text: 'background' },
|
||||
{ type: 'text', text: '\n\n## My request:\n' },
|
||||
{ type: 'text', text: 'question' },
|
||||
],
|
||||
}])
|
||||
expect(displayPromptContent(event.data)).toEqual([{ type: 'text', text: 'question' }])
|
||||
expect(Object.isFrozen(event.data.envelope?.displayContent)).toBe(true)
|
||||
expect(new Session(SessionId('prompt-envelope-replay'), session.events).deriveMessages())
|
||||
.toEqual(session.deriveMessages())
|
||||
})
|
||||
|
||||
it('keeps context meta 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'))
|
||||
const meta = {
|
||||
kind: 'workspace-instructions',
|
||||
version: 1,
|
||||
changes: [{ action: 'set', scope: 'pkg', path: 'pkg/AGENTS.md', digest: 'abc123' }],
|
||||
}
|
||||
session.append('context/message', {
|
||||
const message = createUserMessage({
|
||||
content: [{ type: 'text', text: '<system-reminder>Additional instructions from: pkg/AGENTS.md</system-reminder>' }],
|
||||
source: { kind: 'plugin', plugin: 'workspace-context' },
|
||||
meta,
|
||||
}, { 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 === 'context/message' && event.data.meta).toEqual(meta)
|
||||
expect(event?.type === 'user/message' && event.data.source).toEqual({ kind: 'plugin', plugin: 'workspace-context' })
|
||||
})
|
||||
|
||||
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])
|
||||
@@ -224,7 +217,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,
|
||||
@@ -240,12 +233,197 @@ describe('Session', () => {
|
||||
.toEqual([unrelatedPrimitiveData])
|
||||
})
|
||||
|
||||
it('rejects event-specific malformed message shapes on seed/load', () => {
|
||||
const user = {
|
||||
id: 'user',
|
||||
role: 'user',
|
||||
content: [{ type: 'text', text: 'content' }],
|
||||
source: { kind: 'user' },
|
||||
}
|
||||
const assistant = {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [{ type: 'text', text: 'content' }],
|
||||
source: { kind: 'model', provider: 'mock', model: 'mock' },
|
||||
}
|
||||
const tool = {
|
||||
id: 'tool',
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'tool-result',
|
||||
toolCallId: 'call',
|
||||
content: [{ type: 'text', text: 'result' }],
|
||||
}],
|
||||
source: { kind: 'tool', callId: 'call' },
|
||||
}
|
||||
const invalid = [
|
||||
{
|
||||
name: 'message record',
|
||||
event: {
|
||||
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: null,
|
||||
},
|
||||
message: 'lacks an identified message',
|
||||
},
|
||||
{
|
||||
name: 'user role',
|
||||
event: {
|
||||
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: { ...user, role: 'assistant' },
|
||||
},
|
||||
message: 'message must have role "user"',
|
||||
},
|
||||
{
|
||||
name: 'source',
|
||||
event: {
|
||||
type: 'user/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: { ...user, source: null },
|
||||
},
|
||||
message: 'message has invalid source',
|
||||
},
|
||||
{
|
||||
name: 'assistant source',
|
||||
event: {
|
||||
type: 'assistant/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { ...assistant, source: { kind: 'user' } },
|
||||
},
|
||||
},
|
||||
message: 'message must have model source',
|
||||
},
|
||||
{
|
||||
name: 'content block',
|
||||
event: {
|
||||
type: 'steering/message', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
message: { ...user, content: 'not-an-array' },
|
||||
},
|
||||
},
|
||||
message: 'message has invalid content',
|
||||
},
|
||||
{
|
||||
name: 'tool source',
|
||||
event: {
|
||||
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { ...tool, source: { kind: 'user' } },
|
||||
},
|
||||
},
|
||||
message: 'message must have tool source',
|
||||
},
|
||||
{
|
||||
name: 'tool tuple',
|
||||
event: {
|
||||
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: { ...tool, content: [{ type: 'text', text: 'not a result' }] },
|
||||
},
|
||||
},
|
||||
message: 'message must contain one tool-result block',
|
||||
},
|
||||
{
|
||||
name: 'tool correlation',
|
||||
event: {
|
||||
type: 'tool/result', seq: 0, time: 1, surfaceOp: 'append',
|
||||
data: {
|
||||
turn: 1,
|
||||
step: 1,
|
||||
message: {
|
||||
...tool,
|
||||
source: { kind: 'tool', callId: 'other-call' },
|
||||
},
|
||||
},
|
||||
},
|
||||
message: 'message has mismatched tool call ids',
|
||||
},
|
||||
] as const
|
||||
|
||||
for (const { name, event, message } of invalid) {
|
||||
expect(
|
||||
() => new Session(SessionId(`invalid-${name}`), [event as unknown as SessionEvent]),
|
||||
name,
|
||||
).toThrow(message)
|
||||
}
|
||||
})
|
||||
|
||||
it('snapshots message events without validating plugin-owned block details', () => {
|
||||
const boundary = snapshotSessionEvent({
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
expect(boundary).toEqual({
|
||||
type: 'turn/start',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: { turn: 1, trigger: { kind: 'message', source: { kind: 'user' } } },
|
||||
})
|
||||
|
||||
const extended = snapshotSessionEvent({
|
||||
type: 'user/message',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
surfaceOp: 'append',
|
||||
data: {
|
||||
id: 'extended-message',
|
||||
role: 'user',
|
||||
content: [{ type: 'plugin-block', value: 1 }],
|
||||
source: { kind: 'plugin-source', value: 1 },
|
||||
},
|
||||
} as unknown as SessionEvent)
|
||||
expect(extended.type === 'user/message' && extended.data.content)
|
||||
.toEqual([{ type: 'plugin-block', value: 1 }])
|
||||
})
|
||||
|
||||
it('round-trips a non-empty reasoning effort and rejects invalid durable values', () => {
|
||||
const valid = {
|
||||
type: 'request/header',
|
||||
seq: 0,
|
||||
time: 1,
|
||||
data: {
|
||||
header: {
|
||||
config: {
|
||||
provider: 'mock',
|
||||
model: 'model',
|
||||
reasoningEffort: ReasoningEffortId('adapter-owned'),
|
||||
},
|
||||
},
|
||||
reason: 'initial',
|
||||
},
|
||||
} as const
|
||||
expect(new Session(SessionId('reasoning-effort'), [valid]).events[0])
|
||||
.toEqual(valid)
|
||||
|
||||
for (const reasoningEffort of ['', 1]) {
|
||||
const invalid = structuredClone(valid) as unknown as SessionEvent
|
||||
if (invalid.type !== 'request/header') throw new Error('test fixture must be a request header')
|
||||
const config = invalid.data.header.config as unknown as Record<string, unknown>
|
||||
config.reasoningEffort = reasoningEffort
|
||||
expect(() => new Session(SessionId('invalid-reasoning-effort'), [invalid]))
|
||||
.toThrow('seed request/header at index 0 has an invalid reasoningEffort')
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
|
||||
@@ -301,7 +479,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)
|
||||
@@ -337,7 +517,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/)
|
||||
@@ -346,7 +528,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)
|
||||
@@ -399,7 +583,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[]
|
||||
|
||||
@@ -417,7 +603,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[]
|
||||
|
||||
@@ -464,13 +652,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[]
|
||||
@@ -496,13 +688,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[]
|
||||
@@ -518,7 +714,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)
|
||||
@@ -535,7 +735,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.
|
||||
@@ -571,7 +776,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([])
|
||||
@@ -587,7 +794,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([])
|
||||
@@ -597,7 +806,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
|
||||
@@ -611,7 +822,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,
|
||||
)
|
||||
|
||||
@@ -769,7 +982,7 @@ describe('Session', () => {
|
||||
{ header: 1, error: /not a plain JSON record/ },
|
||||
{ header: null, error: /not a plain JSON record/ },
|
||||
{ header: { ...base, version: 1 }, error: /header version/ },
|
||||
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a finite number/ },
|
||||
{ header: { ...base, createdAt: '123' }, error: /createdAt must be a non-negative safe integer/ },
|
||||
{ header: { ...base, cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ header: { ...base, cwd: 'relative' }, error: /header cwd must be an absolute path/ },
|
||||
{ header: { ...base, parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
@@ -828,7 +1041,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')
|
||||
@@ -844,7 +1059,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())
|
||||
})
|
||||
@@ -974,7 +1191,7 @@ describe('SessionStore', () => {
|
||||
await ctx.plugin(SessionStore)
|
||||
const session = ctx.sessions.create(SessionId('plain'))
|
||||
expect(session.header).toMatchObject({ version: SESSION_FORMAT_VERSION, id: 'plain' })
|
||||
expect(typeof session.header.createdAt).toBe('number')
|
||||
expect(Number.isSafeInteger(session.header.createdAt)).toBe(true)
|
||||
expect(session.header.cwd).toBeUndefined()
|
||||
expect(session.header.parentSession).toBeUndefined()
|
||||
})
|
||||
@@ -1013,7 +1230,10 @@ describe('SessionStore', () => {
|
||||
{ meta: { parentSession: 1n }, error: /header is not losslessly JSON-serializable/ },
|
||||
{ meta: { cwd: 1 }, error: /header cwd must be a string/ },
|
||||
{ meta: { parentSession: 1 }, error: /header parentSession must be a string/ },
|
||||
{ meta: { createdAt: '123' }, error: /header createdAt must be a finite number/ },
|
||||
{ meta: { createdAt: '123' }, error: /header createdAt must be a non-negative safe integer/ },
|
||||
{ meta: { createdAt: 1.5 }, error: /header createdAt must be a non-negative safe integer/ },
|
||||
{ meta: { createdAt: -1 }, error: /header createdAt must be a non-negative safe integer/ },
|
||||
{ meta: { createdAt: Number.MAX_SAFE_INTEGER + 1 }, error: /header createdAt must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: '1' }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: 0.5 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
{ meta: { seedLength: -1 }, error: /seedLength must be a non-negative safe integer/ },
|
||||
@@ -1059,7 +1279,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)
|
||||
})
|
||||
|
||||
@@ -1086,7 +1308,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')
|
||||
})
|
||||
|
||||
@@ -1173,10 +1397,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) => {
|
||||
@@ -1187,10 +1411,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],
|
||||
@@ -1200,10 +1430,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)
|
||||
})
|
||||
@@ -1409,7 +1639,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…
|
||||
|
||||
Reference in New Issue
Block a user