Add branded ID types: CallId, SessionId, AgentId
Nominal string types via a unique-symbol brand (zero runtime cost): an AgentId can no longer be passed where a CallId is expected. Each core package brands the IDs it owns — CallId in dsh-llm (tool-call correlation across blocks, chunks, session events, and execution results), SessionId in dsh-session, AgentId in dsh-agent. Construction goes through same-named factory functions; public string-in APIs (sessions.create, agentLoop.create) keep accepting plain strings and brand internally. Policy note in the brand module: brand IDs that cross package boundaries, not every string.
This commit is contained in:
@@ -8,6 +8,7 @@
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { ContentBlock, Message, MessageSource } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from './types.ts'
|
||||
import type { SessionEvent, SessionEventMap, SessionEventType } from './types.ts'
|
||||
|
||||
export * from './types.ts'
|
||||
@@ -60,7 +61,7 @@ export class Session {
|
||||
/** Set by the store so appends are observable; undefined when detached. */
|
||||
onAppend: ((event: SessionEvent) => void) | undefined
|
||||
|
||||
constructor(public readonly id: string, seed?: SessionEvent[]) {
|
||||
constructor(public readonly id: SessionId, seed?: SessionEvent[]) {
|
||||
if (seed) this.log = [...seed]
|
||||
}
|
||||
|
||||
@@ -155,16 +156,16 @@ export class SessionStore extends Service {
|
||||
* session from the store.
|
||||
*/
|
||||
create(id?: string, seed?: SessionEvent[]): Session {
|
||||
id ??= `session-${++this.counter}`
|
||||
if (this.store.has(id)) throw new Error(`session "${id}" already exists`)
|
||||
const session = new Session(id, seed)
|
||||
const sessionId = SessionId(id ?? `session-${++this.counter}`)
|
||||
if (this.store.has(sessionId)) throw new Error(`session "${sessionId}" already exists`)
|
||||
const session = new Session(sessionId, seed)
|
||||
this.ctx.effect(() => {
|
||||
session.onAppend = (event) => { this.ctx.emit('session/event', session, event) }
|
||||
this.store.set(id, session)
|
||||
this.store.set(sessionId, session)
|
||||
this.ctx.emit('session/created', session)
|
||||
return () => {
|
||||
session.onAppend = undefined
|
||||
this.store.delete(id)
|
||||
this.store.delete(sessionId)
|
||||
}
|
||||
}, 'sessions.create()')
|
||||
return session
|
||||
|
||||
@@ -1,4 +1,12 @@
|
||||
import type { ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
import type { Branded, CallId, ContentBlock, MessageSource, StreamChunk, TokenUsage } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
/** Identifies one session in the store (and its persistence artifacts). */
|
||||
export type SessionId = Branded<'SessionId'>
|
||||
|
||||
/** Brand a string as a {@link SessionId}. */
|
||||
export function SessionId(id: string): SessionId {
|
||||
return id as SessionId
|
||||
}
|
||||
|
||||
/**
|
||||
* What started a turn.
|
||||
@@ -53,8 +61,8 @@ export interface SessionEventMap {
|
||||
'assistant/chunk': { turn: number; step: number; chunk: StreamChunk }
|
||||
/** Assembled assistant message for one step (derived history uses this). */
|
||||
'assistant/message': { turn: number; step: number; content: ContentBlock[] }
|
||||
'tool/call': { turn: number; step: number; callId: string; name: string; arguments: string }
|
||||
'tool/result': { turn: number; step: number; callId: string; content: ContentBlock[]; isError: boolean }
|
||||
'tool/call': { turn: number; step: number; callId: CallId; name: string; arguments: string }
|
||||
'tool/result': { turn: number; step: number; callId: CallId; content: ContentBlock[]; isError: boolean }
|
||||
/** Steering content injected between steps of a running turn. */
|
||||
'steering/message': { turn: number; content: ContentBlock[]; source: MessageSource }
|
||||
'usage': { turn: number; step: number; usage: TokenUsage }
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import SessionStore, { Session, SessionEvent } from '@deepseek-ai/dsh-session'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import SessionStore, { Session, SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
|
||||
|
||||
describe('Session', () => {
|
||||
it('derives message history from the event log', () => {
|
||||
const session = new Session('s1')
|
||||
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' } })
|
||||
session.append('assistant/chunk', { turn: 1, step: 1, chunk: { type: 'text-delta', index: 0, text: 'hi' } })
|
||||
@@ -12,21 +13,21 @@ describe('Session', () => {
|
||||
turn: 1, step: 1,
|
||||
content: [
|
||||
{ type: 'text', text: 'let me check' },
|
||||
{ type: 'tool-call', id: 'c1', name: 'echo', arguments: '{}' },
|
||||
{ type: 'tool-call', id: CallId('c1'), name: 'echo', arguments: '{}' },
|
||||
],
|
||||
})
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: 'c1', content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
session.append('tool/result', { turn: 1, step: 1, callId: CallId('c1'), content: [{ type: 'text', text: 'ok' }], isError: false })
|
||||
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
|
||||
|
||||
const messages = session.deriveMessages()
|
||||
expect(messages.map(m => m.role)).toEqual(['user', 'assistant', 'user'])
|
||||
// raw chunks must NOT appear in derived history
|
||||
expect(messages[1]!.content).toHaveLength(2)
|
||||
expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: 'c1' })
|
||||
expect(messages[2]!.content[0]).toMatchObject({ type: 'tool-result', toolCallId: CallId('c1') })
|
||||
})
|
||||
|
||||
it('renders context and steering messages as tagged synthetic user content', () => {
|
||||
const session = new Session('s2')
|
||||
const session = new Session(SessionId('s2'))
|
||||
session.append('context/message', {
|
||||
content: [{ type: 'text', text: 'file changed: a.ts' }],
|
||||
source: { kind: 'plugin', plugin: 'watcher' },
|
||||
@@ -45,11 +46,11 @@ describe('Session', () => {
|
||||
})
|
||||
|
||||
it('replays identically from a seeded event log', () => {
|
||||
const original = new Session('s3')
|
||||
const original = new Session(SessionId('s3'))
|
||||
original.append('user/message', { content: [{ type: 'text', text: 'q' }], source: { kind: 'user' } })
|
||||
original.append('assistant/message', { turn: 1, step: 1, content: [{ type: 'text', text: 'a' }] })
|
||||
|
||||
const replayed = new Session('s3-replay', [...original.events])
|
||||
const replayed = new Session(SessionId('s3-replay'), [...original.events])
|
||||
expect(replayed.deriveMessages()).toEqual(original.deriveMessages())
|
||||
expect(replayed.seq).toBe(original.seq)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user