Merge origin/master into xtr/session-format-migration

This commit is contained in:
_Kerman
2026-08-24 10:31:05 +08:00
1840 changed files with 68765 additions and 34686 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ import type { Context, Events } from '@deepseek-ai/cordis'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt'
import type { Agent } from './runtime-types.ts'
import type { Agent } from './types.ts'
/** Extract the parameter tuple from an event handler type (its `this` is not part of the tuple). */
type Params<F> = F extends (...args: infer P) => unknown ? P : never
+3 -12
View File
@@ -12,8 +12,8 @@ import { isPromise } from 'node:util/types'
import { scopeTarget } from '@deepseek-ai/dsh-scope'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session'
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
import type { Agent, AgentOptions } from './runtime-types.ts'
import type { Agent } from './types.ts'
import type { AgentOptions } from './runtime-types.ts'
export * from './runtime-types.ts'
export * from './types.ts'
@@ -23,16 +23,6 @@ export * from './model-selection.ts'
export { agentCarrier, agentEvents, assembleContextFor, emitAgentEvent } from './dispatch.ts'
export type { AgentEventDispatch, AgentSubjectEvent } from './dispatch.ts'
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertLookupMap {
agent: TypertLookup<Agent, SessionId>
}
interface TypertContextMap {
agent: TypertContext<SessionId>
}
}
declare module '@deepseek-ai/cordis' {
interface Context {
agents: AgentRegistry
@@ -276,6 +266,7 @@ export class AgentRegistry extends Service {
typeCtx.typert.contexts.registerHost('agent', {
wire: 'agentId',
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
identity: candidate => candidate.agent?.id,
resolve: sessionId => this.get(sessionId)?.ctx,
})
})
+30 -30
View File
@@ -8,10 +8,11 @@
import type { Context } from '@deepseek-ai/cordis'
import type { Scoped } from '@deepseek-ai/dsh-scope'
import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm'
import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session'
import type { AgentCancelCause, Session, UserMessage } from '@deepseek-ai/dsh-session'
export type { AgentCancelCause } from '@deepseek-ai/dsh-session'
import type { Inbox } from './inbox.ts'
import type { InboxTarget } from './types.ts'
import type { Agent } from './types.ts'
export type { Agent } from './types.ts'
import type {} from '@deepseek-ai/dsh-system-prompt'
declare module '@deepseek-ai/dsh-system-prompt' {
interface AssembleContext {
@@ -60,39 +61,37 @@ export type RequestErrorAction = { kind: 'retry' } | undefined
/** Why a session lifecycle began; seeded creates are `startup`, while persisted loads are `resume`. */
export type SessionStartSource = 'startup' | 'resume' | 'clear' | 'compact'
/** Public live-agent handle. */
export interface Agent {
/** The single identity shared with {@link session}. */
readonly id: SessionId
/** The provider route and model this agent's requests use. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
declare module './types.ts' {
interface Agent {
/** The provider route and model this agent's requests use. */
readonly options: AgentOptions
/** The live session this agent drives; its log is the durable source of truth. */
readonly session: Session
/** The agent-owned projection of durable pending work. */
readonly inbox: Inbox
/** The current lifecycle state, mirrored on every `agent/status` transition. */
readonly status: AgentStatus
/** Agent-scoped context; its contributions are agent-local, unwind on disposal, and reject registration afterward. */
readonly ctx: Context
/**
/**
* Clear queued and steering work — unless `keepInbox` — and abort the active
* turn or between-turn task. The first cause wins for that activity. With no
* active activity, cancellation is a no-op and does not arm later work.
* @param cause - the stable caller intent carried by the active operation signal.
* @param options - cancellation options; `keepInbox` preserves pending work.
*/
cancel(cause: AgentCancelCause, options?: CancelOptions): void
cancel(cause: AgentCancelCause, options?: CancelOptions): void
/**
/**
* Resolve after the current whole-agent activity reaches quiescence. This
* follows replacement work started before the observed driver retires,
* but does not identify the settlement of any particular message.
* @returns fulfillment after no active driver or maintenance task remains.
*/
whenIdle(): Promise<void>
whenIdle(): Promise<void>
/**
/**
* Run one non-turn maintenance task from the true idle phase. The task starts
* synchronously after claiming that phase; later waking input remains in the
* inbox until the task settles, while public status stays `idle`.
@@ -101,9 +100,9 @@ export interface Agent {
* @throws synchronously when turn-driving or another maintenance task already owns the agent.
* @returns the task promise.
*/
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
runMaintenance<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T>
/**
/**
* Route identified input to an inbox boundary and optionally wake the driver.
* Waking input submitted after active cancellation is queued for the next
* turn and runs when the aborted activity converges to idle; a `disposed`
@@ -114,25 +113,25 @@ export interface Agent {
* @param target - the preferred next-turn or next-step inbox boundary.
* @param wakeup - whether delivery may wake the driver.
*/
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
send(message: UserMessage, target: InboxTarget, wakeup: boolean): void
/**
/**
* Queue an ordinary follow-up turn and wake the driver. The item becomes the
* sole ordinary message of its own turn.
* @param message - identified prompt content and the source that supplied it.
*/
followup(message: UserMessage): void
followup(message: UserMessage): void
/**
/**
* Submit steering for the nearest step. An idle driver starts a turn;
* a running driver consumes it at its next step boundary.
* A rejected step leaves steering parked in the inbox until the next
* wake; cancellation or disposal may discard pending steering.
* @param message - identified steering content and the source that supplied it.
*/
steer(message: UserMessage): void
steer(message: UserMessage): void
/**
/**
* Queue model-facing context for the next pre-step without waking the
* driver. A running driver claims it at the nearest later step boundary;
* idle drivers leave it pending until follow-up or steering
@@ -140,7 +139,8 @@ export interface Agent {
* batch. Cancellation or disposal may discard pending context.
* @param message - identified injected context and the source that supplied it.
*/
inject(message: UserMessage): void
inject(message: UserMessage): void
}
}
declare module '@deepseek-ai/cordis' {
+19
View File
@@ -5,6 +5,25 @@
*/
import type { UserMessage } from '@deepseek-ai/dsh-llm/types'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { TypertContext, TypertLookup } from '@deepseek-ai/dsh-typert-protocol'
/** Public live-agent handle; the runtime face augments its live capabilities. */
export interface Agent {
/** Session-backed Agent identity. */
readonly id: SessionId
}
declare module '@deepseek-ai/dsh-typert-protocol' {
interface TypertLookupMap {
agent: TypertLookup<Agent, SessionId>
}
interface TypertContextMap {
/** Agent Context identity shared by Host and Client adapters. */
agent: TypertContext<SessionId>
}
}
/** One of the two ordered pending-message lists owned by an agent. */
export type InboxTarget = 'next-turn' | 'next-step'
+5 -1
View File
@@ -149,6 +149,7 @@ describe('AgentRegistry', () => {
await agentFiber
await ctx.plugin(TypertRegistry)
const agent = stubAgent('remote-agent')
Object.defineProperty(agent, 'ctx', { value: agent.ctx.extend({ agent }) })
const disposeAgent = ctx.agents.register(agent)
const lookup = ctx.typert.lookups.get('agent')
@@ -159,7 +160,10 @@ describe('AgentRegistry', () => {
wireTypeSymbol: '@deepseek-ai/dsh-session/types#SessionId',
})
expect(lookup?.resolve(agent.id)).toBe(agent)
expect(ctx.typert.contexts.getHost('agent')?.resolve(agent.id)).toBe(agent.ctx)
const context = ctx.typert.contexts.getHost('agent')
expect(context?.identity(agent.ctx)).toBe(agent.id)
expect(context?.identity(ctx)).toBeUndefined()
expect(context?.resolve(agent.id)).toBe(agent.ctx)
disposeAgent()
expect(lookup?.resolve(agent.id)).toBeUndefined()
@@ -34,6 +34,7 @@ const scopedSubjectResolvers: Readonly<Record<string, ScopedSubjectResolver | nu
'tools/post-execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/pre-execute': args => (args[0] as Record<string, unknown>)['agent'],
'tools/result': args => (args[0] as Record<string, unknown>)['agent'],
'user-questions/request': args => (args[0] as Record<string, unknown>)['agent'],
})
/**
@@ -79,6 +79,7 @@ describe('scoped-dispatch invariants', () => {
['tools/post-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }, () => Promise.resolve({ kind: 'accept' })]],
['tools/pre-execute', [{ callId: 'c', name: 't', arguments: {}, agent }, () => Promise.resolve({ kind: 'allow' })]],
['tools/result', [{ callId: 'c', name: 't', arguments: {}, agent }, { content: [], isError: false }]],
['user-questions/request', [{ agent, questions: [] }, () => Promise.resolve({ answers: [] })]],
]
for (const [event, args] of rows) {
-1
View File
@@ -147,7 +147,6 @@ function validateEvent(
case 'session/end-seed':
// Unconstrained: an unbalanced seed legally puts it inside an open turn.
break
case 'todo/write':
case 'request/header':
case 'request/context': {
if (trace.openTurn === null) {
@@ -42,6 +42,7 @@ export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet<string> = new Set([
'request/header',
'sandbox/mode',
'schedule/change',
'session-log-deepseek/delivery-accepted',
'session/end-seed',
'session/title',
'session/title-llm-request',
-19
View File
@@ -176,23 +176,6 @@ export interface TurnEndReasonMap {
/** The union over {@link TurnEndReasonMap} — why a turn ended; plugins extend it by merging variants into the map. */
export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap]
/**
* One entry in an agent's todo list — the unit of the `todo/write`
* {@link SessionEventMap} event's whole-list snapshot.
*
* Deliberately minimal: a human-readable `content` line and a three-state
* `status`. No id, priority, or `activeForm` — the list is replaced wholesale
* on every write (last-write-wins), so entries need no stable identity. The
* three statuses describe the complete portable lifecycle needed by model and
* UI consumers.
*/
export interface TodoItem {
/** What this task is — a short imperative line shown in the UI. */
content: string
/** Lifecycle state. `in_progress` marks a task being worked now; parallel work may mark several. */
status: 'pending' | 'in_progress' | 'completed'
}
/**
* Logged request state outside derived history: call config, system prompt, and
* tools. The latest full `request/header` snapshot reconstructs it; canonical
@@ -299,8 +282,6 @@ export interface SessionEventMap {
error?: { name: string; code: string }
meta?: JsonValue
}
/** Whole-list snapshot; latest write wins on replay. Log-only UI state; never derived history. */
'todo/write': { todos: TodoItem[] }
/**
* Full header for the next request, appended inside its step before dispatch.
* It is log-only; the latest snapshot reconstructs the request header.
@@ -141,7 +141,6 @@ describe('session-log invariants', () => {
const enclosed = (await setup()).ctx.sessions.create()
enclosed.append('turn/start', { turn: 1 })
enclosed.append('step/start', { turn: 1, step: 1 })
expect(() => enclosed.append('todo/write', { todos: [] })).not.toThrow()
expect(() => enclosed.append('request/header', {
header: { config: { provider: 'mock', model: 'mock' } },
reason: 'initial',
@@ -146,7 +146,9 @@ describe('Session.requestContext', () => {
it('advances incrementally across appends and skips unrelated events', () => {
const session = Session.create(SessionId('incremental-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('todo/write', { todos: [] })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, model: 'next', contextWindow: 64_000 })
expect(session.requestContext()).toEqual({ provider: 'mock', model: 'next', contextWindow: 64_000 })
@@ -158,7 +160,9 @@ describe('Session.requestContext', () => {
const session = Session.create(SessionId('batched-capacity'), seedWith(CAPACITY))
expect(session.requestContext()).toEqual(CAPACITY)
session.append('request/context', { ...CAPACITY, contextWindow: 200_000 })
session.append('todo/write', { todos: [] })
session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'unrelated' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('request/context', { ...CAPACITY, contextWindow: 300_000 })
expect(session.requestContext()?.contextWindow).toBe(300_000)
})
+9 -74
View File
@@ -9,7 +9,7 @@ import SessionStore, {
SessionId,
snapshotSessionEvent,
} from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface, TodoItem } from '@deepseek-ai/dsh-session'
import type { CreateSessionOptions, SessionEventType, SessionHeader, SessionSurface } from '@deepseek-ai/dsh-session'
describe('Session', () => {
it('exposes one stable readonly surface view', () => {
@@ -787,7 +787,7 @@ describe('Session', () => {
},
})
const event = session.append('todo/write', data as never)
const event = session.append('request/context', data as never)
expect(reads).toBe(1)
expect(event.data).toEqual({ value: 'accepted' })
@@ -914,14 +914,14 @@ describe('Session', () => {
expect(() => { seededEvent.data.turn = 99 }).toThrow(TypeError)
const appended = Session.create(SessionId('append-frozen'))
const appendedEvent = appended.append('todo/write', {
todos: [{ content: 'first', status: 'pending' }],
})
const appendedEvent = appended.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'first' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
expect(Object.isFrozen(appendedEvent)).toBe(true)
expect(Object.isFrozen(appendedEvent.data)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.todos[0])).toBe(true)
expect(() => { appendedEvent.data.todos[0]!.content = 'mutated' }).toThrow(TypeError)
expect(Object.isFrozen(appendedEvent.data.content)).toBe(true)
expect(Object.isFrozen(appendedEvent.data.content[0])).toBe(true)
expect(() => { (appendedEvent.data.content[0] as { text: string }).text = 'mutated' }).toThrow(TypeError)
})
it('iteratively freezes deeply nested restored event data', () => {
@@ -1531,7 +1531,7 @@ describe('SessionStore', () => {
const session = ctx.sessions.create(SessionId('reentrant-observer'))
const heard: SessionEvent[] = []
ctx.on('session/event', (observedSession) => {
observedSession.append('todo/write', { todos: [] })
observedSession.append('request/context', { provider: 'mock', model: 'mock' })
})
ctx.on('session/event', (_observedSession, event) => { heard.push(event) })
@@ -1663,68 +1663,3 @@ describe('SessionStore', () => {
expect(heard).toEqual([session])
})
})
describe('todo/write event', () => {
it('appends the whole-list snapshot and isolates the log from later mutation', () => {
const session = Session.create(SessionId('t1'))
const todos: TodoItem[] = [
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
]
session.append('todo/write', { todos })
const event = session.events.findLast(e => e.type === 'todo/write')!
expect(event.type).toBe('todo/write')
expect(event.data.todos).toEqual(todos)
// The append snapshots its input: mutating the caller's array afterward must
// not change what the log holds (the durable-source-of-truth contract).
todos.push({ content: 'sneak in', status: 'pending' })
todos[0]!.status = 'completed'
expect(event.data.todos).toEqual([
{ content: 'plan the work', status: 'in_progress' },
{ content: 'write the code', status: 'pending' },
])
})
it('is last-write-wins: the current list is the most recent todo/write', () => {
const session = Session.create(SessionId('t2'))
session.append('todo/write', { todos: [{ content: 'first', status: 'pending' }] })
session.append('todo/write', { todos: [
{ content: 'first', status: 'completed' },
{ content: 'second', status: 'in_progress' },
] })
const current = session.events.findLast(e => e.type === 'todo/write')!.data.todos
expect(current).toEqual([
{ content: 'first', status: 'completed' },
{ content: 'second', status: 'in_progress' },
])
})
it('is NOT a surface event: it produces no derived message and joins no surface node', () => {
const session = Session.create(SessionId('t3'))
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…
expect(session.deriveMessages()).toHaveLength(before)
// …and must not appear on the ordered surface.
expect(session.surface.nodes).not.toContain(session.seq - 1)
})
it('round-trips through a seeded replay identically (durable, no surfaceOp needed)', () => {
const original = Session.create(SessionId('t4'))
original.append('turn/start', { turn: 1 })
original.append('todo/write', { todos: [{ content: 'only', status: 'completed' }] })
original.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
// Seeding a non-surface event with no surfaceOp must not throw.
const replayed = Session.create(SessionId('t4-replay'), [...original.events])
expect(replayed.events.findLast(e => e.type === 'todo/write')!.data.todos)
.toEqual([{ content: 'only', status: 'completed' }])
expect(replayed.events.slice(0, original.seq)).toEqual(original.events)
expect(replayed.firstLiveSeq).toBe(original.seq)
})
})